Development

E2E Testing

End-to-end testing with Playwright, shared fixtures, and CI integration

Quick Start

Run All E2E Tests

# Using turbo (recommended)
bun test:e2e

# Using Playwright CLI directly
bunx playwright test

Run Tests for Specific App

# Using turbo
turbo test:e2e --filter=calnexus
turbo test:e2e --filter=lexilink

# Using app-level scripts
cd apps/calnexus && bun test:e2e
cd apps/lexilink && bun test:e2e

Run Tests in UI Mode (Development)

cd apps/calnexus
bun test:e2e:ui

Run Tests in Headed Mode (See Browser)

cd apps/calnexus
bun test:e2e:headed

Project Structure

hn-monorepo/
├── playwright.config.ts              # Root config (runs all apps)
├── apps/
│   ├── calnexus/
│   │   ├── playwright.config.ts     # CalNexus config
│   │   └── tests/e2e/               # CalNexus E2E tests
│   ├── lexilink/
│   │   ├── playwright.config.ts     # Lexilink config
│   │   └── e2e/                     # Lexilink E2E tests
│   ├── planex/
│   │   ├── playwright.config.ts     # Planex config
│   │   └── e2e/                     # Planex E2E tests
│   ├── nexus-lms/
│   │   ├── playwright.config.ts     # Nexus LMS config
│   │   └── e2e/                     # Nexus LMS E2E tests
│   ├── qript/
│   │   ├── playwright.config.ts     # Qript config
│   │   └── e2e/                     # Qript E2E tests
│   └── portfolio/
│       ├── playwright.config.ts     # Portfolio config
│       └── e2e/                     # Portfolio E2E tests
└── packages/
    └── testing/
        ├── playwright-config.ts     # Shared config factory
        └── fixtures/
            └── playwright.ts        # Shared E2E helpers

Writing Tests

Basic Test Structure

import { expect, test } from "@playwright/test";
import { waitForHydration } from "@hn-monorepo/testing/fixtures";

test.describe("Feature Name", () => {
  test("should do something", async ({ page }) => {
    await page.goto("/");
    await waitForHydration(page);

    await expect(page.locator("h1")).toBeVisible();
  });
});

Using Shared Helpers

The @hn-monorepo/testing package provides many helpful utilities:

import {
  waitForHydration,
  checkBasicAccessibility,
  mockApiRoute,
  fillAndSubmitForm,
  testData,
} from "@hn-monorepo/testing/fixtures";

// Wait for Next.js hydration
await waitForHydration(page);

// Check basic accessibility
const a11y = await checkBasicAccessibility(page);
expect(a11y.hasMain).toBe(true);

// Mock API responses
await mockApiRoute(page, "/api/users", { users: [] });

// Fill and submit forms
await fillAndSubmitForm(page, {
  email: "test@example.com",
  password: "Password123!",
}, "Sign In");

// Generate test data
const user = testData.user({ email: "custom@example.com" });

Test Naming Conventions

  • Use descriptive test names that explain what’s being tested
  • Group related tests using test.describe()
  • Use .spec.ts suffix for test files
  • Use .setup.ts suffix for setup/teardown files
  • Use .auth-test.ts suffix for authenticated tests (planex pattern)

Test Categories

Smoke Tests (smoke.spec.ts)

  • Basic page loads
  • Critical user flows
  • No console errors
  • Quick checks that core functionality works

Authentication Tests (auth.spec.ts)

  • Login/logout flows
  • Registration
  • Password reset
  • Protected routes

Feature Tests (named after feature)

  • Detailed testing of specific features
  • User interactions
  • Edge cases

Responsive Tests (responsive.spec.ts or within other tests)

  • Mobile viewport (375x667)
  • Tablet viewport (768x1024)
  • Desktop viewport (1280x720)

Configuration

Shared Configuration

All apps use the shared config factory from @hn-monorepo/testing/playwright-config:

import { createPlaywrightConfig } from "@hn-monorepo/testing/playwright-config";

export default createPlaywrightConfig({
  baseURL: "http://localhost:3000",
  testDir: "./e2e",
  devServerCommand: "bun run dev",
  port: 3000,
  workers: 1,
  fullyParallel: false,
  includeMobile: true,
  includeTablet: true,
});

Custom Configuration

You can extend the base config for app-specific needs:

import { createPlaywrightConfig } from "@hn-monorepo/testing/playwright-config";
import { defineConfig } from "@playwright/test";

const baseConfig = createPlaywrightConfig({
  // ... options
});

export default defineConfig({
  ...baseConfig,
  projects: [
    // Custom projects for auth workflows
    {
      name: "setup",
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: "authenticated",
      testMatch: /.*\.auth-test\.ts/,
      use: {
        storageState: "playwright/.auth/user.json",
      },
      dependencies: ["setup"],
    },
  ],
});

