1. 项目概述:为什么单元测试是高质量代码的基石

在开发SpringBoot应用时,我们常常陷入一种“功能驱动”的循环:快速实现业务逻辑,通过Postman或浏览器手动点击几下,功能跑通就万事大吉。直到某天,一个看似简单的字段修改,却引发了线上订单支付的连环报错,排查到深夜才发现是某个Service层方法的一个边界条件没处理好。这种场景,相信不少后端开发都经历过。问题的根源,往往在于我们对代码的“信心”是建立在脆弱的手工验证之上,而非一套稳固、自动化的质量保障体系。单元测试,正是构建这套体系的第一道,也是最重要的一道防线。

我见过很多项目,初期为了赶进度完全忽略单元测试,随着代码量膨胀到几十万行,再想补测试简直是一场灾难,最终只能陷入“不敢改、不能动”的泥潭。相反,那些从项目初期就坚持编写高质量单元测试的团队,代码的可维护性和迭代速度会随着时间推移而显著提升。本次实战,我们就聚焦于SpringBoot环境下,如何运用JUnit5和Mockito这两个黄金组合,写出不仅“能用”,而且“可靠”、易于维护的单元测试,并最终通过测试覆盖率这个客观指标来量化我们的测试成果。无论你是刚开始接触单元测试的新手,还是想优化现有测试套件的老手,这套方法论都能给你带来直接的、可落地的价值。

2. 环境搭建与核心依赖配置

2.1 项目初始化与依赖引入

我们从一个全新的SpringBoot 3.x项目开始。使用Spring Initializr(或IDE的创建向导)生成项目时,除了选择必要的Web、JPA等依赖, 务必勾选“Spring Boot Test” 这个Starter。它会为我们引入 spring-boot-starter-test ,这是所有测试依赖的集合包。

查看生成的 pom.xml ,你会看到类似下面的依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

这个Starter默认包含了JUnit Jupiter(JUnit5的核心)、Spring Test、AssertJ、Hamcrest、Mockito、JSONassert等一整套测试工具。对于大多数场景,这一个依赖就够了。但为了更清晰地管理和使用Mockito,我习惯显式地引入其核心依赖,这能避免因SpringBoot版本升级带来的潜在兼容性问题,也方便我们锁定特定版本。

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <scope>test</scope>
</dependency>
<!-- 如果需要Mockito的Inline mock maker(用于Mock final类/方法),可以额外添加 -->
<!-- <dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-inline</artifactId>
    <scope>test</scope>
</dependency> -->

2.2 测试目录结构与基础注解

SpringBoot遵循Maven/Gradle的标准目录结构。所有的测试类都应该放在 src/test/java 下,并且其包路径最好与 src/main/java 中被测类的包路径保持一致。例如,你的 UserService com.example.service 包下,那么它的测试类 UserServiceTest 就应该放在 src/test/java/com/example/service 下。这样做的好处是,IDE和构建工具能更容易地关联测试与源码。

在测试类的顶部,我们会使用 @SpringBootTest 注解。这个注解会启动一个完整的Spring应用上下文,用于集成测试。但对于 单元测试 ,我们的目标是测试单个类(如Service)的孤立功能,加载整个Spring容器既笨重又缓慢。因此,更推荐使用 @ExtendWith 注解组合。

JUnit5的最佳实践是使用 @SpringBootTest webEnvironment 属性或更轻量的配置:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.test.context.SpringBootTest;

// 方式一:轻量级单元测试,不启动Web容器,仅加载你指定的配置
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ExtendWith(MockitoExtension.class) // 启用Mockito支持
public class UserServiceUnitTest {
    // ...
}

// 方式二:更极致的单元测试,连Spring容器都不启动,纯POJO测试(需结合@MockBean等)
// 通常用于测试不依赖Spring容器的工具类或纯逻辑组件

对于大多数Service层的单元测试,我强烈推荐方式一。它平衡了隔离性和便利性,既能使用 @MockBean 来模拟Spring管理的Bean,又避免了启动Tomcat等Web服务器的开销,测试执行速度非常快。

3. JUnit5核心特性与实战应用

