Skip to content

测试用例编写规范

概述

编写高质量的测试用例是确保代码可靠性的关键。本文将介绍测试用例编写的最佳实践、命名规范、组织结构以及常见模式,帮助你构建清晰、可维护的测试套件。

测试命名规范

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 coverage

Test 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);
});

编写高质量的测试用例需要遵循清晰的规范和最佳实践。通过良好的命名、组织和文档化,可以创建易于维护、易于理解的测试套件,为代码质量提供可靠保障。