4、单元测试进阶:Mock与Stub、依赖隔离、并发代码测试
单元测试做到一定程度,你就会发现一个尴尬的局面——被测函数调了数据库、发了HTTP请求、还起了个线程。这时候再写纯单元测试,基本就是给自己挖坑。我个人习惯把这类问题归为三类:依赖怎么隔离、外部服务怎么模拟、并发代码怎么测。今天咱们就一个一个拆开聊。
4.1 为什么需要Mock和Stub?
先问个问题:你写单元测试时,最头疼的是什么?
我猜十有八九是「这个函数调了外部服务」。比如一个订单服务,它要查库存、扣余额、发通知。如果每次跑测试都真的去调数据库、真的去扣钱,那测试跑完,生产环境的数据也乱套了。
这时候就需要Mock和Stub上场。它们的作用说白了就是「假装」——假装调了数据库,假装返回了你想要的结果。
核心区别一句话讲清楚:
- Stub(桩):给你一个预设好的返回值。比如「调用getUser(),永远返回一个固定用户」。
- Mock(模拟):不仅给返回值,还会验证「你有没有按预期调用」。比如「getUser()必须被调用一次,且参数是123」。
我在项目中遇到过不少团队把这两个混着用。其实选哪个很简单:你只是需要返回值,用Stub;你需要验证调用行为,用Mock。别搞复杂了。
4.2 依赖隔离:让测试不再「牵一发动全身」
依赖隔离,说白了就是「把被测代码和它的外部依赖拆开」。你想想看,如果一段代码直接new了一个数据库连接,那测试时你根本没法替换它。所以核心思路就一个:面向接口编程,依赖注入。
举个例子,一个糟糕的设计:
public class OrderService {
private Database db = new Database(); // 硬编码依赖
public boolean createOrder(Order order) {
return db.save(order);
}
}
这种代码,测试时你只能真的连数据库。改一下:
public class OrderService {
private final Database db; // 通过构造器注入
public OrderService(Database db) {
this.db = db;
}
public boolean createOrder(Order order) {
return db.save(order);
}
}
测试时传一个Stub进去:
Database stubDb = new Database() {
@Override
public boolean save(Order order) {
return true; // 永远返回成功
}
};
OrderService service = new OrderService(stubDb);
assertTrue(service.createOrder(new Order()));
我的一个小习惯:写代码时先想「这个依赖能不能在测试时被替换」。如果不能,就说明耦合太紧。我一般会用依赖注入框架(比如Spring的DI)来管理,但测试时直接手动new一个Stub进去,反而更可控。
4.3 并发代码测试:最难啃的骨头
并发代码的测试,说实话是单元测试里最让人头疼的部分。我刚开始做分布式系统时,就吃过这个亏——一个看似正确的并发方法,线上跑着跑着就出数据不一致。为什么?因为并发问题具有「不可复现性」。你跑100次可能只失败1次,但线上用户量一大,那1%就是灾难。
并发测试的核心难点有三个:
- 竞态条件:两个线程同时读写同一个变量,结果取决于谁先谁后
- 死锁:线程互相等待对方释放资源
- 可见性问题:一个线程的修改,另一个线程看不到
怎么测?我一般分三步走:
4.3.1 用工具模拟并发场景
Java里我常用java.util.concurrent包下的工具,比如CountDownLatch和CyclicBarrier。它们能让你精确控制多个线程的执行时机。
举个例子,测试一个计数器是否线程安全:
public class CounterTest {
@Test
public void testConcurrentIncrement() throws InterruptedException {
Counter counter = new Counter();
int threadCount = 10;
int incrementsPerThread = 1000;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
for (int j = 0; j < incrementsPerThread; j++) {
counter.increment();
}
latch.countDown();
}).start();
}
latch.await(); // 等待所有线程完成
assertEquals(threadCount * incrementsPerThread, counter.getValue());
}
}
这个测试跑100次,如果每次都通过,说明计数器大概率是线程安全的。如果偶尔失败,那就说明有竞态条件。
注意:并发测试不能保证100%发现所有问题。它只能增加你的信心。我曾经有一个项目,并发测试跑了200次全绿,上线后还是出了bug——因为测试环境CPU核数和生产环境不一样。所以并发测试的结果,一定要结合压力测试一起看。
4.3.2 使用专门的并发测试框架
手动写线程代码太麻烦,我推荐几个工具:
- Java:JCStress(专门测并发正确性)、Thread Weaver(控制线程交错)
- Go:go test -race(内置竞态检测)
- Python:pytest-timeout + threading
我个人最常用的是JCStress。它能把一个简单的测试用例跑成千上万次,每次用不同的线程交错方式。比如测试一个AtomicInteger是否真的原子:
@JCStressTest
@Outcome(id = "1, 1", expect = ACCEPTABLE, desc = "Both incremented")
@Outcome(id = "1, 0", expect = FORBIDDEN, desc = "One lost update")
public class AtomicTest {
private final AtomicInteger counter = new AtomicInteger(0);
@Actor
public void actor1() {
counter.incrementAndGet();
}
@Actor
public void actor2() {
counter.incrementAndGet();
}
@Arbiter
public void arbiter(I_Result r) {
r.r1 = counter.get();
}
}
这个框架会自动帮你生成各种线程交错场景,比你自己写循环靠谱多了。
4.3.3 不可变对象:从根源上避免并发问题
说了这么多测试方法,其实最好的策略是「让并发问题不发生」。怎么做到?用不可变对象。
你想想看,如果一个对象创建后就不能修改,那多个线程同时读它,还会有竞态条件吗?不会。因为读操作本身是线程安全的。
Java里用final关键字、Collections.unmodifiableList()、或者直接使用record类型。Go里用结构体值传递。Python里用namedtuple或@dataclass(frozen=True)。
避坑指南:我曾经在一个高并发项目中,把所有共享状态都改成了不可变对象。结果发现性能反而提升了——因为不需要加锁了。当然,不是所有场景都能用不可变,比如计数器就必须可变。但能用不可变的地方,尽量用。
4.4 总结一下
单元测试进阶,说白了就是解决三个问题:
- 依赖隔离:用依赖注入+接口,让测试能替换外部服务
- Mock与Stub:需要返回值用Stub,需要验证行为用Mock
- 并发测试:用工具模拟并发场景,用不可变对象减少问题
嗯,这些技巧说起来简单,但真正用好需要大量实践。我建议你从一个小模块开始,先把依赖隔离做好,再逐步引入Mock和并发测试。别一上来就想把所有代码都测到,那样反而容易放弃。
下一章我们会聊集成测试和契约测试,到时候再细讲怎么测服务间的交互。今天就先到这里。