1025 lines
25 KiB
Markdown
1025 lines
25 KiB
Markdown
# JavaScript Testing Patterns Implementation Playbook
|
|
|
|
This file contains detailed patterns, checklists, and code samples referenced by the skill.
|
|
|
|
# JavaScript Testing Patterns
|
|
|
|
Comprehensive guide for implementing robust testing strategies in JavaScript/TypeScript applications using modern testing frameworks and best practices.
|
|
|
|
## When to Use This Skill
|
|
|
|
- Setting up test infrastructure for new projects
|
|
- Writing unit tests for functions and classes
|
|
- Creating integration tests for APIs and services
|
|
- Implementing end-to-end tests for user flows
|
|
- Mocking external dependencies and APIs
|
|
- Testing React, Vue, or other frontend components
|
|
- Implementing test-driven development (TDD)
|
|
- Setting up continuous testing in CI/CD pipelines
|
|
|
|
## Testing Frameworks
|
|
|
|
### Jest - Full-Featured Testing Framework
|
|
|
|
**Setup:**
|
|
```typescript
|
|
// jest.config.ts
|
|
import type { Config } from 'jest';
|
|
|
|
const config: Config = {
|
|
preset: 'ts-jest',
|
|
testEnvironment: 'node',
|
|
roots: ['<rootDir>/src'],
|
|
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
|
|
collectCoverageFrom: [
|
|
'src/**/*.ts',
|
|
'!src/**/*.d.ts',
|
|
'!src/**/*.interface.ts',
|
|
],
|
|
coverageThreshold: {
|
|
global: {
|
|
branches: 80,
|
|
functions: 80,
|
|
lines: 80,
|
|
statements: 80,
|
|
},
|
|
},
|
|
setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
|
|
};
|
|
|
|
export default config;
|
|
```
|
|
|
|
### Vitest - Fast, Vite-Native Testing
|
|
|
|
**Setup:**
|
|
```typescript
|
|
// vitest.config.ts
|
|
import { defineConfig } from 'vitest/config';
|
|
|
|
export default defineConfig({
|
|
test: {
|
|
globals: true,
|
|
environment: 'node',
|
|
coverage: {
|
|
provider: 'v8',
|
|
reporter: ['text', 'json', 'html'],
|
|
exclude: ['**/*.d.ts', '**/*.config.ts', '**/dist/**'],
|
|
},
|
|
setupFiles: ['./src/test/setup.ts'],
|
|
},
|
|
});
|
|
```
|
|
|
|
## Unit Testing Patterns
|
|
|
|
### Pattern 1: Testing Pure Functions
|
|
|
|
```typescript
|
|
// utils/calculator.ts
|
|
export function add(a: number, b: number): number {
|
|
return a + b;
|
|
}
|
|
|
|
export function divide(a: number, b: number): number {
|
|
if (b === 0) {
|
|
throw new Error('Division by zero');
|
|
}
|
|
return a / b;
|
|
}
|
|
|
|
// utils/calculator.test.ts
|
|
import { describe, it, expect } from 'vitest';
|
|
import { add, divide } from './calculator';
|
|
|
|
describe('Calculator', () => {
|
|
describe('add', () => {
|
|
it('should add two positive numbers', () => {
|
|
expect(add(2, 3)).toBe(5);
|
|
});
|
|
|
|
it('should add negative numbers', () => {
|
|
expect(add(-2, -3)).toBe(-5);
|
|
});
|
|
|
|
it('should handle zero', () => {
|
|
expect(add(0, 5)).toBe(5);
|
|
expect(add(5, 0)).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('divide', () => {
|
|
it('should divide two numbers', () => {
|
|
expect(divide(10, 2)).toBe(5);
|
|
});
|
|
|
|
it('should handle decimal results', () => {
|
|
expect(divide(5, 2)).toBe(2.5);
|
|
});
|
|
|
|
it('should throw error when dividing by zero', () => {
|
|
expect(() => divide(10, 0)).toThrow('Division by zero');
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
### Pattern 2: Testing Classes
|
|
|
|
```typescript
|
|
// services/user.service.ts
|
|
export class UserService {
|
|
private users: Map<string, User> = new Map();
|
|
|
|
create(user: User): User {
|
|
if (this.users.has(user.id)) {
|
|
throw new Error('User already exists');
|
|
}
|
|
this.users.set(user.id, user);
|
|
return user;
|
|
}
|
|
|
|
findById(id: string): User | undefined {
|
|
return this.users.get(id);
|
|
}
|
|
|
|
update(id: string, updates: Partial<User>): User {
|
|
const user = this.users.get(id);
|
|
if (!user) {
|
|
throw new Error('User not found');
|
|
}
|
|
const updated = { ...user, ...updates };
|
|
this.users.set(id, updated);
|
|
return updated;
|
|
}
|
|
|
|
delete(id: string): boolean {
|
|
return this.users.delete(id);
|
|
}
|
|
}
|
|
|
|
// services/user.service.test.ts
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { UserService } from './user.service';
|
|
|
|
describe('UserService', () => {
|
|
let service: UserService;
|
|
|
|
beforeEach(() => {
|
|
service = new UserService();
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('should create a new user', () => {
|
|
const user = { id: '1', name: 'John', email: 'john@example.com' };
|
|
const created = service.create(user);
|
|
|
|
expect(created).toEqual(user);
|
|
expect(service.findById('1')).toEqual(user);
|
|
});
|
|
|
|
it('should throw error if user already exists', () => {
|
|
const user = { id: '1', name: 'John', email: 'john@example.com' };
|
|
service.create(user);
|
|
|
|
expect(() => service.create(user)).toThrow('User already exists');
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('should update existing user', () => {
|
|
const user = { id: '1', name: 'John', email: 'john@example.com' };
|
|
service.create(user);
|
|
|
|
const updated = service.update('1', { name: 'Jane' });
|
|
|
|
expect(updated.name).toBe('Jane');
|
|
expect(updated.email).toBe('john@example.com');
|
|
});
|
|
|
|
it('should throw error if user not found', () => {
|
|
expect(() => service.update('999', { name: 'Jane' }))
|
|
.toThrow('User not found');
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
### Pattern 3: Testing Async Functions
|
|
|
|
```typescript
|
|
// services/api.service.ts
|
|
export class ApiService {
|
|
async fetchUser(id: string): Promise<User> {
|
|
const response = await fetch(`https://api.example.com/users/${id}`);
|
|
if (!response.ok) {
|
|
throw new Error('User not found');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
async createUser(user: CreateUserDTO): Promise<User> {
|
|
const response = await fetch('https://api.example.com/users', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(user),
|
|
});
|
|
return response.json();
|
|
}
|
|
}
|
|
|
|
// services/api.service.test.ts
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { ApiService } from './api.service';
|
|
|
|
// Mock fetch globally
|
|
global.fetch = vi.fn();
|
|
|
|
describe('ApiService', () => {
|
|
let service: ApiService;
|
|
|
|
beforeEach(() => {
|
|
service = new ApiService();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('fetchUser', () => {
|
|
it('should fetch user successfully', async () => {
|
|
const mockUser = { id: '1', name: 'John', email: 'john@example.com' };
|
|
|
|
(fetch as any).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => mockUser,
|
|
});
|
|
|
|
const user = await service.fetchUser('1');
|
|
|
|
expect(user).toEqual(mockUser);
|
|
expect(fetch).toHaveBeenCalledWith('https://api.example.com/users/1');
|
|
});
|
|
|
|
it('should throw error if user not found', async () => {
|
|
(fetch as any).mockResolvedValueOnce({
|
|
ok: false,
|
|
});
|
|
|
|
await expect(service.fetchUser('999')).rejects.toThrow('User not found');
|
|
});
|
|
});
|
|
|
|
describe('createUser', () => {
|
|
it('should create user successfully', async () => {
|
|
const newUser = { name: 'John', email: 'john@example.com' };
|
|
const createdUser = { id: '1', ...newUser };
|
|
|
|
(fetch as any).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => createdUser,
|
|
});
|
|
|
|
const user = await service.createUser(newUser);
|
|
|
|
expect(user).toEqual(createdUser);
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
'https://api.example.com/users',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify(newUser),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
## Mocking Patterns
|
|
|
|
### Pattern 1: Mocking Modules
|
|
|
|
```typescript
|
|
// services/email.service.ts
|
|
import nodemailer from 'nodemailer';
|
|
|
|
export class EmailService {
|
|
private transporter = nodemailer.createTransport({
|
|
host: process.env.SMTP_HOST,
|
|
port: 587,
|
|
auth: {
|
|
user: process.env.SMTP_USER,
|
|
pass: process.env.SMTP_PASS,
|
|
},
|
|
});
|
|
|
|
async sendEmail(to: string, subject: string, html: string) {
|
|
await this.transporter.sendMail({
|
|
from: process.env.EMAIL_FROM,
|
|
to,
|
|
subject,
|
|
html,
|
|
});
|
|
}
|
|
}
|
|
|
|
// services/email.service.test.ts
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { EmailService } from './email.service';
|
|
|
|
vi.mock('nodemailer', () => ({
|
|
default: {
|
|
createTransport: vi.fn(() => ({
|
|
sendMail: vi.fn().mockResolvedValue({ messageId: '123' }),
|
|
})),
|
|
},
|
|
}));
|
|
|
|
describe('EmailService', () => {
|
|
let service: EmailService;
|
|
|
|
beforeEach(() => {
|
|
service = new EmailService();
|
|
});
|
|
|
|
it('should send email successfully', async () => {
|
|
await service.sendEmail(
|
|
'test@example.com',
|
|
'Test Subject',
|
|
'<p>Test Body</p>'
|
|
);
|
|
|
|
expect(service['transporter'].sendMail).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
to: 'test@example.com',
|
|
subject: 'Test Subject',
|
|
})
|
|
);
|
|
});
|
|
});
|
|
```
|
|
|
|
### Pattern 2: Dependency Injection for Testing
|
|
|
|
```typescript
|
|
// services/user.service.ts
|
|
export interface IUserRepository {
|
|
findById(id: string): Promise<User | null>;
|
|
create(user: User): Promise<User>;
|
|
}
|
|
|
|
export class UserService {
|
|
constructor(private userRepository: IUserRepository) {}
|
|
|
|
async getUser(id: string): Promise<User> {
|
|
const user = await this.userRepository.findById(id);
|
|
if (!user) {
|
|
throw new Error('User not found');
|
|
}
|
|
return user;
|
|
}
|
|
|
|
async createUser(userData: CreateUserDTO): Promise<User> {
|
|
// Business logic here
|
|
const user = { id: generateId(), ...userData };
|
|
return this.userRepository.create(user);
|
|
}
|
|
}
|
|
|
|
// services/user.service.test.ts
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { UserService, IUserRepository } from './user.service';
|
|
|
|
describe('UserService', () => {
|
|
let service: UserService;
|
|
let mockRepository: IUserRepository;
|
|
|
|
beforeEach(() => {
|
|
mockRepository = {
|
|
findById: vi.fn(),
|
|
create: vi.fn(),
|
|
};
|
|
service = new UserService(mockRepository);
|
|
});
|
|
|
|
describe('getUser', () => {
|
|
it('should return user if found', async () => {
|
|
const mockUser = { id: '1', name: 'John', email: 'john@example.com' };
|
|
vi.mocked(mockRepository.findById).mockResolvedValue(mockUser);
|
|
|
|
const user = await service.getUser('1');
|
|
|
|
expect(user).toEqual(mockUser);
|
|
expect(mockRepository.findById).toHaveBeenCalledWith('1');
|
|
});
|
|
|
|
it('should throw error if user not found', async () => {
|
|
vi.mocked(mockRepository.findById).mockResolvedValue(null);
|
|
|
|
await expect(service.getUser('999')).rejects.toThrow('User not found');
|
|
});
|
|
});
|
|
|
|
describe('createUser', () => {
|
|
it('should create user successfully', async () => {
|
|
const userData = { name: 'John', email: 'john@example.com' };
|
|
const createdUser = { id: '1', ...userData };
|
|
|
|
vi.mocked(mockRepository.create).mockResolvedValue(createdUser);
|
|
|
|
const user = await service.createUser(userData);
|
|
|
|
expect(user).toEqual(createdUser);
|
|
expect(mockRepository.create).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
### Pattern 3: Spying on Functions
|
|
|
|
```typescript
|
|
// utils/logger.ts
|
|
export const logger = {
|
|
info: (message: string) => console.log(`INFO: ${message}`),
|
|
error: (message: string) => console.error(`ERROR: ${message}`),
|
|
};
|
|
|
|
// services/order.service.ts
|
|
import { logger } from '../utils/logger';
|
|
|
|
export class OrderService {
|
|
async processOrder(orderId: string): Promise<void> {
|
|
logger.info(`Processing order ${orderId}`);
|
|
// Process order logic
|
|
logger.info(`Order ${orderId} processed successfully`);
|
|
}
|
|
}
|
|
|
|
// services/order.service.test.ts
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { OrderService } from './order.service';
|
|
import { logger } from '../utils/logger';
|
|
|
|
describe('OrderService', () => {
|
|
let service: OrderService;
|
|
let loggerSpy: any;
|
|
|
|
beforeEach(() => {
|
|
service = new OrderService();
|
|
loggerSpy = vi.spyOn(logger, 'info');
|
|
});
|
|
|
|
afterEach(() => {
|
|
loggerSpy.mockRestore();
|
|
});
|
|
|
|
it('should log order processing', async () => {
|
|
await service.processOrder('123');
|
|
|
|
expect(loggerSpy).toHaveBeenCalledWith('Processing order 123');
|
|
expect(loggerSpy).toHaveBeenCalledWith('Order 123 processed successfully');
|
|
expect(loggerSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
```
|
|
|
|
## Integration Testing
|
|
|
|
### Pattern 1: API Integration Tests
|
|
|
|
```typescript
|
|
// tests/integration/user.api.test.ts
|
|
import request from 'supertest';
|
|
import { app } from '../../src/app';
|
|
import { pool } from '../../src/config/database';
|
|
|
|
describe('User API Integration Tests', () => {
|
|
beforeAll(async () => {
|
|
// Setup test database
|
|
await pool.query('CREATE TABLE IF NOT EXISTS users (...)');
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Cleanup
|
|
await pool.query('DROP TABLE IF EXISTS users');
|
|
await pool.end();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Clear data before each test
|
|
await pool.query('TRUNCATE TABLE users CASCADE');
|
|
});
|
|
|
|
describe('POST /api/users', () => {
|
|
it('should create a new user', async () => {
|
|
const userData = {
|
|
name: 'John Doe',
|
|
email: 'john@example.com',
|
|
password: 'password123',
|
|
};
|
|
|
|
const response = await request(app)
|
|
.post('/api/users')
|
|
.send(userData)
|
|
.expect(201);
|
|
|
|
expect(response.body).toMatchObject({
|
|
name: userData.name,
|
|
email: userData.email,
|
|
});
|
|
expect(response.body).toHaveProperty('id');
|
|
expect(response.body).not.toHaveProperty('password');
|
|
});
|
|
|
|
it('should return 400 if email is invalid', async () => {
|
|
const userData = {
|
|
name: 'John Doe',
|
|
email: 'invalid-email',
|
|
password: 'password123',
|
|
};
|
|
|
|
const response = await request(app)
|
|
.post('/api/users')
|
|
.send(userData)
|
|
.expect(400);
|
|
|
|
expect(response.body).toHaveProperty('error');
|
|
});
|
|
|
|
it('should return 409 if email already exists', async () => {
|
|
const userData = {
|
|
name: 'John Doe',
|
|
email: 'john@example.com',
|
|
password: 'password123',
|
|
};
|
|
|
|
await request(app).post('/api/users').send(userData);
|
|
|
|
const response = await request(app)
|
|
.post('/api/users')
|
|
.send(userData)
|
|
.expect(409);
|
|
|
|
expect(response.body.error).toContain('already exists');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/users/:id', () => {
|
|
it('should get user by id', async () => {
|
|
const createResponse = await request(app)
|
|
.post('/api/users')
|
|
.send({
|
|
name: 'John Doe',
|
|
email: 'john@example.com',
|
|
password: 'password123',
|
|
});
|
|
|
|
const userId = createResponse.body.id;
|
|
|
|
const response = await request(app)
|
|
.get(`/api/users/${userId}`)
|
|
.expect(200);
|
|
|
|
expect(response.body).toMatchObject({
|
|
id: userId,
|
|
name: 'John Doe',
|
|
email: 'john@example.com',
|
|
});
|
|
});
|
|
|
|
it('should return 404 if user not found', async () => {
|
|
await request(app)
|
|
.get('/api/users/999')
|
|
.expect(404);
|
|
});
|
|
});
|
|
|
|
describe('Authentication', () => {
|
|
it('should require authentication for protected routes', async () => {
|
|
await request(app)
|
|
.get('/api/users/me')
|
|
.expect(401);
|
|
});
|
|
|
|
it('should allow access with valid token', async () => {
|
|
// Create user and login
|
|
await request(app)
|
|
.post('/api/users')
|
|
.send({
|
|
name: 'John Doe',
|
|
email: 'john@example.com',
|
|
password: 'password123',
|
|
});
|
|
|
|
const loginResponse = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({
|
|
email: 'john@example.com',
|
|
password: 'password123',
|
|
});
|
|
|
|
const token = loginResponse.body.token;
|
|
|
|
const response = await request(app)
|
|
.get('/api/users/me')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
|
|
expect(response.body.email).toBe('john@example.com');
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
### Pattern 2: Database Integration Tests
|
|
|
|
```typescript
|
|
// tests/integration/user.repository.test.ts
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
import { Pool } from 'pg';
|
|
import { UserRepository } from '../../src/repositories/user.repository';
|
|
|
|
describe('UserRepository Integration Tests', () => {
|
|
let pool: Pool;
|
|
let repository: UserRepository;
|
|
|
|
beforeAll(async () => {
|
|
pool = new Pool({
|
|
host: 'localhost',
|
|
port: 5432,
|
|
database: 'test_db',
|
|
user: 'test_user',
|
|
password: 'test_password',
|
|
});
|
|
|
|
repository = new UserRepository(pool);
|
|
|
|
// Create tables
|
|
await pool.query(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id SERIAL PRIMARY KEY,
|
|
name VARCHAR(255) NOT NULL,
|
|
email VARCHAR(255) UNIQUE NOT NULL,
|
|
password VARCHAR(255) NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await pool.query('DROP TABLE IF EXISTS users');
|
|
await pool.end();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await pool.query('TRUNCATE TABLE users CASCADE');
|
|
});
|
|
|
|
it('should create a user', async () => {
|
|
const user = await repository.create({
|
|
name: 'John Doe',
|
|
email: 'john@example.com',
|
|
password: 'hashed_password',
|
|
});
|
|
|
|
expect(user).toHaveProperty('id');
|
|
expect(user.name).toBe('John Doe');
|
|
expect(user.email).toBe('john@example.com');
|
|
});
|
|
|
|
it('should find user by email', async () => {
|
|
await repository.create({
|
|
name: 'John Doe',
|
|
email: 'john@example.com',
|
|
password: 'hashed_password',
|
|
});
|
|
|
|
const user = await repository.findByEmail('john@example.com');
|
|
|
|
expect(user).toBeTruthy();
|
|
expect(user?.name).toBe('John Doe');
|
|
});
|
|
|
|
it('should return null if user not found', async () => {
|
|
const user = await repository.findByEmail('nonexistent@example.com');
|
|
expect(user).toBeNull();
|
|
});
|
|
});
|
|
```
|
|
|
|
## Frontend Testing with Testing Library
|
|
|
|
### Pattern 1: React Component Testing
|
|
|
|
```typescript
|
|
// components/UserForm.tsx
|
|
import { useState } from 'react';
|
|
|
|
interface Props {
|
|
onSubmit: (user: { name: string; email: string }) => void;
|
|
}
|
|
|
|
export function UserForm({ onSubmit }: Props) {
|
|
const [name, setName] = useState('');
|
|
const [email, setEmail] = useState('');
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
onSubmit({ name, email });
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit}>
|
|
<input
|
|
type="text"
|
|
placeholder="Name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
data-testid="name-input"
|
|
/>
|
|
<input
|
|
type="email"
|
|
placeholder="Email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
data-testid="email-input"
|
|
/>
|
|
<button type="submit">Submit</button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
// components/UserForm.test.tsx
|
|
import { render, screen, fireEvent } from '@testing-library/react';
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { UserForm } from './UserForm';
|
|
|
|
describe('UserForm', () => {
|
|
it('should render form inputs', () => {
|
|
render(<UserForm onSubmit={vi.fn()} />);
|
|
|
|
expect(screen.getByPlaceholderText('Name')).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('should update input values', () => {
|
|
render(<UserForm onSubmit={vi.fn()} />);
|
|
|
|
const nameInput = screen.getByTestId('name-input') as HTMLInputElement;
|
|
const emailInput = screen.getByTestId('email-input') as HTMLInputElement;
|
|
|
|
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
|
|
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
|
|
|
|
expect(nameInput.value).toBe('John Doe');
|
|
expect(emailInput.value).toBe('john@example.com');
|
|
});
|
|
|
|
it('should call onSubmit with form data', () => {
|
|
const onSubmit = vi.fn();
|
|
render(<UserForm onSubmit={onSubmit} />);
|
|
|
|
fireEvent.change(screen.getByTestId('name-input'), {
|
|
target: { value: 'John Doe' },
|
|
});
|
|
fireEvent.change(screen.getByTestId('email-input'), {
|
|
target: { value: 'john@example.com' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
|
|
|
|
expect(onSubmit).toHaveBeenCalledWith({
|
|
name: 'John Doe',
|
|
email: 'john@example.com',
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
### Pattern 2: Testing Hooks
|
|
|
|
```typescript
|
|
// hooks/useCounter.ts
|
|
import { useState, useCallback } from 'react';
|
|
|
|
export function useCounter(initialValue = 0) {
|
|
const [count, setCount] = useState(initialValue);
|
|
|
|
const increment = useCallback(() => setCount((c) => c + 1), []);
|
|
const decrement = useCallback(() => setCount((c) => c - 1), []);
|
|
const reset = useCallback(() => setCount(initialValue), [initialValue]);
|
|
|
|
return { count, increment, decrement, reset };
|
|
}
|
|
|
|
// hooks/useCounter.test.ts
|
|
import { renderHook, act } from '@testing-library/react';
|
|
import { describe, it, expect } from 'vitest';
|
|
import { useCounter } from './useCounter';
|
|
|
|
describe('useCounter', () => {
|
|
it('should initialize with default value', () => {
|
|
const { result } = renderHook(() => useCounter());
|
|
expect(result.current.count).toBe(0);
|
|
});
|
|
|
|
it('should initialize with custom value', () => {
|
|
const { result } = renderHook(() => useCounter(10));
|
|
expect(result.current.count).toBe(10);
|
|
});
|
|
|
|
it('should increment count', () => {
|
|
const { result } = renderHook(() => useCounter());
|
|
|
|
act(() => {
|
|
result.current.increment();
|
|
});
|
|
|
|
expect(result.current.count).toBe(1);
|
|
});
|
|
|
|
it('should decrement count', () => {
|
|
const { result } = renderHook(() => useCounter(5));
|
|
|
|
act(() => {
|
|
result.current.decrement();
|
|
});
|
|
|
|
expect(result.current.count).toBe(4);
|
|
});
|
|
|
|
it('should reset to initial value', () => {
|
|
const { result } = renderHook(() => useCounter(10));
|
|
|
|
act(() => {
|
|
result.current.increment();
|
|
result.current.increment();
|
|
});
|
|
|
|
expect(result.current.count).toBe(12);
|
|
|
|
act(() => {
|
|
result.current.reset();
|
|
});
|
|
|
|
expect(result.current.count).toBe(10);
|
|
});
|
|
});
|
|
```
|
|
|
|
## Test Fixtures and Factories
|
|
|
|
```typescript
|
|
// tests/fixtures/user.fixture.ts
|
|
import { faker } from '@faker-js/faker';
|
|
|
|
export function createUserFixture(overrides?: Partial<User>): User {
|
|
return {
|
|
id: faker.string.uuid(),
|
|
name: faker.person.fullName(),
|
|
email: faker.internet.email(),
|
|
createdAt: faker.date.past(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
export function createUsersFixture(count: number): User[] {
|
|
return Array.from({ length: count }, () => createUserFixture());
|
|
}
|
|
|
|
// Usage in tests
|
|
import { createUserFixture, createUsersFixture } from '../fixtures/user.fixture';
|
|
|
|
describe('UserService', () => {
|
|
it('should process user', () => {
|
|
const user = createUserFixture({ name: 'John Doe' });
|
|
// Use user in test
|
|
});
|
|
|
|
it('should handle multiple users', () => {
|
|
const users = createUsersFixture(10);
|
|
// Use users in test
|
|
});
|
|
});
|
|
```
|
|
|
|
## Snapshot Testing
|
|
|
|
```typescript
|
|
// components/UserCard.test.tsx
|
|
import { render } from '@testing-library/react';
|
|
import { describe, it, expect } from 'vitest';
|
|
import { UserCard } from './UserCard';
|
|
|
|
describe('UserCard', () => {
|
|
it('should match snapshot', () => {
|
|
const user = {
|
|
id: '1',
|
|
name: 'John Doe',
|
|
email: 'john@example.com',
|
|
avatar: 'https://example.com/avatar.jpg',
|
|
};
|
|
|
|
const { container } = render(<UserCard user={user} />);
|
|
|
|
expect(container.firstChild).toMatchSnapshot();
|
|
});
|
|
|
|
it('should match snapshot with loading state', () => {
|
|
const { container } = render(<UserCard loading />);
|
|
expect(container.firstChild).toMatchSnapshot();
|
|
});
|
|
});
|
|
```
|
|
|
|
## Coverage Reports
|
|
|
|
```typescript
|
|
// package.json
|
|
{
|
|
"scripts": {
|
|
"test": "vitest",
|
|
"test:coverage": "vitest --coverage",
|
|
"test:ui": "vitest --ui"
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **Follow AAA Pattern**: Arrange, Act, Assert
|
|
2. **One assertion per test**: Or logically related assertions
|
|
3. **Descriptive test names**: Should describe what is being tested
|
|
4. **Use beforeEach/afterEach**: For setup and teardown
|
|
5. **Mock external dependencies**: Keep tests isolated
|
|
6. **Test edge cases**: Not just happy paths
|
|
7. **Avoid implementation details**: Test behavior, not implementation
|
|
8. **Use test factories**: For consistent test data
|
|
9. **Keep tests fast**: Mock slow operations
|
|
10. **Write tests first (TDD)**: When possible
|
|
11. **Maintain test coverage**: Aim for 80%+ coverage
|
|
12. **Use TypeScript**: For type-safe tests
|
|
13. **Test error handling**: Not just success cases
|
|
14. **Use data-testid sparingly**: Prefer semantic queries
|
|
15. **Clean up after tests**: Prevent test pollution
|
|
|
|
## Common Patterns
|
|
|
|
### Test Organization
|
|
|
|
```typescript
|
|
describe('UserService', () => {
|
|
describe('createUser', () => {
|
|
it('should create user successfully', () => {});
|
|
it('should throw error if email exists', () => {});
|
|
it('should hash password', () => {});
|
|
});
|
|
|
|
describe('updateUser', () => {
|
|
it('should update user', () => {});
|
|
it('should throw error if not found', () => {});
|
|
});
|
|
});
|
|
```
|
|
|
|
### Testing Promises
|
|
|
|
```typescript
|
|
// Using async/await
|
|
it('should fetch user', async () => {
|
|
const user = await service.fetchUser('1');
|
|
expect(user).toBeDefined();
|
|
});
|
|
|
|
// Testing rejections
|
|
it('should throw error', async () => {
|
|
await expect(service.fetchUser('invalid')).rejects.toThrow('Not found');
|
|
});
|
|
```
|
|
|
|
### Testing Timers
|
|
|
|
```typescript
|
|
import { vi } from 'vitest';
|
|
|
|
it('should call function after delay', () => {
|
|
vi.useFakeTimers();
|
|
|
|
const callback = vi.fn();
|
|
setTimeout(callback, 1000);
|
|
|
|
expect(callback).not.toHaveBeenCalled();
|
|
|
|
vi.advanceTimersByTime(1000);
|
|
|
|
expect(callback).toHaveBeenCalled();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
```
|
|
|
|
## Resources
|
|
|
|
- **Jest Documentation**: https://jestjs.io/
|
|
- **Vitest Documentation**: https://vitest.dev/
|
|
- **Testing Library**: https://testing-library.com/
|
|
- **Kent C. Dodds Testing Blog**: https://kentcdodds.com/blog/
|