测试

“我改了点代码,应该没事吧?“——这句话是程序员最常被打脸的瞬间。改了 A,B 突然挂了;修了 bug,引入两个新 bug。靠”手动点一遍”测,慢、累、漏。自动化测试就是解药——把测试写成代码,每次改动都自动跑一遍,几分钟告诉你哪里坏了。

这一章我们看 Java 测试生态的核心——JUnit 5(测试框架)+ Mockito(模拟框架)+ JaCoCo(覆盖率),以及 TDD(测试驱动开发)思想。

一、为什么需要测试

不写测试的项目长这样:

  • 改代码像拆炸弹 —— 不敢动,怕踩雷。
  • bug 反复出现 —— 同一个 bug 修了又犯,没回归保护。
  • 不敢重构 —— 代码烂了也不敢动,因为没人知道动了哪里会坏。
  • 手动回归到吐 —— 上线前点一遍功能,一晚上搞不完。

写测试的好处:

  1. 回归保护 —— 改代码立刻知道有没有破坏旧功能。
  2. 设计反馈 —— 难测的代码往往设计差(耦合重),测试逼你解耦。
  3. 活的文档 —— 测试用例就是”这段代码该怎么用”的实例。
  4. 敢于重构 —— 有测试兜底,敢动烂代码。
  5. 减少手动测试 —— 一键跑几千个测试,省人力。

二、测试金字塔

测试金字塔(Test Pyramid)——层次从底到顶:

        /\
       /E2E\          少而慢 (端到端, 启动整个应用)
      /------\
     /集成测试\        中等 (多模块协作, 真实数据库)
    /----------\
   /  单元测试   \     多而快 (单方法, mock 依赖)
  /--------------\
层次范围速度数量
单元测试单个类/方法毫秒级70%+
集成测试多模块协作秒级20%
端到端测试(E2E)整个系统分钟级10%

塔基要厚——单元测试最多最快,每次提交都跑。塔尖要薄——E2E 慢且脆,跑关键路径即可。倒金字塔(E2E 多,单元少)是反模式——慢、难维护。

三、JUnit 5

JUnit 是 Java 的事实标准测试框架。JUnit 5(2017 年)由三个子模块组成:

  • JUnit Platform —— 测试运行的基础平台。
  • JUnit Jupiter —— 新的编程/扩展模型(@Test 等注解在这)。
  • JUnit Vintage —— 兼容 JUnit 3/4 老测试。

3.1 Maven 集成

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.0</version>
    <scope>test</scope>
</dependency>

<build>
    <plugins>
        <plugin>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.1.2</version>
        </plugin>
    </plugins>
</build>

3.2 第一个测试

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

class CalculatorTest {
    private Calculator calc;

    @BeforeEach
    void setUp() {
        calc = new Calculator();   // 每个测试前都跑
    }

    @Test
    @DisplayName("加法应该正确返回两数之和")
    void shouldAddTwoNumbers() {
        assertEquals(5, calc.add(2, 3));
        assertEquals(0, calc.add(-1, 1));
        assertEquals(-5, calc.add(-2, -3));
    }

    @Test
    void shouldThrowOnDivideByZero() {
        assertThrows(ArithmeticException.class, () -> calc.divide(1, 0));
    }
}

class Calculator {
    int add(int a, int b) { return a + b; }
    int divide(int a, int b) {
        if (b == 0) throw new ArithmeticException("/ by zero");
        return a / b;
    }
}

3.3 常用注解

注解作用
@Test标记测试方法
@BeforeEach每个测试方法前执行(初始化)
@AfterEach每个测试方法后执行(清理)
@BeforeAll所有测试前执行一次(static,初始化重资源)
@AfterAll所有测试后执行一次(static,清理重资源)
@DisplayName自定义测试显示名
@Disabled跳过此测试
@Tag给测试打标签,可选择性跑
@Nested嵌套测试类(按场景分组)
@ParameterizedTest参数化测试(同逻辑多组数据)
@ValueSource参数化测试的数据源

3.4 参数化测试

同一逻辑测多组数据,避免重复代码:

@ParameterizedTest
@DisplayName("加法参数化测试")
@ValueSource(ints = {1, 5, 10, 100, -1})
void shouldAddCorrectly(int n) {
    assertEquals(n + n, calc.add(n, n));
}

