Playwright E2E Automated Testing Integration Guide
Summarized from the OAuth2 Admin project's practices, as a reference for adopting Playwright E2E testing in other frontend projects.
Table of Contents
- Core Principles
- Project Setup
- Mock API Layer Design
- Test File Organization
- Test Writing Patterns
- Advanced Techniques
- CI/CD Integration
- FAQ
1. Core Principles
1.1 Why request interception instead of a real backend
| Approach | Pros | Cons |
|---|---|---|
| Request interception mocks | No backend dependency, fast execution (<5s), stable and non-flaky, full control over responses | Does not verify frontend-backend integration |
| Real backend | Verifies end-to-end integration | Requires database/cache/services, slow (minutes), many environment dependencies, hard data isolation |
| MSW (Mock Service Worker) | Intercepts at the browser layer, closer to real behavior | Complex configuration, requires Service Worker support |
This project uses Playwright's native page.route() request interception, for the following reasons:
- Zero extra dependencies (built into Playwright)
- Clean and intuitive API
- Interception happens before the network layer, delivering the best performance
- Supports precise URL pattern and HTTP method matching
- Supports overriding the global mocks within a single test
1.2 How request interception works
┌──────────────┐ HTTP request ┌── ────────────────┐
│ Frontend │ ───────────────────→ │ page.route() │
│ app code │ │ URL pattern │
│ (browser) │ ←─────────────────── │ route.fulfill() │
└──────────────┘ Mock response └──────────────────┘
↑ bypasses the
network layer entirely
(no real HTTP connection)
Playwright's page.route() intercepts requests at the browser's network layer, so requests never leave the browser process. This means:
- No backend service needs to be running
- No network connection required
- Responses are immediate, with zero latency
- Tests are fully deterministic, with no network flakiness
1.3 Three-layer architecture
tests/e2e/
├── helpers/
│ └── mock-api.ts ← Layer 1: mock data + interceptors
├── auth.spec.ts ← Layer 2: test cases
├── applications.spec.ts
└── ...
playwright.config.ts ← Layer 3: Playwright configuration
| Layer | Responsibility | Change frequency |
|---|---|---|
| Mock layer | Defines mock data constants + the setupAuthenticatedMocks() global interception function | When the backend API changes |
| Test layer | Contains the actual test cases and calls functions provided by the mock layer | When new features/pages are added |
| Config layer | Playwright runtime options, browsers, webServer | At project setup |
2. Project Setup
2.1 Install dependencies
npm install -D @playwright/test
npx playwright install chromium
Chromium alone is enough; there is no need to install WebKit/Firefox. The goal of E2E tests is to verify functional logic, not cross-browser compatibility.
2.2 Playwright configuration
Create playwright.config.ts:
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e', // test file directory
fullyParallel: true, // run fully in parallel (enable when tests have no interdependencies)
forbidOnly: !!process.env.CI, // forbid test.only on CI (prevents accidental commits)
retries: process.env.CI ? 2 : 0, // retry twice on CI (mitigates flakiness)
workers: process.env.CI ? 1 : undefined, // single worker on CI (avoids resource contention)
reporter: 'html', // HTML test report
use: {
baseURL: 'http://localhost:5174', // application base URL (used with page.goto())
trace: 'on-first-retry', // record a trace on failed retries (for debugging)
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev', // start the dev server automatically
url: 'http://localhost:5174/', // wait until this URL is reachable
reuseExistingServer: !process.env.CI, // reuse an already-running server locally
timeout: 30000, // 30s startup timeout
},
})
Key configuration notes:
| Option | Purpose | Recommended value |
|---|---|---|
baseURL | Prefix automatically prepended when calling page.goto('/path') | Dev server address |
webServer | Starts/reuses the dev server automatically | Distinct settings for dev mode vs CI mode |
trace | Generates a trace file on failure, inspectable with npx playwright show-trace | on-first-retry |
fullyParallel | Runs multiple test files in parallel | true (safe with mocking) |
retries | Number of retries on failure | CI: 2, local: 0 |
2.3 package.json scripts
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui"
}
}
2.4 Directory structure
your-project/
├── playwright.config.ts
├── package.json
├── src/ ← application source
└── tests/
└── e2e/
├── helpers/
│ └── mock-api.ts ← mock data + interception functions
├── auth.spec.ts ← authentication-related tests
├── page-a.spec.ts ← page A tests
└── page-b.spec.ts ← page B tests
3. Mock API Layer Design
The mock API layer is the core of the entire testing system. Design this layer well and writing test cases becomes straightforward.
3.1 File structure
tests/e2e/helpers/mock-api.ts consists of three parts:
Part 1: Mock data constants → fake data for all APIs
Part 2: setupAuthenticatedMocks() → registers global route interception
Part 3: Helper functions → common operations such as loginAsAdmin()
3.2 Mock data constants
Principle: keep the data as realistic as possible, with fields matching the backend API responses.
// ✅ Good design: field names and types match the real API
export const MOCK_USERS = [
{
id: '550e8400-e29b-41d4-a716-446655440000',
username: 'admin',
email_verified: true, // boolean, not a string
mfa_enabled: true,
},
{
id: '660e8400-e29b-41d4-a716-446655440001',
username: 'testuser',
email_verified: false,
mfa_enabled: false,
},
]
// ❌ Bad design: arbitrary field names, unrealistic data
export const users = [
{ uid: 1, name: 'a', mail: 'a@b' }, // field names don't match the real API
]
Why multiple data sets?
Prepare at least two states in the mock data to cover different UI presentations:
email_verified: true+false→ test the "verified" and "pending verification" badgesmfa_enabled: true+false→ test the "enabled" and "disabled" badges
3.3 Route interception: setupAuthenticatedMocks()
This is the most critical function; it intercepts every API request the frontend makes.
import { Page } from '@playwright/test'
export async function setupAuthenticatedMocks(page: Page) {
// Interception rule: ** is a wildcard that matches any origin
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ users: MOCK_USERS }),
})
})
}
URL matching patterns:
| Pattern | What it matches | Example |
|---|---|---|
**/api/users | Any origin + exact path match | http://localhost:5174/api/users ✅ |
**/api/admin/logs** | Path prefix match (including query parameters) | /api/admin/logs?page=2 ✅ |
**/api/admin/clients/* | Path + single-segment wildcard | /api/admin/clients/vue-client ✅ |
**/api/admin/clients/*/reset-secret | Multi-segment path combination | /api/admin/clients/vue-client/reset-secret ✅ |
Same URL, different HTTP methods:
await page.route('**/api/admin/clients', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, body: JSON.stringify({ clients: MOCK_CLIENTS }) })
} else if (route.request().method() === 'POST') {
await route.fulfill({ status: 201, body: JSON.stringify({ client_id: 'new-123' }) })
} else {
// Unexpected method: pass to the next handler or the real network
await route.continue()
}
})
Sub-resource route priority:
Playwright matches routes in registration order, so more specific routes should be registered first:
// ✅ Correct: register more specific routes first
await page.route('**/api/admin/clients/*/reset-secret', ...) // matched first
await page.route('**/api/admin/clients/*', ...) // matched later (fallback)
// In practice, Playwright's wildcard matching has an implicit priority,
// but explicitly checking inside the handler is safer:
await page.route('**/api/admin/clients/*', async (route) => {
const url = route.request().url()
if (url.includes('/scopes') || url.includes('/reset-secret')) {
await route.continue() // skip; let a more specific handler deal with it
return
}
// ... handle DELETE / GET / PUT
})
3.4 Helper function: loginAsAdmin()
export async function loginAsAdmin(page: Page) {
await page.goto('/login')
await page.fill('input[type="text"]', 'admin')
await page.fill('input[type="password"]', 'admin')
await page.click('button[type="submit"]')
await page.waitForURL('**/dashboard') // wait for the successful-login redirect
}
Design notes:
- Logs in through UI interactions (simulating a real user)
- Relies on
setupAuthenticatedMocks()having intercepted the authentication APIs waitForURLguarantees the login has completed, so subsequent tests run in an authenticated state
3.5 Mock template for adapting to other projects
// === helpers/mock-api.ts template ===
import { Page } from '@playwright/test'
// ---- Part 1: mock data ----
export const CURRENT_USER = {
id: '1',
name: 'Test User',
role: 'admin',
}
export const MOCK_ITEMS = [
{ id: '1', title: 'Item A', status: 'active' },
{ id: '2', title: 'Item B', status: 'inactive' },
]
// ---- Part 2: global route interception ----
export async function setupAuthenticatedMocks(page: Page) {
// Authentication
await page.route('**/auth/login', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ token: 'mock-jwt-token', user: CURRENT_USER }),
})
})
await page.route('**/auth/me', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(CURRENT_USER),
})
})
// Business data
await page.route('**/api/items', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: MOCK_ITEMS }),
})
} else if (route.request().method() === 'POST') {
const body = JSON.parse(route.request().postData() || '{}')
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ id: 'new-' + Date.now(), ...body }),
})
} else {
await route.continue()
}
})
// Single-item operations (GET / PUT / DELETE)
await page.route('**/api/items/*', async (route) => {
if (route.request().method() === 'DELETE') {
await route.fulfill({ status: 204 })
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_ITEMS[0]),
})
}
})
}
// ---- Part 3: helper functions ----
export async function loginAs(page: Page, username = 'admin', password = 'admin') {
await page.goto('/login')
await page.fill('[name="username"]', username)
await page.fill('[name="password"]', password)
await page.click('button[type="submit"]')
await page.waitForURL('**/dashboard')
}
4. Test File Organization
4.1 Naming conventions
tests/e2e/
├── helpers/
│ └── mock-api.ts ← fixed name, shared by all tests
├── auth.spec.ts ← authentication/login related
├── {page-name}.spec.ts ← one file per page
└── navigation.spec.ts ← global navigation/layout
One page = one spec file, organized by functional domain rather than by operation type.
4.2 test.describe grouping
test.describe('Page/Feature name', () => {
// beforeEach: shared setup
test.beforeEach(async ({ page }) => {
await setupAuthenticatedMocks(page)
await loginAsAdmin(page)
// navigate to the target page
await page.click('nav a:has-text("Target Page")')
await page.waitForURL('**/target-page')
})
// Test cases ordered: rendering → data → interaction → edge cases
test('displays page title', ...)
test('shows data from API', ...)
test('button click triggers action', ...)
test('shows error on failure', ...)
})
4.3 Test case naming
Use declarative sentences that describe expected behavior, not operation steps:
// ✅ Good: describes the expected outcome
test('displays users list with correct columns', ...)
test('shows error on login failure', ...)
test('delete button removes item from list', ...)
// ❌ Bad: describes operation steps
test('clicks button and checks result', ...)
test('test 1', ...)
5. Test Writing Patterns
5.1 Standard test flow (the beforeEach pattern)
The most common pattern — 90% of tests follow this flow:
test.describe('User Management', () => {
test.beforeEach(async ({ page }) => {
// Step 1: register global mocks
await setupAuthenticatedMocks(page)
// Step 2: simulate login
await loginAsAdmin(page)
// Step 3: navigate to the target page
await page.click('nav a:has-text("Users")')
await page.waitForURL('**/users')
})
test('displays user table', async ({ page }) => {
await expect(page.locator('h2')).toContainText('Users')
await expect(page.locator('th:has-text("Username")')).toBeVisible()
})
})
Flow diagram:
beforeEach:
setupAuthenticatedMocks(page)
↓
loginAsAdmin(page)
↓
navigate to the target page + waitForURL
↓
═══════════════════════════
↓ test case 1 runs ↓
↓ test case 2 runs ↓
↓ ... ↓
═══════════════════════════
5.2 Verifying page rendering
Verify that the page displays data correctly:
test('displays users list with correct columns', async ({ page }) => {
// Verify the title
await expect(page.locator('h2')).toContainText('Users')
// Verify the table headers
await expect(page.locator('th:has-text("Username")')).toBeVisible()
await expect(page.locator('th:has-text("Email")')).toBeVisible()
// Verify data rows (from the mock data)
const tableBody = page.locator('tbody')
await expect(tableBody.getByRole('cell', { name: 'admin', exact: true })).toBeVisible()
})
5.3 Form interaction tests
Verify form filling, submission, and responses:
test('creates a new application and shows secret', async ({ page }) => {
// 1. Open the dialog
await page.click('button:has-text("Create Application")')
// 2. Verify the dialog appears
await expect(page.locator('h3:has-text("Create Application")')).toBeVisible()
// 3. Fill in the form
await page.fill('input[placeholder="My App"]', 'Test Application')
await page.selectOption('select', 'CONFIDENTIAL')
// 4. Submit
await page.locator('.fixed button[type="submit"]').click()
// 5. Verify the result
await expect(page.locator('h3:has-text("Client Secret")')).toBeVisible()
await expect(page.locator('.font-mono.select-all')).toContainText('generated-secret-abc123xyz')
})
5.4 Modal/dialog tests
test('opens and closes role assignment modal', async ({ page }) => {
// Open
await page.click('button:has-text("Assign Roles")')
await expect(page.locator('h3:has-text("Assign Roles")')).toBeVisible()
// Close
await page.click('button:has-text("Cancel")')
await expect(page.locator('h3:has-text("Assign Roles")')).not.toBeVisible()
})
// Handling native confirm dialogs
test('delete with confirmation', async ({ page }) => {
page.on('dialog', (dialog) => dialog.accept()) // auto-accept the confirm
await page.click('button:has-text("Delete")')
await expect(page.locator('h2')).toContainText('Applications')
})
5.5 Pagination tests
test('pagination sends correct page parameter', async ({ page }) => {
// Build enough data to enable the "Next" button
const manyItems = Array.from({ length: 50 }, (_, i) => ({
id: i + 1, title: `Item ${i}`, status: 'active',
}))
let requestedPage = 1
await page.route('**/api/items**', async (route) => {
const url = new URL(route.request().url())
requestedPage = parseInt(url.searchParams.get('page') || '1')
await route.fulfill({
status: 200,
body: JSON.stringify({ items: requestedPage === 1 ? manyItems : MOCK_ITEMS }),
})
})
// Navigate away and back to trigger a reload
await page.click('nav a:has-text("Dashboard")')
await page.click('nav a:has-text("Items")')
await page.waitForURL('**/items')
// Click Next
const nextBtn = page.locator('button:has-text("Next")')
await expect(nextBtn).not.toBeDisabled()
await nextBtn.click()
await expect(page.locator('text=Page 2')).toBeVisible()
expect(requestedPage).toBe(2)
})
5.6 Navigation tests
test('sidebar navigation works for all pages', async ({ page }) => {
// Verify nav items are visible
await expect(page.locator('nav a:has-text("Dashboard")')).toBeVisible()
await expect(page.locator('nav a:has-text("Users")')).toBeVisible()
// Click and verify URL + page title
await page.click('nav a:has-text("Users")')
await expect(page).toHaveURL(/\/users/)
await expect(page.locator('h2')).toContainText('Users')
// Navigate back and verify
await page.click('nav a:has-text("Dashboard")')
await expect(page).toHaveURL(/\/dashboard/)
})
6. Advanced Techniques
6.1 Overriding global mocks (testing error scenarios)
This is the most powerful feature of this approach: override the global mocks within a single test, without modifying setupAuthenticatedMocks().
How it works: handlers registered later via page.route() are matched first. A later registration overrides an earlier one for the same URL pattern.
test.describe('Dashboard', () => {
test.beforeEach(async ({ page }) => {
await setupAuthenticatedMocks(page) // global mocks: health returns ok
await loginAsAdmin(page)
})
test('displays healthy status', async ({ page }) => {
await expect(page.locator('text=Healthy')).toBeVisible() // uses the global mock
})
test('shows unhealthy status when backend is down', async ({ page }) => {
// Key point: registered after the global mocks, overriding the global health response
await page.route('**/health/ready', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'error', message: 'Database unreachable' }),
})
})
await loginAsAdmin(page) // log in again to trigger the health check
await expect(page.locator('text=Unhealthy')).toBeVisible()
})
})
Common override scenarios:
// Scenario 1: API returns an error
await page.route('**/oauth2/login', async (route) => {
await route.fulfill({ status: 401, body: JSON.stringify({ error: 'invalid_credentials' }) })
})
// Scenario 2: API returns empty data
await page.route('**/api/admin/clients', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, body: JSON.stringify({ clients: [] }) })
} else {
await route.continue()
}
})
// Scenario 3: non-admin user
await page.route('**/oauth2/userinfo', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify({ sub: '123', username: 'user', roles: ['user'] }),
})
})
// Scenario 4: MFA required
await page.route('**/oauth2/login', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify({ mfa_required: true, mfa_token: 'mfa-token-123' }),
})
})
6.2 Capturing and verifying request bodies
Verify that the parameters the frontend sends are correct:
test('assigns roles with correct request body', async ({ page }) => {
let requestBody: any = null
// Override the global mock and capture the request body at the same time
await page.route('**/api/admin/users/*/roles', async (route) => {
requestBody = JSON.parse(route.request().postData() || '{}')
await route.fulfill({
status: 200,
body: JSON.stringify({ message: 'Roles updated' }),
})
})
// Perform the action
await page.locator('button:has-text("Assign Roles")').first().click()
await page.fill('input[placeholder="admin, user"]', 'admin, editor')
await page.click('button:has-text("Save Roles")')
// Verify the request body
expect(requestBody).toEqual({ roles: ['admin', 'editor'] })
})
6.3 Empty-state tests
Verify the UI presentation when the list is empty:
test('shows empty state when no items exist', async ({ page }) => {
// Override the mock to return an empty list
await page.route('**/api/items', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, body: JSON.stringify({ items: [] }) })
} else {
await route.continue()
}
})
// Navigate away and back to trigger a data reload
await page.click('nav a:has-text("Dashboard")')
await page.click('nav a:has-text("Items")')
await page.waitForURL('**/items')
// Verify the empty-state prompt
await expect(page.locator('text=No items yet')).toBeVisible()
await expect(page.locator('button:has-text("Create your first item")')).toBeVisible()
})
Why "navigate away and back"?
Because beforeEach has already navigated to the target page and the data is loaded, overriding the mock requires forcing the component to remount and re-fetch the data. Two approaches:
- Navigate to another page and back (recommended; simulates real user behavior)
await page.reload()(simple, but some components may not re-request)
6.4 Verifying request query parameters
test('filter sends correct parameters', async ({ page }) => {
let capturedUrl = ''
await page.route('**/api/items**', async (route) => {
capturedUrl = route.request().url()
await route.fulfill({ status: 200, body: JSON.stringify({ items: [] }) })
})
// Perform the filter operation
await page.selectOption('select[name="status"]', 'active')
await page.click('button:has-text("Filter")')
// Verify the URL parameters
const url = new URL(capturedUrl)
expect(url.searchParams.get('status')).toBe('active')
})
7. CI/CD Integration
7.1 GitHub Actions example
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
7.2 CI configuration notes
| Option | CI value | Local value | Reason |
|---|---|---|---|
workers | 1 | auto (multiple) | CI resources are limited; avoids contention |
retries | 2 | 0 | CI networks are unstable; retries mitigate flakiness |
reuseExistingServer | false | true | CI must start a fresh server |
forbidOnly | true | false | Prevents test.only from being committed |
8. FAQ
Q1: What if tests fail intermittently (flaky)?
- Check whether you use
page.waitForTimeout()— switch towaitForSelector/waitForURL/expect().toBeVisible() - Check the mocks for race conditions — make sure every API is intercepted and no request is left unmocked
- Enable
trace: 'on-first-retry'and analyze failures withnpx playwright show-trace
Q2: What if a request is not intercepted?
- Check that the URL pattern matches (the scope of the
**wildcard) - Open the browser DevTools and inspect the full URL of the actual request
- Add
console.log(route.request().url())inside the handler to debug
Q3: How do I test file uploads?
// Listen for the filechooser event
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.click('button:has-text("Upload")'), // triggers the file selection
])
await fileChooser.setFiles({
name: 'test.csv',
mimeType: 'text/csv',
buffer: Buffer.from('name,value\ntest,123'),
})
Q4: How do I run tests without auto-starting the dev server?
Edit playwright.config.ts, remove the webServer configuration, start the dev server manually, then run the tests:
# Terminal 1
npm run dev
# Terminal 2
npm run test:e2e
Q5: How do mock-mode and real-backend tests coexist?
tests/
├── e2e/
│ ├── helpers/
│ │ ├── mock-api.ts ← mock mode
│ │ └── api-client.ts ← real API calls
│ ├── auth.spec.ts ← mock tests
│ └── ...
└── integration/
└── full-flow.spec.ts ← real-backend integration tests
Use separate playwright.config.ts files:
playwright.config.ts— mock mode (daily development, every commit)playwright.integration.config.ts— real backend (pre-release, nightly)
9. Backend Integration Test Troubleshooting
Problem: the test script fails entirely on its second run
Symptoms:
POST http://localhost:5174/oauth2/login 401 (Unauthorized)
Backend logs:
WARN Account locked for user: admin until 1779441748
INFO [METRIC] oauth2_login_failures_total reason=bad_credentials
Cause: The OAuth2 system implements an account lockout mechanism. Once the number of failed logins reaches a threshold, the account is temporarily locked:
- 5 failures → 1-minute lockout
- 10 failures → 5-minute lockout
- 15 failures → 30-minute lockout
- 20+ failures → 1-hour lockout
Solutions:
Option 1: use a test script with automatic cleanup
The backend test scripts (for example test-admin-endpoints.ps1) already reset the account lockout state automatically when they finish.
For a local PostgreSQL database, you need to configure the database password:
# Edit the test script, find the cleanup section, and set the password
$env:PGPASSWORD = "your_password" # change to the actual password
Option 2: manually reset the account lockout
# Use the reset script
$env:PGPASSWORD = "your_password"
.\scripts\backend\reset-account-lockout.ps1
$env:PGPASSWORD = $null
# Or use SQL directly
psql -U fulla_user -d fulla_db -h localhost -c "UPDATE users SET failed_login_count = 0, locked_until = 0 WHERE username='admin';"
Option 3: wait for the lockout to expire
Depending on the number of failures, the account unlocks automatically after the corresponding time.
Preventive measures:
- Use a dedicated test account: never use the production admin account in tests
- Make sure credentials are correct: check the username and password in the test script
- Clean up automatically after testing: append cleanup code at the end of the test script
For details, see Account lockout mechanism.
Appendix A: From-scratch adoption checklist
Use this checklist when adopting E2E testing in a new project:
-
npm install -D @playwright/test -
npx playwright install chromium - Create
playwright.config.ts - Create
tests/e2e/helpers/mock-api.ts - Define mock data constants (matching the backend API response structure)
- Implement
setupAuthenticatedMocks(page)— intercept all authentication + business APIs - Implement
loginAsAdmin(page)— a UI login helper - Create the first test file (auth is a good starting point)
- Add
package.jsonscripts - Configure the CI pipeline
Appendix B: Admin frontend E2E test statistics
| Metric | Value |
|---|---|
| Test files | 16 |
| Test cases | 174 |
| Execution time | ~1 minute |
| Backend dependency | None (fully mocked) |
| Browser | Chromium |
| Parallel execution | Yes |
| Mocked API endpoints | 15+ |
This document summarizes practices from the fulla Admin frontend project. Project source:
frontends/admin/tests/e2e/