单元测试是质量的第一道防线,也是重构的安全气囊。 写好单元测试的本质不是「验证代码正确」,而是「用最小成本证明代码按预期行为」。
一、单元测试的 FIRST 原则
由 Tim Ottinger 和 Jeff Langr 提出,FIRST 是判断一个好单元测试的五条黄金标准:
| 原则 | 英文 | 含义 | 反例 |
|---|---|---|---|
| Fast | 快速 | 单个测试 < 1s,全量 < 1min | 每个测试连真实数据库,运行 5 分钟 |
| Independent | 独立 | 测试之间无共享状态、无执行顺序依赖 | 测试 A 修改全局变量,测试 B 依赖这个值 |
| Repeatable | 可重复 | 任意次数运行结果一致,不依赖外部条件 | 测试依赖当前日期,周三通过、周四失败 |
| Self-validating | 自验证 | 测试通过/失败是明确的布尔结果 | 需要人工肉眼对比输出判断是否通过 |
| Timely | 及时 | 和生产代码一起编写(TDD)或紧随其后 | 两周后补测试,已忘记边界条件 |
二、AAA 模式:Arrange-Act-Assert
Arrange ──► Act ──► Assert
准备数据 执行操作 验证结果
# Python 示例(pytest):
def test_calculate_total_with_discount():
# Arrange
cart = ShoppingCart()
cart.add_item(Item("book", price=100, quantity=2))
cart.add_item(Item("pen", price=10, quantity=3))
coupon = Coupon(code="SAVE10", discount_percent=10)
# Act
total = cart.calculate_total(coupon)
# Assert
# 100*2 + 10*3 = 230, 打9折 = 207
assert total == 207
// Java 示例(JUnit 5):
@Test
@DisplayName("计算订单总额应正确应用折扣")
void calculateTotalWithDiscount() {
// Arrange
ShoppingCart cart = new ShoppingCart();
cart.addItem(new Item("book", 100, 2));
cart.addItem(new Item("pen", 10, 3));
Coupon coupon = new Coupon("SAVE10", 10);
// Act
int total = cart.calculateTotal(coupon);
// Assert
assertEquals(207, total);
}
// Go 示例:
func TestCalculateTotalWithDiscount(t *testing.T) {
// Arrange
cart := NewShoppingCart()
cart.AddItem(Item{Name: "book", Price: 100, Quantity: 2})
cart.AddItem(Item{Name: "pen", Price: 10, Quantity: 3})
coupon := Coupon{Code: "SAVE10", DiscountPercent: 10}
// Act
total := cart.CalculateTotal(coupon)
// Assert
if total != 207 {
t.Errorf("expected 207, got %d", total)
}
}
AAA 的变体:有时需要加入第四部分 Annihilate/Teardown(清理资源),但 Modern Testing 更倾向于每个测试自力更生、自动清理。
三、pytest:Python 单元测试的巅峰
3.1 为什么用 pytest
| 特性 | pytest | unittest |
|---|---|---|
| 写法 | 简洁函数,assert 原生 | 必须继承 TestCase |
| Fixture | 灵活的 fixture 机制 | setUp/tearDown |
| 插件 | 2000+ 插件生态 | 有限 |
| 参数化 | 装饰器一行搞定 | 子类化或循环 |
| 错误报告 | 直观、彩色、详细 | 较为简陋 |
3.2 Fixture:依赖注入式的测试环境
# conftest.py —— pytest 自动发现的共享 fixture
import pytest
@pytest.fixture(scope="function") # 每次测试新建一次
def empty_cart():
cart = ShoppingCart()
yield cart
# 清理:fixture 自动 teardown
@pytest.fixture(scope="session") # 整个测试会话只创建一次
def redis_client():
import redis
client = redis.Redis(host="localhost", port=6379, db=1)
yield client
client.flushdb() # 测试结束后清空
@pytest.fixture
def sample_order():
return Order(
id="ORD-001",
items=[Item("laptop", 9999, 1)],
customer_id="CUST-42",
status="pending"
)
# 测试中使用
def test_order_total(sample_order):
assert sample_order.total == 9999
# fixture 互相依赖
@pytest.fixture
def paid_order(sample_order):
sample_order.pay()
return sample_order
def test_paid_order_status(paid_order):
assert paid_order.status == "paid"
Fixture Scope 对比:
| 作用域 | 生命周期 | 适用场景 |
|---|---|---|
function | 每个测试函数 | 默认,无状态数据 |
class | 每个测试类 | 类级别的共享资源 |
module | 每个模块文件 | 模块级数据准备 |
package | 每个包 | 包级资源 |
session | 整个测试会话 | 数据库连接、外部服务 |
3.3 pytest.parametrize:暴力枚举的正确方式
import pytest
from calculator import is_prime
@pytest.mark.parametrize(
"n,expected",
[
(2, True),
(3, True),
(4, False),
(17, True),
(18, False),
(97, True),
(1, False), # 边界
(0, False), # 边界
(-5, False), # 负数
(2147483647, True), # 大质数(int32 最大值是质数)
],
ids=["prime_2", "prime_3", "not_prime_4", "prime_17",
"not_prime_18", "prime_97", "edge_1", "edge_0",
"negative", "large_prime"] # 自定义测试名
)
def test_is_prime(n, expected):
assert is_prime(n) == expected
3.4 monkeypatch:运行时环境替换
def test_time_sensitive_logic(monkeypatch):
# 冻结时间,摆脱时间依赖
class FakeDatetime:
@classmethod
def now(cls):
return datetime(2024, 1, 15, 10, 30, 0)
monkeypatch.setattr("mymodule.datetime", FakeDatetime)
result = mymodule.is_business_hours()
assert result is True # 周一 10:30 是工作时间
3.5 pytest-cov:覆盖率报告
pytest --cov=src --cov-report=term-missing --cov-report=html tests/
# pytest.ini —— 覆盖率阈值
[pytest]
addopts = --cov=src --cov-report=term --cov-report=html
--cov-fail-under=80
四、JUnit 5:Java 单元测试的新标杆
4.1 JUnit 5 = JUnit Platform + Jupiter + Vintage
JUnit Platform ──► 在 JVM 上启动测试框架的基础
│
├── JUnit Jupiter ──► JUnit 5 新编程和扩展模型
│
└── JUnit Vintage ──► 向后兼容 JUnit 3/4
4.2 核心注解纵览
// 基本测试
@Test
@DisplayName("订单金额超过 1000 应触发会员升级")
void shouldUpgradeMemberWhenOrderExceeds1000() { }
// 禁用与条件
@Disabled("等待优惠券系统上线后再启用")
@Test
void disabledTest() { }
@EnabledOnOs(OS.MAC) // 只在 macOS 运行
@Test
void macOnly() { }
@EnabledIfSystemProperty(named = "env", matches = "ci")
@Test
void ciOnly() { }
// 超时
@Test
@Timeout(value = 500, unit = TimeUnit.MILLISECONDS)
void shouldCompleteWithin500ms() { }
// 重复
@RepeatedTest(5)
void repeatedTest() { }
// 嵌套组织
@DisplayName("购物车服务")
class ShoppingCartTest {
@Nested
@DisplayName("当购物车为空时")
class WhenEmpty {
@Test @DisplayName("计算总额应返回 0")
void totalIsZero() { assertEquals(0, cart.total()); }
@Test @DisplayName("添加商品后不应为空")
void notEmptyAfterAdd() { }
}
@Nested
@DisplayName("当购物车有商品时")
class WhenHasItems {
@Test @DisplayName("计算总额应累加所有商品")
void totalIsSum() { }
}
}
4.3 @ParameterizedTest:多种数据源
@ParameterizedTest
@ValueSource(strings = {"hello", "racecar", "world"})
@DisplayName("回文检测应正确判断")
void isPalindrome(String candidate) {
assertTrue(StringUtils.isPalindrome(candidate));
}
@ParameterizedTest
@CsvSource({
"1, 1, 2", // a + b = expected
"2, 3, 5",
"10, 20, 30",
"-1, 1, 0",
"0, 0, 0"
})
@DisplayName("加法运算应正确")
void add(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
@ParameterizedTest
@MethodSource("provideEmailTestCases")
@DisplayName("邮箱格式校验")
void isValidEmail(String email, boolean expected) {
assertEquals(expected, validator.isValidEmail(email));
}
static Stream<Arguments> provideEmailTestCases() {
return Stream.of(
Arguments.of("test@example.com", true),
Arguments.of("invalid.email", false),
Arguments.of("", false),
Arguments.of("user@domain.co.uk", true),
Arguments.of("a@b.c", true)
);
}
4.4 Extension Model:扩展点机制
// 自定义扩展:自动重置数据库
public class DatabaseResetExtension implements BeforeEachCallback, AfterEachCallback {
@Override
public void beforeEach(ExtensionContext context) {
TestDataUtils.resetSequences();
}
@Override
public void afterEach(ExtensionContext context) {
TestDataUtils.truncateAllTables();
}
}
// 使用扩展
@ExtendWith(DatabaseResetExtension.class)
class OrderServiceTest { }
4.5 ArgumentConverter:自定义类型转换
@ParameterizedTest
@CsvSource({"2024-01-15, true", "2024-12-25, false"}) // 工作日 vs 假日
void isBusinessDay(
@ConvertWith(LocalDateConverter.class) LocalDate date,
boolean expected
) {
assertEquals(expected, BusinessCalendar.isBusinessDay(date));
}
static class LocalDateConverter extends SimpleArgumentConverter {
@Override
protected Object convert(Object source, Class<?> targetType) {
return LocalDate.parse(source.toString());
}
}
五、Go testing:简洁而强大的内置测试
5.1 Table-Driven Tests:Go 测试的灵魂
package calculator
import (
"testing"
"math"
)
// Table-Driven Test — Go 最经典的测试模式
func TestIsPrime(t *testing.T) {
tests := []struct {
name string
input int
expected bool
}{
{"prime_2", 2, true},
{"prime_3", 3, true},
{"not_prime_4", 4, false},
{"prime_17", 17, true},
{"not_prime_18", 18, false},
{"edge_1", 1, false},
{"edge_0", 0, false},
{"negative", -5, false},
{"large_prime", 2147483647, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsPrime(tt.input)
if got != tt.expected {
t.Errorf("IsPrime(%d) = %v, want %v",
tt.input, got, tt.expected)
}
})
}
}
5.2 Subtest:细粒度控制
func TestUserService(t *testing.T) {
svc := NewUserService(testDB)
t.Run("Create", func(t *testing.T) {
user, err := svc.Create("alice", "alice@example.com")
if err != nil {
t.Fatal(err) // 子测试失败不影响其他子测试
}
if user.ID == "" {
t.Error("expected non-empty ID")
}
})
t.Run("GetByID", func(t *testing.T) {
user, err := svc.GetByID("test-id-1")
if err != nil {
t.Skip("依赖 Create 测试的数据") // 跳过而非失败
}
_ = user
})
}
5.3 Benchmark:性能基准测试
// 测试 Fibonacci 算法性能
func BenchmarkFibonacci(b *testing.B) {
for i := 0; i < b.N; i++ {
Fibonacci(20)
}
}
// 带不同参数的 Benchmark
func BenchmarkFibonacciSizes(b *testing.B) {
sizes := []int{10, 20, 30}
for _, n := range sizes {
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
for i := 0; i < b.N; i++ {
Fibonacci(n)
}
})
}
}
// 对比两个实现的 Benchmark
func BenchmarkFibRecursive(b *testing.B) {
for i := 0; i < b.N; i++ {
FibRecursive(25)
}
}
func BenchmarkFibIterative(b *testing.B) {
for i := 0; i < b.N; i++ {
FibIterative(25)
}
}
$ go test -bench=. -benchmem
BenchmarkFibRecursive-8 48921 24510 ns/op 0 B/op 0 allocs/op
BenchmarkFibIterative-8 10000000 105 ns/op 0 B/op 0 allocs/op
5.4 testify:Go 测试的瑞士军刀
import "github.com/stretchr/testify/assert"
import "github.com/stretchr/testify/mock"
func TestDivide(t *testing.T) {
result, err := Divide(10, 2)
assert.NoError(t, err)
assert.Equal(t, 5.0, result)
_, err = Divide(10, 0)
assert.Error(t, err)
assert.Contains(t, err.Error(), "division by zero")
}
// testify/mock
type MockPaymentGateway struct {
mock.Mock
}
func (m *MockPaymentGateway) Charge(amount float64, cardToken string) (string, error) {
args := m.Called(amount, cardToken)
return args.String(0), args.Error(1)
}
func TestCheckoutService(t *testing.T) {
gateway := new(MockPaymentGateway)
svc := NewCheckoutService(gateway)
gateway.On("Charge", 100.0, "tok_visa").
Return("ch_123", nil).Once()
chargeID, err := svc.ProcessPayment(100.0, "tok_visa")
assert.NoError(t, err)
assert.Equal(t, "ch_123", chargeID)
gateway.AssertExpectations(t)
}
六、Mock 策略:Stub vs Fake vs Mock
6.1 三种替身彻底区分
被测代码 测试替身 真实依赖
──────── ──────────────── ─────────
UserService ──► EmailSenderStub SMTP Server
│ (始终返回 OK) (慢/不确定)
│
├── EmailSenderFake SMTP Server
│ (内存队列) (慢/不确定)
│
└── EmailSenderMock SMTP Server
(验证调用参数) (慢/不确定)
| 类型 | 行为 | 验证内容 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| Stub | 预定义返回值 | 返回值 | 低 | 不关心交互,只关心结果 |
| Fake | 简化真实实现(内存数据库) | 行为正确性 | 中 | 需要真实但轻量的依赖 |
| Mock | 记录调用并验证 | 调用次数/参数/顺序 | 高 | 需要验证交互协议的边界 |
6.2 Don’t Mock What You Don’t Own
核心原则:不要 Mock 不属于你(团队/系统)的第三方库。
# ❌ 错误:直接 mock requests 库
def test_user_service_bad():
with patch("requests.get") as mock_get:
mock_get.return_value.json.return_value = {"name": "Alice"}
user = fetch_user("123") # 直接依赖 requests
assert user.name == "Alice"
# ✅ 正确:mock 自己的抽象层(repository 接口)
class UserRepository:
def get_by_id(self, user_id: str) -> User:
...
class HttpUserRepository(UserRepository):
def get_by_id(self, user_id: str) -> User:
resp = requests.get(f"/users/{user_id}")
return User(**resp.json())
# 测试中 mock 接口
def test_user_service_good():
mock_repo = Mock(spec=UserRepository)
mock_repo.get_by_id.return_value = User(name="Alice")
svc = UserService(mock_repo)
user = svc.get_user("123")
assert user.name == "Alice"
6.3 Mock 最佳实践总结
| 最佳实践 | 说明 |
|---|---|
| 只 Mock 边界 | 系统外部依赖(HTTP、DB、消息队列) |
| 验证行为而非实现 | verify(emailSender).send(...) 而不是 verify(service).internalStep() |
| 不要 Mock 值对象 | DTO、Entity 直接构建,不值得 Mock |
| 一个 Mock 一个角色 | 不要一个 Mock 扮演多个角色 |
| 优先 Fake 而非 Mock | 内存 Fake 更真实、更不容易 brittle |
七、被测单元边界与可测性设计
7.1 一个单元该多大?
太细 刚好 太粗
───── ────────── ─────────
只测 getter 一个业务操作 整个用户注册流程
(无价值) (一个用例 = 一个测试) (覆盖多个逻辑分支)
经验法则:
- 一个测试验证一个行为(不是一段代码)
- 失败信息应该直接指出哪个行为出问题
- 通常对应一个 public 方法的单一职责场景
7.2 依赖注入:测试友好的架构基础
// ❌ 紧耦合:难以测试
type UserService struct {
db *sql.DB // 直接依赖,无法替换
}
// ✅ 解耦:通过 interface 注入
type UserRepository interface {
FindByID(id string) (*User, error)
Save(u *User) error
}
type UserService struct {
repo UserRepository // 依赖接口而非实现
}
7.3 可测性代码气味(Code Smell)
| 气味 | 问题 | 重构方案 |
|---|---|---|
new Database() 硬编码 | 无法注入假依赖 | 改为构造函数注入 |
| 静态方法/单例模式 | 全局状态,测试互相影响 | 改为实例方法 / 接口 |
| 私有方法过多 | 不可直接测试,需通过公共路径间接覆盖 | 提取为独立类或 protected |
| 随机/时间依赖 | 结果不可重复 | 注入时钟 / 随机数源 |
| 环境变量散落在代码中 | 配置与逻辑耦合 | 集中配置对象注入 |
八、单元测试反模式
8.1 测试私有方法
// ❌ 错误:通过反射测私有方法(brittle!)
@Test
void testPrivateMethod() throws Exception {
Method m = Service.class.getDeclaredMethod("validateEmail", String.class);
m.setAccessible(true);
boolean result = (boolean) m.invoke(service, "test@example.com");
assertTrue(result);
}
// ✅ 正确:私有方法的逻辑通过公共方法间接验证
// 如果私有方法逻辑复杂到需要单独测试,说明它应该成为一个独立的类
8.2 测试 getter/setter
// ❌ 毫无意义:测试 JavaBean getter/setter
@Test
void testSetAndGetName() {
User user = new User();
user.setName("Alice");
assertEquals("Alice", user.getName());
}
// 如果你的 getter/setter 有 complex 逻辑,那它不是真正的 getter/setter
8.3 过度 Mocking(Mock Turtles)
// ❌ 过度 Mock:测试里全是 mock,测了个寂寞
@Test
void testOrderProcessing() {
when(mockRepo.findById(any())).thenReturn(mockOrder);
when(mockOrder.getItems()).thenReturn(List.of(mockItem));
when(mockItem.getPrice()).thenReturn(100);
when(mockCalculator.calculate(any())).thenReturn(100);
when(mockPayment.charge(any())).thenReturn(true);
// ... 测了 5 个 mock 的交互,没测真正逻辑
}
// ✅ 合理 Mock:只 mock 外部边界
@Test
void testOrderProcessing() {
// 真实 Order + 真实计算逻辑
Order order = new Order(List.of(new Item("laptop", 100, 1)));
when(paymentGateway.charge(100, "tok_123")).thenReturn("ch_456");
String chargeId = service.process(order, "tok_123");
assertEquals("ch_456", chargeId);
}
8.4 魔法数字
# ❌ 看不懂 200 和 600 为什么是这个结果
def test_calc():
assert calculate(100, 200) == 600 # 为什么?
# ✅ 命名变量,让测试文档化行为
def test_calc_with_discount():
base_price = 100
quantity = 2
discount = 0.1 # 10% 折扣
result = calculate(base_price, quantity, discount)
expected = 180 # 100 * 2 * (1 - 0.1)
assert result == expected
九、三大框架特性对比总表
| 特性 | pytest (Python) | JUnit 5 (Java) | Go testing |
|---|---|---|---|
| 断言风格 | 原生 assert | Fluent (assertThat) | if + t.Errorf |
| 参数化 | @pytest.mark.parametrize | @ParameterizedTest | Table-Driven |
| Fixture | fixture + conftest | @BeforeEach / 构造器 | TestMain / 自定义 |
| Mock | unittest.mock / pytest-mock | Mockito | testify/mock |
| Coverage | pytest-cov | JaCoCo | go test -cover |
| 嵌套组织 | class / 文件 | @Nested | Subtest (t.Run) |
| 扩展机制 | pytest hooks | Extension Model | 无(依赖组合) |
| 并行执行 | pytest-xdist | Surefire Fork | go test -parallel |
| Benchmark | pytest-benchmark | JMH | func Benchmark |
| 最佳实践 | AAA + Fixture | AAA + Nested | Table-Driven |
十、面试高频问题
Q1:Mock 和 Stub 有什么区别?
Stub 提供预定义返回值,不关心被测代码如何调用它。
Mock 不仅提供返回值,还验证被测代码是否以预期方式调用了它(调用次数、参数、顺序)。
实际使用中Fake 往往比两者更好:一个内存实现的 ShoppingCartRepository 比一堆 Mock/Stub 更可靠。
Q2:如何测试 private 方法?
不直接测。如果一个私有方法复杂到需要单独测试,说明:
- 它违反了单一职责,应提取到独立的类中
- 它的逻辑应该通过公有方法的场景间接覆盖
如果确实需要(遗留代码重构期),Java 可用 @VisibleForTesting,Go 中无公有/私有语法限制。绝不要用反射来测私有方法。
Q3:单元测试的粒度应该是多大?
验证一个行为(不是一段代码)。如果一个测试需要测 3 个不同行为,拆成 3 个测试。
判断标准:测试名应该完整描述一个场景(shouldUpgradeMemberWhenOrderExceeds1000),且失败时一眼就知道哪个行为出问题。
参考资源
- Kent Beck, Test-Driven Development by Example
- Roy Osherove, The Art of Unit Testing
- pytest 官方文档: https://docs.pytest.org/
- JUnit 5 User Guide: https://junit.org/junit5/docs/current/user-guide/
- Go Testing 文档: https://pkg.go.dev/testing
- Martin Fowler: “Mocks Aren’t Stubs”
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。