Fix: legit logins only
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
# Angular Testing with Playwright
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Configuration](#configuration)
|
||||
2. [Locator Strategies](#locator-strategies)
|
||||
3. [Reactive Forms](#reactive-forms)
|
||||
4. [Angular Material Components](#angular-material-components)
|
||||
5. [Router Navigation](#router-navigation)
|
||||
6. [Lazy-Loaded Modules](#lazy-loaded-modules)
|
||||
7. [Signals and Observables](#signals-and-observables)
|
||||
8. [Zone.js and Change Detection](#zonejs-and-change-detection)
|
||||
9. [SSR Testing](#ssr-testing)
|
||||
10. [Protractor Migration Reference](#protractor-migration-reference)
|
||||
11. [Build Configurations](#build-configurations)
|
||||
12. [CDK Overlay Container](#cdk-overlay-container)
|
||||
13. [Anti-Patterns](#anti-patterns)
|
||||
14. [Related](#related)
|
||||
|
||||
> **When to use**: Testing Angular applications with reactive forms, Angular Material components, Router navigation, lazy-loaded modules, signals, observables, and Zone.js change detection.
|
||||
> **Prerequisites**: [core/configuration.md](../core/configuration.md), [core/locators.md](../core/locators.md)
|
||||
|
||||
## Configuration
|
||||
|
||||
### Playwright Config
|
||||
|
||||
```typescript
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testMatch: '**/*.spec.ts',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? '50%' : undefined,
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:4200',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? 'npx ng build && npx http-server dist/my-app/browser -p 4200 -s'
|
||||
: 'npx ng serve',
|
||||
url: 'http://localhost:4200',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```text
|
||||
my-angular-app/
|
||||
src/
|
||||
e2e/
|
||||
tests/
|
||||
dashboard.spec.ts
|
||||
login.spec.ts
|
||||
fixtures/
|
||||
auth.fixture.ts
|
||||
playwright.config.ts
|
||||
angular.json
|
||||
```
|
||||
|
||||
### Package Scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"e2e": "playwright test",
|
||||
"e2e:headed": "playwright test --headed",
|
||||
"e2e:debug": "playwright test --debug",
|
||||
"e2e:report": "playwright show-report"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Locator Strategies
|
||||
|
||||
Angular generates internal attributes (`_ngcontent-*`, `_nghost-*`, `ng-reflect-*`) that change every build. Always use semantic locators.
|
||||
|
||||
```typescript
|
||||
test('use semantic locators for Angular apps', async ({ page }) => {
|
||||
await page.goto('/projects');
|
||||
|
||||
// Role-based locators work with Angular Material and native HTML
|
||||
await page.getByRole('button', { name: 'New project' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Create Project' })).toBeVisible();
|
||||
|
||||
// Label-based for form fields
|
||||
await page.getByLabel('Project title').fill('Alpha');
|
||||
|
||||
// Test IDs for complex components without semantic roles
|
||||
const chart = page.getByTestId('metrics-chart');
|
||||
await expect(chart).toBeVisible();
|
||||
|
||||
// Scope locators within component boundaries
|
||||
const projectTable = page.getByRole('table', { name: 'Projects' });
|
||||
const activeRow = projectTable.getByRole('row').filter({
|
||||
has: page.getByRole('cell', { name: 'Active' }),
|
||||
});
|
||||
await activeRow.getByRole('button', { name: 'Edit' }).click();
|
||||
});
|
||||
```
|
||||
|
||||
## Reactive Forms
|
||||
|
||||
Playwright interacts with the rendered DOM, so reactive forms (`FormGroup`, `FormControl`, `FormArray`) are transparent.
|
||||
|
||||
```typescript
|
||||
test.describe('form validation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/signup');
|
||||
});
|
||||
|
||||
test('displays validation errors on blur', async ({ page }) => {
|
||||
const emailField = page.getByLabel('Email');
|
||||
await emailField.click();
|
||||
await emailField.blur();
|
||||
await expect(page.getByText('Email is required')).toBeVisible();
|
||||
|
||||
await emailField.fill('invalid');
|
||||
await emailField.blur();
|
||||
await expect(page.getByText('Invalid email format')).toBeVisible();
|
||||
});
|
||||
|
||||
test('validates password confirmation', async ({ page }) => {
|
||||
await page.getByLabel('Password', { exact: true }).fill('Secret123!');
|
||||
await page.getByLabel('Confirm password').fill('Mismatch');
|
||||
await page.getByLabel('Confirm password').blur();
|
||||
|
||||
await expect(page.getByText('Passwords must match')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Confirm password').fill('Secret123!');
|
||||
await expect(page.getByText('Passwords must match')).toBeHidden();
|
||||
});
|
||||
|
||||
test('handles FormArray add/remove', async ({ page }) => {
|
||||
await page.goto('/contacts/edit');
|
||||
|
||||
await page.getByRole('button', { name: 'Add email' }).click();
|
||||
const emailInputs = page.getByLabel(/Email address/);
|
||||
await expect(emailInputs).toHaveCount(2);
|
||||
|
||||
await emailInputs.nth(1).fill('backup@example.com');
|
||||
await page.getByRole('button', { name: 'Remove email 1' }).click();
|
||||
|
||||
await expect(emailInputs).toHaveCount(1);
|
||||
await expect(emailInputs.first()).toHaveValue('backup@example.com');
|
||||
});
|
||||
|
||||
test('disables submit until form is valid', async ({ page }) => {
|
||||
const submitBtn = page.getByRole('button', { name: 'Register' });
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
await page.getByLabel('Name').fill('Alice');
|
||||
await page.getByLabel('Email').fill('alice@test.com');
|
||||
await page.getByLabel('Password', { exact: true }).fill('Secret123!');
|
||||
await page.getByLabel('Confirm password').fill('Secret123!');
|
||||
await page.getByLabel('Accept terms').check();
|
||||
|
||||
await expect(submitBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
test('shows async validator loading state', async ({ page }) => {
|
||||
await page.route('**/api/username-check*', async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
await route.fulfill({ json: { available: true } });
|
||||
});
|
||||
|
||||
await page.getByLabel('Username').fill('alice');
|
||||
await page.getByLabel('Username').blur();
|
||||
|
||||
await expect(page.getByTestId('username-loading')).toBeVisible();
|
||||
await expect(page.getByTestId('username-loading')).toBeHidden();
|
||||
await expect(page.getByText('Username available')).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Angular Material Components
|
||||
|
||||
Angular Material uses proper ARIA attributes. Use role-based locators instead of CSS classes like `.mat-mdc-button`.
|
||||
|
||||
```typescript
|
||||
test.describe('Material components', () => {
|
||||
test('mat-select dropdown', async ({ page }) => {
|
||||
await page.goto('/preferences');
|
||||
|
||||
await page.getByRole('combobox', { name: 'Language' }).click();
|
||||
await page.getByRole('option', { name: 'Spanish' }).click();
|
||||
|
||||
await expect(page.getByRole('combobox', { name: 'Language' })).toContainText('Spanish');
|
||||
});
|
||||
|
||||
test('mat-autocomplete suggestions', async ({ page }) => {
|
||||
await page.goto('/members/add');
|
||||
|
||||
const roleField = page.getByRole('combobox', { name: 'Role' });
|
||||
await roleField.fill('dev');
|
||||
|
||||
await expect(page.getByRole('option', { name: 'Developer' })).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: 'DevOps' })).toBeVisible();
|
||||
|
||||
await page.getByRole('option', { name: 'Developer' }).click();
|
||||
await expect(roleField).toHaveValue('Developer');
|
||||
});
|
||||
|
||||
test('mat-dialog interaction', async ({ page }) => {
|
||||
await page.goto('/items');
|
||||
|
||||
await page.getByRole('button', { name: 'Remove item' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText('Confirm deletion?')).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
});
|
||||
|
||||
test('mat-table sorting', async ({ page }) => {
|
||||
await page.goto('/members');
|
||||
|
||||
await page.getByRole('columnheader', { name: 'Name' }).click();
|
||||
const header = page.getByRole('columnheader', { name: 'Name' });
|
||||
await expect(header).toHaveAttribute('aria-sort', 'ascending');
|
||||
|
||||
await page.getByRole('columnheader', { name: 'Name' }).click();
|
||||
await expect(header).toHaveAttribute('aria-sort', 'descending');
|
||||
});
|
||||
|
||||
test('mat-paginator navigation', async ({ page }) => {
|
||||
await page.goto('/members');
|
||||
|
||||
await expect(page.getByText('1 - 10 of 100')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Next page' }).click();
|
||||
await expect(page.getByText('11 - 20 of 100')).toBeVisible();
|
||||
|
||||
await page.getByRole('combobox', { name: 'Items per page' }).click();
|
||||
await page.getByRole('option', { name: '50' }).click();
|
||||
await expect(page.getByText('1 - 50 of 100')).toBeVisible();
|
||||
});
|
||||
|
||||
test('mat-snack-bar notification', async ({ page }) => {
|
||||
await page.goto('/preferences');
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(page.getByText('Changes saved')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(page.getByText('Changes saved')).toBeHidden();
|
||||
});
|
||||
|
||||
test('mat-stepper wizard', async ({ page }) => {
|
||||
await page.goto('/wizard');
|
||||
|
||||
await expect(page.getByText('Step 1 of 3')).toBeVisible();
|
||||
await page.getByLabel('Name').fill('Bob');
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
|
||||
await expect(page.getByText('Step 2 of 3')).toBeVisible();
|
||||
await page.getByLabel('Organization').fill('Acme');
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
|
||||
await expect(page.getByText('Step 3 of 3')).toBeVisible();
|
||||
await expect(page.getByText('Bob')).toBeVisible();
|
||||
await expect(page.getByText('Acme')).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Router Navigation
|
||||
|
||||
```typescript
|
||||
test.describe('Angular Router', () => {
|
||||
test('lazy-loaded module loads on navigation', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByRole('link', { name: 'Reports' }).click();
|
||||
await page.waitForURL('/reports');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Reports Dashboard' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('route guard redirects unauthorized users', async ({ page }) => {
|
||||
await page.goto('/admin/settings');
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('resolver prefetches data', async ({ page }) => {
|
||||
const resolverPromise = page.waitForResponse('**/api/items/*');
|
||||
await page.goto('/items/42');
|
||||
await resolverPromise;
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('Item');
|
||||
});
|
||||
|
||||
test('nested router-outlet renders children', async ({ page }) => {
|
||||
await page.goto('/account/profile');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Profile', level: 2 })).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Security' }).click();
|
||||
await page.waitForURL('/account/security');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();
|
||||
});
|
||||
|
||||
test('query parameters drive filters', async ({ page }) => {
|
||||
await page.goto('/products?type=hardware&page=3');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Hardware' })).toBeVisible();
|
||||
await expect(page.getByText('Page 3')).toBeVisible();
|
||||
});
|
||||
|
||||
test('browser back navigates history', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('link', { name: 'Products' }).click();
|
||||
await page.waitForURL('/products');
|
||||
await page.getByRole('link', { name: 'About' }).click();
|
||||
await page.waitForURL('/about');
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/products/);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Lazy-Loaded Modules
|
||||
|
||||
```typescript
|
||||
test('lazy module loads without chunk errors', async ({ page }) => {
|
||||
const consoleErrors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const chunkRequest = page.waitForResponse((r) =>
|
||||
r.url().includes('.js') && r.status() === 200
|
||||
);
|
||||
await page.getByRole('link', { name: 'Analytics' }).click();
|
||||
await chunkRequest;
|
||||
|
||||
await page.waitForURL('/analytics');
|
||||
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();
|
||||
|
||||
const chunkErrors = consoleErrors.filter(
|
||||
(e) => e.includes('ChunkLoadError') || e.includes('Loading chunk')
|
||||
);
|
||||
expect(chunkErrors).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
## Signals and Observables
|
||||
|
||||
Playwright cannot subscribe to observables or read signals directly. Test through the rendered output.
|
||||
|
||||
```typescript
|
||||
test.describe('signals through UI', () => {
|
||||
test('signal-based counter updates DOM', async ({ page }) => {
|
||||
await page.goto('/counter');
|
||||
|
||||
await expect(page.getByTestId('value')).toHaveText('0');
|
||||
|
||||
await page.getByRole('button', { name: 'Increment' }).click();
|
||||
await expect(page.getByTestId('value')).toHaveText('1');
|
||||
|
||||
await page.getByRole('button', { name: 'Reset' }).click();
|
||||
await expect(page.getByTestId('value')).toHaveText('0');
|
||||
});
|
||||
|
||||
test('computed signal updates derived values', async ({ page }) => {
|
||||
await page.goto('/cart');
|
||||
await expect(page.getByTestId('total')).toHaveText('$0.00');
|
||||
|
||||
await page.goto('/catalog');
|
||||
await page.getByRole('listitem')
|
||||
.filter({ hasText: '$19.99' })
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
|
||||
await page.getByRole('link', { name: 'Cart' }).click();
|
||||
await expect(page.getByTestId('total')).toHaveText('$19.99');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('observables through UI', () => {
|
||||
test('debounced search batches API calls', async ({ page }) => {
|
||||
await page.goto('/search');
|
||||
|
||||
const apiCalls: string[] = [];
|
||||
await page.route('**/api/search*', async (route) => {
|
||||
apiCalls.push(route.request().url());
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.getByRole('textbox', { name: 'Search' }).pressSequentially('playwright', {
|
||||
delay: 50,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('listitem')).toHaveCount(5);
|
||||
expect(apiCalls.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('switchMap cancels stale requests', async ({ page }) => {
|
||||
await page.goto('/search');
|
||||
|
||||
await page.getByRole('textbox', { name: 'Search' }).fill('initial');
|
||||
await page.getByRole('textbox', { name: 'Search' }).fill('final');
|
||||
|
||||
await expect(page.getByRole('listitem').first()).toContainText(/final/i);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Zone.js and Change Detection
|
||||
|
||||
Angular uses Zone.js for change detection. Playwright does not depend on Zone.js and interacts with the DOM directly.
|
||||
|
||||
- **Change detection timing**: After interactions, Angular schedules change detection via Zone.js. Playwright's auto-waiting handles this.
|
||||
- **Zoneless Angular**: Angular 17+ supports zoneless change detection. Tests work identically since Playwright waits for DOM changes.
|
||||
- **Long-running async**: `setInterval` or long-running observables keep Angular "not stable." This does not affect Playwright (unlike Protractor).
|
||||
|
||||
## SSR Testing
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts for SSR
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? 'npx ng build --ssr && node dist/my-app/server/server.mjs'
|
||||
: 'npx ng serve --ssr',
|
||||
url: 'http://localhost:4200',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
},
|
||||
```
|
||||
|
||||
```typescript
|
||||
test('no hydration errors', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error' && msg.text().includes('hydration')) {
|
||||
errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Get started' }).click();
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
## Protractor Migration Reference
|
||||
|
||||
| Protractor | Playwright |
|
||||
|---|---|
|
||||
| `element(by.css('.btn'))` | `page.getByRole('button', { name: '...' })` |
|
||||
| `element(by.id('login'))` | `page.getByTestId('login')` |
|
||||
| `element(by.buttonText('Submit'))` | `page.getByRole('button', { name: 'Submit' })` |
|
||||
| `element(by.model('user.name'))` | `page.getByLabel('Name')` |
|
||||
| `element(by.binding('user.name'))` | `page.getByText(expectedValue)` |
|
||||
| `element(by.repeater('item in items'))` | `page.getByRole('listitem')` |
|
||||
| `browser.waitForAngular()` | Not needed — Playwright auto-waits |
|
||||
| `browser.sleep(3000)` | `await expect(locator).toBeVisible()` |
|
||||
| `browser.get('/path')` | `await page.goto('/path')` |
|
||||
| `protractor.ExpectedConditions` | `await expect(locator).toBeVisible()` |
|
||||
|
||||
## Build Configurations
|
||||
|
||||
| Scenario | Command | Notes |
|
||||
|---|---|---|
|
||||
| Local dev | `npx ng serve` | Fast rebuild, source maps |
|
||||
| CI production | `npx ng build && npx http-server dist/app/browser -p 4200 -s` | Tests production bundle |
|
||||
| CI SSR | `npx ng build --ssr && node dist/app/server/server.mjs` | Tests server-side rendering |
|
||||
| Staging | No `webServer` | Point `baseURL` to staging URL |
|
||||
|
||||
The `-s` flag on `http-server` enables SPA fallback for Angular Router.
|
||||
|
||||
## CDK Overlay Container
|
||||
|
||||
Angular Material and CDK render overlays (dialogs, menus, selects) in a special container outside the component tree. Playwright sees these as regular DOM elements:
|
||||
|
||||
```typescript
|
||||
const dialog = page.getByRole('dialog');
|
||||
const menu = page.getByRole('menu');
|
||||
const listbox = page.getByRole('listbox');
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
|---|---|---|
|
||||
| `page.locator('[_ngcontent-xyz]')` | Scoped style attributes change every build | Use `getByRole`, `getByLabel`, `getByTestId` |
|
||||
| `page.locator('[ng-reflect-model]')` | Only exists in dev mode | Test rendered value: `expect(input).toHaveValue()` |
|
||||
| `page.locator('app-my-component')` | Component selectors are implementation details | Target rendered content with semantic locators |
|
||||
| `page.locator('.mat-mdc-button')` | Material classes change between versions | `page.getByRole('button', { name: '...' })` |
|
||||
| `page.evaluate(() => window.ng)` | Not available in production builds | Test through the DOM |
|
||||
| `await page.waitForTimeout(500)` | Zone.js timing varies | Use auto-retrying assertions |
|
||||
| `browser.waitForAngular()` | Does not exist in Playwright | Remove entirely |
|
||||
| `ng serve` in CI | Slower, includes debug code | Use `ng build && http-server` |
|
||||
|
||||
## Related
|
||||
|
||||
- [core/locators.md](../core/locators.md) — locator strategies for Angular Material
|
||||
- [core/assertions-waiting.md](../core/assertions-waiting.md) — auto-waiting assertions
|
||||
- [core/forms-validation.md](../testing-patterns/forms-validation.md) — form testing patterns
|
||||
- [architecture/test-architecture.md](../architecture/test-architecture.md) — E2E vs unit tests with TestBed
|
||||
@@ -0,0 +1,469 @@
|
||||
# Next.js Testing Patterns
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Setup](#setup)
|
||||
2. [App Router Patterns](#app-router-patterns)
|
||||
3. [Pages Router Patterns](#pages-router-patterns)
|
||||
4. [Dynamic Routes](#dynamic-routes)
|
||||
5. [API Routes](#api-routes)
|
||||
6. [Middleware Testing](#middleware-testing)
|
||||
7. [Hydration Testing](#hydration-testing)
|
||||
8. [next/image Testing](#nextimage-testing)
|
||||
9. [NextAuth.js Authentication](#nextauthjs-authentication)
|
||||
10. [Tips](#tips)
|
||||
11. [Anti-Patterns](#anti-patterns)
|
||||
12. [Related](#related)
|
||||
|
||||
> **When to use**: Testing Next.js applications with App Router, Pages Router, API routes, middleware, SSR, dynamic routes, and server components.
|
||||
> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)
|
||||
|
||||
## Setup
|
||||
|
||||
### Configuration with webServer
|
||||
|
||||
```typescript
|
||||
// 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 ? '50%' : undefined,
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? 'npm run build && npm run start'
|
||||
: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
NODE_ENV: process.env.CI ? 'production' : 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Next.js loads `.env.test` when `NODE_ENV=test`:
|
||||
|
||||
```bash
|
||||
# .env.test (commit this)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||||
DATABASE_URL=postgresql://localhost:5432/test_db
|
||||
|
||||
# .env.test.local (gitignored)
|
||||
NEXTAUTH_SECRET=test-secret-local
|
||||
```
|
||||
|
||||
## App Router Patterns
|
||||
|
||||
### Server Component Content
|
||||
|
||||
```typescript
|
||||
test('renders server component content', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Welcome', level: 1 })).toBeVisible();
|
||||
await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Loading States with Streaming
|
||||
|
||||
```typescript
|
||||
test('loading state during data streaming', async ({ page }) => {
|
||||
await page.route('**/api/stats', async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page.getByRole('progressbar')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByRole('progressbar')).toBeHidden();
|
||||
});
|
||||
```
|
||||
|
||||
### Nested Layouts
|
||||
|
||||
```typescript
|
||||
test('layouts persist across navigation', async ({ page }) => {
|
||||
await page.goto('/dashboard/analytics');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Dashboard' });
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('link', { name: 'Settings' }).click();
|
||||
await page.waitForURL('/dashboard/settings');
|
||||
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Pages Router Patterns
|
||||
|
||||
### SSR with getServerSideProps
|
||||
|
||||
```typescript
|
||||
test('page with getServerSideProps renders data', async ({ page }) => {
|
||||
await page.goto('/blog');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Blog', level: 1 })).toBeVisible();
|
||||
await expect(page.getByRole('article')).toHaveCount(10);
|
||||
await expect(page.getByRole('article').first()).toContainText(/\w+/);
|
||||
});
|
||||
```
|
||||
|
||||
### Static Generation with getStaticProps
|
||||
|
||||
```typescript
|
||||
test('static page shows pre-rendered content', async ({ page }) => {
|
||||
await page.goto('/about');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
|
||||
await expect(page.getByText('Founded in 2020')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Dynamic Routes
|
||||
|
||||
### Slug Parameters
|
||||
|
||||
```typescript
|
||||
test('dynamic [slug] renders correct content', async ({ page }) => {
|
||||
await page.goto('/blog/testing-guide');
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('Testing Guide');
|
||||
await expect(page.getByText('Page not found')).toBeHidden();
|
||||
});
|
||||
|
||||
test('non-existent slug shows 404', async ({ page }) => {
|
||||
const response = await page.goto('/blog/nonexistent-post');
|
||||
|
||||
expect(response?.status()).toBe(404);
|
||||
await expect(page.getByRole('heading', { name: '404' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Catch-All Routes
|
||||
|
||||
```typescript
|
||||
test('catch-all handles nested paths', async ({ page }) => {
|
||||
await page.goto('/docs/getting-started/installation');
|
||||
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
||||
|
||||
await page.goto('/docs/api/configuration');
|
||||
await expect(page.getByRole('heading', { name: 'Configuration' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
|
||||
```typescript
|
||||
test('query parameters filter content', async ({ page }) => {
|
||||
await page.goto('/products?category=electronics&sort=price-asc');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();
|
||||
|
||||
const prices = await page.getByTestId('product-price').allTextContents();
|
||||
const numericPrices = prices.map((p) => parseFloat(p.replace('$', '')));
|
||||
expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b));
|
||||
});
|
||||
```
|
||||
|
||||
## API Routes
|
||||
|
||||
### Direct API Testing
|
||||
|
||||
```typescript
|
||||
test('GET /api/products returns list', async ({ request }) => {
|
||||
const response = await request.get('/api/products');
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
expect(body.products).toBeInstanceOf(Array);
|
||||
expect(body.products[0]).toHaveProperty('id');
|
||||
expect(body.products[0]).toHaveProperty('name');
|
||||
});
|
||||
|
||||
test('POST /api/products creates item', async ({ request }) => {
|
||||
const response = await request.post('/api/products', {
|
||||
data: { name: 'Test Product', price: 29.99 },
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(201);
|
||||
const body = await response.json();
|
||||
expect(body.product.name).toBe('Test Product');
|
||||
});
|
||||
|
||||
test('POST /api/products validates fields', async ({ request }) => {
|
||||
const response = await request.post('/api/products', {
|
||||
data: { name: '' },
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.error).toContainEqual(expect.objectContaining({ field: 'price' }));
|
||||
});
|
||||
```
|
||||
|
||||
### API Through UI
|
||||
|
||||
```typescript
|
||||
test('form submission calls API', async ({ page }) => {
|
||||
await page.goto('/products/new');
|
||||
|
||||
await page.getByLabel('Product name').fill('Widget');
|
||||
await page.getByLabel('Price').fill('19.99');
|
||||
await page.getByRole('button', { name: 'Create product' }).click();
|
||||
|
||||
await expect(page.getByText('Product created successfully')).toBeVisible();
|
||||
await page.waitForURL('/products/**');
|
||||
});
|
||||
```
|
||||
|
||||
## Middleware Testing
|
||||
|
||||
### Auth Redirects
|
||||
|
||||
```typescript
|
||||
test('unauthenticated user redirected to login', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
|
||||
expect(page.url()).toContain('/login');
|
||||
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('redirect preserves return URL', async ({ page }) => {
|
||||
await page.goto('/dashboard/settings');
|
||||
|
||||
const url = new URL(page.url());
|
||||
expect(url.pathname).toBe('/login');
|
||||
expect(url.searchParams.get('callbackUrl') || url.searchParams.get('returnTo'))
|
||||
.toContain('/dashboard/settings');
|
||||
});
|
||||
```
|
||||
|
||||
### Security Headers
|
||||
|
||||
```typescript
|
||||
test('middleware sets security headers', async ({ page }) => {
|
||||
const response = await page.goto('/');
|
||||
|
||||
const headers = response!.headers();
|
||||
expect(headers['x-frame-options']).toBe('DENY');
|
||||
expect(headers['x-content-type-options']).toBe('nosniff');
|
||||
});
|
||||
```
|
||||
|
||||
### Locale Rewrites
|
||||
|
||||
```typescript
|
||||
test('middleware rewrites based on locale', async ({ page, context }) => {
|
||||
await context.setExtraHTTPHeaders({
|
||||
'Accept-Language': 'fr-FR,fr;q=0.9',
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Bienvenue')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Hydration Testing
|
||||
|
||||
### Console Error Detection
|
||||
|
||||
```typescript
|
||||
test('no hydration errors in console', async ({ page }) => {
|
||||
const consoleErrors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Get started' }).click();
|
||||
|
||||
const hydrationErrors = consoleErrors.filter(
|
||||
(e) =>
|
||||
e.includes('Hydration') ||
|
||||
e.includes('hydration') ||
|
||||
e.includes('did not match')
|
||||
);
|
||||
expect(hydrationErrors).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
### Interactive Elements After Hydration
|
||||
|
||||
```typescript
|
||||
test('interactive elements work after hydration', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
const counter = page.getByTestId('counter-value');
|
||||
await expect(counter).toHaveText('0');
|
||||
|
||||
await page.getByRole('button', { name: 'Increment' }).click();
|
||||
await expect(counter).toHaveText('1');
|
||||
});
|
||||
```
|
||||
|
||||
## next/image Testing
|
||||
|
||||
```typescript
|
||||
test('hero image loads with srcset', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
const heroImage = page.getByRole('img', { name: 'Hero banner' });
|
||||
await expect(heroImage).toBeVisible();
|
||||
|
||||
const srcset = await heroImage.getAttribute('srcset');
|
||||
expect(srcset).toBeTruthy();
|
||||
expect(srcset).toContain('w=');
|
||||
|
||||
const loading = await heroImage.getAttribute('loading');
|
||||
expect(loading).not.toBe('lazy');
|
||||
});
|
||||
|
||||
test('offscreen images lazy load', async ({ page }) => {
|
||||
await page.goto('/gallery');
|
||||
|
||||
const offscreenImage = page.getByRole('img', { name: 'Gallery item 20' });
|
||||
|
||||
await offscreenImage.scrollIntoViewIfNeeded();
|
||||
await expect(offscreenImage).toBeVisible();
|
||||
|
||||
const naturalWidth = await offscreenImage.evaluate(
|
||||
(img: HTMLImageElement) => img.naturalWidth
|
||||
);
|
||||
expect(naturalWidth).toBeGreaterThan(0);
|
||||
});
|
||||
```
|
||||
|
||||
## NextAuth.js Authentication
|
||||
|
||||
### Setup Project
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
|
||||
{
|
||||
name: 'authenticated',
|
||||
use: { storageState: 'playwright/.auth/user.json' },
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{ name: 'unauthenticated', testMatch: '**/*.unauth.spec.ts' },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Auth Setup
|
||||
|
||||
```typescript
|
||||
// tests/auth.setup.ts
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
|
||||
const authFile = 'playwright/.auth/user.json';
|
||||
|
||||
setup('authenticate via credentials', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill('test@example.com');
|
||||
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
|
||||
await page.waitForURL('/dashboard');
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
```
|
||||
|
||||
### Authenticated Tests
|
||||
|
||||
```typescript
|
||||
test('authenticated user sees dashboard', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByText('test@example.com')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
### Dev Server vs Production Build
|
||||
|
||||
| Scenario | Command | Trade-off |
|
||||
|---|---|---|
|
||||
| Local development | `npm run dev` | Fast iteration, no production behavior |
|
||||
| CI pipeline | `npm run build && npm run start` | Tests real production bundle |
|
||||
|
||||
### Turbopack
|
||||
|
||||
```typescript
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? 'npm run build && npm run start'
|
||||
: 'npx next dev --turbopack',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
```
|
||||
|
||||
### Multiple webServer Entries
|
||||
|
||||
```typescript
|
||||
webServer: [
|
||||
{
|
||||
command: 'npm run dev:api',
|
||||
url: 'http://localhost:4000/health',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
{
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't Do This | Problem | Do This Instead |
|
||||
|---|---|---|
|
||||
| `await page.waitForTimeout(3000)` | Arbitrary waits are fragile | `await page.waitForURL('/path')` or `await expect(locator).toBeVisible()` |
|
||||
| Test `getServerSideProps` directly | Depends on req/res context | Navigate to page and verify rendered output |
|
||||
| Mock your own API routes | Hides real API bugs | Let real API handle requests; mock only external services |
|
||||
| `page.goto('http://localhost:3000/path')` | Breaks when port changes | Use `page.goto('/path')` with `baseURL` |
|
||||
| Run `npm run build` locally for every test | Extremely slow | Use `npm run dev` locally with `reuseExistingServer: true` |
|
||||
| Test `next/image` by checking exact URLs | Paths change between dev/prod | Assert on `alt`, visibility, `naturalWidth > 0`, `srcset` |
|
||||
| Test server actions by calling as functions | Server actions need Next.js runtime | Trigger through UI (forms, buttons) |
|
||||
|
||||
## Related
|
||||
|
||||
- [configuration.md](../core/configuration.md) -- Playwright configuration including `webServer`
|
||||
- [authentication.md](../advanced/authentication.md) -- authentication setup and `storageState`
|
||||
- [api-testing.md](../testing-patterns/api-testing.md) -- testing API routes with `request` context
|
||||
- [react.md](react.md) -- React patterns for Next.js client components
|
||||
@@ -0,0 +1,531 @@
|
||||
# React Application Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Patterns](#patterns)
|
||||
2. [Setup](#setup)
|
||||
3. [Framework Tips](#framework-tips)
|
||||
4. [Anti-Patterns](#anti-patterns)
|
||||
5. [Related](#related)
|
||||
|
||||
> **When to use**: Testing React apps built with Vite, Create React App, or custom bundlers. Covers E2E testing, component testing, React Router navigation, form libraries, portals, error boundaries, and context/state verification.
|
||||
> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)
|
||||
|
||||
## Patterns
|
||||
|
||||
### Testing Context and Global State
|
||||
|
||||
**Use when**: Verifying React context (theme, auth, locale) and state management (Redux, Zustand) produce correct UI changes.
|
||||
**Avoid when**: You want to assert on raw state objects—test the UI, not internal state.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('theme switching', () => {
|
||||
test('toggle applies dark mode across pages', async ({ page }) => {
|
||||
await page.goto('/preferences');
|
||||
|
||||
const root = page.locator('html');
|
||||
await expect(root).not.toHaveClass(/dark-mode/);
|
||||
|
||||
await page.getByRole('switch', { name: 'Enable dark theme' }).click();
|
||||
await expect(root).toHaveClass(/dark-mode/);
|
||||
|
||||
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||
await expect(page.locator('html')).toHaveClass(/dark-mode/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('cart state persistence', () => {
|
||||
test('item count updates globally', async ({ page }) => {
|
||||
await page.goto('/catalog');
|
||||
|
||||
const badge = page.getByTestId('cart-badge');
|
||||
|
||||
await page.getByRole('listitem')
|
||||
.filter({ hasText: 'Wireless Headphones' })
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
await expect(badge).toHaveText('1');
|
||||
|
||||
await page.getByRole('link', { name: 'Contact' }).click();
|
||||
await expect(badge).toHaveText('1');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('auth state', () => {
|
||||
test('login updates header across components', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Login' })).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Login' }).click();
|
||||
await page.getByLabel('Username').fill('testuser');
|
||||
await page.getByLabel('Password').fill('secret123');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Login' })).toBeHidden();
|
||||
await expect(page.getByText('testuser')).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### React Router Navigation
|
||||
|
||||
**Use when**: Testing client-side routing with React Router v6+—route transitions, URL parameters, protected routes, browser history.
|
||||
**Avoid when**: Server-side routing (Next.js App Router—see [nextjs.md](nextjs.md)).
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('client routing', () => {
|
||||
test('navigation preserves SPA state', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).__spaMarker = 'active';
|
||||
});
|
||||
|
||||
await page.getByRole('link', { name: 'Inventory' }).click();
|
||||
await page.waitForURL('/inventory');
|
||||
|
||||
const marker = await page.evaluate(() => (window as any).__spaMarker);
|
||||
expect(marker).toBe('active');
|
||||
});
|
||||
|
||||
test('query params filter content', async ({ page }) => {
|
||||
await page.goto('/items?type=books');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Books' })).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Music' }).click();
|
||||
await page.waitForURL('/items?type=music');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Music' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('nested routes render layouts', async ({ page }) => {
|
||||
await page.goto('/account/security');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Privacy' }).click();
|
||||
await page.waitForURL('/account/privacy');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Privacy', level: 2 })).toBeVisible();
|
||||
});
|
||||
|
||||
test('history navigation works', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('link', { name: 'Inventory' }).click();
|
||||
await page.waitForURL('/inventory');
|
||||
await page.getByRole('link', { name: 'Help' }).click();
|
||||
await page.waitForURL('/help');
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/inventory/);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
});
|
||||
|
||||
test('protected route redirects', async ({ page }) => {
|
||||
await page.goto('/admin/users');
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test('unknown route shows 404', async ({ page }) => {
|
||||
await page.goto('/nonexistent-path');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Hooks Through UI
|
||||
|
||||
**Use when**: Verifying custom hooks produce correct UI behavior—Playwright cannot call hooks directly.
|
||||
**Avoid when**: Hook logic is pure computation—use unit tests instead.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('useDebounce via SearchBox', () => {
|
||||
test('batches rapid input', async ({ page }) => {
|
||||
await page.goto('/search');
|
||||
|
||||
const apiCalls: string[] = [];
|
||||
await page.route('**/api/query*', async (route) => {
|
||||
apiCalls.push(route.request().url());
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.getByRole('textbox', { name: 'Search' }).pressSequentially('testing', {
|
||||
delay: 40,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('listitem')).toHaveCount(3);
|
||||
expect(apiCalls.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('usePagination via DataGrid', () => {
|
||||
test('page controls work', async ({ page }) => {
|
||||
await page.goto('/records');
|
||||
|
||||
await expect(page.getByText('Page 1 of 10')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
await expect(page.getByText('Page 2 of 10')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Previous' }).click();
|
||||
await expect(page.getByText('Page 1 of 10')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Previous' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Form Libraries (React Hook Form, Formik)
|
||||
|
||||
**Use when**: Testing forms built with react-hook-form or Formik—Playwright interacts with DOM, form library is transparent.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('signup form', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/signup');
|
||||
});
|
||||
|
||||
test('validation on empty submit', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
|
||||
await expect(page.getByText('Email required')).toBeVisible();
|
||||
await expect(page.getByText('Password required')).toBeVisible();
|
||||
});
|
||||
|
||||
test('inline validation on blur', async ({ page }) => {
|
||||
const email = page.getByLabel('Email');
|
||||
await email.fill('invalid');
|
||||
await email.blur();
|
||||
|
||||
await expect(page.getByText('Invalid email format')).toBeVisible();
|
||||
});
|
||||
|
||||
test('password strength indicator', async ({ page }) => {
|
||||
const pwd = page.getByLabel('Password', { exact: true });
|
||||
|
||||
await pwd.fill('weak');
|
||||
await expect(page.getByText('Minimum 8 characters')).toHaveClass(/invalid/);
|
||||
|
||||
await pwd.fill('StrongPass1!');
|
||||
await expect(page.getByText('Minimum 8 characters')).toHaveClass(/valid/);
|
||||
});
|
||||
|
||||
test('successful submission redirects', async ({ page }) => {
|
||||
await page.getByLabel('Name').fill('Alice');
|
||||
await page.getByLabel('Email').fill('alice@test.com');
|
||||
await page.getByLabel('Password', { exact: true }).fill('Secure123!');
|
||||
await page.getByLabel('Confirm').fill('Secure123!');
|
||||
await page.getByLabel('Accept terms').check();
|
||||
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
|
||||
await page.waitForURL('/welcome');
|
||||
await expect(page.getByText('Hello, Alice')).toBeVisible();
|
||||
});
|
||||
|
||||
test('submit button disabled during request', async ({ page }) => {
|
||||
await page.route('**/api/signup', async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
await route.fulfill({ status: 201, json: { id: 1 } });
|
||||
});
|
||||
|
||||
await page.getByLabel('Name').fill('Bob');
|
||||
await page.getByLabel('Email').fill('bob@test.com');
|
||||
await page.getByLabel('Password', { exact: true }).fill('Secure123!');
|
||||
await page.getByLabel('Confirm').fill('Secure123!');
|
||||
await page.getByLabel('Accept terms').check();
|
||||
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: /Registering|Loading/ })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Portals (Modals, Tooltips, Dropdowns)
|
||||
|
||||
**Use when**: Testing components rendered via `ReactDOM.createPortal()`—modals, dialogs, tooltips, menus. These render outside parent DOM but Playwright sees the full document.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('portal components', () => {
|
||||
test('modal interaction', async ({ page }) => {
|
||||
await page.goto('/items');
|
||||
|
||||
await page.getByRole('button', { name: 'Remove' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Confirm removal' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Remove' }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
});
|
||||
|
||||
test('escape closes modal', async ({ page }) => {
|
||||
await page.goto('/items');
|
||||
await page.getByRole('button', { name: 'Remove' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toBeHidden();
|
||||
});
|
||||
|
||||
test('tooltip on hover', async ({ page }) => {
|
||||
await page.goto('/panel');
|
||||
|
||||
await page.getByRole('button', { name: 'Help' }).hover();
|
||||
await expect(page.getByRole('tooltip')).toBeVisible();
|
||||
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByRole('tooltip')).toBeHidden();
|
||||
});
|
||||
|
||||
test('dropdown menu', async ({ page }) => {
|
||||
await page.goto('/panel');
|
||||
|
||||
await page.getByRole('button', { name: 'Actions' }).click();
|
||||
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
await menu.getByRole('menuitem', { name: 'Rename' }).click();
|
||||
await expect(menu).toBeHidden();
|
||||
});
|
||||
|
||||
test('toast auto-dismisses', async ({ page }) => {
|
||||
await page.goto('/preferences');
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(page.getByText('Preferences saved')).toBeVisible();
|
||||
|
||||
await expect(page.getByText('Preferences saved')).toBeHidden({ timeout: 8000 });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Error Boundaries
|
||||
|
||||
**Use when**: Verifying error boundaries catch rendering errors and show fallback UI.
|
||||
**Avoid when**: Testing error handling in event handlers or async code—error boundaries only catch render errors.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('error boundary', () => {
|
||||
test('shows fallback on crash', async ({ page }) => {
|
||||
await page.route('**/api/widgets', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
json: { widgets: null },
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/panel');
|
||||
|
||||
await expect(page.getByText('Something went wrong')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
|
||||
await expect(page.getByRole('navigation')).toBeVisible();
|
||||
});
|
||||
|
||||
test('retry recovers component', async ({ page }) => {
|
||||
let calls = 0;
|
||||
await page.route('**/api/widgets', (route) => {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
route.fulfill({ status: 200, json: { widgets: null } });
|
||||
} else {
|
||||
route.fulfill({ status: 200, json: { widgets: [{ id: 1, name: 'Chart' }] } });
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/panel');
|
||||
|
||||
await expect(page.getByText('Something went wrong')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Retry' }).click();
|
||||
|
||||
await expect(page.getByText('Something went wrong')).toBeHidden();
|
||||
await expect(page.getByText('Chart')).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Component Testing (Experimental)
|
||||
|
||||
**Use when**: Testing complex interactive components in isolation—data tables, form wizards, rich editors. Needs real browser but not full app.
|
||||
**Avoid when**: Component depends heavily on backend data or routing—use E2E instead.
|
||||
|
||||
```typescript
|
||||
// playwright-ct.config.ts
|
||||
import { defineConfig, devices } from '@playwright/experimental-ct-react';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/components',
|
||||
testMatch: '**/*.ct.ts',
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
ctPort: 3100,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/components/Stepper.ct.ts
|
||||
import { test, expect } from '@playwright/experimental-ct-react';
|
||||
import Stepper from '../../src/components/Stepper';
|
||||
|
||||
test('increments on click', async ({ mount }) => {
|
||||
const component = await mount(<Stepper initial={0} />);
|
||||
|
||||
await expect(component.getByText('Value: 0')).toBeVisible();
|
||||
await component.getByRole('button', { name: '+' }).click();
|
||||
await expect(component.getByText('Value: 1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('fires onChange callback', async ({ mount }) => {
|
||||
const values: number[] = [];
|
||||
const component = await mount(
|
||||
<Stepper initial={0} onChange={(v) => values.push(v)} />
|
||||
);
|
||||
|
||||
await component.getByRole('button', { name: '+' }).click();
|
||||
await component.getByRole('button', { name: '+' }).click();
|
||||
|
||||
expect(values).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test('respects min boundary', async ({ mount }) => {
|
||||
const component = await mount(<Stepper initial={0} min={0} />);
|
||||
|
||||
await expect(component.getByRole('button', { name: '-' })).toBeDisabled();
|
||||
});
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### E2E Config (Vite)
|
||||
|
||||
```typescript
|
||||
// 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 ? '50%' : undefined,
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: process.env.CI ? 'npm run build && npx vite preview --port 5173' : 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### CRA vs Vite Differences
|
||||
|
||||
| Aspect | Create React App | Vite |
|
||||
|---|---|---|
|
||||
| Default port | `3000` | `5173` |
|
||||
| Build output | `build/` | `dist/` |
|
||||
| Serve production | `npx serve -s build -l 3000` | `npx vite preview --port 5173` |
|
||||
| Env var prefix | `REACT_APP_*` | `VITE_*` |
|
||||
|
||||
## Framework Tips
|
||||
|
||||
### Strict Mode Double Effects
|
||||
|
||||
React Strict Mode runs effects twice in development. Tests should be resilient:
|
||||
|
||||
- Don't assert exact API call counts in dev mode
|
||||
- Run against production build for call count assertions, or account for double invocations
|
||||
|
||||
### Suspense and Lazy Components
|
||||
|
||||
```typescript
|
||||
test('lazy route loads content', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByRole('link', { name: 'Analytics' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Detecting Memory Leaks
|
||||
|
||||
```typescript
|
||||
test('no unmounted state warnings', async ({ page }) => {
|
||||
const warnings: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'warning' && msg.text().includes('unmounted')) {
|
||||
warnings.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/panel');
|
||||
await page.getByRole('link', { name: 'Settings' }).click();
|
||||
await page.goBack();
|
||||
await page.getByRole('link', { name: 'Profile' }).click();
|
||||
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't | Problem | Do Instead |
|
||||
|---|---|---|
|
||||
| `page.evaluate(() => store.getState())` | Couples tests to implementation | Assert on UI: `expect(badge).toHaveText('3')` |
|
||||
| Import components in E2E tests | E2E runs in Node, not browser | Use `@playwright/experimental-ct-react` for components |
|
||||
| `page.waitForTimeout(500)` after state changes | Timing varies across machines | `expect(locator).toHaveText('value')` auto-retries |
|
||||
| `page.locator('.MuiButton-root')` | Class names change between versions | `page.getByRole('button', { name: 'Submit' })` |
|
||||
| Test every component with CT | Overhead for simple components | CT for complex widgets, unit tests for logic, E2E for flows |
|
||||
| Skip keyboard navigation tests | Accessibility regressions common | Test Tab, Enter, Escape, Arrow interactions |
|
||||
| Assert on `__REACT_FIBER__` internals | Not stable across versions | Only interact with rendered DOM |
|
||||
|
||||
## Related
|
||||
|
||||
- [locators.md](../core/locators.md) — locator strategies for any React component library
|
||||
- [assertions-waiting.md](../core/assertions-waiting.md) — auto-waiting for React state changes
|
||||
- [forms-validation.md](../testing-patterns/forms-validation.md) — form testing patterns
|
||||
- [component-testing.md](../testing-patterns/component-testing.md) — in-depth component testing
|
||||
- [test-architecture.md](../architecture/test-architecture.md) — E2E vs component vs unit decisions
|
||||
- [nextjs.md](nextjs.md) — Next.js-specific patterns for SSR
|
||||
@@ -0,0 +1,574 @@
|
||||
# Vue and Nuxt Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Commands](#commands)
|
||||
2. [Configuration](#configuration)
|
||||
3. [Patterns](#patterns)
|
||||
4. [Vue vs Nuxt Differences](#vue-vs-nuxt-differences)
|
||||
5. [Component Testing Dependencies](#component-testing-dependencies)
|
||||
6. [Testing v-model](#testing-v-model)
|
||||
7. [Capturing Vue Warnings](#capturing-vue-warnings)
|
||||
8. [Anti-Patterns](#anti-patterns)
|
||||
|
||||
> **When to use**: Testing Vue 3 applications with composition API, Pinia stores, Vue Router, Nuxt 3 apps, Teleport portals, and transitions.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm init playwright@latest
|
||||
npm install -D @playwright/experimental-ct-vue
|
||||
npx playwright test
|
||||
npx playwright test -c playwright-ct.config.ts
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Vue with Vite
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
testMatch: '**/*.spec.ts',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? '50%' : undefined,
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? 'npm run build && npx vite preview --port 5173'
|
||||
: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Nuxt 3
|
||||
|
||||
Nuxt uses port 3000 and requires a build step before testing.
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
testMatch: '**/*.spec.ts',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? 'npx nuxi build && npx nuxi preview'
|
||||
: 'npx nuxi dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
NUXT_PUBLIC_API_BASE: 'http://localhost:3000/api',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Component Testing
|
||||
|
||||
```typescript
|
||||
// playwright-ct.config.ts
|
||||
import { defineConfig, devices } from '@playwright/experimental-ct-vue';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/components',
|
||||
testMatch: '**/*.ct.ts',
|
||||
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
ctPort: 3100,
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Component Testing with Experimental CT
|
||||
|
||||
**Use when**: Testing complex interactive Vue components in isolation (data tables, form components, custom dropdowns).
|
||||
|
||||
**Avoid when**: Component depends heavily on Pinia stores, Vue Router, or backend data—use E2E tests instead.
|
||||
|
||||
```typescript
|
||||
// tests/components/Stepper.ct.ts
|
||||
import { test, expect } from '@playwright/experimental-ct-vue';
|
||||
import Stepper from '../../src/components/Stepper.vue';
|
||||
|
||||
test('increments value on button click', async ({ mount }) => {
|
||||
const component = await mount(Stepper, {
|
||||
props: { value: 0 },
|
||||
});
|
||||
|
||||
await expect(component.getByText('Value: 0')).toBeVisible();
|
||||
await component.getByRole('button', { name: '+' }).click();
|
||||
await expect(component.getByText('Value: 1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('emits change event', async ({ mount }) => {
|
||||
const changes: number[] = [];
|
||||
const component = await mount(Stepper, {
|
||||
props: { value: 10 },
|
||||
on: {
|
||||
change: (val: number) => changes.push(val),
|
||||
},
|
||||
});
|
||||
|
||||
await component.getByRole('button', { name: '+' }).click();
|
||||
await component.getByRole('button', { name: '+' }).click();
|
||||
|
||||
expect(changes).toEqual([11, 12]);
|
||||
});
|
||||
|
||||
test('renders slot content', async ({ mount }) => {
|
||||
const component = await mount(Stepper, {
|
||||
props: { value: 0 },
|
||||
slots: {
|
||||
default: '<span class="label">Quantity</span>',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(component.getByText('Quantity')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Pinia Store Testing Through UI
|
||||
|
||||
**Use when**: Verifying Pinia stores produce correct UI behavior. If the UI is correct, the store is correct.
|
||||
|
||||
**Avoid when**: Testing pure store logic with no UI side effect—use unit tests with Vitest.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('shopping cart store', () => {
|
||||
test('adding products updates cart badge', async ({ page }) => {
|
||||
await page.goto('/shop');
|
||||
|
||||
const badge = page.getByTestId('cart-badge');
|
||||
await expect(badge).toHaveText('0');
|
||||
|
||||
await page.getByRole('listitem')
|
||||
.filter({ hasText: 'Hoodie' })
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
await expect(badge).toHaveText('1');
|
||||
|
||||
await page.getByRole('listitem')
|
||||
.filter({ hasText: 'Cap' })
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
await expect(badge).toHaveText('2');
|
||||
|
||||
await page.getByRole('link', { name: 'Cart' }).click();
|
||||
await page.waitForURL('/cart');
|
||||
|
||||
await expect(page.getByText('Hoodie')).toBeVisible();
|
||||
await expect(page.getByText('Cap')).toBeVisible();
|
||||
});
|
||||
|
||||
test('persisted state survives reload', async ({ page }) => {
|
||||
await page.goto('/shop');
|
||||
|
||||
await page.getByRole('listitem')
|
||||
.filter({ hasText: 'Hoodie' })
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByTestId('cart-badge')).toHaveText('1');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Vue Router Navigation
|
||||
|
||||
**Use when**: Testing client-side routing, navigation guards, URL parameters, browser history.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('router navigation', () => {
|
||||
test('client-side navigation preserves state', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).__marker = 'spa';
|
||||
});
|
||||
|
||||
await page.getByRole('link', { name: 'Shop' }).click();
|
||||
await page.waitForURL('/shop');
|
||||
|
||||
const marker = await page.evaluate(() => (window as any).__marker);
|
||||
expect(marker).toBe('spa');
|
||||
});
|
||||
|
||||
test('dynamic route params render content', async ({ page }) => {
|
||||
await page.goto('/items/99');
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
await expect(page.getByText('Item #99')).toBeVisible();
|
||||
});
|
||||
|
||||
test('navigation guard redirects unauthorized users', async ({ page }) => {
|
||||
await page.goto('/admin/dashboard');
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('browser history navigation works', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('link', { name: 'Shop' }).click();
|
||||
await page.waitForURL('/shop');
|
||||
await page.getByRole('link', { name: 'Contact' }).click();
|
||||
await page.waitForURL('/contact');
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/shop/);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(/\/shop/);
|
||||
});
|
||||
|
||||
test('query params update reactive state', async ({ page }) => {
|
||||
await page.goto('/items?sort=price&type=clothing');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Clothing' })).toBeVisible();
|
||||
|
||||
await page.getByRole('combobox', { name: 'Sort' }).selectOption('name');
|
||||
await expect(page).toHaveURL(/sort=name/);
|
||||
});
|
||||
|
||||
test('catch-all route shows 404', async ({ page }) => {
|
||||
await page.goto('/nonexistent-page');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Teleport Components
|
||||
|
||||
**Use when**: Testing components rendered via `<Teleport>` (modals, notifications, overlay menus).
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('teleported elements', () => {
|
||||
test('modal is visible and interactive', async ({ page }) => {
|
||||
await page.goto('/items');
|
||||
|
||||
await page.getByRole('button', { name: 'Remove' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Confirm' });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
});
|
||||
|
||||
test('notification auto-dismisses', async ({ page }) => {
|
||||
await page.goto('/profile');
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
const alert = page.getByRole('alert');
|
||||
await expect(alert).toBeVisible();
|
||||
await expect(alert).toContainText('Saved');
|
||||
|
||||
await expect(alert).toBeHidden({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('dropdown closes on outside click', async ({ page }) => {
|
||||
await page.goto('/home');
|
||||
|
||||
await page.getByRole('button', { name: 'Menu' }).click();
|
||||
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
await page.locator('body').click({ position: { x: 10, y: 10 } });
|
||||
await expect(menu).toBeHidden();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Transitions and Animations
|
||||
|
||||
**Use when**: Verifying `<Transition>` and `<TransitionGroup>` work correctly. Focus on end state, not animation details.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('transitions', () => {
|
||||
test('item appears after add', async ({ page }) => {
|
||||
await page.goto('/tasks');
|
||||
|
||||
await page.getByRole('textbox', { name: 'Task' }).fill('Write tests');
|
||||
await page.getByRole('button', { name: 'Add' }).click();
|
||||
|
||||
await expect(page.getByText('Write tests')).toBeVisible();
|
||||
});
|
||||
|
||||
test('item disappears after delete', async ({ page }) => {
|
||||
await page.goto('/tasks');
|
||||
|
||||
await page.getByRole('textbox', { name: 'Task' }).fill('Temp item');
|
||||
await page.getByRole('button', { name: 'Add' }).click();
|
||||
await expect(page.getByText('Temp item')).toBeVisible();
|
||||
|
||||
await page.getByRole('listitem')
|
||||
.filter({ hasText: 'Temp item' })
|
||||
.getByRole('button', { name: 'Remove' })
|
||||
.click();
|
||||
|
||||
await expect(page.getByText('Temp item')).toBeHidden();
|
||||
});
|
||||
|
||||
test('disable animations for faster tests', async ({ page }) => {
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0s !important;
|
||||
animation-delay: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
transition-delay: 0s !important;
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
await page.goto('/tasks');
|
||||
|
||||
await page.getByRole('textbox', { name: 'Task' }).fill('Quick task');
|
||||
await page.getByRole('button', { name: 'Add' }).click();
|
||||
|
||||
await expect(page.getByText('Quick task')).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Composition API Components
|
||||
|
||||
**Use when**: Testing components with `<script setup>` or `setup()`. From Playwright's perspective, Composition API and Options API are identical.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('composition API', () => {
|
||||
test('computed properties update reactively', async ({ page }) => {
|
||||
await page.goto('/pricing');
|
||||
|
||||
await page.getByLabel('Amount').fill('50');
|
||||
await page.getByLabel('Qty').fill('4');
|
||||
|
||||
await expect(page.getByTestId('sum')).toHaveText('$200.00');
|
||||
|
||||
await page.getByLabel('Discount').fill('20');
|
||||
await expect(page.getByTestId('sum')).toHaveText('$160.00');
|
||||
});
|
||||
|
||||
test('watcher triggers on change', async ({ page }) => {
|
||||
await page.goto('/preferences');
|
||||
|
||||
await page.getByRole('combobox', { name: 'Locale' }).selectOption('de');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Einstellungen' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('composable provides debounced search', async ({ page }) => {
|
||||
await page.goto('/shop');
|
||||
|
||||
const input = page.getByRole('textbox', { name: 'Search' });
|
||||
await input.pressSequentially('hoodie', { delay: 50 });
|
||||
|
||||
await expect(page.getByRole('listitem')).toHaveCount(2);
|
||||
await expect(page.getByText('Black Hoodie')).toBeVisible();
|
||||
});
|
||||
|
||||
test('provide/inject updates all consumers', async ({ page }) => {
|
||||
await page.goto('/home');
|
||||
|
||||
await page.getByRole('switch', { name: 'Dark theme' }).click();
|
||||
|
||||
await expect(page.locator('body')).toHaveClass(/dark/);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Nuxt-Specific Patterns
|
||||
|
||||
**Use when**: Testing Nuxt 3 with SSR, auto-imports, server routes, and middleware.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('nuxt features', () => {
|
||||
test('SSR renders server-fetched data', async ({ page }) => {
|
||||
await page.goto('/posts');
|
||||
|
||||
await expect(page.getByRole('article')).toHaveCount(10);
|
||||
await expect(page.getByRole('article').first()).toContainText(/\w+/);
|
||||
});
|
||||
|
||||
test('server route returns data', async ({ request }) => {
|
||||
const response = await request.get('/api/items');
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const data = await response.json();
|
||||
expect(data).toBeInstanceOf(Array);
|
||||
expect(data[0]).toHaveProperty('id');
|
||||
});
|
||||
|
||||
test('middleware redirects unauthorized', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test('NuxtLink enables SPA navigation', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).__marker = 'spa';
|
||||
});
|
||||
|
||||
await page.getByRole('link', { name: 'Posts' }).click();
|
||||
await page.waitForURL('/posts');
|
||||
|
||||
const marker = await page.evaluate(() => (window as any).__marker);
|
||||
expect(marker).toBe('spa');
|
||||
});
|
||||
|
||||
test('useHead sets meta tags', async ({ page }) => {
|
||||
await page.goto('/posts/hello-world');
|
||||
|
||||
const title = await page.title();
|
||||
expect(title).toContain('Hello World');
|
||||
|
||||
const desc = await page.locator('meta[name="description"]').getAttribute('content');
|
||||
expect(desc).toBeTruthy();
|
||||
expect(desc!.length).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Vue vs Nuxt Differences
|
||||
|
||||
| Aspect | Vue 3 (Vite) | Nuxt 3 |
|
||||
| --- | --- | --- |
|
||||
| Default port | `5173` | `3000` |
|
||||
| Dev command | `npm run dev` | `npx nuxi dev` |
|
||||
| Build + preview | `npm run build && npx vite preview` | `npx nuxi build && npx nuxi preview` |
|
||||
| SSR | Optional | Built-in |
|
||||
| API routes | External backend | `/server/api/` built-in |
|
||||
| Env variables | `VITE_*` prefix | `NUXT_PUBLIC_*` (client), `NUXT_*` (server) |
|
||||
| File-based routing | No | Yes |
|
||||
|
||||
## Component Testing Dependencies
|
||||
|
||||
Components depending on Pinia or Vue Router need these provided:
|
||||
|
||||
```typescript
|
||||
// playwright/index.ts
|
||||
import { beforeMount } from '@playwright/experimental-ct-vue/hooks';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
|
||||
beforeMount(async ({ app, hooksConfig }) => {
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
|
||||
if (hooksConfig?.routes) {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: hooksConfig.routes,
|
||||
});
|
||||
app.use(router);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Testing v-model
|
||||
|
||||
`v-model` works through standard HTML events. Playwright methods trigger correct events automatically:
|
||||
|
||||
```typescript
|
||||
await page.getByLabel('Email').fill('user@test.com');
|
||||
await page.getByRole('checkbox', { name: 'Subscribe' }).check();
|
||||
await page.getByRole('combobox', { name: 'Country' }).selectOption('US');
|
||||
```
|
||||
|
||||
## Capturing Vue Warnings
|
||||
|
||||
```typescript
|
||||
test('no Vue warnings during render', async ({ page }) => {
|
||||
const warnings: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'warning' && msg.text().includes('[Vue warn]')) {
|
||||
warnings.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/home');
|
||||
await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();
|
||||
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Avoid | Problem | Instead |
|
||||
| --- | --- | --- |
|
||||
| `page.evaluate(() => app.__vue_app__.config.globalProperties.$store)` | Accesses Vue internals; breaks on upgrades | Assert on UI that state produces |
|
||||
| `page.locator('[data-v-abc123]')` | Scoped style hashes change on every build | Use `getByRole`, `getByText`, `getByTestId` |
|
||||
| Import `.vue` files in E2E tests | E2E tests run in Node.js; `.vue` needs compilation | Use `@playwright/experimental-ct-vue` for component tests |
|
||||
| `page.waitForTimeout(300)` for transitions | Arbitrary waits are fragile | `await expect(locator).toBeVisible()` auto-waits |
|
||||
| Mock Pinia by patching `window.__pinia` | Fragile; may not trigger reactivity | Control state through UI or mock API responses |
|
||||
| Test composables via `page.evaluate` | Composables need Vue's setup context | Test through components or unit test with Vitest |
|
||||
| `page.locator('.v-btn')` for Vuetify | Class names change between versions | `page.getByRole('button', { name: 'Submit' })` |
|
||||
| Run Nuxt dev server in CI | Dev mode is slower with hot reload overhead | Use `npx nuxi build && npx nuxi preview` |
|
||||
Reference in New Issue
Block a user