跳至主要內容

Java 单元测试实战:JUnit 5 + Mockito 从入门到自信

郑天祺大约 14 分钟测试JUnit测试ROI

Java 单元测试实战:JUnit 5 + Mockito 从入门到自信

写好单元测试不光需要工具,更需要思维。本文从 ROI 分析到实战案例,带你全面掌握 Java 单元测试。

1. 为什么要写单元测试——ROI 分析

1.1 不写测试的真实代价

很多团队觉得写测试"浪费时间"。但让我们算一笔账:

场景:一个电商系统的订单金额计算逻辑有 Bug

没有测试:
  开发 → 写代码(2h) → 提交 → 测试发现Bug → 修Bug(30min) → 重新提测(1h) → 通过
  上线 → 用户投诉 → 排查(1h) → 改Bug(1h) → 加急上线(2h)
  总时间:7.5 小时 + 线上事故风险

有测试:
  开发 → 写测试(1h) → 写代码(1h) → 测试通过 → 提交
  上线 → OK
  总时间:2 小时,零线上风险

单元测试的实际 ROI

影响维度无测试有测试差异
Bug 发现时间上线后编写阶段提前发现 85% 的 Bug
修 Bug 成本10-100x1x节省 90%
重构信心提心吊胆大胆重构重构速度 ×3
新人上手靠问人和猜看测试即文档上手速度 ×2
文档维护容易过期测试即文档,永不过期零维护成本

1.2 什么应该测试

必须测试(高优先级):
├── 业务逻辑层(Service):核心计算、状态流转
├── 工具类(Utils):无副作用的纯函数
├── 领域模型(Domain):实体之间的关联和约束
└── 算法逻辑:排序、计算、验证

建议测试(中优先级):
├── Controller:请求参数校验、响应格式
├── Repository:自定义查询方法
└── 配置类:条件装配逻辑

可以不做(低优先级):
├── POJO 的 getter/setter
├── 纯配置类(无逻辑)
├── 框架生成的代码
└── 一行简单的委托代码

2. JUnit 5 核心注解

2.1 基础注解速查

注解说明作用域
@Test标记测试方法方法
@BeforeAll所有测试前执行一次(必须 static)
@AfterAll所有测试后执行一次(必须 static)
@BeforeEach每个测试前执行方法
@AfterEach每个测试后执行方法
@DisplayName测试方法的可读名称方法/类
@Disabled跳过测试方法/类
@Tag给测试打标签(分类)方法/类
@Nested内嵌测试类(分组)
@ParameterizedTest参数化测试方法
@RepeatedTest重复执行测试方法
@Timeout测试超时限制方法/类
@TestMethodOrder指定测试执行顺序

2.2 生命周期演示

@DisplayName("订单服务测试")
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private OrderService orderService;

    // ═══ 生命周期方法 ═══

    @BeforeAll
    static void beforeAll() {
        // 整个测试类运行前执行一次
        // 适合:初始化数据库连接池、启动嵌入式服务等重量级操作
        System.out.println("【BeforeAll】初始化共享资源");
    }

    @AfterAll
    static void afterAll() {
        // 整个测试类运行完后执行一次
        // 适合:关闭连接池、清理共享资源
        System.out.println("【AfterAll】清理共享资源");
    }

    @BeforeEach
    void setUp() {
        // 每个 @Test 方法执行前运行
        // 适合:初始化 Mock、准备测试数据
        System.out.println("  【BeforeEach】准备测试数据");
    }

    @AfterEach
    void tearDown() {
        // 每个 @Test 方法执行后运行
        // 适合:清理测试数据、重置 Mock
        System.out.println("  【AfterEach】清理测试数据");
    }

    // ═══ 测试方法 ═══

    @Test
    @DisplayName("创建订单成功 → 库存充足、支付成功")
    void shouldCreateOrderSuccessfully() {
        System.out.println("    【测试】创建订单成功");
        // ...
    }

    @Test
    @DisplayName("创建订单失败 → 库存不足")
    void shouldFailWhenStockInsufficient() {
        System.out.println("    【测试】库存不足");
        // ...
    }

    // 执行顺序:
    // BeforeAll → [BeforeEach → 测试1 → AfterEach] → [BeforeEach → 测试2 → AfterEach] → AfterAll
}