@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({
    "1, 2, 3",
    "0, 0, 0",
    "-1, 1, 0",
    "100, 200, 300"
})
void shouldAddCsv(int a, int b, int expected) {
    assertEquals(expected, calc.add(a, b));
}

@ParameterizedTest 配合数据源(@ValueSource/@CsvSource/@MethodSource)能跑多组数据,名字用 {0}/{1} 占位符显示参数值。

3.5 断言

断言用途
assertEquals(expected, actual)相等
assertNotEquals不等
assertTrue(cond) / assertFalse真/假
assertNull / assertNotNullnull/非null
assertSame同一对象(==)
assertArrayEquals数组相等
assertIterableEqualsIterable 相等
assertThrows(Exception.class, () -> ...)抛异常
assertDoesNotThrow不抛异常
assertAll(...)批量断言,全部跑完才报错
assertTimeout超时

assertAll 是个亮点——普通断言遇到第一个失败就停,assertAll 把所有断言都跑完一起报告:

@Test
void shouldValidateUser() {
    User u = new User("张三", 20);
    assertAll("用户属性",
        () -> assertEquals("张三", u.getName()),
        () -> assertEquals(20, u.getAge()),
        () -> assertNotNull(u.getId())
    );
    // 即使第一个失败, 后面也会跑, 一次看到所有问题
}

四、Mockito:模拟依赖

被测类往往依赖其他类(数据库、网络、时间)。单元测试不该真连数据库——慢、不稳定。Mockito 提供模拟对象——告诉它”调用 X 时返回 Y”,验证”调用 X 了没”。

4.1 mock 与 when

import static org.mockito.Mockito.*;

// 1. 创建 mock
UserRepository mockRepo = mock(UserRepository.class);

// 2. 桩: when X then Y
when(mockRepo.findById(1)).thenReturn(new User(1, "张三"));
when(mockRepo.findById(99)).thenThrow(new RuntimeException("不存在"));

// 3. 用
User u = mockRepo.findById(1);   // 返回 张三
User u2 = mockRepo.findById(2);  // 返回 null (没设置)

// 4. 验证: 确实调用了
verify(mockRepo).findById(1);          // 至少一次
verify(mockRepo, times(2)).findById(anyInt());  // 调了 2 次
verify(mockRepo, never()).deleteById(anyInt()); // 从没调过

4.2 测试 Service(依赖 Repository)

class UserService {
    private final UserRepository repo;
    public UserService(UserRepository repo) { this.repo = repo; }
    public String getUserName(int id) {
        User u = repo.findById(id);
        if (u == null) throw new IllegalArgumentException("用户不存在");
        return u.getName();
    }
}

class UserServiceTest {
    @Test
    void shouldReturnName() {
        UserRepository mockRepo = mock(UserRepository.class);
        when(mockRepo.findById(1)).thenReturn(new User(1, "张三"));

        UserService service = new UserService(mockRepo);
        assertEquals("张三", service.getUserName(1));
        verify(mockRepo).findById(1);   // 验证确实调了
    }

    @Test
    void shouldThrowWhenUserNotFound() {
        UserRepository mockRepo = mock(UserRepository.class);
        when(mockRepo.findById(99)).thenReturn(null);

        UserService service = new UserService(mockRepo);
        assertThrows(IllegalArgumentException.class, () -> service.getUserName(99));
    }
}

Mock 让你聚焦被测类——UserService 依赖 UserRepository,mock 掉 Repo,专心测 Service 的逻辑。这就是”单元测试”——只测一个单元。

五、测试覆盖率:JaCoCo

覆盖率(Coverage)——测试跑过多少行代码。JaCoCo(Java Code Coverage)是主流工具。

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.11</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/index.html,看每个类的覆盖率:

  • 行覆盖率 —— 跑过的代码行比例。
  • 分支覆盖率 —— if/else 各分支跑过的比例。
  • 方法覆盖率 —— 调用的方法比例。

重要认知:高覆盖率 ≠ 高质量。100% 覆盖率也可能漏掉关键边界——比如只测了 add(2, 3) 而没测 add(Integer.MAX_VALUE, 1) 溢出。覆盖率是”下限”,不是”目标”。盲目追 100% 会让团队写无意义测试应付指标。

六、TDD:测试驱动开发

TDD(Test-Driven Development)——Kent Beck 提出的开发节奏:先写测试,再写实现。三步循环(红-绿-重构):

