Testing Standards
Standards for unit, integration, and E2E testing in modern TypeScript applications.
When to Use
- Writing unit tests for functions/components
- Setting up integration tests
- Creating E2E test suites
- Mocking APIs and external services
Resources
Quick Reference
Test Structure (AAA Pattern)
test('should add item to cart', async () => {
// Arrange
const user = userEvent.setup();
render(<Cart />);
// Act
await user.click(screen.getByRole('button', { name: /add to cart/i }));
// Assert
expect(screen.getByText(/1 item in cart/i)).toBeInTheDocument();
});
Vitest Setup
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
thresholds: { lines: 80, functions: 80, branches: 75 },
},
},
});
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, afterAll, beforeAll, vi } from 'vitest';
import { server } from './mocks/server';
// MSW server lifecycle
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
server.resetHandlers();
cleanup();
});
afterAll(() => server.close());
// Mock browser APIs
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
});
RTL Query Priority
// ✅ Accessible queries (preferred)
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText(/email address/i);
screen.getByText(/welcome back/i);
// ⚠️ Semantic queries (acceptable)
screen.getByAltText(/company logo/i);
// ❌ Test IDs (last resort)
screen.getByTestId('submit-button');
Custom Render with Providers
// test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
export function renderWithProviders(ui: ReactElement, options: RenderOptions = {}) {
const queryClient = createTestQueryClient();
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
return {
user: userEvent.setup(),
queryClient,
...render(ui, { wrapper: Wrapper, ...options }),
};
}
MSW Handlers
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
]);
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '3', ...body }, { status: 201 });
}),
];
// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
Runtime Handler Override
test('handles server error', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({ message: 'Error' }, { status: 500 });
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/error loading/i)).toBeInTheDocument();
});
});
Playwright E2E
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
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: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Page Object Model
// e2e/pages/login.page.ts
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(private page: Page) {
this.emailInput = page.getByLabel(/email/i);
this.passwordInput = page.getByLabel(/password/i);
this.submitButton = page.getByRole('button', { name: /sign in/i });
}
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();
}
}
Coverage Targets
| Metric |
Minimum |
Notes |
| Lines |
80% |
Overall code coverage |
| Functions |
80% |
All functions tested |
| Branches |
75% |
Conditional paths covered |
NPM Scripts
{
"scripts": {
"test": "vitest",
"test:watch": "vitest --watch",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
}
Amp Tools to Use
finder - Find existing test patterns
Read - Check test file conventions
oracle - Guidance on complex test scenarios
Related Skills
standards-global - TypeScript conventions
standards-frontend - Component patterns to test
standards-backend - API patterns to test