JUnit5相对于JUnit4是一次彻底的重构,模块化更清晰,功能也更强大。我们重点掌握几个最常用的特性。

3.1 生命周期注解:@BeforeEach, @AfterEach

在JUnit4中,我们用 @Before @After 。在JUnit5中,它们被更语义化的 @BeforeEach @AfterEach 取代。这两个注解标注的方法会在 每一个 @Test 方法执行 之前 之后 运行。

public class OrderServiceTest {

    private OrderService orderService;
    private SomeExpensiveResource resource;

    @BeforeEach
    void setUp() {
        // 在每个测试方法前执行,用于初始化测试环境
        resource = new SomeExpensiveResource();
        resource.init();
        orderService = new OrderService(resource);
    }

    @AfterEach
    void tearDown() {
        // 在每个测试方法后执行,用于清理资源
        if (resource != null) {
            resource.close();
        }
    }

    @Test
    void testCreateOrder() {
        // 这里的orderService已经是setUp中初始化好的
    }
}

注意 @BeforeEach 方法应该是 void 类型,并且不应该抛出异常(除非你希望整个测试类失败)。确保 setUp tearDown 是幂等的,即多次执行效果相同,因为理论上每个测试都是独立的。

3.2 断言库的升级:从JUnit Assert到AssertJ

JUnit5自带的 Assertions 类已经比JUnit4好用,但社区更推崇AssertJ。它提供了一套 流式API ,断言语句读起来就像自然语言,并且错误信息极其友好。

对比一下:

// JUnit5 原生断言
import static org.junit.jupiter.api.Assertions.*;
@Test
void testWithJUnitAssert() {
    List<String> result = someService.getList();
    assertNotNull(result);
    assertEquals(3, result.size());
    assertTrue(result.contains("expectedItem"));
}

// AssertJ 流式断言
import static org.assertj.core.api.Assertions.*;
@Test
void testWithAssertJ() {
    List<String> result = someService.getList();
    assertThat(result)
        .isNotNull()
        .hasSize(3)
        .contains("expectedItem")
        .doesNotContainNull()
        .allSatisfy(item -> assertThat(item.length()).isGreaterThan(0));
}

AssertJ的错误信息在失败时直接告诉你具体哪里不对,比如“expected size 3 but was 2”,并且链式调用让测试代码更清晰。 spring-boot-starter-test 默认包含了AssertJ,直接使用即可。

3.3 参数化测试:@ParameterizedTest

这是JUnit5的一大亮点,允许你用不同的输入参数多次运行同一个测试逻辑。非常适合测试边界条件、有效/无效输入等场景。

@ParameterizedTest
@ValueSource(strings = {"", "  ", "\t\n"}) // 参数来源:直接提供值
void testIsBlank_ShouldReturnTrueForAllBlankInputs(String input) {
    assertThat(StringUtils.isBlank(input)).isTrue();
}

@ParameterizedTest
@CsvSource({
    "1, 1, 2",
    "2, 3, 5",
    "10, -5, 5"
}) // 参数来源:CSV格式,每行一组参数
void testAdd(int a, int b, int expectedSum) {
    Calculator calc = new Calculator();
    assertThat(calc.add(a, b)).isEqualTo(expectedSum);
}

@ParameterizedTest
@MethodSource("provideTestData") // 参数来源:指定一个返回Stream的方法
void testWithMethodSource(int input, boolean expected) {
    assertThat(SomeUtil.isValid(input)).isEqualTo(expected);
}

static Stream<Arguments> provideTestData() {
    return Stream.of(
        Arguments.of(0, false),
        Arguments.of(5, true),
        Arguments.of(100, true),
        Arguments.of(101, false)
    );
}

参数化测试能极大减少重复的测试代码,让测试用例的意图更明确。 实操心得 :对于业务规则复杂的计算、验证逻辑,优先考虑使用参数化测试来覆盖各种临界情况。

4. Mockito深度使用:模拟、打桩与验证

单元测试的核心是“隔离”。我们的目标是测试 A 类,但如果 A 依赖 B C 等外部组件(如数据库访问层 Repository 、第三方服务客户端 Client ),我们就需要把这些依赖“模拟”出来。Mockito就是干这个的。