2.3 参数化测试

class CalculatorTest {

    private final Calculator calculator = new Calculator();

    // ─── @ValueSource:简单值 ───
    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3, 4, 5, 100, Integer.MAX_VALUE})
    @DisplayName("加法运算 → 正数相加")
    void addition(int input) {
        assertTrue(calculator.add(input, 0) == input);
    }

    // ─── @CsvSource:多参数 ───
    @ParameterizedTest
    @CsvSource({
        "1, 1, 2",
        "2, 3, 5",
        "100, 200, 300",
        "-1, 1, 0",
        "0, 0, 0"
    })
    @DisplayName("加法运算 → 验证多种情况")
    void additionWithMultipleParams(int a, int b, int expected) {
        assertEquals(expected, calculator.add(a, b));
    }

    // ─── @CsvFileSource:从文件读取测试数据 ───
    @ParameterizedTest
    @CsvFileSource(resources = "/test-data/addition.csv", numLinesToSkip = 1)
    @DisplayName("加法运算 → 批量数据")
    void additionFromFile(int a, int b, int expected) {
        assertEquals(expected, calculator.add(a, b));
    }

    // ─── @MethodSource:从方法获取数据 ───
    @ParameterizedTest
    @MethodSource("provideDivisionTestData")
    @DisplayName("除法运算 → 复杂场景")
    void division(int dividend, int divisor, Integer expected,
                  Class<? extends Exception> expectedException) {
        if (expectedException != null) {
            assertThrows(expectedException,
                () -> calculator.divide(dividend, divisor));
        } else {
            assertEquals(expected, calculator.divide(dividend, divisor));
        }
    }

    static Stream<Arguments> provideDivisionTestData() {
        return Stream.of(
            Arguments.of(10, 2, 5, null),
            Arguments.of(7, 2, 3, null),     // 整数除法
            Arguments.of(0, 5, 0, null),
            Arguments.of(10, 0, null, ArithmeticException.class)
        );
    }

    // ─── @EnumSource:枚举值 ───
    @ParameterizedTest
    @EnumSource(value = OrderStatus.class,
                names = {"PENDING", "CONFIRMED"})
    @DisplayName("可取消的状态")
    void canCancel(OrderStatus status) {
        assertTrue(status.canCancel());
    }
}

2.4 @Nested:组织复杂测试

@DisplayName("订单服务")
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @InjectMocks
    private OrderService orderService;

    @Nested
    @DisplayName("创建订单")
    class CreateOrder {

        @Test
        @DisplayName("成功 → 库存充足")
        void successWhenStockSufficient() { /* ... */ }

        @Test
        @DisplayName("失败 → 库存不足")
        void failWhenStockInsufficient() { /* ... */ }

        @Test
        @DisplayName("失败 → 用户已禁用")
        void failWhenUserDisabled() { /* ... */ }
    }

    @Nested
    @DisplayName("取消订单")
    class CancelOrder {

        @Test
        @DisplayName("成功 → 待支付状态")
        void successWhenPending() { /* ... */ }

        @Test
        @DisplayName("失败 → 已发货状态不可取消")
        void failWhenAlreadyShipped() { /* ... */ }

        @Test
        @DisplayName("失败 → 非本人订单")
        void failWhenNotOwner() { /* ... */ }
    }

    @Nested
    @DisplayName("退款")
    class Refund {

        @Test
        @DisplayName("全额退款")
        void fullRefund() { /* ... */ }

        @Test
        @DisplayName("部分退款")
        void partialRefund() { /* ... */ }
    }
}

