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包下的工具,比如CountDownLatchCyclicBarrier。它们能让你精确控制多个线程的执行时机。

举个例子,测试一个计数器是否线程安全:

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和并发测试。别一上来就想把所有代码都测到,那样反而容易放弃。

下一章我们会聊集成测试和契约测试,到时候再细讲怎么测服务间的交互。今天就先到这里。