
E2e Testing
- 1 installs
- Updated August 3, 2026
- dotnetmentor/glenn-code-factory
Writes resilient Playwright end-to-end tests using Page Object Models, web-first assertions, and API response interception.
About
Guides writing and fixing Playwright E2E tests with Page Object Models, web-first assertions, and selector and waiting strategies. A developer uses it when adding E2E coverage, fixing flaky tests, or adding data-testid attributes.
- Page Object Model architecture and selector strategy
- Web-first assertions and API response interception
E2e Testing by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,750 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dotnetmentor/glenn-code-factory --skill e2e-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | August 3, 2026 |
| Repository | dotnetmentor/glenn-code-factory ↗ |
What it does
Writes resilient Playwright end-to-end tests using Page Object Models, web-first assertions, and API response interception.
Files
E2E Testing with Playwright
Project Structure
packages/e2e/
├── playwright.config.ts # 5 projects: setup, auth, unauthenticated, super-admin, regular-user
├── auth.setup.ts # Health check + authenticate users (super-admin + regular-user)
├── fixtures/
│ ├── base.ts # Super-admin POM fixtures (import this, not @playwright/test)
│ └── regular-user.ts # Regular user POM fixtures
├── pages/ # Page Object Models
│ ├── login.page.ts
│ └── dashboard.page.ts
└── tests/
├── auth/ # Tests that handle their own auth (no saved state)
├── unauthenticated/ # Unauthenticated user tests (redirects, guards)
├── super-admin/ # Super-admin feature tests
└── regular-user/ # Regular user feature testsRunning Tests
cd packages/e2e
npx playwright test # headless
npx playwright test --headed # visible browser
npx playwright test --ui # interactive UI mode
npx playwright show-report # view last report with tracesWhen to Write E2E Tests (And When NOT To)
E2E tests are expensive to write, slow to run, and costly to maintain. Use them only where lower-level tests can't give you the same confidence.
Write E2E for:
- Critical user journeys (booking a slot, signing in, completing a purchase)
- Flows that cross multiple services/boundaries (auth + API + UI)
- Revenue-critical paths where a failure = user can't do their job
- Flows that have broken in production before
DON'T write E2E for:
- Form validation ("email shows error") — component/unit test
- Sorting, filtering, pagination — component test
- Permission checks ("non-admin can't see admin page") — API test
- CRUD for every entity — test one representative flow e2e, rest via API
- Error states and edge cases — component test with mocked data
- Third-party integrations — mock the boundary
The rule: If the same confidence can be achieved with a unit or integration test, write that instead.
Core Rules
1. NEVER Use waitForTimeout or networkidle
// ❌ NEVER — arbitrary delay, slow AND flaky
await page.waitForTimeout(2_000);
// ❌ NEVER in SPAs — waits for "no network for 500ms", breaks with
// SignalR WebSockets, analytics pings, health checks, long-polling
await page.waitForLoadState('networkidle');
// ✅ Web-first assertion — auto-retries until true or timeout
await expect(page.getByTestId('booking-success-title')).toBeVisible();
// ✅ Wait for specific network response (precise signal)
const responsePromise = page.waitForResponse(r =>
r.url().includes('/api/bookings') && r.request().method() === 'POST'
);
await page.getByTestId('booking-confirm-btn').click();
const response = await responsePromise;
// ✅ Wait for a loading transition to complete
const loaded = calendarGrid.or(page.getByText('Inga lediga tider'));
await expect(loaded).toBeVisible();
// ✅ Wait for navigation label to change (proves nav happened)
const currentLabel = await weekLabel.textContent();
await weekNavNext.click();
await expect(weekLabel).not.toHaveText(currentLabel ?? '', { timeout: 5_000 });2. Selector Priority
Use in this order. Fall to next level only when the higher one doesn't work.
| Priority | Selector | When | Why |
|---|---|---|---|
| 1st | getByRole('button', { name: 'Save' }) | Interactive elements (buttons, links, headings) | Validates accessibility, resilient to refactors |
| 2nd | getByLabel('Email') | Form fields with labels | User-facing, semantic |
| 3rd | getByText('Welcome') | Asserting non-interactive content | Readable |
| 4th | getByTestId('nav-tab-book') | Structural/navigation elements, containers, dynamic lists | Stable contract when roles/text are ambiguous |
| Never | locator('.css-class') | Never. Add a data-testid instead | Breaks on any styling change |
When `getByTestId` beats `getByRole`:
- Navigation tabs in a Swedish/multilingual UI (text changes per locale)
- Container/wrapper elements with no semantic role
- Dynamic lists:
data-testid={\card-${item.id}\} - Elements where multiple matches exist for the same role+name
3. API Response Interception — The Gold Standard
For any action that triggers an API call, intercept the response before clicking. This is the most reliable pattern for CRUD operations — it eliminates the gap between "API done" and "DOM updated."
async confirmBooking() {
await expect(this.confirmButton).toBeEnabled();
// 1. Listen for API response BEFORE clicking
const responsePromise = this.page.waitForResponse(
(r) => r.url().includes('/resource-bookings') && r.request().method() === 'POST',
);
// 2. Perform the action
await this.confirmButton.click();
// 3. Wait for and validate API response (source of truth)
const response = await responsePromise;
if (!response.ok()) {
const body = await response.json();
throw new Error(`Booking API failed (${response.status()}): ${body.error ?? body.title}`);
}
// 4. Only THEN assert on DOM (which should now be updated)
await expect(this.successTitle).toBeVisible();
}Why this matters: Without API interception, you're guessing whether the DOM update reflects a successful API call or a stale state. With it, you know the API succeeded before checking the UI.
4. Always Use Web-First Assertions
// ❌ WRONG — checks once, returns false immediately if not visible
expect(await element.isVisible()).toBe(true);
const text = await element.textContent();
expect(text).toBe('Expected');
// ✅ CORRECT — auto-retries until timeout (default 30s)
await expect(element).toBeVisible();
await expect(element).toHaveText('Expected');
await expect(element).toBeEnabled();
await expect(element).not.toBeVisible(); // waits for disappearance5. Use expect.soft() for Multi-Assertion Tests
When verifying multiple things on a page, a hard assertion on item 2 skips items 3-5. Use soft assertions to collect ALL failures:
// ❌ First failure stops test — you miss other failures
await expect(page.getByText('Court 1')).toBeVisible();
await expect(page.getByText('60 min')).toBeVisible(); // fails here
await expect(page.getByText('200 kr')).toBeVisible(); // never checked
// ✅ Reports ALL failures at once
await expect.soft(page.getByText('Court 1')).toBeVisible();
await expect.soft(page.getByText('60 min')).toBeVisible();
await expect.soft(page.getByText('200 kr')).toBeVisible();6. Always Use Page Object Models
Tests import from fixtures/base.ts (member) or fixtures/admin.ts (admin), never from @playwright/test directly (except auth/guest tests).
// ✅ Test reads like English — POM handles complexity
import { test, expect } from '../../fixtures/base';
test('member can book a slot', async ({ memberPortal, bookingPage }) => {
await memberPortal.goToBooking();
const slot = await bookingPage.findAvailableSlot();
await bookingPage.selectSlot(slot);
await bookingPage.confirmBooking();
});// ✅ Admin tests use admin fixture
import { test, expect } from '../../fixtures/admin';
test('admin can create a block', async ({ adminPage }) => {
await adminPage.goto('/resources');
// ...
});7. Per-Test Data Setup via API
For tests that create/modify data, set up test-specific data via API instead of relying on shared seed data. This enables test isolation and future parallel execution.
test('cancel a booking', async ({ request, page }) => {
// Create test data via API — fast, isolated, doesn't affect other tests
const booking = await request.post('/api/resource-bookings', {
data: { resourceId, startTime, endTime }
});
expect(booking.ok()).toBeTruthy();
const { id } = await booking.json();
// Now test the actual UI flow
await page.goto(`/members/e2e-test/bookings`);
await page.getByTestId(`booking-${id}`).click();
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByText('Booking cancelled')).toBeVisible();
});Global seed = baseline data that every test needs (org, resources, users). Per-test API setup = data specific to what THIS test is verifying.
8. Adding data-testid to Frontend Components
When writing E2E tests for a new feature, first add data-testid to the React components.
Add testid to:
- Navigation elements (tabs, menus, breadcrumbs)
- Dialogs and their action buttons
- Cards and list items (use dynamic IDs: `
data-testid={card-${item.id}}`) - Navigation arrows/pagination
- Success/error states
- Calendar grids and structural containers
Skip testid for:
- Simple buttons with clear accessible names (use
getByRoleinstead) - Form inputs with labels (use
getByLabelinstead) - Headings (use
getByRole('heading', { name: ... }))
Convention: kebab-case, descriptive: booking-confirm-btn, nav-tab-home, week-nav-next
9. Writing a Page Object Model
See references/pom-pattern.md for the full POM + fixture pattern with examples.
Key rules:
- Store locators as
readonlyproperties in constructor - Use
getByRolefor interactive elements,getByTestIdfor structural ones - Methods should wait for the result (web-first assertions inside the POM)
- Use API response interception for any method that triggers an API call
- Wrap POMs in fixtures in
fixtures/base.tsorfixtures/admin.ts
10. Auth Setup Pattern
See references/auth-and-seed.md for the full auth and seed data pattern.
Key points:
auth.setup.tsruns before all tests (seeds data + authenticates member + admin)- Seed endpoint is idempotent (201 created, 409 already exists)
- Member auth saved to
.auth/member.json, admin to.auth/admin.json - Auth tests go in
tests/auth/and use base Playwright test (no saved state) - Guest tests go in
tests/guest/(no saved state) - Feature tests import from
fixtures/base.ts(member) orfixtures/admin.ts(admin)
11. Anti-Patterns Cheat Sheet
| Anti-Pattern | Fix |
|---|---|
waitForTimeout(N) | await expect(locator).toBeVisible() — auto-retries |
waitForLoadState('networkidle') | Wait for a specific signal: element visible, API response, label change |
expect(await loc.isVisible()).toBe(true) | await expect(loc).toBeVisible() — web-first, retries |
page.locator('.btn-primary') | page.getByRole('button', { name: '...' }) |
page.locator('#id > div > button') | Add data-testid and use getByTestId |
getByRole('listitem').nth(2) | .filter({ hasText: 'Name' }) — readable + stable |
| Inline navigation logic in tests | POM method: portal.goToBooking() |
import { test } from '@playwright/test' | import { test } from '../../fixtures/base' |
| Text-dependent nav in multilingual UI | getByTestId('nav-tab-book') for nav, getByRole for buttons |
| Asserting after click without API check | Intercept API response first, then assert DOM |
| Creating test data through UI clicks | API setup: request.post('/api/...') — 10-100x faster |
| One giant 200-line test | Small focused tests, each setting up its own data |
| Testing form validation e2e | Component/unit test — e2e is for user journeys |
| Serial test dependencies (B needs A's data) | Each test creates its own data via API |
12. CI/CD Tips
// playwright.config.ts — CI optimizations
export default defineConfig({
maxFailures: process.env.CI ? 5 : undefined, // Stop cascade failures early
retries: process.env.CI ? 2 : 1,
workers: 1, // Keep at 1 until tests are fully isolated
});CI workflow tips:
- Only install Chromium:
npx playwright install chromium --with-deps - Upload
playwright-report/as artifact on ALL outcomes (not just failure) - Upload
test-results/(traces, screenshots) on failure only - Use
cancel-in-progress: trueconcurrency to save CI minutes - Set
maxFailuresto avoid burning CI time on cascade failures
13. Debugging Flaky Tests
When a test is flaky:
1. Check the trace — npx playwright show-report → click the test → view trace 2. Look for missing wait signals — Are you waiting for the right thing? 3. Check for `networkidle` — Replace with specific element/response waits 4. Check for animation interference — Consider disabling animations:
await page.addStyleTag({
content: '*, *::before, *::after { transition: none !important; animation: none !important; }'
});5. Check for shared state — Does this test depend on another test's data? 6. Never ignore flaky tests — A flaky test is a test that sometimes lies to you. Fix it or delete it.
Auth Setup & Seed Data
Table of Contents
- Overview
- Test Users
- Seed Data Architecture
- Auth Setup File
- Playwright Config Projects
- Per-Test API Data Setup
- Adding New Seed Data
Overview
E2E tests need two things before they run: 1. Seed data — dev seed users with known credentials and roles 2. Authentication — saved browser state with auth cookies (super-admin + regular user)
Both happen in auth.setup.ts, which runs before all test files via Playwright's dependencies config.
Test Users
From DevSeedData.cs — these users exist automatically in dev:
| Password | OTP | Role | Auth State File | |
|---|---|---|---|---|
admin@test.com | Test123! | 111111 | SuperAdmin | .auth/super-admin.json |
user@test.com | Test123! | 222222 | Regular user | .auth/regular-user.json |
test@test.com | Test123! | 123456 | SuperAdmin | — (used for UI login tests) |
Auth methods: All users support both password login (POST /api/auth/login) and OTP login (POST /api/auth/verify-otp). For E2E setup, use password login (simpler, single request).
Seed Data Architecture
Backend: Infrastructure/DevSeed/DevSeedService.cs
The dev seed service runs on startup in Development mode and creates:
- Three test users with known passwords and OTP codes
- Role assignments (SuperAdmin for admin@test.com and test@test.com)
This happens automatically — no explicit seed endpoint needed. The dev seed runs every time the API starts in development mode.
Auth Setup File
// auth.setup.ts — runs BEFORE all tests
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const SUPER_ADMIN_AUTH_FILE = path.join(__dirname, '.auth/super-admin.json');
const REGULAR_USER_AUTH_FILE = path.join(__dirname, '.auth/regular-user.json');
// Step 1: Health check — wait for backend to be ready
setup('wait for backend', async ({ request }) => {
let healthy = false;
for (let i = 0; i < 30; i++) {
try {
const res = await request.get('/health');
if (res.ok()) { healthy = true; break; }
} catch { /* retry */ }
await new Promise(r => setTimeout(r, 1000));
}
expect(healthy, 'Backend did not become healthy within 30s').toBeTruthy();
});
// Step 2: Authenticate as super-admin and save browser state
setup('authenticate as super-admin', async ({ page }) => {
// Navigate first to set the origin for cookies
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
// Auth via page.evaluate (sets HttpOnly cookie in browser context)
const authResult = await page.evaluate(async () => {
const res = await fetch(window.location.origin + '/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'admin@test.com', password: 'Test123!' })
});
return { ok: res.ok, status: res.status };
});
expect(authResult.ok).toBeTruthy();
await page.reload();
// Wait for auth to be established (app renders authenticated content)
await page.waitForLoadState('domcontentloaded');
// Save auth state for all subsequent super-admin tests
await page.context().storageState({ path: SUPER_ADMIN_AUTH_FILE });
});
// Step 3: Authenticate as regular user and save browser state
setup('authenticate as regular user', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
const authResult = await page.evaluate(async () => {
const res = await fetch(window.location.origin + '/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'user@test.com', password: 'Test123!' })
});
return { ok: res.ok, status: res.status };
});
expect(authResult.ok).toBeTruthy();
await page.reload();
await page.waitForLoadState('domcontentloaded');
// Save auth state for all subsequent regular-user tests
await page.context().storageState({ path: REGULAR_USER_AUTH_FILE });
});Why `page.evaluate` for auth? The API uses HttpOnly cookies (auth-token). request.post doesn't set cookies in the browser context. By calling fetch() from inside page.evaluate, the cookie is set in the actual browser's cookie jar.
Why password login instead of OTP? Both work, but password login is a single request (POST /api/auth/login) vs two for OTP (POST /api/auth/send-otp → POST /api/auth/verify-otp). Simpler and faster.
Why `domcontentloaded` and NOT `networkidle`? networkidle waits for "no network activity for 500ms" which fails with SignalR WebSockets, analytics pings, or any persistent connection. domcontentloaded is sufficient here because we only need the page origin to be set before calling page.evaluate.
Playwright Config Projects
// playwright.config.ts
projects: [
// 1. Setup runs first: health check + saves auth state
{
name: 'setup',
testDir: '.',
testMatch: /auth\.setup\.ts/,
},
// 2. Auth tests: run after setup, NO saved auth (tests handle their own login)
{
name: 'auth-tests',
testMatch: /auth\/.*\.spec\.ts/,
dependencies: ['setup'],
// No storageState — tests start as unauthenticated
},
// 3. Unauthenticated tests: run after setup, NO saved auth
{
name: 'unauthenticated-tests',
testMatch: /unauthenticated\/.*\.spec\.ts/,
dependencies: ['setup'],
// No storageState — tests start as unauthenticated
},
// 4. Super-admin tests: run after setup, WITH saved super-admin auth
{
name: 'super-admin-tests',
testMatch: /super-admin\/.*\.spec\.ts/,
use: { storageState: '.auth/super-admin.json' },
dependencies: ['setup'],
},
// 5. Regular-user tests: run after setup, WITH saved regular-user auth
{
name: 'regular-user-tests',
testMatch: /regular-user\/.*\.spec\.ts/,
use: { storageState: '.auth/regular-user.json' },
dependencies: ['setup'],
},
]Execution order:
setup (health check + 2 auth sequences)
↓
├─ auth-tests (tests/auth/*, no storageState)
├─ unauthenticated-tests (tests/unauthenticated/*, no storageState)
├─ super-admin-tests (tests/super-admin/*, storageState: super-admin)
└─ regular-user-tests (tests/regular-user/*, storageState: regular-user)Per-Test API Data Setup
Global seed = dev seed users with known credentials and roles (auto-seeded on startup).
Per-test API setup = data specific to what THIS test verifies. This pattern enables test isolation and future parallel execution.
test('can delete an entity', async ({ request, page }) => {
// Create test data via API (specific to this test)
const createRes = await request.post('/api/some-entity', {
data: { name: 'Test Entity for Deletion' }
});
expect(createRes.ok()).toBeTruthy();
const { id } = await createRes.json();
// Test the actual UI flow
await page.goto('/super-admin/some-entity');
await page.getByTestId(`entity-${id}`).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Deleted successfully')).toBeVisible();
});Benefits over shared seed data:
- Tests don't interfere with each other
- Tests can run in parallel
- Failures are isolated — one test's data doesn't corrupt another
- Tests are self-documenting — you can see exactly what data they need
- No cleanup needed — DB is thrown away after CI run
Adding New Seed Data
When a new feature needs baseline data that all tests share:
1. Extend DevSeedData.cs and DevSeedService.cs 2. Keep it idempotent — check if data exists before creating 3. Return deterministic data — same seed always produces same state 4. Don't clean up — seed data persists, DB is disposable
When a test needs specific data only it cares about — use per-test API setup instead (see above).
Page Object Model + Fixture Pattern
Table of Contents
- Architecture Overview
- Writing a Page Object
- API Response Interception in POMs
- Registering in Fixtures
- Admin Fixture
- Using in Tests
- Adding a New Feature's Tests
Architecture Overview
Three layers, each with a clear job:
React Components (data-testid) → Page Objects (locators + actions) → Tests (intent only)- Components own the
data-testidcontract - Page Objects own the locators, waiting logic, and API response validation
- Tests own the user intent (5-15 lines, reads like English)
Writing a Page Object
// pages/booking.page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class BookingPage {
readonly page: Page;
// Store locators as readonly properties — one place to update if selectors change
readonly confirmationDialog: Locator;
readonly confirmButton: Locator;
readonly successTitle: Locator;
readonly calendarGrid: Locator;
constructor(page: Page) {
this.page = page;
// Use data-testid for structural elements
this.confirmationDialog = page.getByTestId('booking-confirmation-dialog');
this.confirmButton = page.getByTestId('booking-confirm-btn');
this.successTitle = page.getByTestId('booking-success-title');
this.calendarGrid = page.getByTestId('calendar-grid');
}
/** Methods encapsulate waiting — the test never waits manually */
async selectSlot(slot: Locator) {
await slot.click();
// Web-first assertion: auto-retries until dialog is visible
await expect(this.confirmationDialog).toBeVisible();
}
/** Wait for a loading transition to complete */
async waitForCalendarLoaded() {
// Wait for EITHER content OR empty state — both prove loading is done
const loaded = this.calendarGrid.or(this.page.getByText('Inga lediga tider'));
await expect(loaded).toBeVisible();
}
}Key patterns:
readonlylocator properties in constructor — single source of truth- Methods contain their own waiting (web-first assertions)
- Methods named as user actions:
selectSlot,confirmBooking,goToActivities - Throw descriptive errors when things go wrong
- Use
locatorA.or(locatorB)for loading state transitions
API Response Interception in POMs
For any POM method that triggers an API call, intercept the response. This is the most reliable CRUD pattern — it eliminates the gap between "API done" and "DOM updated."
async confirmBooking() {
await expect(this.confirmButton).toBeEnabled();
// 1. Listen for API response BEFORE clicking
const responsePromise = this.page.waitForResponse(
(r) => r.url().includes('/resource-bookings') && r.request().method() === 'POST',
);
// 2. Perform the action
await this.confirmButton.click();
// 3. Wait for and validate API response (source of truth)
const response = await responsePromise;
if (!response.ok()) {
const body = await response.json();
throw new Error(`Booking API failed (${response.status()}): ${body.error ?? body.title}`);
}
const body = await response.json();
if (body.paymentLinkUrl) {
throw new Error('Booking requires payment redirect — test user should have free access');
}
// 4. Only THEN assert on DOM (which should now be updated)
await expect(this.successTitle).toBeVisible();
}Why this pattern is critical:
- Without it: clicking "Confirm" and checking for success text might pass even if the API failed (stale UI)
- With it: you KNOW the API succeeded, and the error message tells you exactly what went wrong
- Use for: creating, updating, deleting — any mutation that hits the backend
When to use API interception vs. pure DOM assertions:
| Scenario | Approach |
|---|---|
| Form submit (create/update/delete) | API interception + DOM assertion |
| Page navigation | DOM assertion only (expect(heading).toBeVisible()) |
| View switching (tabs, calendar views) | DOM assertion (expect(grid.or(emptyState)).toBeVisible()) |
| Loading data on page load | API interception if data determines what to test next |
Registering in Fixtures
// fixtures/base.ts — for member-authenticated tests
import { test as base, expect } from '@playwright/test';
import { MemberPortalPage } from '../pages/member-portal.page';
import { BookingPage } from '../pages/booking.page';
export const test = base.extend<{
memberPortal: MemberPortalPage;
bookingPage: BookingPage;
}>({
// Fixture with setup: navigates + verifies auth automatically
memberPortal: async ({ page }, use) => {
const portal = new MemberPortalPage(page);
await portal.goto();
await portal.expectAuthenticated();
await use(portal); // Test runs here
// Teardown runs after (if needed)
},
// Simple fixture: just creates the POM
bookingPage: async ({ page }, use) => {
await use(new BookingPage(page));
},
});
export { expect };Why fixtures over `beforeEach`:
- On-demand: only created if the test requests the fixture
- Encapsulated: setup + teardown in one place
- Composable: fixtures can depend on each other
- Type-safe: full TypeScript inference
Admin Fixture
// fixtures/admin.ts — for tenant-admin tests
import { test as base, expect } from '@playwright/test';
export const test = base.extend<{
adminPage: {
goto: (path: string) => Promise<void>;
page: Page;
};
}>({
adminPage: async ({ page }, use) => {
const adminPage = {
page,
goto: async (path: string) => {
const fullPath = `/t/e2e-test${path.startsWith('/') ? path : '/' + path}`;
await page.goto(fullPath);
// Wait for the page content to load
await page.waitForLoadState('domcontentloaded');
},
};
await use(adminPage);
},
});
export { expect };Usage: Admin tests import from fixtures/admin.ts and use storageState: '.auth/admin.json' in the config project.
Using in Tests
// tests/booking/create-booking.spec.ts
import { test, expect } from '../../fixtures/base';
test.describe('Create a booking', () => {
test('member can book an available time slot', async ({ memberPortal, bookingPage }) => {
await memberPortal.goToBooking();
const slot = await bookingPage.findAvailableSlot();
await bookingPage.selectSlot(slot);
// Only test-specific assertions live in the test
const dialog = bookingPage.confirmationDialog;
await expect(dialog.locator('text=/\\d+ kr|Gratis/')).toBeVisible();
await bookingPage.confirmBooking();
});
});// tests/admin/blocks/resource-blocks.spec.ts
import { test, expect } from '../../../fixtures/admin';
test('admin can create a resource block', async ({ adminPage }) => {
await adminPage.goto('/resources');
// ... admin test logic
});Adding a New Feature's Tests
Step-by-step for adding E2E coverage to a new feature:
Step 1: Decide IF this needs an E2E test
Ask yourself:
- Is this a critical user journey? (booking, auth, payment)
- Does it cross service boundaries that integration tests can't cover?
- Has it broken in production before?
If not, write a component or API test instead.
Step 2: Add data-testid to React components
// In your React component
<Dialog data-testid="invoice-dialog" open={open}>
<Button data-testid="invoice-send-btn" onClick={handleSend}>
Skicka faktura
</Button>
</Dialog>Only add data-testid where getByRole or getByLabel won't work cleanly.
Step 3: Create a Page Object
// pages/invoice.page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class InvoicePage {
readonly page: Page;
readonly sendDialog: Locator;
readonly sendButton: Locator;
constructor(page: Page) {
this.page = page;
this.sendDialog = page.getByTestId('invoice-dialog');
this.sendButton = page.getByTestId('invoice-send-btn');
}
async sendInvoice() {
await expect(this.sendButton).toBeEnabled();
// Intercept API response for reliable assertion
const responsePromise = this.page.waitForResponse(
(r) => r.url().includes('/api/invoices') && r.request().method() === 'POST',
);
await this.sendButton.click();
const response = await responsePromise;
if (!response.ok()) {
const body = await response.json();
throw new Error(`Send invoice failed: ${body.error ?? body.title}`);
}
await expect(this.page.getByText(/faktura skickad/i)).toBeVisible();
}
}Step 4: Register in fixtures
// fixtures/base.ts — add to the existing extend call
import { InvoicePage } from '../pages/invoice.page';
export const test = base.extend<{
memberPortal: MemberPortalPage;
bookingPage: BookingPage;
invoicePage: InvoicePage; // Add here
}>({
// ... existing fixtures ...
invoicePage: async ({ page }, use) => {
await use(new InvoicePage(page));
},
});Step 5: Write the test
// tests/invoices/send-invoice.spec.ts
import { test, expect } from '../../fixtures/base';
test('admin can send an invoice', async ({ memberPortal, invoicePage }) => {
await memberPortal.goToInvoices();
await invoicePage.sendInvoice();
});Step 6: If needed, add seed data or per-test API setup
For shared baseline data — extend SeedE2eTest.cs:
var invoice = new Invoice { Id = Guid.Parse("..."), /* ... */ };
if (!await db.Invoices.AnyAsync(i => i.Id == invoice.Id))
db.Invoices.Add(invoice);For test-specific data — use API setup in the test:
test('cancel a pending invoice', async ({ request, memberPortal, invoicePage }) => {
// Create invoice via API (fast, isolated)
const res = await request.post('/api/invoices', {
data: { amount: 500, customerId: testCustomerId }
});
const { id } = await res.json();
// Test the cancellation UI
await memberPortal.goToInvoices();
await invoicePage.cancelInvoice(id);
});