3、单元测试最佳实践:可测试性设计、测试覆盖率、TDD与BDD入门

单元测试这件事,说起来简单,做起来全是坑。我见过太多团队,测试代码写了一堆,但每次改业务代码,测试也跟着崩。为什么?说白了,就是代码本身不好测。今天咱们聊聊怎么让代码「天生好测」。

3.1 可测试性设计:让代码主动配合测试

你想想看,一个方法里 new 了个数据库连接,或者调了个静态工具类,你怎么测?要么启动整个服务,要么 mock 半天。我个人的习惯是:写代码之前,先问自己一句「这玩意儿能单元测试吗?」。

3.4.1 依赖注入:把「依赖」交出去

依赖注入不是什么高深概念。说白了,就是别让代码自己 new 对象。把依赖从「内部创建」变成「外部传入」。

// 不好的写法
public class OrderService {
    private PaymentGateway gateway = new AlipayGateway();
    
    public boolean pay(Order order) {
        return gateway.charge(order.getAmount());
    }
}

// 可测试的写法
public class OrderService {
    private PaymentGateway gateway;
    
    // 依赖从构造器传入
    public OrderService(PaymentGateway gateway) {
        this.gateway = gateway;
    }
    
    public boolean pay(Order order) {
        return gateway.charge(order.getAmount());
    }
}

我在项目中遇到过这样的场景:一个老系统里,所有 Service 都是自己 new DAO。想加单元测试?得先把所有 new 改成注入。改了一个月,测试还没写几行。所以,从一开始就用构造器注入或者 Setter 注入,后面会省很多事。

我的小技巧: 写接口时,先写测试用例,再写实现。这样你自然就会把依赖设计成可注入的。

3.4.2 接口隔离:别让测试依赖不需要的东西

接口隔离原则,说白了就是「别把胖接口塞给测试」。如果一个接口有 10 个方法,但你的类只用其中 2 个,那测试时就得 mock 另外 8 个。烦不烦?

// 胖接口
public interface UserRepository {
    User findById(Long id);
    List<User> findAll();
    void save(User user);
    void delete(Long id);
    void batchSave(List<User> users);
    // ... 还有更多
}

// 瘦接口
public interface UserQuery {
    User findById(Long id);
}

public interface UserCommand {
    void save(User user);
    void delete(Long id);
}

嗯,这里要注意:不是让你把接口拆得稀碎。而是让每个接口只做一件事。测试时,你只需要 mock 那个小接口,清爽多了。

3.2 测试覆盖率:Jacoco 的正确打开方式

覆盖率这东西,我见过两种极端:一种是完全不看,上线全靠胆;另一种是死磕 100%,为了凑数字写一堆没用的测试。我个人觉得,覆盖率是工具,不是目标。

3.2.1 什么是 Jacoco

Jacoco 是 Java 圈最常用的覆盖率工具。它能告诉你:哪些代码被执行了,哪些分支没走到。说白了,就是一张「测试地图」。

指标 含义 我建议的目标
行覆盖率 代码行被执行的比例 80% 以上
分支覆盖率 if/else、switch 等分支的覆盖情况 70% 以上
方法覆盖率 方法被调用的比例 90% 以上
类覆盖率 类被加载的比例 95% 以上
我曾经踩过的坑: 有一回,我把行覆盖率堆到了 95%,但分支覆盖率只有 40%。结果上线后,一个 else 分支没测到,线上出了 P0 事故。从那以后,我重点看分支覆盖率。

3.2.2 在 Maven 中集成 Jacoco

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.8</version>
    <executions>
        <execution>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
    </executions>
</plugin>

跑完 mvn test,在 target/site/jacoco 目录下就能看到 HTML 报告。红绿相间,一目了然。红色就是没测到的代码,绿色就是覆盖到的。

3.2.3 覆盖率不是万能的

你想想看,一个方法里全是 getter/setter,覆盖率 100% 又怎样?业务逻辑可能一点没测到。我见过有人为了凑覆盖率,给每个 getter 都写了个测试。嗯,这纯粹是自欺欺人。

我的原则: 核心业务逻辑覆盖率 90% 以上,工具类、配置类不强制。别为了数字好看,写一堆垃圾测试。

3.3 TDD 与 BDD 入门:从「先写代码」到「先写行为」

TDD 和 BDD,听起来像两个高大上的概念。其实没那么玄乎。TDD 是「测试驱动开发」,BDD 是「行为驱动开发」。说白了,都是让你在写代码之前,先想清楚「要测什么」。

3.3.1 TDD:红-绿-重构

TDD 的核心循环就三步:

  1. 红: 先写一个会失败的测试
  2. 绿: 写最简代码让测试通过
  3. 重构: 优化代码,保持测试通过
// 第一步:写测试(红)
@Test
public void should_calculate_discount_for_vip_user() {
    DiscountCalculator calculator = new DiscountCalculator();
    double result = calculator.calculate("VIP", 100.0);
    assertEquals(80.0, result, 0.01); // VIP 打 8 折
}

// 第二步:写实现(绿)
public class DiscountCalculator {
    public double calculate(String userType, double amount) {
        if ("VIP".equals(userType)) {
            return amount * 0.8;
        }
        return amount;
    }
}

// 第三步:重构(比如提取常量)
private static final double VIP_DISCOUNT_RATE = 0.8;

我个人习惯用 TDD 写工具类或者算法。为什么?因为这类逻辑边界清晰,测试写起来快。但如果是 UI 或者复杂业务流程,TDD 反而会拖慢节奏。别迷信,选对场景。

3.3.2 BDD:用「故事」驱动开发

BDD 和 TDD 最大的区别是什么?TDD 关注「代码对不对」,BDD 关注「功能对不对」。BDD 用自然语言描述行为,业务人员也能看懂。

// 使用 Cucumber 的 BDD 示例
Feature: 用户登录
  Scenario: 输入正确的用户名和密码
    Given 用户访问登录页面
    When 输入用户名 "admin" 和密码 "123456"
    Then 登录成功,跳转到首页

// 对应的 Java 代码
@Given("用户访问登录页面")
public void user_visits_login_page() {
    // 模拟访问登录页
}

@When("输入用户名 {string} 和密码 {string}")
public void user_enters_credentials(String username, String password) {
    loginPage.login(username, password);
}

@Then("登录成功,跳转到首页")
public void login_successful() {
    assertTrue(dashboardPage.isDisplayed());
}
我的经验: BDD 最适合做集成测试和端到端测试。单元测试用 TDD 就够了,别硬上 BDD,否则测试代码会变得很啰嗦。

3.3.3 TDD vs BDD:怎么选?

维度 TDD BDD
关注点 代码逻辑 业务行为
受众 开发人员 开发 + 测试 + 业务
粒度 方法级别 场景级别
适合场景 工具类、算法、核心逻辑 业务流程、用户故事

你想想看,如果团队里业务人员愿意参与,BDD 是个好东西。但如果业务人员根本不看测试,那 BDD 就变成了「开发自嗨」。我建议:小团队先用 TDD 练手,等测试文化成熟了再引入 BDD。

总结一下: 可测试性设计是基础,Jacoco 是工具,TDD/BDD 是方法论。三者结合,才能写出高质量的单元测试。别想着一步到位,先从「让代码好测」开始。