Skip to main content
CharleOS uses Cypress for end-to-end testing of critical user flows. This page covers how to write and run E2E tests.

Test Users

E2E tests use dedicated test accounts that are isolated from real user data:
EmailAccess LevelWork TypeUse For
test-admin@charle.co.ukAdminCSMAdmin features, settings, full access
test-manager@charle.co.ukManagerPMManagement features, scheduling
test-staff@charle.co.ukStaffDevelopmentIC features, task assignment
Important: Always run npm run db:seed:e2e before running E2E tests to ensure test users exist.

Custom Commands

Login Commands

// Login as specific user
cy.login("test-admin@charle.co.uk");

// Convenience commands for role-based login
cy.loginAsAdmin();   // test-admin@charle.co.uk
cy.loginAsManager(); // test-manager@charle.co.uk
cy.loginAsStaff();   // test-staff@charle.co.uk

What to E2E Test

Focus on critical user journeys and integration points:

Authentication

Sign in, sign out, session management

Core Workflows

Create task, approve quote, assign subtask

Client Portal

Client sign-in, view tasks, submit tickets

Permissions

Admin vs manager vs staff access

Test Structure

File Organization

cypress/
├── e2e/
│   ├── auth.cy.ts          # Authentication flows
│   ├── clients.cy.ts       # Client management
│   ├── tasks.cy.ts         # Task viewing/access
│   └── permissions.cy.ts   # Role-based access
├── support/
│   ├── e2e.ts              # Custom commands
│   └── commands.ts         # Additional commands
└── fixtures/               # Test data

Basic Test Example

// cypress/e2e/auth.cy.ts
describe("Authentication", () => {
  it("redirects unauthenticated users to sign-in", () => {
    cy.visit("/clients");
    cy.url().should("include", "sign-in");
  });

  describe("Authenticated - Admin", () => {
    beforeEach(() => {
      cy.loginAsAdmin();
    });

    it("can access dashboard", () => {
      cy.visit("/");
      cy.url().should("not.include", "sign-in");
    });

    it("can access admin settings", () => {
      cy.visit("/admin/settings");
      cy.url().should("include", "/admin/settings");
    });
  });
});

Cypress Features

// Navigate to page
cy.visit("/tasks");

// Wait for URL
cy.url().should("include", "/tasks");

// Go back/forward
cy.go("back");
cy.go("forward");

Locators

// By text
cy.contains("Create Task");
cy.contains("button", "Submit");

// By selector
cy.get('[data-testid="task-card"]');
cy.get(".task-card");
cy.get("#task-123");

// By attribute
cy.get('input[name="title"]');
cy.get('button[type="submit"]');

Interactions

// Click
cy.get("button").click();
cy.contains("Submit").click();

// Type
cy.get('input[name="title"]').type("New Task");
cy.get('input[name="title"]').clear().type("Updated Task");

// Select
cy.get('select[name="priority"]').select("high");

// Check/uncheck
cy.get('input[type="checkbox"]').check();
cy.get('input[type="checkbox"]').uncheck();

Assertions

// Visibility
cy.get(".success-message").should("be.visible");
cy.get(".error-message").should("not.exist");

// Text content
cy.get("h1").should("have.text", "Dashboard");
cy.get(".status").should("contain", "Complete");

// URL
cy.url().should("include", "/tasks");
cy.url().should("match", /\/tasks\/task-/);

// Count
cy.get(".task-card").should("have.length", 3);

// Attribute
cy.get("button").should("be.disabled");
cy.get("input").should("have.value", "Initial value");

Common Test Patterns

Authentication Flow

// cypress/e2e/auth.cy.ts
describe("Authentication", () => {
  it("redirects unauthenticated users to sign-in", () => {
    cy.visit("/clients");
    cy.url().should("include", "sign-in");
  });

  describe("Admin Access", () => {
    beforeEach(() => {
      cy.loginAsAdmin();
    });

    it("can access all pages", () => {
      cy.visit("/admin/settings");
      cy.url().should("include", "/admin/settings");
    });
  });

  describe("Staff Access", () => {
    beforeEach(() => {
      cy.loginAsStaff();
    });

    it("cannot access admin pages", () => {
      cy.visit("/admin/settings");
      // Should redirect away from admin
      cy.url().should("not.include", "/admin");
    });
  });
});