// 运行后的测试报告结构:
// 订单服务
//  ├── 创建订单
//  │    ├── 成功 → 库存充足          ✓
//  │    ├── 失败 → 库存不足          ✓
//  │    └── 失败 → 用户已禁用        ✓
//  ├── 取消订单
//  │    ├── 成功 → 待支付状态        ✓
//  │    ├── 失败 → 已发货状态不可取消   ✓
//  │    └── 失败 → 非本人订单        ✓
//  └── 退款
//       ├── 全额退款                ✓
//       └── 部分退款                ✓

3. 断言体系

3.1 基础断言

class AssertionShowcase {

    @Test
    @DisplayName("JUnit 5 断言大全")
    void showcase() {

        // ─── 相等性 ───
        assertEquals(42, answer());
        assertEquals(3.14, pi(), 0.001);              // 带容差的浮点数
        assertNotEquals(42, 24);

        // ─── 布尔判断 ───
        assertTrue(isValid());
        assertFalse(isExpired());

        // ─── Null 判断 ───
        assertNull(findById(999L));
        assertNotNull(findById(1L));

        // ─── 同一性(引用相等) ───
        assertSame(expectedObject, actualObject);
        assertNotSame(differentObject, actualObject);

        // ─── 数组比较 ───
        assertArrayEquals(new int[]{1, 2, 3}, getNumbers());

        // ─── 可迭代对象 ───
        assertIterableEquals(List.of("a", "b"), getList());

        // ─── 行匹配(字符串) ───
        assertLinesMatch(
            List.of("Hello", ">> skip any line >>", "World"),
            List.of("Hello", "something", "World")
        );

        // ─── 异常断言 ───
        assertThrows(IllegalArgumentException.class, () -> {
            service.validate(null);
        });

        // ─── 超时断言 ───
        assertTimeout(Duration.ofSeconds(1), () -> {
            // 必须在 1 秒内执行完成
            service.process();
        });

        // ─── assertAll:批量断言(所有断言都会执行,不会短路) ───
        User user = userService.findById(1L);
        assertAll("User properties",
            () -> assertEquals("John", user.getName()),
            () -> assertEquals(25, user.getAge()),
            () -> assertTrue(user.isActive()),
            () -> assertNotNull(user.getEmail())
        );
    }
}

3.2 assertThrows 详解

@Test
@DisplayName("assertThrows 的各种用法")
void testExceptionHandling() {

    // ─── 1. 基本用法 ───
    IllegalArgumentException ex = assertThrows(
        IllegalArgumentException.class,
        () -> service.transfer(null, 100)
    );
    assertEquals("账户不能为空", ex.getMessage());

    // ─── 2. 验证异常的 detail message ───
    BusinessException ex2 = assertThrows(
        BusinessException.class,
        () -> orderService.cancel(999L)
    );
    assertEquals("ORDER_NOT_FOUND", ex2.getErrorCode());
    assertEquals(404, ex2.getHttpStatus());

    // ─── 3. 验证异常不抛出 ───
    assertDoesNotThrow(() -> service.validate(validInput));

    // ─── 4. 检查异常中的嵌套原因 ───
    RuntimeException ex3 = assertThrows(RuntimeException.class,
        () -> service.doSomething()
    );
    assertTrue(ex3.getCause() instanceof IOException);
}

3.3 自定义断言(提高可读性)

// 自定义断言方法:让测试读起来像自然语言
public class OrderAssertions {

    public static void assertOrder(Order order) {
        assertNotNull(order, "订单不应为空");
        assertNotNull(order.getId(), "订单 ID 不应为空");
        assertNotNull(order.getStatus(), "订单状态不应为空");
        assertTrue(order.getTotalAmount().compareTo(BigDecimal.ZERO) > 0,
                "订单金额应大于 0");
    }

    public static void assertOrderStatus(Order order, OrderStatus expected) {
        assertEquals(expected, order.getStatus(),
                () -> String.format("期望订单 %d 状态为 %s,实际为 %s",
                        order.getId(), expected, order.getStatus()));
    }