4.1 创建Mock对象与注入依赖

有两种主要方式创建Mock对象:

  1. 使用注解(推荐) :结合 @ExtendWith(MockitoExtension.class) 使用。

    @ExtendWith(MockitoExtension.class)
    class OrderServiceTest {
        @Mock
        private OrderRepository orderRepository; // 被Mock的依赖
    
        @Mock
        private PaymentClient paymentClient;
    
        @InjectMocks
        private OrderService orderService; // 被测试类,Mock会自动注入
    
        @Test
        void testPlaceOrder() {
            // ... 测试逻辑
        }
    }
    

    @InjectMocks 会让Mockito尝试通过构造函数、setter或字段注入的方式,将 @Mock 标注的模拟对象注入到 orderService 中。这是最简洁的方式。

  2. 手动创建

    class OrderServiceTest {
        private OrderRepository orderRepository;
        private OrderService orderService;
    
        @BeforeEach
        void setUp() {
            orderRepository = Mockito.mock(OrderRepository.class);
            orderService = new OrderService(orderRepository); // 通过构造函数注入
        }
    }
    

4.2 行为打桩:when(...).thenReturn(...)

打桩就是定义当Mock对象的方法被调用时,应该返回什么值或抛出什么异常。

@Test
void testFindOrderById_Success() {
    // 1. 准备测试数据
    Long orderId = 123L;
    Order mockOrder = new Order();
    mockOrder.setId(orderId);
    mockOrder.setStatus(OrderStatus.CREATED);

    // 2. 打桩:当orderRepository.findById(orderId)被调用时,返回一个包含mockOrder的Optional
    when(orderRepository.findById(orderId)).thenReturn(Optional.of(mockOrder));

    // 3. 执行被测方法
    OrderDTO result = orderService.getOrderDetail(orderId);

    // 4. 断言结果
    assertThat(result).isNotNull();
    assertThat(result.getId()).isEqualTo(orderId);
    assertThat(result.getStatus()).isEqualTo("CREATED");
}

高级打桩技巧:

  • thenThrow() : 模拟方法抛出异常。
    when(paymentClient.process(any())).thenThrow(new RuntimeException("网络超时"));
    
  • thenAnswer() : 根据调用参数动态返回结果。
    when(idGenerator.generate()).thenAnswer(invocation -> "ID-" + System.currentTimeMillis());
    
  • 连续打桩 :同一个方法调用,第一次和第二次返回不同结果。
    when(iterator.next())
        .thenReturn("first")
        .thenReturn("second")
        .thenThrow(new NoSuchElementException());
    

4.3 参数匹配器:any(), eq()

在打桩或验证时,我们不一定关心具体的参数值,可以使用参数匹配器。

// 当save方法被调用,且传入的参数是任意Order类型时,都返回这个预定义的Order
when(orderRepository.save(any(Order.class))).thenReturn(savedOrder);

// 更精确的匹配:当findByStatusAndUserId被调用,且第一个参数是“PAID”,第二个参数是具体的userId时
when(orderRepository.findByStatusAndUserId(eq(OrderStatus.PAID), eq(userId)))
    .thenReturn(paidOrders);

踩坑提醒 :一旦在方法调用中使用了一个参数匹配器(如 any() ),那么 该方法的所有参数都必须使用匹配器 ,不能混用具体值和匹配器。 eq() 就是一个将具体值包装成匹配器的工具。

4.4 验证交互:verify()

单元测试不仅要验证结果(状态验证),有时还需要验证行为(交互验证),即某个依赖方法是否被调用、调用了几次、以什么参数调用。

@Test
void testPlaceOrder_ShouldSaveAndCallPayment() {
    // ... 准备数据、打桩

    orderService.placeOrder(orderRequest);

    // 验证 orderRepository.save 被调用了一次,且参数是 order 对象
    verify(orderRepository, times(1)).save(orderArgumentCaptor.capture());
    // 验证 paymentClient.process 被调用了一次,且参数是特定的 paymentRequest
    verify(paymentClient, times(1)).process(eq(expectedPaymentRequest));

    // 绝对禁止调用某个方法
    verify(emailService, never()).send(any());
}

