《Spring Boot 入门》17.1 单元测试

单元测试是测试金字塔的底座,也是唯一不需要启动 Spring 容器的测试。本节讲清 JUnit 5 的核心注解与断言,重点演示 Mockito 的 @Mock、when、verify 与 ArgumentCaptor,并说明 4.x 下用 @ExtendWith(MockitoExtension.class) 集成 Mockito 的正确方式,最后为图书服务写出一组单元测试。

本节目标:理解测试金字塔与「单元测试不该启动 Spring 容器」这条原则,掌握 JUnit 5 的注解与断言,以及 Mockito 打桩与校验的完整用法,为图书服务写出一组可运行的单元测试。
适用版本:Spring Boot 4.1.x(Java 21)

17.1 单元测试

前面 16 章我们把图书服务从零搭了起来:实体、Repository、Service、Controller、异常、配置一应俱全。但验证行为的方式一直是「启动应用,用浏览器或 curl 手动点一遍」。这一章把它补成真正的测试。

本章按测试金字塔自下而上走三层:17.1 单元测试(不启动容器)、17.2 切片测试(只启动一层)、17.3 集成测试(启动完整容器)。三节统一围绕同一个图书服务展开,方便对照。

17.1.1 测试金字塔:单元测试为什么不该启动 Spring 容器

把测试按粒度和数量排成一座金字塔:

层级数量单次耗时启动 Spring 容器隔离度典型工具
单元测试最多毫秒级否高,只测一个类JUnit 5 + Mockito
切片测试中等百毫秒~秒级只加载一层中@WebMvcTest / @DataJpaTest
集成测试最少数秒是,完整上下文低,端到端@SpringBootTest

金字塔的形态由成本决定:越靠上的测试,单次执行越慢、越难定位失败、越容易受环境影响。所以「多写单元测试」不是教条,而是性价比最高的选择。

单元测试的定义:只验证一个类(一个单元)的行为,它依赖的协作者(数据库、HTTP 客户端、其他 Service)全部用测试替身替换掉。既然是替身,就不需要真数据库连接,自然也不需要启动 Spring 容器来创建这些 Bean。

对照两种做法测同一个 BookService.getById:走 @SpringBootTest 要先让 Spring 加载上下文、扫描 Bean、建 DataSource 与 EntityManagerFactory、再建 Tomcat(第 3 章实测约 0.8–1.1 秒),之后才轮到你的断言;而纯单元测试只是 new BookService(mockRepository) 然后断言,约 5 毫秒。

一个 Service 类可能有 10 个方法、20 条分支。用单元测试,20 条分支总共几十毫秒;用集成测试,每条分支都要付一次上下文启动成本。单元测试的收益,一半来自它跑得快。

17.1.2 spring-boot-starter-test 里到底有什么

spring-boot-starter-test 是一个 test 作用域的聚合依赖,一次性带来下面这些库:

库作用本章用到
JUnit 5(Jupiter)测试框架与断言全程
Mockito打桩与校验的替身框架17.1.6 起
AssertJ流式断言(assertThat)示例中少量使用
Hamcrest匹配器库,供 assertThat 旧式写法了解即可
JsonPath校验 JSON 响应字段(jsonPath)17.2
JSONassert / Awaitility / XMLUnitJSON 比较 / 异步轮询 / XML 比较了解即可

引入方式:在 pom.xml 里加一个 spring-boot-starter-test 依赖,<scope>test</scope>,无需写版本号(版本由父 POM 管理)。

要记住:单元测试里并不需要这个 starter 的全部。JUnit 5 与 Mockito 才是主角,其余库在切片与集成测试里才登场。不要因为 starter 里有 JsonPath,就在单元测试里做 JSON 断言——那是 17.2 的事。

17.1.3 JUnit 5 核心注解

注解作用
@Test标记一个测试方法
@BeforeEach / @AfterEach每个测试方法前后各执行一次
@BeforeAll / @AfterAll整个测试类前后各执行一次(方法须为 static)
@DisplayName给测试类或方法起可读名字,支持中文
@Disabled暂时跳过某个测试(附上原因)
@Nested定义内嵌测试类,把相关用例分组
@ParameterizedTest参数化测试,一次覆盖多组输入

@BeforeEach 常用来准备被测对象和桩数据,@AfterEach 用来清理临时文件、关闭资源。JUnit 5 默认为每个测试方法创建新的测试类实例,字段不会在方法之间串味,@BeforeEach 里重新赋值即可。

参数化测试用 @ParameterizedTest 加数据源避免复制粘贴:@ValueSource(strings = {"Joshua Bloch", "Martin Fowler"}) 供单参数,@CsvSource({ "2018, true", "1500, false" }) 供多参数,测试方法把每组数据声明成形参,运行时会逐组执行并单独报告结果。

17.1.4 断言

JUnit 5 的断言在 org.junit.jupiter.api.Assertions 里,用静态导入(import static org.junit.jupiter.api.Assertions.*;)。三个最常用的:

@Test
void assertions() {
    // 相等断言:期望值在前,实际值在后(顺序反了报错信息会误导人)
    assertEquals("Effective Java", book.getTitle());

    // 断言抛异常:返回捕获到的异常,可继续断言它的属性
    BookNotFoundException ex = assertThrows(
            BookNotFoundException.class,
            () -> bookService.getById(999L));
    assertEquals(999L, ex.getId());

    // 一组断言一起执行:任一失败都不中断其余断言,最后汇总报告
    assertAll("book",
            () -> assertEquals("Effective Java", book.getTitle()),
            () -> assertEquals("Joshua Bloch", book.getAuthor()),
            () -> assertEquals(2018, book.getPublishedYear()));
}

assertAll 的价值在于「一次跑完所有断言」。普通 assertEquals 在第一条失败后就停下,你只能一轮一轮地修;用 assertAll 一次拿到全部不匹配项。对同一对象的多个字段做校验时优先用它。

17.1.5 测试命名与结构

结构:given-when-then(Arrange-Act-Assert),每个测试方法切成三段:先准备输入与桩(given),再调用被测方法、且只在这一段调用一次(when),最后断言结果(then)。三段之间用空行和注释隔开,读测试的人一眼能分清「准备」与「断言」。

命名:方法名_条件_期望结果,例如 create_duplicateTitle_throws、findByAuthor_noMatch_returnsEmptyList。再叠加 @DisplayName 写一句人话,运行报告里会直接显示它。

17.1.6 Mockito:替身三件套

单元测试的难点不是断言,而是把依赖换掉。Mockito 的核心就三个动作:造替身、打桩、校验。

注解/方法作用
@Mock造一个替身对象,未打桩的方法返回默认值(对象 null、int 0)
@InjectMocks造被测对象,并把上面的 @Mock 注入进去(优先构造器注入)
when(...).thenReturn(...)打桩:指定某方法在某入参下返回什么
when(...).thenThrow(...)打桩:指定某方法抛出异常
verify(...)校验某方法是否被调用、几次、用什么参数
ArgumentCaptor捕获方法实际收到的参数,用于深入断言
when(bookRepository.findByTitle("Effective Java"))
        .thenReturn(Optional.of(existingBook));

verify(bookRepository).save(any(Book.class));
verify(bookRepository, never()).delete(any(Book.class));

never() 表示「从未调用」,常用于校验「异常路径下不应该写库」。还有 times(n)、atLeastOnce()、atMost(n) 等调用次数限定。

ArgumentCaptor 用来断言「传进去的参数」:ArgumentCaptor.forClass(Book.class) 建捕获器,verify(...).save(captor.capture()) 在调用发生处抓取实参,再 captor.getValue() 取回对象做字段断言。当返回值被忽略、而你关心它究竟收到了什么时,它是唯一的办法。

17.1.7 4.x 下集成 Mockito 的正确方式

要让 @Mock / @InjectMocks 生效,必须把 Mockito 注册为 JUnit 5 扩展。4.x 的正确写法是加 @ExtendWith(MockitoExtension.class):

import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class BookServiceTest {
    // ...
}

两点要特别注意:

  1. Spring Boot 3.x 时代那套基于 MockitoTestExecutionListener 的自动初始化已经在 4.x 移除。不要再依赖「什么都不加,@Mock 也能被填充」的旧行为,显式声明扩展才是 4.x 的方式。
  2. MockitoExtension 默认开启严格打桩(strict stubs):打了一个桩却从未用到,会报 UnnecessaryStubbingException。这不是 bug,而是提醒你「这条桩已经和被测行为脱节」。确有需要时用 @MockitoSettings(strictness = Strictness.LENIENT) 或 lenient() 显式放宽。

17.1.8 测 Service 层:该 mock 什么、不该 mock 什么

判断标准只有一条:mock 跨越边界的协作者,不 mock 被测逻辑本身的数据。

该 mock理由
Repository / DAO真连数据库就变成集成测试了
外部 HTTP 客户端、消息发送器依赖网络,不可控
时钟、随机数、UUID 生成器需要确定性输出
不该 mock理由
被测对象自身那就没在测任何东西
领域实体 / 值对象(Book、Money)用真实对象构造更简单,也更能暴露问题
集合、Optional、String用真实值即可
仅仅为「让测试看起来干净」而 mock 的一切过度 mock 会把测试变成对实现的复述

一个判断过度 mock 的信号:测试里 when(...) 的行数比 assert 还多。这时测试校验的其实是「你写了哪些调用」,而不是业务行为是否正确;一旦重构内部实现,这种测试会成片失败,却抓不到任何真实缺陷。

17.1.9 完整示例:BookService 单元测试

本节及后续两节复用同一个图书服务(领域模型 Book 与 BookRepository 见第 12 章):

package com.example.library.service;

import java.util.List;

import org.springframework.stereotype.Service;

import com.example.library.domain.Book;
import com.example.library.repository.BookRepository;

@Service
public class BookService {

    private final BookRepository bookRepository;

