Webapp Testing Skill
Testing patterns for Next.js applications with Playwright and Vitest
PRINCIPLES
- •Test behavior, not implementation: Focus on user interactions
- •Arrange-Act-Assert: Clear test structure
- •Isolation: Each test independent
- •Fast feedback: Quick unit tests, slower E2E tests
PLAYWRIGHT E2E TESTING
Setup
typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile', use: { ...devices['iPhone 13'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Page Object Model
typescript
// e2e/pages/login.page.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).toHaveText(message);
}
}
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login.page';
test.describe('Login', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.goto();
});
test('successful login redirects to dashboard', async ({ page }) => {
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
test('shows error for invalid credentials', async () => {
await loginPage.login('wrong@email.com', 'wrongpass');
await loginPage.expectError('Invalid credentials');
});
});
Common Patterns
typescript
// Wait for network
await page.waitForResponse(resp =>
resp.url().includes('/api/users') && resp.status() === 200
);
// Screenshot testing
await expect(page).toHaveScreenshot('homepage.png');
// Mock API responses
await page.route('**/api/users', route => {
route.fulfill({
status: 200,
body: JSON.stringify([{ id: 1, name: 'John' }]),
});
});
// Test accessibility
import AxeBuilder from '@axe-core/playwright';
test('should pass accessibility audit', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
// File upload
await page.setInputFiles('input[type="file"]', 'path/to/file.pdf');
// Drag and drop
await page.dragAndDrop('#source', '#target');
COMPONENT TESTING (Vitest + Testing Library)
Setup
typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
include: ['**/*.test.{ts,tsx}'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
Component Tests
typescript
// components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('applies variant classes', () => {
const { rerender } = render(<Button variant="primary">Button</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-blue-600');
rerender(<Button variant="destructive">Button</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-red-600');
});
});
Testing Hooks
typescript
// hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
});
MOCKING WITH MSW
Setup
typescript
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Doe' },
]);
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 3, ...body }, { status: 201 });
}),
http.get('/api/users/:id', ({ params }) => {
const { id } = params;
return HttpResponse.json({ id: Number(id), name: 'John Doe' });
}),
];
// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// vitest.setup.ts
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Using MSW in Tests
typescript
import { http, HttpResponse } from 'msw';
import { server } from '@/mocks/server';
test('handles API error', async () => {
// Override handler for this test
server.use(
http.get('/api/users', () => {
return HttpResponse.json(
{ message: 'Internal Server Error' },
{ status: 500 }
);
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('Error loading users')).toBeInTheDocument();
});
});
TESTING SERVER COMPONENTS
typescript
// For Next.js Server Components
import { render } from '@testing-library/react';
// Mock server-side functions
vi.mock('@/lib/db', () => ({
getUsers: vi.fn().mockResolvedValue([
{ id: 1, name: 'Test User' }
]),
}));
// Test async server component
test('renders server component with data', async () => {
const ServerComponent = await import('./ServerComponent');
const { container } = render(await ServerComponent.default());
expect(container).toHaveTextContent('Test User');
});
TEST PATTERNS
AAA Pattern
typescript
test('user can submit form', async () => {
// Arrange
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ContactForm onSubmit={onSubmit} />);
// Act
await user.type(screen.getByLabelText('Name'), 'John');
await user.type(screen.getByLabelText('Email'), 'john@test.com');
await user.click(screen.getByRole('button', { name: 'Submit' }));
// Assert
expect(onSubmit).toHaveBeenCalledWith({
name: 'John',
email: 'john@test.com',
});
});
Data-Testid (Last Resort)
typescript
// Only when semantic queries don't work
<div data-testid="custom-element">Content</div>
// In test
screen.getByTestId('custom-element');
// Priority of queries (best to worst):
// 1. getByRole - accessible roles
// 2. getByLabelText - form elements
// 3. getByPlaceholderText
// 4. getByText - non-interactive elements
// 5. getByDisplayValue
// 6. getByAltText - images
// 7. getByTitle
// 8. getByTestId - last resort
ANTI-PATTERNS
❌ Avoid
typescript
// ❌ Testing implementation details
expect(component.state.isOpen).toBe(true);
// ❌ Snapshot abuse
expect(component).toMatchSnapshot(); // For everything
// ❌ Waiting with setTimeout
await new Promise(r => setTimeout(r, 1000));
// ❌ Using container.querySelector
const button = container.querySelector('.btn-primary');
✅ Prefer
typescript
// ✅ Test behavior
expect(screen.getByRole('dialog')).toBeVisible();
// ✅ Snapshots for specific use cases
expect(component).toMatchInlineSnapshot(`"expected output"`);
// ✅ Use waitFor or findBy
await waitFor(() => expect(element).toBeVisible());
await screen.findByText('Loaded');
// ✅ Use accessible queries
const button = screen.getByRole('button', { name: 'Submit' });
QUICK REFERENCE
| Query | When to Use |
|---|---|
getByRole | Buttons, links, inputs, headings |
getByLabelText | Form fields with labels |
getByText | Static text content |
findByX | Async/waiting for element |
queryByX | Assert element NOT present |
| Matcher | Assertion |
|---|---|
toBeInTheDocument() | Element exists in DOM |
toBeVisible() | Element is visible |
toHaveTextContent() | Has text content |
toHaveAttribute() | Has attribute value |
toBeDisabled() | Element is disabled |