单元测试框架
概览
编写高效的单元测试,这些测试快速、隔离、易读且易于维护,遵循行业最佳实践和AAA(Arrange-Act-Assert)模式。
何时使用
- 编写新代码的测试
- 提高测试覆盖率
- 建立测试标准
- 安全重构
- 实施TDD(测试驱动开发)
- 创建测试工具和模拟
指令
1. 测试结构(AAA模式)
// Jest/JavaScript示例
describe('UserService', () => {
describe('createUser', () => {
it('应该用有效数据创建用户', async () => {
// Arrange - 设置测试数据和依赖项
const userData = {
email: 'john@example.com',
firstName: 'John',
lastName: 'Doe'
};
const mockDatabase = createMockDatabase();
const service = new UserService(mockDatabase);
// Act - 执行被测试的函数
const result = await service.createUser(userData);
// Assert - 验证结果
expect(result.id).toBeDefined();
expect(result.email).toBe('john@example.com');
expect(mockDatabase.save).toHaveBeenCalledWith(
expect.objectContaining(userData)
);
});
});
});
2. 按语言测试用例
JavaScript/TypeScript (Jest)
import { Calculator } from './calculator';
describe('Calculator', () => {
let calculator: Calculator;
beforeEach(() => {
calculator = new Calculator();
});
describe('add', () => {
it('应该将两个正数相加', () => {
expect(calculator.add(2, 3)).toBe(5);
});
it('应该处理负数', () => {
expect(calculator.add(-2, 3)).toBe(1);
expect(calculator.add(-2, -3)).toBe(-5);
});
it('应该处理零', () => {
expect(calculator.add(0, 5)).toBe(5);
expect(calculator.add(5, 0)).toBe(5);
});
});
describe('divide', () => {
it('应该正确地除以数字', () => {
expect(calculator.divide(10, 2)).toBe(5);
});
it('应该在除以零时抛出错误', () => {
expect(() => calculator.divide(10, 0)).toThrow('除以零');
});
it('应该处理小数结果', () => {
expect(calculator.divide(10, 3)).toBeCloseTo(3.333, 2);
});
});
});
Python (pytest)
import pytest
from user_service import UserService, ValidationError
class TestUserService:
@pytest.fixture
def service(self, mock_database):
"""创建UserService实例的固定装置"""
return UserService(mock_database)
@pytest.fixture
def valid_user_data(self):
return {
'email': 'john@example.com',
'first_name': 'John',
'last_name': 'Doe'
}
def test_create_user_with_valid_data(self, service, valid_user_data):
"""应该用有效输入创建用户"""
# Act
user = service.create_user(valid_user_data)
# Assert
assert user.id is not None
assert user.email == 'john@example.com'
assert user.first_name == 'John'
def test_create_user_with_invalid_email(self, service):
"""应该为无效电子邮件引发ValidationError"""
无效数据 = {'email': 'invalid', 'first_name': 'John'}
with pytest.raises(ValidationError) as exc_info:
service.create_user(invalid_data)
assert 'email' in str(exc_info.value)
@pytest.mark.parametrize('email,expected', [
('user@example.com', True),
('invalid', False),
('', False),
(None, False),
])
def test_email_validation(self, service, email, expected):
"""应该正确验证电子邮件格式"""
assert service.validate_email(email) == expected
Java (JUnit 5)
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class UserServiceTest {
private UserService userService;
private UserRepository mockRepository;
@BeforeEach
void setUp() {
mockRepository = mock(UserRepository.class);
userService = new UserService(mockRepository);
}
@Test
@DisplayName("应该用有效数据创建用户")
void testCreateUserWithValidData() {
// Arrange
UserDto userDto = new UserDto("john@example.com", "John", "Doe");
User savedUser = new User(1L, "john@example.com", "John", "Doe");
when(mockRepository.save(any(User.class))).thenReturn(savedUser);
// Act
User result = userService.createUser(userDto);
// Assert
assertNotNull(result.getId());
assertEquals("john@example.com", result.getEmail());
verify(mockRepository, times(1)).save(any(User.class));
}
@Test
@DisplayName("应该为无效电子邮件抛出ValidationException")
void testCreateUserWithInvalidEmail() {
UserDto userDto = new UserDto("invalid", "John", "Doe");
ValidationException exception = assertThrows(
ValidationException.class,
() -> userService.createUser(userDto)
);
assertTrue(exception.getMessage().contains("email"));
}
@ParameterizedTest
@ValueSource(strings = {"user@example.com", "test@domain.co.uk"})
@DisplayName("应该验证正确的电子邮件格式")
void testValidEmailFormats(String email) {
assertTrue(userService.validateEmail(email));
}
@ParameterizedTest
@ValueSource(strings = {"invalid", "", "no-at-sign.com"})
@DisplayName("应该拒绝无效电子邮件格式")
void testInvalidEmailFormats(String email) {
assertFalse(userService.validateEmail(email));
}
}
3. 模拟与测试替身
模拟外部依赖项
// 模拟数据库
const mockDatabase = {
save: jest.fn().mockResolvedValue({ id: '123' }),
findById: jest.fn().mockResolvedValue({ id: '123', name: 'John' }),
delete: jest.fn().mockResolvedValue(true)
};
// 模拟HTTP客户端
jest.mock('axios');
axios.get.mockResolvedValue({ data: { users: [] } });
// 监视方法
const spy = jest.spyOn(userService, 'sendEmail');
expect(spy).toHaveBeenCalledWith('john@example.com', 'Welcome');
Python模拟
from unittest.mock import Mock, patch, MagicMock
def test_send_email(mocker):
"""使用模拟SMTP测试电子邮件发送"""
# 模拟SMTP客户端
mock_smtp = mocker.patch('smtplib.SMTP')
service = EmailService()
# Act
service.send_email('test@example.com', 'Subject', 'Body')
# Assert
mock_smtp.return_value.send_message.assert_called_once()
@patch('requests.get')
def test_fetch_user_data(mock_get):
"""使用模拟请求测试API调用"""
mock_get.return_value.json.return_value = {'id': 1, 'name': 'John'}
user = fetch_user_data(1)
assert user['name'] == 'John'
mock_get.assert_called_with('https://api.example.com/users/1')
4. 测试异步代码
// Jest异步/等待
it('应该获取用户数据', async () => {
const user = await fetchUser('123');
expect(user.id).toBe('123');
});
// 测试承诺
it('应该解决用户数据', () => {
return fetchUser('123').then(user => {
expect(user.id).toBe('123');
});
});
// 测试拒绝
it('应该因无效ID拒绝错误', async () => {
await expect(fetchUser('invalid')).rejects.toThrow('用户未找到');
});
5. 测试覆盖率
# JavaScript (Jest)
npm test -- --coverage
# Python (pytest与覆盖率)
pytest --cov=src --cov-report=html
# Java (Maven)
mvn test jacoco:report
覆盖率目标:
- 语句:80%+覆盖
- 分支:75%+覆盖
- 函数:85%+覆盖
- 行:80%+覆盖
6. 测试边缘情况
describe('边缘情况', () => {
it('应该处理null输入', () => {
expect(processData(null)).toBeNull();
});
it('应该处理undefined输入', () => {
expect(processData(undefined)).toBeUndefined();
});
it('应该处理空字符串', () => {
expect(processData('')).toBe('');
});
it('应该处理空数组', () => {
expect(processData([])).toEqual([]);
});
it('应该处理大数字', () => {
expect(calculate(Number.MAX_SAFE_INTEGER)).toBeDefined();
});
it('应该处理特殊字符', () => {
expect(sanitize('<script>alert("xss")</script>'))
.toBe('<script>alert("xss")</script>');
});
});
最佳实践
✅ 做
- 在代码编写前或同时编写测试(TDD)
- 每个测试测试一件事
- 使用描述性测试名称
- 遵循AAA模式
- 测试边缘情况和错误条件
- 保持测试隔离和独立
- 适当使用设置/拆除
- 模拟外部依赖项
- 针对关键路径追求高覆盖率
- 使测试快速(<10ms每个)
- 对于类似情况使用参数化测试
- 测试公共接口,而不是实现
❌ 不做
- 测试实现细节
- 编写相互依赖的测试
- 忽略失败的测试
- 测试第三方库代码
- 使用真实数据库/API进行单元测试
- 使测试过于复杂
- 跳过边缘情况
- 忘记清理资源
- 测试一切(专注于业务逻辑)
- 编写不稳定的测试
测试组织
src/
├── components/
│ ├── UserProfile.tsx
│ └── __tests__/
│ └── UserProfile.test.tsx
├── services/
│ ├── UserService.ts
│ └── __tests__/
│ ├── UserService.test.ts
│ └── fixtures/
│ └── users.json
└── utils/
├── validation.ts
└── __tests__/
└── validation.test.ts
常见断言
Jest
expect(value).toBe(expected); // 严格相等
expect(value).toEqual(expected); // 深度相等
expect(value).toBeTruthy(); // 真值检查
expect(value).toBeDefined(); // 不是undefined
expect(value).toBeNull(); // 空检查
expect(value).toContain(item); // 数组/字符串包含
expect(value).toMatch(/pattern/); // 正则匹配
expect(fn).toThrow(Error); // 抛出错误
expect(fn).toHaveBeenCalled(); // 模拟被调用
expect(fn).toHaveBeenCalledWith(arg); // 模拟被调用带有参数
pytest
assert value == expected
assert value is True
assert value is not None
assert item in collection
assert pattern in string
with pytest.raises(Exception):
risky_function()
assert mock.called
assert mock.call_count == 2
示例:完整的测试套件
// user-service.test.ts
import { UserService } from './user-service';
import { Database } from './database';
import { EmailService } from './email-service';
// 模拟依赖项
jest.mock('./database');
jest.mock('./email-service');
describe('UserService', () => {
let userService: UserService;
let mockDatabase: jest.Mocked<Database>;
let mockEmailService: jest.Mocked<EmailService>;
beforeEach(() => {
mockDatabase = new Database() as jest.Mocked<Database>;
mockEmailService = new EmailService() as jest.Mocked<EmailService>;
userService = new UserService(mockDatabase, mockEmailService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('createUser', () => {
const validUserData = {
email: 'john@example.com',
firstName: 'John',
lastName: 'Doe'
};
it('应该成功创建用户', async () => {
// Arrange
const savedUser = { id: '123', ...validUserData };
mockDatabase.save.mockResolvedValue(savedUser);
// Act
const result = await userService.createUser(validUserData);
// Assert
expect(result).toEqual(savedUser);
expect(mockDatabase.save).toHaveBeenCalledWith(
expect.objectContaining(validUserData)
);
expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(
validUserData.email
);
});
it('应该为无效电子邮件抛出ValidationError', async () => {
const invalidData = { ...validUserData, email: 'invalid' };
await expect(userService.createUser(invalidData))
.rejects
.toThrow('Invalid email format');
expect(mockDatabase.save).not.toHaveBeenCalled();
});
it('应该处理数据库错误', async () => {
mockDatabase.save.mockRejectedValue(new Error('DB Error'));
await expect(userService.createUser(validUserData))
.rejects
.toThrow('Failed to create user');
});
it('即使欢迎电子邮件失败也应该继续', async () => {
const savedUser = { id: '123', ...validUserData };
mockDatabase.save.mockResolvedValue(savedUser);
mockEmailService.sendWelcomeEmail.mockRejectedValue(
new Error('Email failed')
);
const result = await userService.createUser(validUserData);
expect(result).toEqual(savedUser);
// 即使电子邮件失败,用户仍然被创建
});
});
describe('getUserById', () => {
it('应该在找到时返回用户', async () => {
const user = { id: '123', email: 'john@example.com' };
mockDatabase.findById.mockResolvedValue(user);
const result = await userService.getUserById('123');
expect(result).toEqual(user);
});
it('应该在用户未找到时抛出NotFoundError', async () => {
mockDatabase.findById.mockResolvedValue(null);
await expect(userService.getUserById('999'))
.rejects
.toThrow('User not found');
});
});
});
资源
- Jest: https://jestjs.io/docs/getting-started
- pytest: https://docs.pytest.org/
- JUnit 5: https://junit.org/junit5/docs/current/user-guide/
- Mocha: https://mochajs.org/
- RSpec: https://rspec.info/