.agents/skills/playwright-best-practices/core/test-suite-structure.md
npm init playwright@latest
// playwright.config.ts
import {defineConfig, devices} from '@playwright/test'
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'], ['list']],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{name: 'setup', testMatch: /.*\.setup\.ts/},
{
name: 'chromium',
use: {...devices['Desktop Chrome']},
dependencies: ['setup'],
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
Full user journey tests through the browser.
// tests/e2e/checkout.spec.ts
import {test, expect} from '@playwright/test'
test.describe('Checkout Flow', () => {
test.beforeEach(async ({page}) => {
await page.goto('/products')
})
test('complete purchase as guest', async ({page}) => {
// Add to cart
await page.getByRole('button', {name: 'Add to Cart'}).first().click()
await expect(page.getByTestId('cart-count')).toHaveText('1')
// Go to checkout
await page.getByRole('link', {name: 'Cart'}).click()
await page.getByRole('button', {name: 'Checkout'}).click()
// Fill shipping
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Address').fill('123 Test St')
await page.getByRole('button', {name: 'Continue'}).click()
// Payment
await page.getByLabel('Card Number').fill('4242424242424242')
await page.getByRole('button', {name: 'Pay Now'}).click()
// Confirmation
await expect(page.getByRole('heading')).toHaveText('Order Confirmed')
})
test('apply discount code', async ({page}) => {
await page.getByRole('button', {name: 'Add to Cart'}).first().click()
await page.getByRole('link', {name: 'Cart'}).click()
await page.getByLabel('Discount Code').fill('SAVE10')
await page.getByRole('button', {name: 'Apply'}).click()
await expect(page.getByText('10% discount applied')).toBeVisible()
})
})
Test individual components in isolation using Playwright Component Testing.
npm init playwright@latest -- --ct
For comprehensive component testing patterns including mounting, props, events, slots, mocking, and framework-specific examples (React, Vue, Svelte), see component-testing.md.
Test backend APIs without browser.
For E2E tests that need to mock API responses:
// Mock single endpoint
test('displays mocked users', async ({page}) => {
await page.route('**/api/users', (route) =>
route.fulfill({
status: 200,
json: [{id: 1, name: 'Test User'}],
}),
)
await page.goto('/users')
await expect(page.getByText('Test User')).toBeVisible()
})
// Mock with different responses
test('handles API errors', async ({page}) => {
await page.route('**/api/users', (route) =>
route.fulfill({
status: 500,
json: {error: 'Server error'},
}),
)
await page.goto('/users')
await expect(page.getByText('Server error')).toBeVisible()
})
// Conditional mocking
test('mocks based on request', async ({page}) => {
await page.route('**/api/users', (route, request) => {
if (request.method() === 'GET') {
route.fulfill({json: [{id: 1, name: 'User'}]})
} else {
route.continue()
}
})
})
// Mock with delay (simulate slow network)
test('handles slow API', async ({page}) => {
await page.route('**/api/data', (route) =>
route.fulfill({
json: {data: 'test'},
delay: 2000, // 2 second delay
}),
)
await page.goto('/dashboard')
await expect(page.getByText('Loading...')).toBeVisible()
await expect(page.getByText('test')).toBeVisible()
})
For advanced patterns (GraphQL mocking, HAR recording, request modification, network throttling), see network-advanced.md.
Compare screenshots to detect visual changes.
// tests/visual/homepage.spec.ts
import {test, expect} from '@playwright/test'
test('homepage visual', async ({page}) => {
await page.goto('/')
await expect(page).toHaveScreenshot('homepage.png')
})
test('component visual', async ({page}) => {
await page.goto('/components')
const button = page.getByRole('button', {name: 'Primary'})
await expect(button).toHaveScreenshot('primary-button.png')
})
test('dashboard visual', async ({page}) => {
await page.goto('/dashboard')
await expect(page).toHaveScreenshot('dashboard.png', {
fullPage: true, // Capture entire scrollable page
maxDiffPixels: 100, // Allow up to 100 different pixels
maxDiffPixelRatio: 0.01, // Or 1% difference
threshold: 0.2, // Pixel comparison threshold
animations: 'disabled', // Disable animations
mask: [page.getByTestId('date')], // Mask dynamic content
})
})
test('page with dynamic content', async ({page}) => {
await page.goto('/profile')
// Mask elements that change
await expect(page).toHaveScreenshot('profile.png', {
mask: [page.getByTestId('timestamp'), page.getByTestId('avatar'), page.getByRole('img')],
})
})
// Or hide elements via CSS
test('page hiding dynamic elements', async ({page}) => {
await page.goto('/profile')
await page.addStyleTag({
content: `
.dynamic-content { visibility: hidden !important; }
[data-testid="ad-banner"] { display: none !important; }
`,
})
await expect(page).toHaveScreenshot('profile-stable.png')
})
// playwright.config.ts
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixels: 50,
animations: 'disabled',
},
},
projects: [
{
name: 'visual-chrome',
use: {
...devices['Desktop Chrome'],
viewport: {width: 1280, height: 720},
},
testMatch: /.*visual.*\.spec\.ts/,
},
],
})
# Update all snapshots
npx playwright test --update-snapshots
# Update specific test
npx playwright test homepage.spec.ts --update-snapshots
tests/
├── e2e/ # End-to-end tests
│ ├── auth.spec.ts
│ ├── checkout.spec.ts
│ └── dashboard.spec.ts
├── component/ # Component tests
│ ├── Button.spec.tsx
│ └── Modal.spec.tsx
├── api/ # API tests
│ ├── users.spec.ts
│ └── products.spec.ts
├── visual/ # Visual regression tests
│ └── homepage.spec.ts
├── fixtures/ # Custom fixtures
│ ├── auth.fixture.ts
│ └── api.fixture.ts
└── pages/ # Page objects
├── login.page.ts
└── dashboard.page.ts
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Long test files | Hard to maintain, slow to navigate | Split by feature, use POM |
| Tests depend on execution order | Flaky, hard to debug | Keep tests independent |
| Testing multiple features in one test | Hard to debug failures | One feature per test |
test('user login @smoke @auth', async ({page}) => {
// ...
})
test('checkout flow @e2e @critical', async ({page}) => {
// ...
})
test.describe('API tests @api', () => {
test('create user', async ({request}) => {
// ...
})
})
# Run smoke tests
npx playwright test --grep @smoke
# Run all except slow tests
npx playwright test --grep-invert @slow
# Combine tags
npx playwright test --grep "@smoke|@critical"
For project-based filtering and advanced project configuration, see projects-dependencies.md.