verify 是行为测试的利器。 常见误区 :不要过度使用 verify 。单元测试应更关注被测对象的输出状态(返回值、状态变更),而不是其内部每一个交互细节,否则测试会变得极其脆弱,重构代码时测试也需要大量修改。

5. SpringBoot测试特有技巧:@MockBean与@TestConfiguration

5.1 @MockBean vs @Mock

在纯单元测试(不启动Spring容器)中,我们用 @Mock 。但在集成测试或需要测试Spring Bean(如Controller)时,我们需要 @MockBean @MockBean 是SpringBoot提供的注解,它会将Mock对象添加到Spring的 ApplicationContext 中,替换掉上下文中同类型的真实Bean。

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // 启动真实Web环境
class OrderControllerIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate; // SpringBoot提供的测试客户端

    @MockBean
    private OrderService orderService; // 替换掉真实的OrderService Bean

    @Test
    void testGetOrderApi() {
        OrderDTO mockDto = new OrderDTO();
        when(orderService.getOrderDetail(anyLong())).thenReturn(mockDto);

        ResponseEntity<OrderDTO> response = restTemplate.getForEntity("/api/orders/123", OrderDTO.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isEqualTo(mockDto);
        verify(orderService).getOrderDetail(123L);
    }
}

关键区别 @Mock 是Mockito管理的, @MockBean 是Spring管理的。在 @SpringBootTest 中,如果你想模拟一个Spring Bean,必须用 @MockBean

5.2 使用@TestConfiguration定制测试上下文

有时候,我们只想在测试环境中替换某个特定的Bean,或者添加一些测试专用的配置。这时可以用 @TestConfiguration

@SpringBootTest
class ServiceWithExternalClientTest {

    @Autowired
    private MyService myService;

    @Test
    void testService() {
        // myService 会使用下面内部类中定义的 testClient
        myService.doSomething();
    }

    @TestConfiguration // 这个配置只对当前测试类生效
    static class TestConfig {
        @Bean
        @Primary // 优先使用这个Bean,覆盖主配置中的同名Bean
        public ExternalClient testClient() {
            // 返回一个模拟的、轻量级的客户端,而不是连接真实外部服务的客户端
            return new FakeExternalClient();
        }
    }
}

@TestConfiguration 是一个非常灵活的工具,可以让你在不污染主应用代码的情况下,为测试创建特定的Bean或配置,比如使用内存数据库代替MySQL,使用假的短信发送器等。

6. 测试覆盖率收集与报告解读

写了测试,怎么知道写得够不够?测试覆盖率是一个重要的量化指标。它主要衡量你的测试代码执行了被测源码的哪些部分,通常包括 行覆盖率 分支覆盖率 方法覆盖率 等。

6.1 使用JaCoCo收集覆盖率

JaCoCo是Java生态最流行的覆盖率工具。在Maven项目中集成非常简单,在 pom.xml <build><plugins> 中添加:

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.11</version> <!-- 使用最新版本 -->
    <executions>
        <execution>
            <goals>
                <goal>prepare-agent</goal> <!-- 在测试执行时附加JaCoCo代理 -->
            </goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>verify</phase> <!-- 在verify阶段生成报告 -->
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
    </executions>
</plugin>

配置好后,运行 mvn clean verify (或 mvn test ,但 verify 会运行所有生命周期到 verify 的阶段,包括集成测试)。执行完成后,会在 target/site/jacoco/ 目录下生成HTML格式的覆盖率报告。

6.2 解读覆盖率报告

打开 index.html ,你会看到类似这样的数据:

  • 指令覆盖率 (Instruction Coverage) :最基础的覆盖率,衡量字节码指令被执行的比例。
  • 行覆盖率 (Line Coverage) :最直观的指标,衡量多少行源代码被执行过。
  • 分支覆盖率 (Branch Coverage) 这个指标更重要 。它衡量代码中每个if/else、switch、三元运算符等分支是否都被测试到。例如一个 if (condition) 语句,你需要有测试用例分别让 condition true false ,才能达到100%的分支覆盖。
  • 方法覆盖率 (Method Coverage) :衡量有多少个方法被调用过。

