Complete rewrite of the senior-qa skill addressing all feedback from Issue #51: SKILL.md (444 lines): - Added proper YAML frontmatter with trigger phrases - Added Table of Contents - Focused on React/Next.js testing (Jest, RTL, Playwright) - 3 actionable workflows with numbered steps - Removed marketing language References (3 files, 2,625+ lines total): - testing_strategies.md: Test pyramid, coverage targets, CI/CD patterns - test_automation_patterns.md: Page Object Model, fixtures, mocking, async testing - qa_best_practices.md: Naming conventions, isolation, debugging strategies Scripts (3 files, 2,261+ lines total): - test_suite_generator.py: Scans React components, generates Jest+RTL tests - coverage_analyzer.py: Parses Istanbul/LCOV, identifies critical gaps - e2e_test_scaffolder.py: Scans Next.js routes, generates Playwright tests Documentation: - Updated engineering-team/README.md senior-qa section - Added README.md in senior-qa subfolder Resolves #51 Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1010 lines
24 KiB
Markdown
1010 lines
24 KiB
Markdown
# Test Automation Patterns for React and Next.js
|
|
|
|
Reusable patterns for structuring test code, mocking dependencies, and handling async operations.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
- [Page Object Model for React](#page-object-model-for-react)
|
|
- [Test Data Factories](#test-data-factories)
|
|
- [Fixture Management](#fixture-management)
|
|
- [Mocking Strategies](#mocking-strategies)
|
|
- [Custom Test Utilities](#custom-test-utilities)
|
|
- [Async Testing Patterns](#async-testing-patterns)
|
|
- [Snapshot Testing Guidelines](#snapshot-testing-guidelines)
|
|
|
|
---
|
|
|
|
## Page Object Model for React
|
|
|
|
The Page Object Model (POM) encapsulates page interactions into reusable classes, reducing test maintenance.
|
|
|
|
### Playwright Page Objects
|
|
|
|
```typescript
|
|
// e2e/pages/LoginPage.ts
|
|
import { Page, Locator, expect } from '@playwright/test';
|
|
|
|
export class LoginPage {
|
|
readonly page: Page;
|
|
readonly emailInput: Locator;
|
|
readonly passwordInput: Locator;
|
|
readonly submitButton: Locator;
|
|
readonly errorMessage: Locator;
|
|
|
|
constructor(page: Page) {
|
|
this.page = page;
|
|
this.emailInput = page.getByLabel('Email');
|
|
this.passwordInput = page.getByLabel('Password');
|
|
this.submitButton = page.getByRole('button', { name: 'Sign in' });
|
|
this.errorMessage = page.getByRole('alert');
|
|
}
|
|
|
|
async goto() {
|
|
await this.page.goto('/login');
|
|
}
|
|
|
|
async login(email: string, password: string) {
|
|
await this.emailInput.fill(email);
|
|
await this.passwordInput.fill(password);
|
|
await this.submitButton.click();
|
|
}
|
|
|
|
async expectError(message: string) {
|
|
await expect(this.errorMessage).toContainText(message);
|
|
}
|
|
|
|
async expectRedirectToDashboard() {
|
|
await expect(this.page).toHaveURL('/dashboard');
|
|
}
|
|
}
|
|
```
|
|
|
|
**Usage in Tests:**
|
|
|
|
```typescript
|
|
// e2e/auth.spec.ts
|
|
import { test, expect } from '@playwright/test';
|
|
import { LoginPage } from './pages/LoginPage';
|
|
|
|
test.describe('Authentication', () => {
|
|
let loginPage: LoginPage;
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
loginPage = new LoginPage(page);
|
|
await loginPage.goto();
|
|
});
|
|
|
|
test('successful login redirects to dashboard', async () => {
|
|
await loginPage.login('user@example.com', 'password123');
|
|
await loginPage.expectRedirectToDashboard();
|
|
});
|
|
|
|
test('invalid credentials show error', async () => {
|
|
await loginPage.login('user@example.com', 'wrongpassword');
|
|
await loginPage.expectError('Invalid credentials');
|
|
});
|
|
});
|
|
```
|
|
|
|
### Component Object Model (React Testing Library)
|
|
|
|
```typescript
|
|
// __tests__/objects/LoginFormObject.ts
|
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
|
|
export class LoginFormObject {
|
|
get emailInput() {
|
|
return screen.getByLabelText(/email/i);
|
|
}
|
|
|
|
get passwordInput() {
|
|
return screen.getByLabelText(/password/i);
|
|
}
|
|
|
|
get submitButton() {
|
|
return screen.getByRole('button', { name: /sign in/i });
|
|
}
|
|
|
|
get errorMessage() {
|
|
return screen.queryByRole('alert');
|
|
}
|
|
|
|
async fillEmail(email: string) {
|
|
await userEvent.type(this.emailInput, email);
|
|
}
|
|
|
|
async fillPassword(password: string) {
|
|
await userEvent.type(this.passwordInput, password);
|
|
}
|
|
|
|
async submit() {
|
|
await userEvent.click(this.submitButton);
|
|
}
|
|
|
|
async login(email: string, password: string) {
|
|
await this.fillEmail(email);
|
|
await this.fillPassword(password);
|
|
await this.submit();
|
|
}
|
|
|
|
async expectError(message: string) {
|
|
await waitFor(() => {
|
|
expect(this.errorMessage).toHaveTextContent(message);
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
### When to Use POM
|
|
|
|
| Scenario | Use POM? |
|
|
|----------|----------|
|
|
| Complex pages with many interactions | Yes |
|
|
| Reusable components tested across suites | Yes |
|
|
| Simple single-use tests | No (overkill) |
|
|
| E2E tests with shared flows | Yes |
|
|
|
|
---
|
|
|
|
## Test Data Factories
|
|
|
|
Factories create test data with sensible defaults, reducing boilerplate and improving maintainability.
|
|
|
|
### Basic Factory Pattern
|
|
|
|
```typescript
|
|
// __tests__/factories/userFactory.ts
|
|
interface User {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
role: 'admin' | 'user' | 'guest';
|
|
createdAt: Date;
|
|
preferences: {
|
|
theme: 'light' | 'dark';
|
|
notifications: boolean;
|
|
};
|
|
}
|
|
|
|
let idCounter = 0;
|
|
|
|
export function createUser(overrides: Partial<User> = {}): User {
|
|
return {
|
|
id: `user-${++idCounter}`,
|
|
email: `user${idCounter}@example.com`,
|
|
name: `Test User ${idCounter}`,
|
|
role: 'user',
|
|
createdAt: new Date('2024-01-01'),
|
|
preferences: {
|
|
theme: 'light',
|
|
notifications: true,
|
|
},
|
|
...overrides,
|
|
// Deep merge preferences if provided
|
|
preferences: {
|
|
theme: 'light',
|
|
notifications: true,
|
|
...overrides.preferences,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Specialized builders
|
|
export function createAdmin(overrides: Partial<User> = {}): User {
|
|
return createUser({ role: 'admin', ...overrides });
|
|
}
|
|
|
|
export function createGuest(overrides: Partial<User> = {}): User {
|
|
return createUser({
|
|
role: 'guest',
|
|
name: 'Guest',
|
|
email: '',
|
|
...overrides,
|
|
});
|
|
}
|
|
```
|
|
|
|
### Builder Pattern for Complex Objects
|
|
|
|
```typescript
|
|
// __tests__/factories/orderBuilder.ts
|
|
interface OrderItem {
|
|
productId: string;
|
|
quantity: number;
|
|
price: number;
|
|
}
|
|
|
|
interface Order {
|
|
id: string;
|
|
userId: string;
|
|
items: OrderItem[];
|
|
status: 'pending' | 'processing' | 'shipped' | 'delivered';
|
|
total: number;
|
|
shippingAddress: Address;
|
|
createdAt: Date;
|
|
}
|
|
|
|
export class OrderBuilder {
|
|
private order: Partial<Order> = {};
|
|
private items: OrderItem[] = [];
|
|
|
|
withId(id: string): this {
|
|
this.order.id = id;
|
|
return this;
|
|
}
|
|
|
|
forUser(userId: string): this {
|
|
this.order.userId = userId;
|
|
return this;
|
|
}
|
|
|
|
withItem(productId: string, quantity: number, price: number): this {
|
|
this.items.push({ productId, quantity, price });
|
|
return this;
|
|
}
|
|
|
|
withStatus(status: Order['status']): this {
|
|
this.order.status = status;
|
|
return this;
|
|
}
|
|
|
|
shippedTo(address: Address): this {
|
|
this.order.shippingAddress = address;
|
|
return this;
|
|
}
|
|
|
|
build(): Order {
|
|
const total = this.items.reduce(
|
|
(sum, item) => sum + item.price * item.quantity,
|
|
0
|
|
);
|
|
|
|
return {
|
|
id: this.order.id || `order-${Date.now()}`,
|
|
userId: this.order.userId || 'user-1',
|
|
items: this.items,
|
|
status: this.order.status || 'pending',
|
|
total,
|
|
shippingAddress: this.order.shippingAddress || createAddress(),
|
|
createdAt: new Date(),
|
|
};
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
const order = new OrderBuilder()
|
|
.forUser('user-123')
|
|
.withItem('product-1', 2, 29.99)
|
|
.withItem('product-2', 1, 49.99)
|
|
.withStatus('processing')
|
|
.build();
|
|
```
|
|
|
|
### Factory with Faker
|
|
|
|
```typescript
|
|
// __tests__/factories/productFactory.ts
|
|
import { faker } from '@faker-js/faker';
|
|
|
|
interface Product {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
price: number;
|
|
category: string;
|
|
inStock: boolean;
|
|
imageUrl: string;
|
|
}
|
|
|
|
export function createProduct(overrides: Partial<Product> = {}): Product {
|
|
return {
|
|
id: faker.string.uuid(),
|
|
name: faker.commerce.productName(),
|
|
description: faker.commerce.productDescription(),
|
|
price: parseFloat(faker.commerce.price({ min: 10, max: 500 })),
|
|
category: faker.commerce.department(),
|
|
inStock: faker.datatype.boolean({ probability: 0.8 }),
|
|
imageUrl: faker.image.url(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
export function createProducts(count: number): Product[] {
|
|
return Array.from({ length: count }, () => createProduct());
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Fixture Management
|
|
|
|
Fixtures provide consistent test data and setup across test suites.
|
|
|
|
### Playwright Fixtures
|
|
|
|
```typescript
|
|
// e2e/fixtures/auth.ts
|
|
import { test as base, Page } from '@playwright/test';
|
|
import { createUser } from '../factories/userFactory';
|
|
|
|
interface AuthFixtures {
|
|
authenticatedPage: Page;
|
|
adminPage: Page;
|
|
testUser: ReturnType<typeof createUser>;
|
|
}
|
|
|
|
export const test = base.extend<AuthFixtures>({
|
|
testUser: async ({}, use) => {
|
|
const user = createUser();
|
|
await use(user);
|
|
},
|
|
|
|
authenticatedPage: async ({ page, testUser }, use) => {
|
|
// Login via API to skip UI
|
|
await page.request.post('/api/auth/login', {
|
|
data: {
|
|
email: testUser.email,
|
|
password: 'testpassword',
|
|
},
|
|
});
|
|
|
|
// Get session cookie
|
|
const cookies = await page.context().cookies();
|
|
await page.context().addCookies(cookies);
|
|
|
|
await use(page);
|
|
},
|
|
|
|
adminPage: async ({ page }, use) => {
|
|
const admin = createUser({ role: 'admin' });
|
|
|
|
await page.request.post('/api/auth/login', {
|
|
data: {
|
|
email: admin.email,
|
|
password: 'adminpassword',
|
|
},
|
|
});
|
|
|
|
await use(page);
|
|
},
|
|
});
|
|
|
|
export { expect } from '@playwright/test';
|
|
```
|
|
|
|
**Using Custom Fixtures:**
|
|
|
|
```typescript
|
|
// e2e/dashboard.spec.ts
|
|
import { test, expect } from './fixtures/auth';
|
|
|
|
test('dashboard shows user name', async ({ authenticatedPage, testUser }) => {
|
|
await authenticatedPage.goto('/dashboard');
|
|
await expect(authenticatedPage.getByText(testUser.name)).toBeVisible();
|
|
});
|
|
|
|
test('admin sees admin panel', async ({ adminPage }) => {
|
|
await adminPage.goto('/dashboard');
|
|
await expect(adminPage.getByText('Admin Panel')).toBeVisible();
|
|
});
|
|
```
|
|
|
|
### Jest Test Setup
|
|
|
|
```typescript
|
|
// jest.setup.ts
|
|
import '@testing-library/jest-dom';
|
|
import { server } from './__tests__/mocks/server';
|
|
|
|
// Start MSW server before all tests
|
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
|
|
|
// Reset handlers after each test
|
|
afterEach(() => server.resetHandlers());
|
|
|
|
// Clean up after all tests
|
|
afterAll(() => server.close());
|
|
|
|
// Mock window.matchMedia
|
|
Object.defineProperty(window, 'matchMedia', {
|
|
writable: true,
|
|
value: jest.fn().mockImplementation(query => ({
|
|
matches: false,
|
|
media: query,
|
|
onchange: null,
|
|
addListener: jest.fn(),
|
|
removeListener: jest.fn(),
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
dispatchEvent: jest.fn(),
|
|
})),
|
|
});
|
|
|
|
// Mock IntersectionObserver
|
|
global.IntersectionObserver = class IntersectionObserver {
|
|
constructor() {}
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
};
|
|
```
|
|
|
|
### Shared Test Data Files
|
|
|
|
```typescript
|
|
// __tests__/fixtures/products.json
|
|
{
|
|
"products": [
|
|
{
|
|
"id": "prod-1",
|
|
"name": "Widget Pro",
|
|
"price": 29.99,
|
|
"category": "Electronics"
|
|
},
|
|
{
|
|
"id": "prod-2",
|
|
"name": "Gadget Plus",
|
|
"price": 49.99,
|
|
"category": "Electronics"
|
|
}
|
|
]
|
|
}
|
|
|
|
// __tests__/fixtures/index.ts
|
|
import productsData from './products.json';
|
|
import usersData from './users.json';
|
|
|
|
export const fixtures = {
|
|
products: productsData.products,
|
|
users: usersData.users,
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Mocking Strategies
|
|
|
|
### MSW (Mock Service Worker) for API Mocking
|
|
|
|
MSW intercepts network requests at the service worker level, working in both browser and Node.
|
|
|
|
**Handler Setup:**
|
|
|
|
```typescript
|
|
// __tests__/mocks/handlers.ts
|
|
import { rest } from 'msw';
|
|
import { createUser } from '../factories/userFactory';
|
|
import { createProduct } from '../factories/productFactory';
|
|
|
|
export const handlers = [
|
|
// GET /api/users/:id
|
|
rest.get('/api/users/:id', (req, res, ctx) => {
|
|
const { id } = req.params;
|
|
const user = createUser({ id: id as string });
|
|
return res(ctx.json(user));
|
|
}),
|
|
|
|
// GET /api/products
|
|
rest.get('/api/products', (req, res, ctx) => {
|
|
const category = req.url.searchParams.get('category');
|
|
const products = Array.from({ length: 10 }, () => createProduct());
|
|
const filtered = category
|
|
? products.filter(p => p.category === category)
|
|
: products;
|
|
return res(ctx.json(filtered));
|
|
}),
|
|
|
|
// POST /api/orders
|
|
rest.post('/api/orders', async (req, res, ctx) => {
|
|
const body = await req.json();
|
|
return res(
|
|
ctx.status(201),
|
|
ctx.json({
|
|
id: `order-${Date.now()}`,
|
|
...body,
|
|
status: 'pending',
|
|
})
|
|
);
|
|
}),
|
|
|
|
// Error simulation
|
|
rest.get('/api/error', (req, res, ctx) => {
|
|
return res(
|
|
ctx.status(500),
|
|
ctx.json({ error: 'Internal Server Error' })
|
|
);
|
|
}),
|
|
];
|
|
```
|
|
|
|
**Server Setup:**
|
|
|
|
```typescript
|
|
// __tests__/mocks/server.ts
|
|
import { setupServer } from 'msw/node';
|
|
import { handlers } from './handlers';
|
|
|
|
export const server = setupServer(...handlers);
|
|
```
|
|
|
|
**Overriding Handlers in Tests:**
|
|
|
|
```typescript
|
|
// __tests__/components/ProductList.test.tsx
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import { rest } from 'msw';
|
|
import { server } from '../mocks/server';
|
|
import { ProductList } from '../../src/components/ProductList';
|
|
|
|
describe('ProductList', () => {
|
|
it('shows loading state', () => {
|
|
render(<ProductList />);
|
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders products', async () => {
|
|
render(<ProductList />);
|
|
await waitFor(() => {
|
|
expect(screen.getAllByTestId('product-card')).toHaveLength(10);
|
|
});
|
|
});
|
|
|
|
it('shows error state on API failure', async () => {
|
|
server.use(
|
|
rest.get('/api/products', (req, res, ctx) => {
|
|
return res(ctx.status(500));
|
|
})
|
|
);
|
|
|
|
render(<ProductList />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/error loading products/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows empty state when no products', async () => {
|
|
server.use(
|
|
rest.get('/api/products', (req, res, ctx) => {
|
|
return res(ctx.json([]));
|
|
})
|
|
);
|
|
|
|
render(<ProductList />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('No products found')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
### Jest Module Mocking
|
|
|
|
```typescript
|
|
// Mocking a module
|
|
jest.mock('../../src/services/analytics', () => ({
|
|
trackEvent: jest.fn(),
|
|
trackPageView: jest.fn(),
|
|
setUser: jest.fn(),
|
|
}));
|
|
|
|
// Mocking with implementation
|
|
jest.mock('next/router', () => ({
|
|
useRouter: jest.fn().mockReturnValue({
|
|
pathname: '/test',
|
|
push: jest.fn(),
|
|
replace: jest.fn(),
|
|
query: {},
|
|
}),
|
|
}));
|
|
|
|
// Partial mock (keep some real implementations)
|
|
jest.mock('../../src/utils/helpers', () => ({
|
|
...jest.requireActual('../../src/utils/helpers'),
|
|
sendEmail: jest.fn().mockResolvedValue({ success: true }),
|
|
}));
|
|
```
|
|
|
|
### Mocking Hooks
|
|
|
|
```typescript
|
|
// __tests__/hooks/useAuth.test.tsx
|
|
import { renderHook, act } from '@testing-library/react';
|
|
import { useAuth } from '../../src/hooks/useAuth';
|
|
import * as authService from '../../src/services/auth';
|
|
|
|
jest.mock('../../src/services/auth');
|
|
|
|
const mockAuthService = authService as jest.Mocked<typeof authService>;
|
|
|
|
describe('useAuth', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('logs in user successfully', async () => {
|
|
const mockUser = { id: '1', email: 'test@example.com' };
|
|
mockAuthService.login.mockResolvedValue(mockUser);
|
|
|
|
const { result } = renderHook(() => useAuth());
|
|
|
|
await act(async () => {
|
|
await result.current.login('test@example.com', 'password');
|
|
});
|
|
|
|
expect(result.current.user).toEqual(mockUser);
|
|
expect(result.current.isAuthenticated).toBe(true);
|
|
});
|
|
|
|
it('handles login error', async () => {
|
|
mockAuthService.login.mockRejectedValue(new Error('Invalid credentials'));
|
|
|
|
const { result } = renderHook(() => useAuth());
|
|
|
|
await act(async () => {
|
|
try {
|
|
await result.current.login('test@example.com', 'wrong');
|
|
} catch (e) {
|
|
// Expected
|
|
}
|
|
});
|
|
|
|
expect(result.current.user).toBeNull();
|
|
expect(result.current.error).toBe('Invalid credentials');
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Custom Test Utilities
|
|
|
|
### Render with Providers
|
|
|
|
```typescript
|
|
// __tests__/utils/renderWithProviders.tsx
|
|
import React, { ReactElement } from 'react';
|
|
import { render, RenderOptions } from '@testing-library/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { ThemeProvider } from '../../src/contexts/ThemeContext';
|
|
import { AuthProvider } from '../../src/contexts/AuthContext';
|
|
|
|
interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
|
initialUser?: User | null;
|
|
theme?: 'light' | 'dark';
|
|
}
|
|
|
|
export function renderWithProviders(
|
|
ui: ReactElement,
|
|
{
|
|
initialUser = null,
|
|
theme = 'light',
|
|
...renderOptions
|
|
}: ExtendedRenderOptions = {}
|
|
) {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false, // Disable retries in tests
|
|
},
|
|
},
|
|
});
|
|
|
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<QueryClientProvider client={queryClient}>
|
|
<AuthProvider initialUser={initialUser}>
|
|
<ThemeProvider initialTheme={theme}>
|
|
{children}
|
|
</ThemeProvider>
|
|
</AuthProvider>
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
|
|
return {
|
|
...render(ui, { wrapper: Wrapper, ...renderOptions }),
|
|
queryClient,
|
|
};
|
|
}
|
|
|
|
// Re-export everything from RTL
|
|
export * from '@testing-library/react';
|
|
export { renderWithProviders as render };
|
|
```
|
|
|
|
**Usage:**
|
|
|
|
```typescript
|
|
// __tests__/components/Dashboard.test.tsx
|
|
import { render, screen } from '../utils/renderWithProviders';
|
|
import { Dashboard } from '../../src/components/Dashboard';
|
|
import { createUser } from '../factories/userFactory';
|
|
|
|
describe('Dashboard', () => {
|
|
it('shows user greeting when authenticated', () => {
|
|
const user = createUser({ name: 'John Doe' });
|
|
render(<Dashboard />, { initialUser: user });
|
|
expect(screen.getByText('Hello, John Doe')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows login prompt when not authenticated', () => {
|
|
render(<Dashboard />, { initialUser: null });
|
|
expect(screen.getByText('Please log in')).toBeInTheDocument();
|
|
});
|
|
|
|
it('applies dark theme', () => {
|
|
render(<Dashboard />, { theme: 'dark' });
|
|
expect(document.body).toHaveClass('dark');
|
|
});
|
|
});
|
|
```
|
|
|
|
### Custom Matchers
|
|
|
|
```typescript
|
|
// __tests__/utils/customMatchers.ts
|
|
import { expect } from '@playwright/test';
|
|
|
|
expect.extend({
|
|
async toHaveLoadedSuccessfully(page) {
|
|
const hasNoErrors = await page.evaluate(() => {
|
|
return !document.querySelector('[data-error]');
|
|
});
|
|
const isLoaded = await page.evaluate(() => {
|
|
return document.readyState === 'complete';
|
|
});
|
|
|
|
return {
|
|
pass: hasNoErrors && isLoaded,
|
|
message: () =>
|
|
hasNoErrors
|
|
? 'Page loaded with errors'
|
|
: 'Page did not finish loading',
|
|
};
|
|
},
|
|
|
|
toBeWithinRange(received, floor, ceiling) {
|
|
const pass = received >= floor && received <= ceiling;
|
|
return {
|
|
pass,
|
|
message: () =>
|
|
`expected ${received} ${pass ? 'not ' : ''}to be within range ${floor} - ${ceiling}`,
|
|
};
|
|
},
|
|
});
|
|
|
|
// Type declarations
|
|
declare global {
|
|
namespace PlaywrightTest {
|
|
interface Matchers<R> {
|
|
toHaveLoadedSuccessfully(): Promise<R>;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Async Testing Patterns
|
|
|
|
### Waiting for Elements
|
|
|
|
```typescript
|
|
// Preferred: Use findBy* (waits automatically)
|
|
const element = await screen.findByText('Loaded');
|
|
|
|
// Wait for element to appear
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Loaded')).toBeInTheDocument();
|
|
});
|
|
|
|
// Wait for element to disappear
|
|
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));
|
|
|
|
// Wait with custom timeout
|
|
await waitFor(
|
|
() => {
|
|
expect(mockFn).toHaveBeenCalled();
|
|
},
|
|
{ timeout: 5000 }
|
|
);
|
|
```
|
|
|
|
### Testing Async State Changes
|
|
|
|
```typescript
|
|
// __tests__/components/AsyncButton.test.tsx
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { AsyncButton } from '../../src/components/AsyncButton';
|
|
|
|
describe('AsyncButton', () => {
|
|
it('shows loading state during async operation', async () => {
|
|
const user = userEvent.setup();
|
|
const onClickMock = jest.fn().mockImplementation(
|
|
() => new Promise(resolve => setTimeout(resolve, 100))
|
|
);
|
|
|
|
render(<AsyncButton onClick={onClickMock}>Submit</AsyncButton>);
|
|
|
|
// Initial state
|
|
expect(screen.getByRole('button')).toHaveTextContent('Submit');
|
|
expect(screen.getByRole('button')).not.toBeDisabled();
|
|
|
|
// Click and verify loading state
|
|
await user.click(screen.getByRole('button'));
|
|
expect(screen.getByRole('button')).toHaveTextContent('Loading...');
|
|
expect(screen.getByRole('button')).toBeDisabled();
|
|
|
|
// Wait for completion
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button')).toHaveTextContent('Submit');
|
|
expect(screen.getByRole('button')).not.toBeDisabled();
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
### Testing Debounced/Throttled Functions
|
|
|
|
```typescript
|
|
// __tests__/components/SearchInput.test.tsx
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { SearchInput } from '../../src/components/SearchInput';
|
|
|
|
// Use fake timers for debounce testing
|
|
jest.useFakeTimers();
|
|
|
|
describe('SearchInput', () => {
|
|
it('debounces search calls', async () => {
|
|
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
|
const onSearchMock = jest.fn();
|
|
|
|
render(<SearchInput onSearch={onSearchMock} debounceMs={300} />);
|
|
|
|
// Type quickly
|
|
await user.type(screen.getByRole('textbox'), 'test');
|
|
|
|
// No calls yet (debouncing)
|
|
expect(onSearchMock).not.toHaveBeenCalled();
|
|
|
|
// Advance timers past debounce threshold
|
|
jest.advanceTimersByTime(300);
|
|
|
|
// Now it should be called once with final value
|
|
expect(onSearchMock).toHaveBeenCalledTimes(1);
|
|
expect(onSearchMock).toHaveBeenCalledWith('test');
|
|
});
|
|
});
|
|
```
|
|
|
|
### Playwright Async Patterns
|
|
|
|
```typescript
|
|
// e2e/async-patterns.spec.ts
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test('waits for API response', async ({ page }) => {
|
|
// Wait for specific response
|
|
const responsePromise = page.waitForResponse('/api/data');
|
|
await page.click('button.load-data');
|
|
const response = await responsePromise;
|
|
expect(response.status()).toBe(200);
|
|
});
|
|
|
|
test('waits for navigation', async ({ page }) => {
|
|
await page.goto('/');
|
|
await Promise.all([
|
|
page.waitForURL('/dashboard'),
|
|
page.click('a.dashboard-link'),
|
|
]);
|
|
});
|
|
|
|
test('waits for network idle', async ({ page }) => {
|
|
await page.goto('/', { waitUntil: 'networkidle' });
|
|
});
|
|
|
|
test('retries assertion until pass', async ({ page }) => {
|
|
// Auto-retrying assertion
|
|
await expect(page.locator('.counter')).toHaveText('10', { timeout: 5000 });
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Snapshot Testing Guidelines
|
|
|
|
### When to Use Snapshots
|
|
|
|
| Good Use Cases | Bad Use Cases |
|
|
|----------------|---------------|
|
|
| Static UI components | Dynamic content |
|
|
| Error messages | Timestamps/IDs |
|
|
| Configuration objects | Large component trees |
|
|
| Serializable data | Interactive components |
|
|
|
|
### Component Snapshots
|
|
|
|
```typescript
|
|
// __tests__/components/Button.test.tsx
|
|
import { render } from '@testing-library/react';
|
|
import { Button } from '../../src/components/Button';
|
|
|
|
describe('Button snapshots', () => {
|
|
it('renders primary variant', () => {
|
|
const { container } = render(
|
|
<Button variant="primary">Click me</Button>
|
|
);
|
|
expect(container.firstChild).toMatchSnapshot();
|
|
});
|
|
|
|
it('renders secondary variant', () => {
|
|
const { container } = render(
|
|
<Button variant="secondary">Click me</Button>
|
|
);
|
|
expect(container.firstChild).toMatchSnapshot();
|
|
});
|
|
|
|
it('renders disabled state', () => {
|
|
const { container } = render(
|
|
<Button disabled>Click me</Button>
|
|
);
|
|
expect(container.firstChild).toMatchSnapshot();
|
|
});
|
|
});
|
|
```
|
|
|
|
### Inline Snapshots
|
|
|
|
```typescript
|
|
// Good for small, stable outputs
|
|
it('formats date correctly', () => {
|
|
const result = formatDate(new Date('2024-01-15'));
|
|
expect(result).toMatchInlineSnapshot(`"January 15, 2024"`);
|
|
});
|
|
|
|
it('generates expected error message', () => {
|
|
const error = new ValidationError('email', 'Invalid format');
|
|
expect(error.message).toMatchInlineSnapshot(
|
|
`"Validation failed for 'email': Invalid format"`
|
|
);
|
|
});
|
|
```
|
|
|
|
### Snapshot Best Practices
|
|
|
|
1. **Keep snapshots small** - Snapshot specific elements, not entire pages
|
|
2. **Use inline snapshots for small outputs** - Easier to review in code
|
|
3. **Review snapshot changes carefully** - Don't blindly update
|
|
4. **Avoid snapshots for dynamic content** - Filter out timestamps, IDs
|
|
5. **Combine with other assertions** - Snapshots complement, not replace
|
|
|
|
```typescript
|
|
// Filtering dynamic content from snapshots
|
|
it('renders user card', () => {
|
|
const { container } = render(<UserCard user={mockUser} />);
|
|
|
|
// Remove dynamic elements before snapshot
|
|
const card = container.firstChild;
|
|
const timestamp = card.querySelector('.timestamp');
|
|
timestamp?.remove();
|
|
|
|
expect(card).toMatchSnapshot();
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
1. **Use Page Objects** for complex, reusable page interactions
|
|
2. **Build factories** for consistent test data creation
|
|
3. **Leverage MSW** for realistic API mocking
|
|
4. **Create custom render utilities** for provider wrapping
|
|
5. **Master async patterns** to avoid flaky tests
|
|
6. **Use snapshots wisely** for stable, static content only
|