    public static void assertRefunded(Order order, BigDecimal refundAmount) {
        assertEquals(OrderStatus.REFUNDED, order.getStatus());
        assertEquals(refundAmount, order.getRefundAmount());
    }
}

// 在测试中使用
@Test
@DisplayName("退款成功 → 订单状态变为已退款")
void refundSuccess() {
    Order order = orderService.refund(1L, new BigDecimal("100.00"));

    assertOrder(order);                         // 基础断言
    assertOrderStatus(order, OrderStatus.REFUNDED); // 状态断言
    assertRefunded(order, new BigDecimal("100.00")); // 退款断言
}

4. Mockito 核心用法

4.1 创建 Mock

// ─── 方式一:注解(推荐) ───
@ExtendWith(MockitoExtension.class)  // ← 必须加这个
class UserServiceTest {

    @Mock
    private UserRepository userRepository;  // 自动创建 Mock

    @Mock
    private EmailService emailService;

    @InjectMocks  // 自动将上述 @Mock 注入到被测试对象中
    private UserService userService;

    @BeforeEach
    void setUp() {
        // 如果不用 @ExtendWith(MockitoExtension.class),可以手动初始化:
        // MockitoAnnotations.openMocks(this);
    }
}

// ─── 方式二:手动创建 ───
UserRepository mockRepo = Mockito.mock(UserRepository.class);
EmailService mockEmail = Mockito.mock(EmailService.class);
UserService userService = new UserService(mockRepo, mockEmail);

4.2 Stubbing(打桩):定义行为

@Test
@DisplayName("Mockito Stubbing 示例")
void stubbingExamples() {

    // ─── 1. 返回固定值 ───
    when(userRepository.findById(1L))
        .thenReturn(Optional.of(new User(1L, "张三")));

    // ─── 2. 返回不同的值(连续调用) ───
    when(userRepository.count())
        .thenReturn(0L, 1L, 2L);  // 第1次返回0, 第2次返回1, 第3次返回2

    // ─── 3. 根据参数返回不同的值 ───
    when(userRepository.findById(anyLong())).thenAnswer(invocation -> {
        Long id = invocation.getArgument(0);
        return Optional.of(new User(id, "user-" + id));
    });

    // ─── 4. 抛出异常 ───
    when(userRepository.save(null))
        .thenThrow(new IllegalArgumentException("用户不能为空"));

    // ─── 5. 无返回值的方法(void) ───
    doNothing().when(emailService).sendWelcomeEmail(any(User.class));

    // 或者抛出异常
    doThrow(new RuntimeException("邮件服务不可用"))
        .when(emailService).sendWelcomeEmail(any(User.class));

    // ─── 6. 调用真实方法 ───
    when(userService.realMethod())
        .thenCallRealMethod();

    // ─── 7. 多次调用(第一种返回,后两种抛异常) ───
    when(paymentGateway.pay(any()))
        .thenReturn(new PaymentResult(true))       // 第1次
        .thenThrow(new PaymentFailedException())   // 第2次
        .thenThrow(new PaymentFailedException());  // 第3次
}

4.3 参数匹配器(Argument Matcher)

@Test
@DisplayName("参数匹配器示例")
void argumentMatchers() {

    // ─── 基本匹配器 ───
    when(repository.findByName(anyString())).thenReturn(user);
    when(repository.findById(anyLong())).thenReturn(Optional.of(user));
    when(repository.save(any(User.class))).thenReturn(user);

    // ─── 精确匹配 ───
    when(repository.findByName(eq("张三"))).thenReturn(user);

    // ─── 集合匹配 ───
    when(repository.saveAll(anyList())).thenReturn(users);

    // ─── 字符串匹配 ───
    when(repository.findByName(startsWith("张"))).thenReturn(users);
    when(repository.findByName(contains("test"))).thenReturn(users);
    when(repository.findByEmail(matches(".*@example\\.com"))).thenReturn(user);

    // ─── Null / NotNull ───
    when(repository.findByName(isNull())).thenThrow(IllegalArgumentException.class);
    when(repository.findByName(notNull())).thenReturn(user);

    // ─── 自定义匹配器 ───
    when(repository.save(argThat(u -> u.getAge() >= 18 && u.getAge() <= 65)))
        .thenReturn(user);

    // ─── ⚠️ 注意事项:一旦使用 anyXxx(),所有参数都必须使用匹配器 ───
    // ❌ 错误:混合使用
    // when(service.transfer(1L, any(BigDecimal.class)));  // 编译不报错,运行时报错

    // ✅ 正确:全部使用匹配器
    // when(service.transfer(eq(1L), any(BigDecimal.class)));
}

