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% 以上 |
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 都写了个测试。嗯,这纯粹是自欺欺人。
3.3 TDD 与 BDD 入门:从「先写代码」到「先写行为」
TDD 和 BDD,听起来像两个高大上的概念。其实没那么玄乎。TDD 是「测试驱动开发」,BDD 是「行为驱动开发」。说白了,都是让你在写代码之前,先想清楚「要测什么」。
3.3.1 TDD:红-绿-重构
TDD 的核心循环就三步:
- 红: 先写一个会失败的测试
- 绿: 写最简代码让测试通过
- 重构: 优化代码,保持测试通过
// 第一步:写测试(红)
@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());
}
3.3.3 TDD vs BDD:怎么选?
| 维度 | TDD | BDD |
|---|---|---|
| 关注点 | 代码逻辑 | 业务行为 |
| 受众 | 开发人员 | 开发 + 测试 + 业务 |
| 粒度 | 方法级别 | 场景级别 |
| 适合场景 | 工具类、算法、核心逻辑 | 业务流程、用户故事 |
你想想看,如果团队里业务人员愿意参与,BDD 是个好东西。但如果业务人员根本不看测试,那 BDD 就变成了「开发自嗨」。我建议:小团队先用 TDD 练手,等测试文化成熟了再引入 BDD。