跳到内容

第 6 卷 / 第 4 章 / 第 1 课

4.1 风险驱动的测试组合与单元测试 ​

测试策略先问:系统最可能以什么方式伤害用户或团队,哪一层测试能以合理成本发现这些错误。测试方法的数量可以晚一点数。

从风险清单开始 ​

先列出失败模式,再选择测试手段:

风险可能后果更合适的验证
积分规则算错排名不公单元测试、参数化测试、性质测试
ORM 映射错误数据无法读写数据库集成测试
消费者依赖的字段被删除服务间调用失败契约测试
登录后无法完成报名核心流程中断少量端到端测试
峰值流量下超时大面积请求失败负载与容量测试
越权查看他人记录隐私泄露授权规则测试、安全测试

覆盖率不能替代这张风险表。即使每行代码都执行过,也可能没有断言金额守恒、权限隔离或重试幂等。

测试金字塔是反馈成本模型 ​

“大量快速测试、较少跨进程测试、少量端到端测试”是有用的方向,但不存在适用于所有系统的 70/20/10 标准比例。测试组合取决于架构、风险和工具成本。

一个纯算法库可能几乎都是进程内测试;一个数据库扩展需要大量真实数据库测试;一个薄 UI 的关键风险可能集中在浏览器交互。更稳妥的原则是:

  • 尽量在最靠近错误来源的层验证;
  • 仍要跨越真实边界,检查自建代码与外部系统的契合;
  • 只保留能提供独特信心的高成本测试;
  • 按反馈速度分组,而不是让所有测试阻塞每次编辑。

单元是一个行为边界 ​

单元测试验证一个小范围行为,通常在单进程内运行。这个“单元”可以是函数、类或一组紧密协作的对象,不必机械等同于一个方法。

沿用 1.1 的 Java 21、Maven 与 JUnit 工程。Match.java 放入 src/main/java,MatchTest.java 放入 src/test/java。该对象限于单线程使用;状态检查不能让并发调用自动获得原子性。

下面的领域对象用状态转换表达比赛结果:

java
public final class Match {
    public enum Status { READY, IN_PROGRESS, COMPLETED }

    private final String playerA;
    private final String playerB;
    private Status status = Status.READY;
    private String winner;

    public Match(String playerA, String playerB) {
        this.playerA = java.util.Objects.requireNonNull(playerA, "playerA");
        this.playerB = java.util.Objects.requireNonNull(playerB, "playerB");
        if (this.playerA.equals(this.playerB)) {
            throw new IllegalArgumentException("players must differ");
        }
    }

    public void start() {
        if (status != Status.READY) {
            throw new IllegalStateException("match is not ready");
        }
        status = Status.IN_PROGRESS;
    }

    public void recordWinner(String player) {
        if (status != Status.IN_PROGRESS) {
            throw new IllegalStateException("match is not in progress");
        }
        java.util.Objects.requireNonNull(player, "player");
        if (!player.equals(playerA) && !player.equals(playerB)) {
            throw new IllegalArgumentException("winner is not a participant");
        }
        winner = player;
        status = Status.COMPLETED;
    }

    public Status status() { return status; }
    public java.util.Optional<String> winner() {
        return java.util.Optional.ofNullable(winner);
    }
}

测试围绕可观察行为组织:

java
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;

class MatchTest {
    @Test
    void recordsWinnerAfterMatchStarts() {
        Match match = new Match("A", "B");

        match.start();
        match.recordWinner("B");

        assertAll(
                () -> assertEquals(Match.Status.COMPLETED, match.status()),
                () -> assertEquals("B", match.winner().orElseThrow())
        );
    }

    @Test
    void rejectsWinnerBeforeStartWithoutChangingState() {
        Match match = new Match("A", "B");

        assertThrows(IllegalStateException.class,
                () -> match.recordWinner("A"));
        assertEquals(Match.Status.READY, match.status());
        assertTrue(match.winner().isEmpty());
    }

    @Test
    void rejectsInvalidWinnerWithoutChangingStartedMatch() {
        Match match = new Match("A", "B");
        match.start();
        assertThrows(IllegalArgumentException.class,
                () -> match.recordWinner("C"));
        assertThrows(NullPointerException.class,
                () -> match.recordWinner(null));
        assertEquals(Match.Status.IN_PROGRESS, match.status());
        assertTrue(match.winner().isEmpty());
    }

    @Test
    void completedMatchCannotRestartOrChangeWinner() {
        Match match = new Match("A", "B");
        match.start();
        match.recordWinner("A");
        assertThrows(IllegalStateException.class, match::start);
        assertThrows(IllegalStateException.class,
                () -> match.recordWinner("B"));
        assertEquals(Match.Status.COMPLETED, match.status());
        assertEquals("A", match.winner().orElseThrow());
    }
}

一个测试可以有多个相关断言,只要它们共同描述同一行为。为了追求“一测试一断言”而拆散状态、返回值和副作用,反而会重复准备代码并降低可读性。

用等价类和边界值选案例 ​