如何设定合理的覆盖率目标?

  • 不要盲目追求100% :100%行覆盖可能都很难,100%分支覆盖成本极高。对工具生成的(如Lombok的 @Data )、简单的Getter/Setter、或某些异常捕获后仅打印日志的代码块,追求覆盖可能得不偿失。
  • 核心业务逻辑是关键 :你的 Service 层、核心算法、关键业务规则处理代码,应该追求高覆盖率(例如85%以上的行覆盖,80%以上的分支覆盖)。
  • 分层设定目标 :DAO层(大量简单CRUD)和Controller层(参数校验、格式转换)的覆盖率要求可以低于核心业务逻辑层。
  • 结合CI/CD :在持续集成流水线中设置覆盖率门槛(例如核心模块行覆盖率<80%则构建失败),让覆盖率检查成为自动化流程的一部分。

6.3 覆盖率的局限性

高覆盖率不等于高质量测试! 这是一个必须牢记的准则。覆盖率只能告诉你代码“被执行过”,但不能告诉你“被正确地验证过”。

// 一个糟糕的测试例子
@Test
void testDivide() {
    Calculator calc = new Calculator();
    int result = calc.divide(10, 2); // 这行代码会被覆盖到
    // 忘记写断言了!即使除数为0的bug存在,测试也会通过。
}

这个测试的覆盖率是100%(因为执行了 divide 方法),但它完全没有验证结果是否正确,也没有测试除数为0的异常情况。因此, 有意义的断言(Assertions)比单纯的覆盖率数字更重要 。覆盖率是一个有用的辅助工具和趋势指标,可以帮助你发现未被测试的“死角”,但不能替代严谨的测试用例设计。

7. 编写可维护测试代码的实践与模式

写出能通过编译和运行的测试只是第一步,写出易于理解、维护和扩展的测试才是终极目标。

7.1 测试命名规范

好的测试名应该清晰地表达“在什么条件下,执行什么操作,期望什么结果”。推荐使用 [方法名]_[测试场景]_[预期结果] 的格式。

// 反例:名字毫无信息量
@Test
void test1() { ... }

// 正例:清晰表达意图
@Test
void placeOrder_WithValidRequest_ShouldReturnOrderId() { ... }
@Test
void placeOrder_WithOutOfStockItem_ShouldThrowInventoryException() { ... }
@Test
void getUserById_WhenUserNotFound_ShouldReturnNull() { ... }

7.2 Given-When-Then模式

这是组织测试代码的经典模式,让测试结构一目了然。

@Test
void applyDiscount_WhenCustomerIsVIPAndOrderAmountOver1000_ShouldApply20PercentDiscount() {
    // Given: 准备测试数据和前置条件
    Customer vipCustomer = new Customer();
    vipCustomer.setLevel(CustomerLevel.VIP);
    Order order = new Order();
    order.setCustomer(vipCustomer);
    order.setTotalAmount(new BigDecimal("1200.00"));
    DiscountService discountService = new DiscountService();

    // When: 执行被测方法
    BigDecimal finalAmount = discountService.applyDiscount(order);

    // Then: 验证结果和行为
    assertThat(finalAmount).isEqualByComparingTo("960.00"); // 1200 * 0.8 = 960
}

7.3 测试数据构建的优化

在测试中构造复杂的对象非常繁琐。可以使用 建造者模式(Builder Pattern) 对象工厂方法

// 使用建造者模式(Lombok @Builder 可以自动生成)
Order.OrderBuilder orderBuilder = Order.builder()
    .id(1L)
    .status(OrderStatus.PENDING)
    .customer(Customer.builder().name("张三").vip(true).build());

// 在测试类中定义工厂方法
private Order createTestOrder(Long id, OrderStatus status) {
    Order order = new Order();
    order.setId(id);
    order.setStatus(status);
    order.setCreateTime(LocalDateTime.now());
    // ... 设置其他通用字段
    return order;
}

// 在测试中使用
Order pendingOrder = createTestOrder(1L, OrderStatus.PENDING);