5. 行为验证

5.1 verify:验证方法是否被调用

@Test
@DisplayName("行为验证示例")
void verifyExamples() {

    // ─── 1. 验证调用了 1 次(默认) ───
    orderService.cancel(1L);
    verify(orderRepository).findById(1L);

    // ─── 2. 验证调用次数 ───
    verify(userRepository, times(1)).save(any(User.class));  // 精确 1 次
    verify(emailService, atLeastOnce()).sendEmail(any());    // 至少 1 次
    verify(emailService, atLeast(2)).sendEmail(any());       // 至少 2 次
    verify(emailService, atMostOnce()).sendEmail(any());     // 最多 1 次
    verify(emailService, atMost(5)).sendEmail(any());        // 最多 5 次
    verify(smsService, never()).sendSms(any());              // 从未调用

    // ─── 3. 验证调用顺序 ───
    InOrder inOrder = inOrder(orderRepository, emailService);
    inOrder.verify(orderRepository).findById(1L);    // 先查
    inOrder.verify(orderRepository).save(any());     // 再存
    inOrder.verify(emailService).sendEmail(any());   // 最后发邮件

    // ─── 4. 验证无其他交互 ───
    verify(orderRepository).findById(1L);
    verifyNoMoreInteractions(orderRepository);  // 确认除了 findById 没有其他调用

    // ─── 5. 超时验证(异步操作) ───
    verify(emailService, timeout(3000).atLeastOnce())
        .sendEmail(any(User.class));  // 等待 3 秒,验证至少被调用了 1 次
}

5.2 ArgumentCaptor:捕获参数

@Test
@DisplayName("ArgumentCaptor:验证传入方法的参数")
void captureArguments() {

    // ─── 1. 基本用法 ───
    ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);

    userService.createUser("张三", "zhangsan@example.com", 25);

    // 捕获 save 方法被调用时传入的 User 对象
    verify(userRepository).save(userCaptor.capture());

    User capturedUser = userCaptor.getValue();
    assertEquals("张三", capturedUser.getName());
    assertEquals("zhangsan@example.com", capturedUser.getEmail());
    assertEquals(25, capturedUser.getAge());

    // ─── 2. 捕获多次调用的所有参数 ───
    ArgumentCaptor<Long> idCaptor = ArgumentCaptor.forClass(Long.class);

    userService.deleteUsers(List.of(1L, 2L, 3L));

    verify(userRepository, times(3)).deleteById(idCaptor.capture());

    List<Long> deletedIds = idCaptor.getAllValues();
    assertEquals(List.of(1L, 2L, 3L), deletedIds);

    // ─── 3. @Captor 注解(推荐) ───
    @Captor
    ArgumentCaptor<Order> orderCaptor;

    @Test
    void testWithCaptorAnnotation() {
        orderService.placeOrder(request);
        verify(orderRepository).save(orderCaptor.capture());

        Order order = orderCaptor.getValue();
        assertEquals(OrderStatus.PENDING, order.getStatus());
        assertEquals(request.getUserId(), order.getUserId());
    }
}

6. 常见测试场景

6.1 Service 层测试

