Appearance
测试用例编写规范
概述
编写高质量的测试用例是确保代码可靠性的关键。本文将介绍测试用例编写的最佳实践、命名规范、组织结构以及常见模式,帮助你构建清晰、可维护的测试套件。
测试命名规范
describe命名
typescript
// ✅ 好的describe命名
describe('UserService', () => {
describe('getUserById', () => {
// ...
});
describe('createUser', () => {
// ...
});
});
describe('ShoppingCart', () => {
describe('when empty', () => {
// ...
});
describe('when contains items', () => {
// ...
});
});
// ❌ 差的describe命名
describe('test suite', () => {
// ...
});
describe('UserService tests', () => {
// ...
});it/test命名
typescript
// ✅ 好的test命名 - 描述性的,完整的句子
describe('Login', () => {
it('should display error when email is invalid', () => {
// ...
});
it('should call onSubmit with credentials when form is valid', () => {
// ...
});
it('should disable submit button while loading', () => {
// ...
});
});
// ❌ 差的test命名
describe('Login', () => {
it('works', () => {
// ...
});
it('test 1', () => {
// ...
});
it('email', () => {
// ...
});
});命名模式
typescript
// 模式1: should + 动作 + 条件
it('should display error when password is too short', () => {});
// 模式2: Given-When-Then
it('given empty cart, when adding item, then cart contains one item', () => {});
// 模式3: 行为描述
it('displays validation error for invalid email format', () => {});
// 模式4: 用户故事格式
it('allows user to login with valid credentials', () => {});测试结构
AAA模式(Arrange-Act-Assert)
typescript
test('should calculate total price with discount', () => {
// Arrange - 准备测试数据和环境
const items = [
{ name: 'Apple', price: 1.5, quantity: 2 },
{ name: 'Banana', price: 0.8, quantity: 3 },
];
const discount = 0.1; // 10% discount
// Act - 执行被测试的操作
const total = calculateTotal(items, discount);
// Assert - 验证结果
expect(total).toBe(4.14); // (3 + 2.4) * 0.9
});Given-When-Then模式
typescript
describe('ShoppingCart', () => {
describe('checkout', () => {
it('should apply loyalty discount for premium members', () => {
// Given - 给定一个场景
const cart = new ShoppingCart();
cart.addItem({ id: 1, price: 100 });
const premiumUser = { tier: 'premium', loyaltyPoints: 1000 };
// When - 当执行某个操作时
const total = cart.checkout(premiumUser);
// Then - 那么应该得到预期结果
expect(total).toBe(90); // 10% discount for premium
});
});
});测试组织
按功能分组
typescript
describe('UserAuthentication', () => {
describe('Login', () => {
describe('with valid credentials', () => {
it('should set authentication token', () => {});
it('should redirect to dashboard', () => {});
it('should save user session', () => {});
});
describe('with invalid credentials', () => {
it('should display error message', () => {});
it('should not set authentication token', () => {});
it('should increment failed attempt counter', () => {});
});
});
describe('Logout', () => {
it('should clear authentication token', () => {});
it('should redirect to login page', () => {});
it('should clear user session', () => {});
});
});按场景分组
typescript
describe('Order Processing', () => {
describe('when order is valid', () => {
it('should create order record', () => {});
it('should send confirmation email', () => {});
it('should update inventory', () => {});
});
describe('when payment fails', () => {
it('should rollback order', () => {});
it('should notify user', () => {});
it('should log error', () => {});
});
describe('when inventory is insufficient', () => {
it('should reject order', () => {});
it('should suggest alternatives', () => {});
});
});测试数据管理
测试夹具(Test Fixtures)
typescript
// fixtures/users.ts
export const validUser = {
id: 1,
email: 'john@example.com',
name: 'John Doe',
role: 'user',
};
export const adminUser = {
id: 2,
email: 'admin@example.com',
name: 'Admin User',
role: 'admin',
};
export const invalidUser = {
id: -1,
email: 'invalid',
name: '',
role: 'unknown',
};
// test.ts
import { validUser, adminUser } from './fixtures/users';
test('user permissions', () => {
expect(hasPermission(validUser, 'read')).toBe(true);
expect(hasPermission(validUser, 'admin')).toBe(false);
expect(hasPermission(adminUser, 'admin')).toBe(true);
});工厂函数
typescript
// factories/user.factory.ts
export function createUser(overrides: Partial<User> = {}): User {
return {
id: Math.random(),
email: `user${Math.random()}@example.com`,
name: 'Test User',
role: 'user',
createdAt: new Date(),
...overrides,
};
}
// test.ts
import { createUser } from './factories/user.factory';
test('user creation', () => {
const user = createUser({ role: 'admin' });
expect(user.role).toBe('admin');
expect(user.email).toContain('@example.com');
});
test('multiple users', () => {
const users = [
createUser({ name: 'Alice' }),
createUser({ name: 'Bob' }),
createUser({ name: 'Charlie' }),
];
expect(users).toHaveLength(3);
expect(users[0].name).toBe('Alice');
});Builder模式
typescript
class UserBuilder {
private user: Partial<User> = {
id: 1,
email: 'test@example.com',
name: 'Test User',
role: 'user',
};
withId(id: number): this {
this.user.id = id;
return this;
}
withEmail(email: string): this {
this.user.email = email;
return this;
}
withRole(role: string): this {
this.user.role = role;
return this;
}
asAdmin(): this {
this.user.role = 'admin';
return this;
}
build(): User {
return this.user as User;
}
}
// test.ts
test('user builder', () => {
const admin = new UserBuilder()
.withEmail('admin@example.com')
.asAdmin()
.build();
expect(admin.role).toBe('admin');
expect(admin.email).toBe('admin@example.com');
});测试覆盖场景
正常流程
typescript
describe('Payment Processing', () => {
it('should process valid payment', async () => {
const payment = {
amount: 100,
currency: 'USD',
method: 'credit_card',
cardNumber: '4111111111111111',
};
const result = await processPayment(payment);
expect(result.status).toBe('success');
expect(result.transactionId).toBeDefined();
});
});边界条件
typescript
describe('Age Validation', () => {
it('should accept minimum valid age', () => {
expect(isValidAge(18)).toBe(true);
});
it('should reject age below minimum', () => {
expect(isValidAge(17)).toBe(false);
});
it('should accept maximum valid age', () => {
expect(isValidAge(120)).toBe(true);
});
it('should reject age above maximum', () => {
expect(isValidAge(121)).toBe(false);
});
it('should reject zero age', () => {
expect(isValidAge(0)).toBe(false);
});
it('should reject negative age', () => {
expect(isValidAge(-1)).toBe(false);
});
});错误处理
typescript
describe('User Service Error Handling', () => {
it('should throw error when user not found', async () => {
await expect(getUser(-1)).rejects.toThrow('User not found');
});
it('should handle network errors gracefully', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
await expect(fetchUserData()).rejects.toThrow('Network error');
});
it('should validate required fields', () => {
expect(() => createUser({})).toThrow('Email is required');
});
});异常情况
typescript
describe('Edge Cases', () => {
it('should handle empty array', () => {
expect(sum([])).toBe(0);
});
it('should handle null input', () => {
expect(processInput(null)).toBe(null);
});
it('should handle undefined input', () => {
expect(processInput(undefined)).toBeUndefined();
});
it('should handle very large numbers', () => {
expect(add(Number.MAX_SAFE_INTEGER, 1)).toBe(Number.MAX_SAFE_INTEGER + 1);
});
});Setup和Teardown
beforeEach/afterEach
typescript
describe('Database Operations', () => {
let db: Database;
beforeEach(() => {
db = new Database();
db.connect();
});
afterEach(() => {
db.disconnect();
db.clear();
});
it('should insert record', () => {
const result = db.insert({ name: 'John' });
expect(result.success).toBe(true);
});
it('should query records', () => {
db.insert({ name: 'John' });
const results = db.query({ name: 'John' });
expect(results).toHaveLength(1);
});
});beforeAll/afterAll
typescript
describe('API Tests', () => {
let server: Server;
beforeAll(() => {
server = createTestServer();
server.start();
});
afterAll(() => {
server.stop();
});
beforeEach(() => {
server.reset();
});
it('should handle GET request', async () => {
const response = await fetch(`${server.url}/api/users`);
expect(response.status).toBe(200);
});
});测试隔离
避免测试依赖
typescript
// ❌ 测试相互依赖
describe('Counter', () => {
const counter = new Counter();
it('should increment', () => {
counter.increment();
expect(counter.value).toBe(1);
});
it('should increment twice', () => {
// 依赖上一个测试
counter.increment();
expect(counter.value).toBe(2);
});
});
// ✅ 测试独立
describe('Counter', () => {
let counter: Counter;
beforeEach(() => {
counter = new Counter();
});
it('should increment', () => {
counter.increment();
expect(counter.value).toBe(1);
});
it('should increment twice', () => {
counter.increment();
counter.increment();
expect(counter.value).toBe(2);
});
});清理副作用
typescript
describe('LocalStorage', () => {
afterEach(() => {
localStorage.clear();
});
it('should save data', () => {
saveToLocalStorage('key', 'value');
expect(localStorage.getItem('key')).toBe('value');
});
it('should load data', () => {
localStorage.setItem('key', 'value');
expect(loadFromLocalStorage('key')).toBe('value');
});
});参数化测试
test.each
typescript
describe('Math Operations', () => {
test.each([
[1, 1, 2],
[2, 2, 4],
[3, 5, 8],
])('add(%i, %i) should equal %i', (a, b, expected) => {
expect(add(a, b)).toBe(expected);
});
test.each([
['', false],
['invalid', false],
['test@example.com', true],
['user.name@domain.co.uk', true],
])('isValidEmail("%s") should return %s', (email, expected) => {
expect(isValidEmail(email)).toBe(expected);
});
});对象数组参数化
typescript
describe('User Validation', () => {
test.each([
{ age: 17, expected: false, reason: 'too young' },
{ age: 18, expected: true, reason: 'minimum age' },
{ age: 65, expected: true, reason: 'valid age' },
{ age: 121, expected: false, reason: 'too old' },
])('age $age should be $expected ($reason)', ({ age, expected }) => {
expect(isValidAge(age)).toBe(expected);
});
});断言最佳实践
精确断言
typescript
// ✅ 精确断言
test('user object', () => {
const user = getUser(1);
expect(user).toEqual({
id: 1,
name: 'John',
email: 'john@example.com',
role: 'user',
});
});
// ❌ 模糊断言
test('user object', () => {
const user = getUser(1);
expect(user).toBeDefined();
expect(user.id).toBeTruthy();
});多个断言
typescript
// ✅ 相关的多个断言在一个测试中
test('login success', async () => {
const result = await login('user@example.com', 'password');
expect(result.success).toBe(true);
expect(result.token).toBeDefined();
expect(result.user.email).toBe('user@example.com');
});
// ❌ 无关的断言混在一起
test('mixed concerns', () => {
expect(add(1, 2)).toBe(3);
expect(multiply(2, 3)).toBe(6);
expect(formatDate(new Date())).toMatch(/\d{4}-\d{2}-\d{2}/);
});有意义的错误消息
typescript
// ✅ 自定义错误消息
test('array contains value', () => {
const array = [1, 2, 3];
expect(array).toContain(4, 'Array should contain 4');
});
// 使用toThrow验证错误消息
test('validation error', () => {
expect(() => validateEmail('invalid'))
.toThrow('Invalid email format');
});测试文档化
注释说明
typescript
describe('Complex Business Logic', () => {
/**
* 测试场景: 当用户购买超过$100的商品时
* 预期行为: 自动应用10%的折扣
* 边界条件: 折扣仅适用于非促销商品
*/
it('should apply 10% discount for orders over $100', () => {
const order = {
items: [
{ price: 60, isPromo: false },
{ price: 50, isPromo: false },
],
};
const total = calculateTotal(order);
expect(total).toBe(99); // (60 + 50) * 0.9
});
});README文档
markdown
# Test Documentation
## Running Tests
```bash
npm test # Run all tests
npm test:watch # Watch mode
npm test:coverage # With coverageTest Structure
__tests__/unit/- Unit tests__tests__/integration/- Integration tests__tests__/e2e/- End-to-end tests
Test Patterns
We follow the AAA (Arrange-Act-Assert) pattern for all tests.
Coverage Requirements
- Statements: 80%
- Branches: 80%
- Functions: 80%
- Lines: 80%
## 测试代码质量
### DRY原则
```typescript
// ✅ 提取公共逻辑
function renderLoginForm() {
return render(<LoginForm onSubmit={jest.fn()} />);
}
function fillLoginForm(email: string, password: string) {
userEvent.type(screen.getByLabelText(/email/i), email);
userEvent.type(screen.getByLabelText(/password/i), password);
}
test('valid login', () => {
renderLoginForm();
fillLoginForm('user@example.com', 'password123');
userEvent.click(screen.getByRole('button', { name: /login/i }));
// ...
});
// ❌ 重复代码
test('valid login', () => {
render(<LoginForm onSubmit={jest.fn()} />);
userEvent.type(screen.getByLabelText(/email/i), 'user@example.com');
userEvent.type(screen.getByLabelText(/password/i), 'password123');
// ...
});
test('invalid login', () => {
render(<LoginForm onSubmit={jest.fn()} />);
userEvent.type(screen.getByLabelText(/email/i), 'invalid');
userEvent.type(screen.getByLabelText(/password/i), '123');
// ...
});测试辅助函数
typescript
// test-helpers.ts
export function waitForLoadingToFinish() {
return waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
}
export function expectErrorMessage(message: string) {
expect(screen.getByRole('alert')).toHaveTextContent(message);
}
export async function loginAsUser(user: User) {
const { email, password } = user;
userEvent.type(screen.getByLabelText(/email/i), email);
userEvent.type(screen.getByLabelText(/password/i), password);
userEvent.click(screen.getByRole('button', { name: /login/i }));
await waitForLoadingToFinish();
}常见陷阱
避免测试实现细节
typescript
// ❌ 测试实现细节
test('counter implementation', () => {
const { result } = renderHook(() => useState(0));
const [count, setCount] = result.current;
act(() => {
setCount(count + 1);
});
expect(result.current[0]).toBe(1);
});
// ✅ 测试用户行为
test('counter behavior', () => {
render(<Counter />);
userEvent.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});避免过度Mock
typescript
// ❌ 过度Mock
test('data processing', () => {
jest.mock('./utils');
jest.mock('./validator');
jest.mock('./formatter');
jest.mock('./logger');
// 测试变得脆弱且难以维护
});
// ✅ 只Mock必要的依赖
test('data processing', () => {
jest.mock('./api'); // 只Mock外部API调用
const result = processData(mockData);
expect(result).toEqual(expectedResult);
});编写高质量的测试用例需要遵循清晰的规范和最佳实践。通过良好的命名、组织和文档化,可以创建易于维护、易于理解的测试套件,为代码质量提供可靠保障。