对于更复杂的情况,可以考虑使用 Data Faker库 (如 java-faker )来生成逼真的测试数据。

7.4 保持测试的独立性与隔离性

每个 @Test 方法都应该是独立的,不依赖于其他测试方法的执行顺序或状态。JUnit5默认是随机顺序执行测试的。确保:

  1. 不使用共享的、可变的类级别变量(除非是 static final 常量)。
  2. 如果使用了 @BeforeEach 来初始化,要确保它能将环境重置到干净状态。
  3. 模拟对象(Mock)的行为最好在每个测试方法内部定义,避免在 @BeforeEach 中定义全局打桩,除非该行为在所有测试中都通用。

8. 常见问题排查与调试技巧

8.1 常见异常与解决

  • NullPointerException in @InjectMocks :通常是因为被测试类有多个构造函数,或者依赖项无法通过反射注入。解决方案:使用 @BeforeEach 方法手动构造被测对象,或者为被测试类提供一个包可见的构造函数以便于测试。
  • UnfinishedStubbingException :通常是因为错误的打桩语法,比如 when(mock.method()) 时, method() 内部又调用了另一个需要打桩的方法。仔细检查 when() 括号内的代码。
  • Argument(s) are different! in verify() :验证方法调用时参数不匹配。使用 ArgumentCaptor 捕获实际参数进行调试,或者检查是否应该使用参数匹配器 any() 而不是具体值 eq()
  • 测试通过但实际代码有问题 :最常见的原因是打桩过于宽泛。例如,用 any() 匹配了所有参数,但实际业务逻辑中不同参数会导致不同分支。需要更精确地打桩,或者补充更多测试用例。

8.2 使用ArgumentCaptor进行调试

当验证方法调用参数复杂,或者你想深入检查传入的参数对象时, ArgumentCaptor 非常有用。

@Test
void testComplexMethodCall() {
    // 假设我们要验证 save 方法被调用,并且传入的 Order 对象状态是 PAID
    @Captor
    ArgumentCaptor<Order> orderCaptor;
    // 或者手动创建:ArgumentCaptor<Order> orderCaptor = ArgumentCaptor.forClass(Order.class);

    // 执行被测方法...

    verify(orderRepository).save(orderCaptor.capture()); // 捕获参数
    Order capturedOrder = orderCaptor.getValue(); // 获取捕获的参数

    // 现在可以对这个对象进行详细的断言
    assertThat(capturedOrder.getStatus()).isEqualTo(OrderStatus.PAID);
    assertThat(capturedOrder.getPaidTime()).isNotNull();
}

8.3 测试日志输出

在测试类中临时添加日志输出,可以帮助理解测试执行流程和调试问题。确保你的 src/test/resources 目录下有 logback-test.xml application-test.properties 来配置测试环境的日志级别(例如设置为 DEBUG )。

8.4 测试不稳定的问题(Flaky Tests)

有些测试有时通过,有时失败,这通常是因为:

  1. 依赖外部服务或状态 :测试调用了真实的数据库、网络API。 必须用Mock隔离
  2. 并发问题 :测试中使用了共享的、非线程安全的资源。确保每个测试独立。
  3. 时间依赖 :测试中使用了 new Date() System.currentTimeMillis() 。可以使用 Clock 类进行抽象,在测试中注入一个固定的时钟。
  4. 执行顺序依赖 :测试依赖于全局状态(如静态变量)。重构代码,避免使用可变的静态状态。

处理不稳定测试的最高优先级是 修复它 ,而不是重试或忽略。一个不稳定的测试会严重损害整个测试套件的可信度。

9. 进阶话题:测试分层与集成测试

单元测试(Unit Test)关注单个类或方法。但在实际项目中,我们还需要其他层次的测试。

9.1 测试金字塔

健康的自动化测试结构应该像一个金字塔:

  • 底层(最多) 单元测试 。快速、稳定、隔离。是测试的基石。
  • 中层 集成测试 。验证多个组件(如Service和Repository)协同工作是否正常。速度中等。
  • 高层(最少) 端到端测试 。验证整个应用从UI到数据库的完整流程。速度慢,脆弱,但信心足。