Best Practices

1. Wait for Hydration

Always wait for Next.js hydration to complete before interacting with the page:

await page.goto("/");
await waitForHydration(page);

2. Use Accessible Selectors

Prefer accessible selectors over CSS classes:

// Preferred
await page.getByRole("button", { name: "Submit" }).click();
await page.getByLabel("Email").fill("test@example.com");

// Avoid
await page.locator(".submit-button").click();

3. Handle Loading States

Wait for content to load before assertions:

await page.waitForLoadState("networkidle");
await expect(page.getByText("Loading...")).not.toBeVisible();

4. Avoid Flaky Tests

  • Use proper waiting mechanisms
  • Don’t use arbitrary waitForTimeout() unless absolutely necessary
  • Use waitForSelector() with visible state
  • Check for element visibility before interaction

5. Clean Up After Tests

test.afterEach(async ({ page }) => {
  await clearBrowserStorage(page);
});

6. Test in Isolation

Each test should be independent and not rely on previous test state.

CI/CD Integration

Tests automatically run in CI with optimized settings:

  • Single worker to avoid overwhelming the server
  • 2 retries for transient failures
  • HTML report generated
  • Screenshots on failure
  • Traces on retry

Environment variables in CI:

  • CI=true enables CI mode
  • Tests run sequentially
  • Server doesn’t reuse existing instance

E2E Test Status in CI

AppStatusMock ProviderNotes
calnexusStableNEXT_PUBLIC_MOCK_CONVEX=trueNon-experimental, failures block CI
lexilinkStableNEXT_PUBLIC_MOCK_CONVEX=trueComprehensive mock coverage for all tested Convex functions

E2E tests use NEXT_PUBLIC_MOCK_CONVEX=true to avoid real Convex calls in CI. Mock responses are defined in apps/<app>/e2e/fixtures/mocks.ts.

Debugging

Debug Tests Locally

# Run in headed mode
bun test:e2e:headed

# Open Playwright UI
bun test:e2e:ui

# Run with debug mode
PWDEBUG=1 bun test:e2e

View Test Reports

bunx playwright show-report

Inspect Test Artifacts

After test failures:

  • Screenshots: test-results/*/screenshot.png
  • Videos: test-results/*/video.webm
  • Traces: test-results/*/trace.zip

View traces:

bunx playwright show-trace test-results/*/trace.zip

Common Patterns

Testing Forms

test("submits contact form", async ({ page }) => {
  await page.goto("/contact");

  await fillAndSubmitForm(page, {
    name: "John Doe",
    email: "john@example.com",
    message: "Hello!",
  }, "Send");

  await expect(page.getByText("Message sent")).toBeVisible();
});

Testing Authentication

test("user can log in", async ({ page }) => {
  await page.goto("/login");

  await page.getByLabel("Email").fill("test@example.com");
  await page.getByLabel("Password").fill("password123");
  await page.getByRole("button", { name: "Sign In" }).click();

  await expect(page).toHaveURL(/dashboard/);
});

Testing Responsive Design

test("mobile menu works", async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 667 });
  await page.goto("/");

  const mobileMenu = page.getByRole("button", { name: "Menu" });
  await expect(mobileMenu).toBeVisible();
  await mobileMenu.click();

  await expect(page.getByRole("navigation")).toBeVisible();
});

Mocking API Responses

test("handles API errors", async ({ page }) => {
  await mockApiRoute(page, "/api/users",
    { error: "Server error" },
    500
  );

  await page.goto("/users");
  await expect(page.getByText("Error loading users")).toBeVisible();
});

Performance Testing

test("page loads quickly", async ({ page }) => {
  const start = Date.now();
  await page.goto("/");
  await waitForHydration(page);
  const loadTime = Date.now() - start;

  expect(loadTime).toBeLessThan(3000); // 3 seconds
});

Accessibility Testing

test("page is accessible", async ({ page }) => {
  await page.goto("/");

  const a11y = await checkBasicAccessibility(page);
  expect(a11y.hasMain).toBe(true);
  expect(a11y.hasNav).toBe(true);
});

Troubleshooting

Tests Fail Locally But Pass in CI

  • Check Node.js/Bun version matches CI
  • Clear .next build cache
  • Install latest browsers: bunx playwright install

Port Already in Use

  • Change port in app’s playwright.config.ts
  • Kill process using the port: lsof -ti:3000 | xargs kill

Slow Test Execution

  • Reduce number of workers
  • Use fullyParallel: false
  • Run specific test files instead of entire suite

Browser Not Launching

bunx playwright install
bunx playwright install-deps

Updating Dependencies

# Update Playwright
bun update @playwright/test

# Install new browsers
bunx playwright install
HanseNexus 2026