Skip to content

E2E Tests

End-to-end tests use Playwright to drive a real browser against a running SimpleModule application. They verify complete user flows including authentication, navigation, and CRUD operations.

Setup

E2E tests live in tests/e2e/ and are a separate npm workspace with their own package.json:

json
{
  "name": "@simplemodule/e2e",
  "private": true,
  "scripts": {
    "test": "playwright test",
    "test:ui": "playwright test --ui",
    "test:headed": "playwright test --headed",
    "test:smoke": "playwright test tests/smoke/",
    "test:flows": "playwright test tests/flows/",
    "report": "playwright show-report"
  },
  "devDependencies": {
    "@faker-js/faker": "^10.4.0",
    "@playwright/test": "^1.52.0"
  }
}

Install Playwright browsers before first use:

bash
npx playwright install

Running Tests

From the repository root:

bash
# Run all E2E tests
npm run test:e2e

# Run with Playwright UI (interactive mode)
npm run test:e2e:ui

# Run only smoke tests
npm run test:e2e -- -- tests/smoke/

# Run only flow tests
npm run test:e2e -- -- tests/flows/

Or from within the tests/e2e/ directory:

bash
npm test                          # all tests
npm run test:ui                   # interactive UI mode
npm run test:headed               # headed browser
npm run test:smoke                # smoke tests only
npm run test:flows                # flow tests only
npm run report                    # view last test report

Configuration

Playwright is configured in tests/e2e/playwright.config.ts:

typescript
export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [['html'], ...(process.env.CI ? [['github']] : [])],
  use: {
    baseURL: 'https://localhost:5001',
    trace: 'on-first-retry',
    ignoreHTTPSErrors: true,
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['setup'],
    },
    // Firefox and WebKit are added in CI only
  ],
  webServer: {
    command: 'dotnet run --project ../../template/SimpleModule.Host',
    url: 'https://localhost:5001/health/live',
    reuseExistingServer: true,
    ignoreHTTPSErrors: true,
    timeout: 60_000,
    env: {
      ASPNETCORE_URLS: 'https://localhost:5001',
      Database__DefaultConnection: 'Data Source=e2e-test.db',
    },
  },
});

Key points:

  • webServer -- Playwright automatically starts the .NET host if it is not already running, using the health endpoint to detect readiness
  • reuseExistingServer: true -- if you already have the app running (e.g., via npm run dev), Playwright uses it directly
  • Browser matrix -- locally tests run on Chromium only; CI adds Firefox and WebKit
  • Retries -- CI retries failed tests twice; local runs have zero retries
  • Traces and screenshots -- captured on first retry and on failure for debugging

Authentication

E2E tests authenticate once in a setup project and reuse the auth state across all test files.

Auth Setup

The tests/auth/auth.setup.ts file logs in as the admin user and saves the browser storage state:

typescript
setup('authenticate as admin', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('link', { name: 'Log in' }).click();
  await page.waitForURL('**/Identity/Account/Login**');

  await page.getByPlaceholder('you@example.com')
    .fill('admin@simplemodule.dev');
  await page.locator('input[type="password"]')
    .fill('Admin123!');
  await page.getByRole('button', { name: 'Log in' }).click();

  await page.waitForURL('/');
  await page.context().storageState({ path: authFile });
});

Using Auth in Tests

Tests import a custom test fixture from fixtures/base.ts that automatically loads the saved auth state:

typescript
import { test as base } from '@playwright/test';

const authFile = path.resolve(
  __dirname, '../auth/.auth/user.json');

export const test = base.extend({
  storageState: async (_, use) => {
    await use(authFile);
  },
});

export { expect } from '@playwright/test';

All test files import from this fixture instead of directly from @playwright/test:

typescript
import { expect, test } from '../../fixtures/base';

Test Organization

Tests are organized into two categories:

Smoke Tests (tests/smoke/)

Quick page-load checks that verify pages render without errors:

typescript
test.describe('Products pages', () => {
  test('browse page loads', async ({ page }) => {
    const browse = new ProductsBrowsePage(page);
    await browse.goto();
    await expect(browse.heading).toBeVisible();
  });

  test('create page loads', async ({ page }) => {
    const create = new ProductsCreatePage(page);
    await create.goto();
    await expect(create.heading).toBeVisible();
  });
});

Flow Tests (tests/flows/)

Full CRUD and business workflows that create, read, update, and delete data:

typescript
test.describe('Orders CRUD', () => {
  test.describe.configure({ mode: 'serial' });

  test('create an order and verify it appears in the list',
    async ({ page, request }) => {
      const productsPage = new ProductsCreatePage(page);
      await productsPage.goto();
      await productsPage.createProduct(productName, price);

      const createPage = new OrdersCreatePage(page);
      await createPage.goto();
      await createPage.createOrder(adminUserId, 0, quantity);

      const listPage = new OrdersListPage(page);
      await listPage.goto();
      await expect(
        listPage.orderRowByUser(adminUserId).first()
      ).toBeVisible();
    });
});

Serial Mode

Flow tests use test.describe.configure({ mode: 'serial' }) because they depend on data created by earlier tests in the same describe block (e.g., create then delete).

Page Object Model

E2E tests use the Page Object Model pattern. Each page has a corresponding class in tests/e2e/pages/:

typescript
import type { Page } from '@playwright/test';

export class ProductsBrowsePage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('/products/browse');
  }

  get heading() {
    return this.page.getByRole('heading', { name: /products/i });
  }

  get productCards() {
    return this.page.locator('[data-testid="product-card"]');
  }

  productByName(name: string) {
    return this.page.getByText(name);
  }
}

Page objects are available for all module pages under tests/e2e/pages/:

pages/
  dashboard.page.ts
  products/
    browse.page.ts
    create.page.ts
    edit.page.ts
    manage.page.ts
  orders/
    create.page.ts
    edit.page.ts
    list.page.ts
  settings/
    admin.page.ts
    user.page.ts
    menu-manager.page.ts
  pagebuilder/
    editor.page.ts
    manage.page.ts
    pages-list.page.ts
    viewer.page.ts
  openiddict/
    clients.page.ts

CI Integration

In CI, the Playwright configuration automatically:

  • Expands the browser matrix to include Firefox and WebKit
  • Sets retries to 2
  • Limits to 1 worker for stability
  • Adds the github reporter alongside HTML
yaml
- name: Run E2E tests
  run: npm run test:e2e
  env:
    CI: true

Test results are available as an HTML report via npm run report (or npx playwright show-report from tests/e2e/).

Next Steps

Released under the MIT License.