红: 写测试 -> 跑 -> 失败 (功能还没实现)
绿: 写最少代码让测试通过
重构: 改善代码结构, 测试仍要通过

例子——开发计算斐波那契数的方法:

// 1. 红: 先写测试
@Test
void shouldReturn1ForFib1() {
    assertEquals(1, fib(1));
}
// 2. 绿: 最简实现 (硬编码)
static int fib(int n) { return 1; }
// 3. 加新测试
@Test
void shouldReturn2ForFib3() {
    assertEquals(2, fib(3));
}
// 4. 修改实现
static int fib(int n) {
    if (n <= 2) return 1;
    return fib(n - 1) + fib(n - 2);
}

TDD 的好处:

  • 小步前进 —— 每次只加一个测试,永远在”绿”的状态。
  • 设计驱动 —— 先写测试逼你想”这方法该怎么用”,倒逼好 API 设计。
  • 回归保护 —— 实现一开始就有测试。

但 TDD 不适合所有场景——UI、探索性原型、纯算法研究。它是个工具,不是教条。

七、实战:JUnit + Mockito 风格测试

由于 Piston 在线环境没有 JUnit 和 Mockito 依赖,下面用一个迷你测试框架模拟它们的 API——演示 @Test、断言、mock、verify 的核心思想。本地项目用真 JUnit + Mockito 时,代码风格完全一致。

Java · 在线运行

观察重点

  • @Test 标记测试方法,@BeforeEach 每个测试前跑——JUnit 的核心机制。
  • assertEquals/assertThrows/assertAll——分别测相等、抛异常、批量断言。
  • assertAll 一次报告所有失败——比普通断言更友好。
  • mock 把 UserRepository 替换成可控对象——Service 测试不依赖真实数据库。
  • 测试名表达意图——shouldThrowOnDivByZerotest1 可读得多。

八、测试最佳实践

8.1 命名

测试名要表达意图:

// 坏
@Test void test1() { ... }
@Test void testAdd() { ... }

// 好
@Test void shouldReturn5WhenAdd2And3() { ... }
@Test void shouldThrowWhenDivideByZero() { ... }

8.2 AAA 模式

每个测试三段式:

@Test
void shouldReturnSum() {
    // Arrange (准备)
    Calculator calc = new Calculator();

    // Act (执行)
    int result = calc.add(2, 3);

    // Assert (断言)
    assertEquals(5, result);
}

8.3 一个测试一件事

一个测试只断言一个行为——失败时一眼知道哪里坏了。多个相关断言用 assertAll

8.4 测试要独立

测试之间不能依赖执行顺序——JUnit 不保证顺序。每个测试自己准备数据(@BeforeEach),不共享状态。

8.5 别 mock 被测对象

// 错: mock 了被测对象, 测了个寂寞
UserService mock = mock(UserService.class);
when(mock.getName(1)).thenReturn("张三");
assertEquals("张三", mock.getName(1));   // 没意义!

// 对: mock 被测对象的依赖
UserRepository mockRepo = mock(UserRepository.class);
when(mockRepo.findById(1)).thenReturn(new User("张三"));
UserService service = new UserService(mockRepo);   // 被测对象用真的

九、本章小结

概念核心要点
测试金字塔单元多快、E2E 少慢
JUnit 5@Test/@BeforeEach/@ParameterizedTest
断言assertEquals/assertThrows/assertAll
Mockitomock/when().thenReturn()/verify
覆盖率下限不是目标,JaCoCo 是主流工具
TDD红-绿-重构,先写测试再写实现
AAAArrange-Act-Assert 三段式

记忆口诀

  • 金字塔——单元多、集成中、E2E 少。
  • JUnit 注解——@Test 测、@BeforeEach 前、@ParameterizedTest 参数化。
  • 断言三剑客——assertEquals 相等、assertThrows 异常、assertAll 批量。
  • Mockito 三步——mock 创建、when().thenReturn() 桩、verify 验证。
  • 不 mock 被测对象——mock 它的依赖,测它本身。
  • 覆盖率是底线——100% 不等于没 bug。

结语:从能跑到可测

这一章我们看了测试——从 JUnit 到 Mockito,从覆盖率到 TDD。会写代码不等于会写好代码,会测试才是工程师的分水岭。下一章看另一个工程化利器——日志框架,让你在生产环境能看清楚程序在干什么。