@ExtendWith(MockitoExtension.class)
@DisplayName("用户服务测试")
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private PasswordEncoder passwordEncoder;

    @Mock
    private EmailService emailService;

    @InjectMocks
    private UserService userService;

    @Nested
    @DisplayName("创建用户")
    class CreateUser {

        @Test
        @DisplayName("成功 → 用户名和邮箱不重复")
        void shouldCreateUserSuccessfully() {
            // Given(准备)
            CreateUserRequest request = CreateUserRequest.builder()
                    .username("zhangsan")
                    .password("P@ssw0rd!")
                    .email("zhangsan@example.com")
                    .build();

            when(userRepository.existsByUsername("zhangsan")).thenReturn(false);
            when(userRepository.existsByEmail("zhangsan@example.com")).thenReturn(false);
            when(passwordEncoder.encode("P@ssw0rd!")).thenReturn("$2a$10$encrypted...");
            when(userRepository.save(any(User.class)))
                    .thenAnswer(inv -> inv.getArgument(0));

            // When(执行)
            User user = userService.createUser(request);

            // Then(验证)
            assertAll("创建的用户属性",
                () -> assertEquals("zhangsan", user.getUsername()),
                () -> assertEquals("zhangsan@example.com", user.getEmail()),
                () -> assertEquals("$2a$10$encrypted...", user.getPassword()),
                () -> assertTrue(user.isEnabled())
            );

            // 验证行为
            verify(passwordEncoder).encode("P@ssw0rd!");
            verify(userRepository).save(any(User.class));
            verify(emailService).sendWelcomeEmail(any(User.class));
        }

        @Test
        @DisplayName("失败 → 用户名已存在")
        void shouldFailWhenUsernameExists() {
            CreateUserRequest request = CreateUserRequest.builder()
                    .username("existing_user")
                    .password("password123")
                    .email("new@example.com")
                    .build();

            when(userRepository.existsByUsername("existing_user")).thenReturn(true);

            BusinessException ex = assertThrows(
                BusinessException.class,
                () -> userService.createUser(request)
            );

            assertEquals("用户名已存在", ex.getMessage());
            assertEquals("USERNAME_DUPLICATE", ex.getErrorCode());

            // 验证 save 从未被调用(因为前面就抛异常了)
            verify(userRepository, never()).save(any(User.class));
            verify(emailService, never()).sendWelcomeEmail(any(User.class));
        }

        @Test
        @DisplayName("失败 → 密码强度不够")
        void shouldFailWhenPasswordTooWeak() {
            CreateUserRequest request = CreateUserRequest.builder()
                    .username("newuser")
                    .password("123")  // 太短
                    .build();

            assertThrows(
                IllegalArgumentException.class,
                () -> userService.createUser(request)
            );
        }
    }
}

6.2 Controller 层测试

@WebMvcTest(UserController.class)
@DisplayName("用户控制器测试")
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean  // Spring Boot 的 Mock Bean(不同于 Mockito 的 @Mock)
    private UserService userService;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    @DisplayName("GET /api/users/1 → 返回用户信息")
    void shouldReturnUser() throws Exception {
        User user = User.builder()
                .id(1L)
                .username("zhangsan")
                .email("zhangsan@example.com")
                .build();

        when(userService.findById(1L)).thenReturn(user);

        mockMvc.perform(get("/api/users/{id}", 1L)
                .header("Authorization", "Bearer valid-token")
                .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.data.id").value(1))
            .andExpect(jsonPath("$.data.username").value("zhangsan"))
            .andExpect(jsonPath("$.data.email").value("zhangsan@example.com"));
    }

    @Test
    @DisplayName("POST /api/users → 创建用户成功")
    void shouldCreateUser() throws Exception {
        CreateUserRequest request = CreateUserRequest.builder()
                .username("newuser")
                .password("Password@123")
                .email("new@example.com")
                .build();

        User user = User.builder().id(1L).username("newuser").build();
        when(userService.createUser(any(CreateUserRequest.class))).thenReturn(user);

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.data.id").value(1))
            .andExpect(jsonPath("$.data.username").value("newuser"));
    }

    @Test
    @DisplayName("POST /api/users → 参数校验失败")
    void shouldFailValidation() throws Exception {
        CreateUserRequest request = CreateUserRequest.builder()
                .username("")      // 空白 → @NotBlank 校验
                .password("123")   // 太短 → @Size(min=8) 校验
                .build();

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors").isArray())
            .andExpect(jsonPath("$.errors.length()").value(2));
    }
}