Task Viewing

// cypress/e2e/tasks.cy.ts
describe("Tasks", () => {
  beforeEach(() => {
    cy.loginAsAdmin();
  });

  it("can access tasks page", () => {
    cy.visit("/tasks");
    cy.url().should("include", "/tasks");
    cy.url().should("not.include", "sign-in");
  });

  it("displays task list", () => {
    cy.visit("/tasks");
    // Verify page content loaded, not just that body exists
    cy.contains("Tasks", { timeout: 15000 }).should("be.visible");
  });
});

Permission Tests

// cypress/e2e/permissions.cy.ts
describe("Role-Based Access", () => {
  describe("Admin user", () => {
    beforeEach(() => {
      cy.loginAsAdmin();
    });

    it("can access admin settings", () => {
      cy.visit("/admin/settings");
      cy.url().should("include", "/admin/settings");
    });

    it("can access clients page", () => {
      cy.visit("/clients");
      cy.url().should("include", "/clients");
    });
  });

  describe("Staff user", () => {
    beforeEach(() => {
      cy.loginAsStaff();
    });

    it("cannot access admin settings", () => {
      cy.visit("/admin/settings");
      cy.url().should("not.include", "/admin/settings");
    });

    it("can access tasks page", () => {
      cy.visit("/tasks");
      cy.url().should("include", "/tasks");
    });
  });
});

Running E2E Tests

Basic Commands

# Seed test data first
npm run db:seed:e2e

# Run all E2E tests (headless)
npm run test:e2e

# Run with Cypress UI (interactive)
npm run test:e2e:open

# Run specific file
npx cypress run --spec "cypress/e2e/auth.cy.ts"

# Run with specific browser
npx cypress run --browser chrome

Watch Mode

# Open Cypress UI for interactive development
npm run test:e2e:open

Debugging

Screenshots and Videos

Cypress automatically captures screenshots on failure and videos of test runs:
cypress/
├── screenshots/    # Failure screenshots
└── videos/         # Test run videos

Debug Commands

// Pause test execution
cy.pause();

// Log to console
cy.log("Debug message");

// Debug with devtools
cy.debug();

Run in Headed Mode

# See the browser while tests run
npx cypress run --headed

Best Practices

Always use the dedicated test accounts:
// Good: Use test users
cy.loginAsAdmin();

// Bad: Use real user accounts
cy.login("luke@charle.co.uk");
Focus on what users actually do:
// Good: Complete user flow
it("admin can access settings", () => {
  cy.loginAsAdmin();
  cy.visit("/admin/settings");
  cy.url().should("include", "/admin/settings");
});

// Bad: Testing implementation details
it("API returns 200", () => {
  // Too low-level
});
Prefer data-testid attributes over CSS classes:
// Good: Resilient selectors
cy.get('[data-testid="task-card"]');
cy.contains("button", "Submit");

// Bad: Fragile selectors
cy.get('.btn-primary.create-btn');
Each test should work in isolation:
// Good: Test sets up its own state
beforeEach(() => {
  cy.loginAsAdmin();
});

// Bad: Tests depend on each other
it("creates task"); // Must run first
it("edits task");   // Depends on previous test
Verify actual page content loaded, not just URL or body existence:
// Good: Checks actual page content
cy.visit("/tasks");
cy.contains("Tasks", { timeout: 15000 }).should("be.visible");

// Bad: Would pass with loading spinner or error page
cy.visit("/tasks");
cy.get("body").should("be.visible");
Content assertions ensure the page has fully loaded and rendered the expected content, catching issues that URL-only or body-visibility checks would miss.

CI Integration

E2E tests run automatically on pull requests to main:
# .github/workflows/e2e.yml
on:
  pull_request:
    branches: [main]
    paths:
      - 'app/**'
      - 'components/**'
      - 'lib/**'
      - 'cypress/**'

jobs:
  e2e-tests:
    steps:
      - name: Seed E2E data
        run: npm run db:seed:e2e

      - name: Run Cypress
        uses: cypress-io/github-action@v6
        with:
          build: npm run build
          start: npm start

Testing Overview

Testing strategy

Unit Tests

Vitest unit tests

Cypress Docs

Official Cypress docs