    public BookService(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    public Book create(Book book) {
        bookRepository.findByTitle(book.getTitle()).ifPresent(existing -> {
            throw new DuplicateBookException(book.getTitle());
        });
        return bookRepository.save(book);
    }

    public Book getById(Long id) {
        return bookRepository.findById(id)
                .orElseThrow(() -> new BookNotFoundException(id));
    }
}

对应的单元测试。注意全程没有 @SpringBootTest,没有容器:

package com.example.library.service;

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.util.Optional;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import com.example.library.domain.Book;
import com.example.library.repository.BookRepository;

@ExtendWith(MockitoExtension.class)
class BookServiceTest {

    @Mock
    private BookRepository bookRepository;

    @InjectMocks
    private BookService bookService;

    @Nested
    @DisplayName("create")
    class Create {

        @Test
        @DisplayName("标题不重复时保存并返回图书")
        void create_withNewTitle_savesBook() {
            Book input = new Book("Effective Java", "Joshua Bloch", 2018);
            when(bookRepository.findByTitle("Effective Java")).thenReturn(Optional.empty());
            when(bookRepository.save(any(Book.class))).thenAnswer(inv -> inv.getArgument(0));

            Book result = bookService.create(input);

            assertAll("saved book",
                    () -> assertEquals("Effective Java", result.getTitle()),
                    () -> assertEquals(2018, result.getPublishedYear()));

            ArgumentCaptor<Book> captor = ArgumentCaptor.forClass(Book.class);
            verify(bookRepository).save(captor.capture());
            assertEquals("Effective Java", captor.getValue().getTitle());
        }

        @Test
        @DisplayName("标题已存在时抛出异常且不写库")
        void create_duplicateTitle_throws() {
            Book input = new Book("Effective Java", "Joshua Bloch", 2018);
            when(bookRepository.findByTitle("Effective Java"))
                    .thenReturn(Optional.of(new Book("Effective Java", "Someone Else", 2015)));

            assertThrows(DuplicateBookException.class, () -> bookService.create(input));

            verify(bookRepository, never()).save(any(Book.class));
        }
    }

    @Nested
    @DisplayName("getById")
    class GetById {

        @Test
        @DisplayName("存在时返回图书")
        void getById_found_returnsBook() {
            when(bookRepository.findById(1L))
                    .thenReturn(Optional.of(new Book("Effective Java", "Joshua Bloch", 2018)));

            assertEquals("Effective Java", bookService.getById(1L).getTitle());
        }

        @Test
        @DisplayName("不存在时抛出 BookNotFoundException")
        void getById_missing_throws() {
            when(bookRepository.findById(999L)).thenReturn(Optional.empty());

            BookNotFoundException ex = assertThrows(
                    BookNotFoundException.class,
                    () -> bookService.getById(999L));

            assertEquals(999L, ex.getId());
        }
    }
}

@Nested 把两组用例分门别类,运行报告会显示成树形结构,比一长串平铺方法好读得多。

17.1.10 常见坑

现象原因与对策
被测方法里 NullPointerException某个 @Mock 没被注入或桩没打;先确认 @ExtendWith(MockitoExtension.class) 在,再查 @InjectMocks
桩返回 null 导致空指针未打桩的方法默认返回 null,Optional 场景要显式 thenReturn(Optional.empty())
UnnecessaryStubbingException打了没用到的桩;删掉它,或确认测试是否走错了分支
想测私有方法私有方法是实现细节,应通过公有方法间接覆盖;硬测私有方法说明该类职责该拆了
测试之间互相影响每个方法用独立实例;不要在字段里共享可变状态,@BeforeEach 里重建

小结

  • 测试金字塔决定了「多写单元测试」:单元测试快、稳、定位准,是性价比最高的层级。
  • 单元测试只测一个类,依赖用替身替换,不启动 Spring 容器。
  • spring-boot-starter-test 聚合了 JUnit 5、Mockito、AssertJ、JsonPath、Awaitility 等库;单元测试的主角是 JUnit 5 + Mockito。
  • JUnit 5 常用注解:@Test / @BeforeEach / @AfterEach / @DisplayName / @Nested / @ParameterizedTest。
  • 断言优先用 assertEquals / assertThrows / assertAll;同一对象的多个字段校验用 assertAll 一次跑完。
  • Mockito 三件套:@Mock 造替身、when(...).thenReturn(...) 打桩、verify(...) 校验;ArgumentCaptor 用于断言传入参数。
  • 4.x 下用 @ExtendWith(MockitoExtension.class) 集成 Mockito,旧的 MockitoTestExecutionListener 机制已移除;默认严格打桩。
  • mock 跨越边界的协作者(Repository、外部客户端、时钟),不 mock 被测逻辑本身的数据(实体、集合)。
  • 用 given-when-then 分段、方法名_条件_期望结果 命名,让测试自己会说话。

Service 层的纯逻辑已经有保障。但 Controller 的请求映射、参数绑定、JSON 序列化,以及 Repository 的查询方法,都还没被验证。下一节我们用切片测试,只启动需要的那一层。

阅读导航:上一节:16.3 日志实践 · 下一节:17.2 切片测试 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计