7. 可测试代码的设计原则

7.1 原则一:依赖注入

// ❌ 难以测试:硬编码依赖
public class OrderService {
    public void placeOrder(OrderRequest request) {
        // 直接 new → 无法 Mock
        PaymentGateway gateway = new PayPalGateway(config);
        NotificationSender sender = new EmailSender(config);

        gateway.pay(request.getAmount());
        sender.send("订单已创建");
    }
}

// ✅ 易于测试:依赖注入
public class OrderService {
    private final PaymentGateway paymentGateway;
    private final NotificationSender notificationSender;

    // 构造器注入 → Mock 可以轻松替换
    public OrderService(PaymentGateway paymentGateway,
                        NotificationSender notificationSender) {
        this.paymentGateway = paymentGateway;
        this.notificationSender = notificationSender;
    }

    public void placeOrder(OrderRequest request) {
        paymentGateway.pay(request.getAmount());
        notificationSender.send("订单已创建");
    }
}

7.2 原则二:避免静态方法

// ❌ 难以测试:静态方法无法 Mock(除非用 PowerMock/Mockito 5+)
public class PriceCalculator {
    public BigDecimal calculate(Order order) {
        // 静态方法调用 → 无法控制返回值
        BigDecimal exchangeRate = CurrencyUtil.getExchangeRate("USD", "CNY");
        return order.getAmount().multiply(exchangeRate);
    }
}

// ✅ 易于测试:将外部依赖注入
public class PriceCalculator {
    private final ExchangeRateService exchangeRateService;

    public PriceCalculator(ExchangeRateService exchangeRateService) {
        this.exchangeRateService = exchangeRateService;
    }

    public BigDecimal calculate(Order order) {
        // 实例方法调用 → 可以 Mock
        BigDecimal exchangeRate = exchangeRateService.getRate("USD", "CNY");
        return order.getAmount().multiply(exchangeRate);
    }
}

7.3 原则三:避免直接操作时间

// ❌ 难以测试:时间依赖导致测试不稳定
public class CouponService {
    public boolean isValid(Coupon coupon) {
        return coupon.getExpireTime().isAfter(LocalDateTime.now());
        // ↑ 测试结果依赖当前时间,每天可能不同!
    }
}

// ✅ 易于测试:时间源可注入
public class CouponService {
    private final Clock clock;

    public CouponService(Clock clock) {
        this.clock = clock;
    }

    public boolean isValid(Coupon coupon) {
        return coupon.getExpireTime().isAfter(LocalDateTime.now(clock));
    }
}

// 测试中:
@Test
void testExpiredCoupon() {
    // 固定时间为 2024-12-25
    Clock fixedClock = Clock.fixed(
        Instant.parse("2024-12-25T10:00:00Z"), ZoneId.systemDefault());

    CouponService service = new CouponService(fixedClock);
    Coupon coupon = new Coupon();
    coupon.setExpireTime(LocalDateTime.of(2024, 12, 24, 0, 0));  // 昨天

    assertFalse(service.isValid(coupon));  // ← 结果稳定、可预测
}

7.4 原则四:小而专注的方法

// ❌ 难以测试:大方法包含多种职责
public Order processOrder(OrderRequest request) {
    // 100 行代码:验证、计算价格、检查库存、创建订单、扣款、发通知...
}

// ✅ 易于测试:拆分为小方法
public Order processOrder(OrderRequest request) {
    validateOrder(request);
    BigDecimal totalPrice = calculateTotalPrice(request);
    checkInventory(request.getItems());
    Order order = createOrder(request, totalPrice);
    paymentService.charge(order.getId(), totalPrice);
    notificationService.notify(order);
    return order;
}
// 每个小方法可以独立测试