SpringBoot的 @SpringBootTest 可以用来写集成测试。你可以使用 @DataJpaTest 来测试JPA Repository层(它会配置一个内存数据库),用 @WebMvcTest 来切片测试Controller层(只加载Web层相关的Bean)。

9.2 @DataJpaTest 实践

这个注解专门用于测试JPA层。它会配置一个内存数据库(如H2),并自动注入 TestEntityManager

@DataJpaTest
class OrderRepositoryTest {

    @Autowired
    private TestEntityManager entityManager; // 用于持久化测试数据

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void findByStatus_ShouldReturnOrders() {
        // Given: 使用 TestEntityManager 将数据持久化到内存数据库
        Order order = new Order();
        order.setStatus(OrderStatus.PAID);
        entityManager.persistAndFlush(order);

        // When: 调用真实的Repository方法
        List<Order> found = orderRepository.findByStatus(OrderStatus.PAID);

        // Then: 验证数据库查询结果
        assertThat(found).hasSize(1).extracting(Order::getStatus).containsOnly(OrderStatus.PAID);
    }
}

这种测试比纯Mock的单元测试更“真实”,因为它实际执行了SQL,能发现一些映射和查询语法问题。

9.3 @WebMvcTest 实践

这个注解用于切片测试Controller,不会加载完整的应用上下文,只加载与Web层相关的配置(如 @Controller , @RestController , @JsonComponent 等),Service层需要用 @MockBean 来模拟。

@WebMvcTest(OrderController.class) // 只加载OrderController相关的Web配置
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc; // 模拟HTTP请求的利器

    @MockBean
    private OrderService orderService;

    @Test
    void getOrder_ShouldReturnOrder() throws Exception {
        OrderDTO mockDto = new OrderDTO();
        mockDto.setId(1L);
        when(orderService.getOrderDetail(1L)).thenReturn(mockDto);

        mockMvc.perform(get("/api/orders/1") // 发起GET请求
                .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk()) // 断言HTTP状态码
                .andExpect(jsonPath("$.id").value(1)); // 断言JSON响应体
    }
}

MockMvc 提供了强大且流畅的API来测试Controller的HTTP接口,包括请求头、参数、内容类型、响应状态和内容验证。

10. 将测试融入开发流程

单元测试不是开发完成后才补的作业,而应该是开发流程中不可分割的一部分。

10.1 测试驱动开发

测试驱动开发是一种先写测试,再写实现代码的开发方式。其循环是“红-绿-重构”:

  1. :写一个失败的测试(描述你期望的功能)。
  2. 绿 :写最简单的代码让这个测试通过。
  3. 重构 :在测试保护下,优化代码结构,消除重复。

TDD能迫使你从调用者角度思考设计,往往能得到接口更清晰、耦合度更低的代码。虽然一开始不习惯,但对于核心、复杂的业务逻辑,尝试TDD会有意想不到的收获。

10.2 在CI/CD中运行测试

在持续集成/持续部署流水线中,自动化运行测试是基本要求。通常的步骤是:

  1. 代码推送到版本库(如Git)。
  2. CI服务器(如Jenkins, GitLab CI, GitHub Actions)触发构建。
  3. 运行 mvn clean verify (这会运行所有单元测试和集成测试)。
  4. 如果测试失败,构建标记为失败,阻止向更高级环境部署。
  5. 同时,可以配置CI任务生成并发布JaCoCo覆盖率报告,甚至设置覆盖率质量门禁。

10.3 测试代码的版本控制

测试代码和产品代码同等重要,必须一起提交到版本控制系统。一个常见的坏习惯是本地测试通过了就提交,却忘了把新增的测试文件 git add 进去。确保你的 .gitignore 文件没有忽略 src/test/ 目录下的内容。

最后,记住单元测试的终极目标不是追求工具和框架的炫技,而是为了 提升代码质量、减少缺陷、并赋予开发者安全重构的信心 。从今天开始,为你新增的每一个业务方法配上至少一个测试用例,逐步构建起项目的安全网,你会发现,长期来看,这非但不是负担,反而是提升开发效率和幸福感的强大引擎。

更多推荐