不要凭感觉枚举输入。以 level 允许范围 [18, 100] 为例,至少考虑:

  • 合法等价类:18–100;
  • 下边界:17、18、19;
  • 上边界:99、100、101;
  • 类型允许但领域不允许的极值。

将下面的 Eligibility.java 放入 src/main/java,EligibilityTest.java 放入 src/test/java。上下界都包含在本例契约中。运行 mvn -Dtest=MatchTest,EligibilityTest test,应执行 4 个比赛测试和 8 组参数化案例。

java
public final class Eligibility {
    private Eligibility() {}
    public static boolean acceptsLevel(int level) {
        return level >= 18 && level <= 100;
    }
}

参数化测试适合表达同一规则的多组案例:

java
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class EligibilityTest {
    @ParameterizedTest
    @CsvSource({
            "-2147483648, false",
            "17, false",
            "18, true",
            "19, true",
            "99, true",
            "100, true",
            "101, false",
            "2147483647, false"
    })
    void acceptsOnlySupportedLevels(int level, boolean expected) {
        assertEquals(expected, Eligibility.acceptsLevel(level));
    }
}

测试替身有不同职责 ​

“Mock”不应泛指所有替代对象:

替身用途
Stub为测试提供预设返回值
Fake用简化但可运行的实现替代真实组件,如内存仓库
Spy记录调用,供测试查询
Mock预设或验证交互协议

如果只需要一个 Map 就能实现稳定的内存仓库,Fake 往往比大量 stubbing 更接近真实语义。Fake 仍要符合被替代接口的契约:Map 不会复现 SQL 唯一约束或事务隔离。Mock 可以检查“save 返回后才发布”的进程内协议。下面的完整测试把简化类型嵌在测试类内,不代表实际报名规则。给 1.1 的 POM 添加 test scope 的 org.mockito:mockito-core:5.20.0,将 RegistrationServiceTest.java 放入 src/test/java,运行 mvn -Dtest=RegistrationServiceTest test。

Mockito 5 默认使用 inline mock maker。若测试 JVM 禁止动态附加 agent,按其 Java 21 配置说明 将 Mockito 配为启动时 Java agent;mock maker 初始化失败属于运行环境问题,不是业务断言失败。

java
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;

class RegistrationServiceTest {
    record Registration(String id, String tournament, String player) {}
    record RegistrationConfirmed(String id) {}
    interface RegistrationRepository { void save(Registration registration); }
    interface EventPublisher { void publish(RegistrationConfirmed event); }

    record RegistrationService(RegistrationRepository repository, EventPublisher events) {
        Registration register(String id, String tournament, String player) {
            Registration registration = new Registration(id, tournament, player);
            repository.save(registration);
            events.publish(new RegistrationConfirmed(id));
            return registration;
        }
    }

    @Test
    void publishesEventAfterSaveReturns() {
        RegistrationRepository repository = mock(RegistrationRepository.class);
        EventPublisher events = mock(EventPublisher.class);
        RegistrationService service = new RegistrationService(repository, events);

        Registration result = service.register("r-1", "t-1", "player-A");

        assertEquals(new Registration("r-1", "t-1", "player-A"), result);
        var order = inOrder(repository, events);
        order.verify(repository).save(result);
        order.verify(events).publish(new RegistrationConfirmed(result.id()));
    }

    @Test
    void doesNotPublishWhenSaveThrows() {
        RegistrationRepository repository = mock(RegistrationRepository.class);
        EventPublisher events = mock(EventPublisher.class);
        doThrow(new IllegalStateException("write failed"))
                .when(repository).save(any(Registration.class));
        RegistrationService service = new RegistrationService(repository, events);

        assertThrows(IllegalStateException.class,
                () -> service.register("r-1", "t-1", "player-A"));
        verifyNoInteractions(events);
    }
}

inOrder 验证相对顺序;分开写 verify 并不验证先后。这里也只证明 save 返回后才调用 publish:ORM 可能稍后才 flush 或 commit,进程也可能在两次调用间退出。可靠发布需要相应的事务与 Outbox 设计,见 15.2。

不要 mock 值对象、集合或自己并不拥有的复杂第三方 API。可先在自有端口后面封装第三方 API,再对端口使用替身,并用集成测试验证适配器。

避免把实现细节焊死 ​

下面是 Mockito 方法体片段,只检查调用发生,不检查顺序;它们仍可能把缓存实现钉得过死:

java
verify(cache).get(key);
verify(repository).find(key);
verify(cache).put(key, value);

若测试真正关心的是“缓存未命中仍返回报名记录”,就应优先断言结果;严格调用顺序会让合法重构也导致失败。交互验证用于协议,状态验证用于结果,不要无差别验证每次调用。

一个可靠单元测试的性质 ​

  • 可重复:同一代码和输入得到同一结果;
  • 隔离外部不确定性:不依赖真实网络、当前时间或随机全局状态;
  • 快速且易定位:失败能指向小范围行为;
  • 只通过公开行为观察结果;
  • 明确覆盖正常、边界、失败和状态不变性。

下一节跨出进程内边界,处理数据库、服务契约、端到端流程和测试在 CI 中的分层运行。

参考资料 ​

Built with VitePress | Software Systems Atlas