8. 代码覆盖率:JaCoCo 配置与解读

8.1 Maven 配置

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.12</version>
    <executions>
        <!-- 准备 JaCoCo Agent -->
        <execution>
            <id>prepare-agent</id>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <!-- 生成报告 -->
        <execution>
            <id>report</id>
            <phase>verify</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
        <!-- 覆盖率检查 -->
        <execution>
            <id>check</id>
            <goals>
                <goal>check</goal>
            </goals>
            <configuration>
                <rules>
                    <rule>
                        <element>PACKAGE</element>
                        <limits>
                            <!-- 行覆盖率 ≥ 80% -->
                            <limit>
                                <counter>LINE</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.80</minimum>
                            </limit>
                            <!-- 分支覆盖率 ≥ 70% -->
                            <limit>
                                <counter>BRANCH</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.70</minimum>
                            </limit>
                        </limits>
                    </rule>
                </rules>
                <!-- 排除不需要检查的类 -->
                <excludes>
                    <exclude>**/config/**</exclude>
                    <exclude>**/dto/**</exclude>
                    <exclude>**/entity/**</exclude>
                </excludes>
            </configuration>
        </execution>
    </executions>
</plugin>

8.2 JaCoCo 报告解读

┌─────────────────────────────────────────────────────────────┐
│  JaCoCo 覆盖率报告解读                                       │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  指令覆盖率(Instructions):最细粒度,衡量字节码级别              │
│  分支覆盖率(Branches):if/else、switch 等分支覆盖率             │
│  行覆盖率(Lines):源代码行级别覆盖(最常用)                     │
│  方法覆盖率(Methods):方法级别覆盖                             │
│  类覆盖率(Classes):类级别覆盖                                │
│                                                             │
│  一般关注:行覆盖率 + 分支覆盖率                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

8.3 覆盖率陷阱

⚠️ 高覆盖率 ≠ 高质量测试!

反例:
@Test
void testEverything() {
    User user = new User();
    user.setName("test");
    assertEquals("test", user.getName());

    Order order = orderService.createOrder(request);
    assertNotNull(order);

    // 这个测试拿到了 90% 的行覆盖率,但:
    // - 没有验证边界情况
    // - 没有验证异常路径
    // - 没有验证业务逻辑的正确性
    // → 纯粹为了覆盖率而写的"僵尸测试"
}

✅ 正确的思路:
  1. 先关注核心业务逻辑的测试质量
  2. 再关注覆盖率数值
  3. 对未覆盖的分支逐一分析(是真的不需要测,还是遗漏了?)
  4. 不要把覆盖率作为 KPI

总结

从一个测试新手到写出好测试的关键转变

┌────────────────────────────────────────────────────────────┐
│              测试能力成长路径                                 │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  阶段 1:知道工具怎么用                                       │
│      JUnit 5 注解、Mockito Mock、断言方法                      │
│                                                            │
│  阶段 2:知道写什么测试                                       │
│      正常路径、异常路径、边界条件、并发场景                        │
│                                                            │
│  阶段 3:知道怎么写可测试的代码                                 │
│      依赖注入、小而专注的方法、时间源可注入                         │
│                                                            │
│  阶段 4:把测试当作设计工具                                    │
│      先写测试 → 驱动设计 → 更好的代码结构(TDD)                  │
│                                                            │
└────────────────────────────────────────────────────────────┘

核心建议

  1. 不要追求 100% 覆盖率,追求 80% 有意义覆盖 + 核心逻辑 100% 覆盖
  2. 测试要读起来像文档,用 @DisplayName 描述业务场景
  3. 测试要快,单测应该在毫秒级完成(这样开发者才愿意频繁运行)
  4. 测试要稳定,不依赖外部服务、不依赖当前时间、不依赖执行顺序
  5. 先写最重要的测试,从你"最担心出 Bug 的地方"开始
上次编辑于:
贡献者: zhengtianqi