Java 全链路测试体系:单元测试、集成测试与 TDD 从原理到落地

简介: 本文系统讲解Java测试体系,涵盖测试金字塔模型、单元测试(FIRST原则、JUnit5/Mockito/AssertJ实战)、集成测试(TestContainers真实环境)、TDD红-绿-重构实践,以及覆盖率认知、CI/CD集成等落地准则,助力开发者构建高质可维护代码。

一、测试体系的核心认知:为什么你的测试总是没用?

很多Java开发者对测试的认知停留在“为了应付覆盖率要求”“测试是测试工程师的事”,最终写出的测试要么和业务逻辑脱节,要么一重构就全崩,完全失去了测试的价值。

测试体系的核心价值,是为代码提供一套快速、可重复、确定性的反馈机制:它既能验证当前代码的行为符合预期,也能在后续修改、重构时,快速发现引入的回归问题,让你敢放心修改代码。

1.1 测试金字塔模型:测试体系的核心架构

测试金字塔是业界公认的测试分层架构模型,它定义了不同测试层级的定位、数量占比和反馈速度,从底层到顶层,测试的范围逐渐扩大,执行速度逐渐变慢,数量逐渐减少。

  • 底层单元测试:聚焦单个代码单元的独立逻辑,完全隔离外部依赖,执行速度毫秒级,是整个测试体系的基石,数量占比最高。
  • 中间层集成测试:聚焦多个模块/组件之间的交互逻辑,验证模块间的协作是否符合预期,执行速度秒级,数量适中。
  • 顶层端到端测试:聚焦完整业务链路的全流程验证,从用户入口到数据存储全链路覆盖,执行速度最慢,数量最少。

1.2 核心边界区分:单元测试 vs 集成测试

这是开发者最容易混淆的两个概念,一旦边界模糊,测试就会变得既慢又不可靠,这里给出明确的定义和区分:

对比维度 单元测试 集成测试
测试核心 单个代码单元的独立行为逻辑 多个模块/组件之间的交互协作
依赖处理 完全隔离所有外部依赖(用Mock/Stub替代) 使用真实的组件/依赖(或真实环境的等价替代)
执行速度 毫秒级,单测用例执行时间不超过10ms 秒级,单条用例执行时间通常在100ms以上
失败原因 仅能由被测单元的逻辑错误导致 可能由模块间协作、依赖环境、配置等多种问题导致
运行频率 开发过程中随时运行,每次代码提交必须全量执行 开发阶段按需运行,代码PR/合并前执行,定时全量执行

核心判断标准:如果你的测试需要访问数据库、Redis、启动Spring容器、调用外部服务,那它就不是单元测试


二、单元测试:测试体系的基石

单元测试是整个测试体系中最基础、最核心的部分,它的质量直接决定了整个代码库的可维护性。

2.1 单元测试的核心定义

在Java体系中,一个“单元”指的是一个类的公有方法所暴露的独立行为,单元测试的核心目标,是验证这个类在给定输入下,是否输出符合预期的行为,且完全隔离所有外部依赖——因为我们要验证的是“你写的代码逻辑”,而不是外部依赖的正确性。

2.2 单元测试的核心原则:FIRST

单元测试必须遵循FIRST原则,才能保证它的有效性和可维护性:

  1. Fast(快速):单测用例必须执行得足够快,才能让开发者愿意频繁运行,单条用例执行时间应控制在10ms以内。
  2. Isolated(隔离):每个用例之间完全独立,没有执行顺序依赖;被测类的所有外部依赖必须完全隔离,用例的失败只能由被测类的逻辑错误导致。
  3. Repeatable(可重复):用例在任何环境、任何时间、任何执行顺序下,运行结果都必须完全一致,不能依赖外部环境的状态。
  4. Self-Validating(自验证):用例必须有明确的断言,通过断言自动判断测试是否通过,不需要人工检查日志、输出等内容。
  5. Timely(及时):单元测试应该和业务代码同步编写,甚至先于业务代码编写,而不是业务代码上线后再补。

2.3 技术栈选型与核心API

Java生态中,单元测试的主流技术栈是:

  • 测试框架:JUnit 5(Jupiter),替代老旧的JUnit 4,提供了更强大的测试能力、更灵活的扩展机制。
  • Mock框架:Mockito,用于隔离外部依赖,模拟外部组件的行为。
  • 断言库:AssertJ,提供流式、更符合人类阅读习惯的断言API,替代JUnit原生的断言。

2.3.1 核心依赖配置

<dependencies>
   <!-- JUnit 5 核心依赖 -->
   <dependency>
       <groupId>org.junit.jupiter</groupId>
       <artifactId>junit-jupiter-api</artifactId>
       <version>5.11.0</version>
       <scope>test</scope>
   </dependency>
   <dependency>
       <groupId>org.junit.jupiter</groupId>
       <artifactId>junit-jupiter-engine</artifactId>
       <version>5.11.0</version>
       <scope>test</scope>
   </dependency>
   <!-- Mockito 核心依赖 -->
   <dependency>
       <groupId>org.mockito</groupId>
       <artifactId>mockito-core</artifactId>
       <version>5.12.0</version>
       <scope>test</scope>
   </dependency>
   <dependency>
       <groupId>org.mockito</groupId>
       <artifactId>mockito-junit-jupiter</artifactId>
       <version>5.12.0</version>
       <scope>test</scope>
   </dependency>
   <!-- AssertJ 断言库 -->
   <dependency>
       <groupId>org.assertj</groupId>
       <artifactId>assertj-core</artifactId>
       <version>3.26.3</version>
       <scope>test</scope>
   </dependency>
</dependencies>

2.3.2 JUnit 5 核心注解

注解 作用
@Test 标记一个方法为测试用例
@BeforeEach 每个测试用例执行前都会执行的方法,用于初始化测试数据
@AfterEach 每个测试用例执行后都会执行的方法,用于清理测试数据
@BeforeAll 所有测试用例执行前仅执行一次的方法,必须是static方法
@AfterAll 所有测试用例执行后仅执行一次的方法,必须是static方法
@Disabled 禁用某个测试用例或测试类
@DisplayName 给测试类/用例设置自定义的展示名称

2.4 完整实战示例

我们以一个用户核心业务类为例,编写完整的单元测试用例,覆盖正常流程、异常流程、边界条件。

2.4.1 业务代码实现

package com.example.testdemo.service;

import com.example.testdemo.entity.User;
import com.example.testdemo.repository.UserRepository;
import com.example.testdemo.exception.UserNotFoundException;
import com.example.testdemo.exception.UserLockedException;

import java.util.Optional;

public class UserService {
   private final UserRepository userRepository;

   public UserService(UserRepository userRepository) {
       this.userRepository = userRepository;
   }

   public User getUserById(Long userId) {
       if (userId == null || userId <= 0) {
           throw new IllegalArgumentException("用户ID必须为正整数");
       }
       Optional<User> userOptional = userRepository.findById(userId);
       User user = userOptional.orElseThrow(() -> new UserNotFoundException("用户不存在:" + userId));
       if (user.isLocked()) {
           throw new UserLockedException("用户已被锁定:" + userId);
       }
       return user;
   }
}

package com.example.testdemo.entity;

public class User {
   private Long id;
   private String username;
   private boolean locked;

   public User() {}

   public User(Long id, String username, boolean locked) {
       this.id = id;
       this.username = username;
       this.locked = locked;
   }

   public Long getId() {
       return id;
   }

   public void setId(Long id) {
       this.id = id;
   }

   public String getUsername() {
       return username;
   }

   public void setUsername(String username) {
       this.username = username;
   }

   public boolean isLocked() {
       return locked;
   }

   public void setLocked(boolean locked) {
       this.locked = locked;
   }
}

package com.example.testdemo.repository;

import com.example.testdemo.entity.User;
import java.util.Optional;

public interface UserRepository {
   Optional<User> findById(Long userId);
}

package com.example.testdemo.exception;

public class UserNotFoundException extends RuntimeException {
   public UserNotFoundException(String message) {
       super(message);
   }
}

package com.example.testdemo.exception;

public class UserLockedException extends RuntimeException {
   public UserLockedException(String message) {
       super(message);
   }
}

2.4.2 单元测试实现

package com.example.testdemo.service;

import com.example.testdemo.entity.User;
import com.example.testdemo.repository.UserRepository;
import com.example.testdemo.exception.UserNotFoundException;
import com.example.testdemo.exception.UserLockedException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
@DisplayName("用户服务单元测试")
public class UserServiceTest
{
   @Mock
   private UserRepository userRepository;
   @InjectMocks
   private UserService userService;

   @Test
   @DisplayName("正常查询:用户ID有效、用户存在且未锁定,返回正确用户信息")
   void getUserById_ValidUserId_ReturnsUser() {
       Long userId = 1L;
       User expectedUser = new User(userId, "test_user", false);
       when(userRepository.findById(userId)).thenReturn(Optional.of(expectedUser));

       User actualUser = userService.getUserById(userId);

       assertThat(actualUser).isNotNull();
       assertThat(actualUser.getId()).isEqualTo(userId);
       assertThat(actualUser.getUsername()).isEqualTo("test_user");
       assertThat(actualUser.isLocked()).isFalse();
   }

   @Test
   @DisplayName("参数校验:用户ID为null,抛出非法参数异常")
   void getUserById_NullUserId_ThrowsIllegalArgumentException() {
       assertThatThrownBy(() -> userService.getUserById(null))
               .isInstanceOf(IllegalArgumentException.class)
               .hasMessage("用户ID必须为正整数")
;
   }

   @Test
   @DisplayName("参数校验:用户ID为负数,抛出非法参数异常")
   void getUserById_NegativeUserId_ThrowsIllegalArgumentException() {
       assertThatThrownBy(() -> userService.getUserById(-1L))
               .isInstanceOf(IllegalArgumentException.class)
               .hasMessage("用户ID必须为正整数")
;
   }

   @Test
   @DisplayName("业务异常:用户不存在,抛出用户不存在异常")
   void getUserById_UserNotExists_ThrowsUserNotFoundException() {
       Long userId = 999L;
       when(userRepository.findById(userId)).thenReturn(Optional.empty());

       assertThatThrownBy(() -> userService.getUserById(userId))
               .isInstanceOf(UserNotFoundException.class)
               .hasMessage("用户不存在:999")
;
   }

   @Test
   @DisplayName("业务异常:用户已被锁定,抛出用户锁定异常")
   void getUserById_UserLocked_ThrowsUserLockedException() {
       Long userId = 2L;
       User lockedUser = new User(userId, "locked_user", true);
       when(userRepository.findById(userId)).thenReturn(Optional.of(lockedUser));

       assertThatThrownBy(() -> userService.getUserById(userId))
               .isInstanceOf(UserLockedException.class)
               .hasMessage("用户已被锁定:2")
;
   }
}

2.5 单元测试的常见误区与最佳实践

2.5.1 易踩坑的误区

  1. 测试实现细节,而非行为:很多开发者会测试类的私有方法、mock被测类的公有方法,这是完全错误的。单元测试应该测试类的公开行为,而不是内部实现,否则一旦重构代码,即使行为没变,测试也会全崩。
  2. 过度Mock,导致测试和实现强耦合:比如对一个方法内的局部变量、工具类静态方法进行mock,会导致测试完全依赖代码的实现细节,失去了验证行为的意义。
  3. 一个用例测试多个场景:一个测试用例应该只验证一个行为场景,否则用例失败后,你无法快速定位是哪个场景出了问题。
  4. 没有明确的断言,或者断言太宽松:比如只断言返回值不为null,而不校验返回值的核心字段,等于没有测试。
  5. 用例之间有依赖:比如前一个用例修改了静态变量,后一个用例依赖这个变量的状态,会导致用例执行顺序不同,结果不同,违反了Isolated和Repeatable原则。

2.5.2 最佳实践

  1. 用例命名遵循固定规范:推荐使用被测方法名_输入条件_预期结果的命名格式,比如getUserById_NullUserId_ThrowsIllegalArgumentException,一眼就能看懂用例的测试场景和预期结果。
  2. 遵循AAA模式:每个用例都分为三个清晰的部分:Arrange(准备测试数据和Mock行为)、Act(执行被测方法)、Assert(断言结果),三个部分之间用空行分隔,结构清晰。
  3. 不要测试私有方法:如果私有方法需要测试,说明这个类的职责过大,应该拆分出一个新的类,将私有方法转为新类的公有方法,再进行测试。
  4. 只Mock直接依赖:比如UserService只依赖UserRepository,就只Mock UserRepository,不要Mock UserRepository依赖的DataSource,隔离的边界是被测类的直接外部依赖。
  5. 异常场景必须全覆盖:参数校验异常、业务异常、边界条件,都必须有对应的测试用例,这些场景往往是bug的高发区。

三、集成测试:验证模块协作的核心

单元测试验证了单个模块的独立逻辑,但无法保证多个模块组合在一起时,能正常协作。集成测试的核心目标,就是验证多个模块/组件之间的交互是否符合预期,确保模块间的契约一致。

3.1 集成测试的核心分类

在Java Spring Boot生态中,集成测试主要分为两类:

  1. 纵向集成测试:验证同一业务链路中,不同层级的模块之间的交互,比如Service层和Repository层的集成、Repository层和数据库的集成、Controller层和Service层的集成。
  2. 横向集成测试:验证不同服务/系统之间的交互,比如本服务调用第三方API、微服务之间的RPC/HTTP调用。

3.2 技术栈选型与核心能力

Spring Boot生态中,集成测试的主流技术栈是:

  • 测试框架:Spring Boot Test,基于JUnit 5,提供了Spring容器的集成支持。
  • 环境隔离:TestContainers,提供基于Docker的隔离化测试环境,比如MySQL、Redis、Kafka等组件的真实实例,保证测试环境和生产环境一致。
  • 接口测试:Spring MockMvc,用于测试Controller层的HTTP接口,不需要启动完整的Web服务器。

3.2.1 核心依赖配置

<dependencies>
   <!-- Spring Boot Test 核心依赖 -->
   <dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-test</artifactId>
       <version>3.3.5</version>
       <scope>test</scope>
   </dependency>
   <!-- TestContainers 核心依赖 -->
   <dependency>
       <groupId>org.testcontainers</groupId>
       <artifactId>testcontainers</artifactId>
       <version>1.20.1</version>
       <scope>test</scope>
   </dependency>
   <dependency>
       <groupId>org.testcontainers</groupId>
       <artifactId>mysql</artifactId>
       <version>1.20.1</version>
       <scope>test</scope>
   </dependency>
   <dependency>
       <groupId>org.testcontainers</groupId>
       <artifactId>junit-jupiter</artifactId>
       <version>1.20.1</version>
       <scope>test</scope>
   </dependency>
   <!-- MySQL 驱动 -->
   <dependency>
       <groupId>com.mysql</groupId>
       <artifactId>mysql-connector-j</artifactId>
       <version>8.4.0</version>
       <scope>runtime</scope>
   </dependency>
   <!-- Spring Data JPA 依赖 -->
   <dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-data-jpa</artifactId>
       <version>3.3.5</version>
   </dependency>
</dependencies>

3.3 完整实战示例

我们以Spring Boot环境下的持久层集成测试、Service层与持久层的集成测试为例,编写完整的集成测试用例,使用TestContainers提供真实的MySQL环境,保证测试的真实性。

3.3.1 持久层集成测试

持久层集成测试的核心,是验证Repository层的SQL逻辑、ORM映射是否正确,和真实数据库的交互是否符合预期。Spring Boot提供了@DataJpaTest注解,专门用于JPA Repository的测试,只会加载JPA相关的配置,启动速度更快。

package com.example.testdemo.repository;

import com.example.testdemo.entity.User;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

@Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@DisplayName("用户仓库集成测试")
public class UserRepositoryTest {
   @Container
   @ServiceConnection
   static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.4.0");

   @Autowired
   private UserRepository userRepository;

   @Test
   @DisplayName("保存用户后,通过ID能正确查询到用户信息")
   void saveUser_FindById_ReturnsCorrectUser() {
       User user = new User();
       user.setUsername("integration_test_user");
       user.setLocked(false);

       User savedUser = userRepository.save(user);
       Optional<User> foundUser = userRepository.findById(savedUser.getId());

       assertThat(foundUser).isPresent();
       assertThat(foundUser.get().getUsername()).isEqualTo("integration_test_user");
       assertThat(foundUser.get().isLocked()).isFalse();
   }

   @Test
   @DisplayName("查询不存在的用户ID,返回空的Optional")
   void findById_NotExistsUserId_ReturnsEmpty() {
       Optional<User> foundUser = userRepository.findById(9999L);
       assertThat(foundUser).isEmpty();
   }

   @Test
   @DisplayName("更新用户锁定状态后,查询能正确返回更新后的状态")
   void updateUserLockedStatus_FindById_ReturnsUpdatedStatus() {
       User user = new User();
       user.setUsername("update_test_user");
       user.setLocked(false);
       User savedUser = userRepository.save(user);

       savedUser.setLocked(true);
       userRepository.save(savedUser);
       Optional<User> updatedUser = userRepository.findById(savedUser.getId());

       assertThat(updatedUser).isPresent();
       assertThat(updatedUser.get().isLocked()).isTrue();
   }
}

对应的Repository接口实现:

package com.example.testdemo.repository;

import com.example.testdemo.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
}

3.3.2 Service层与持久层的集成测试

这类集成测试的核心,是验证Service层的业务逻辑和Repository层的数据访问逻辑,组合在一起时是否能正常工作,使用真实的数据库实例,不Mock Repository层。

package com.example.testdemo.service;

import com.example.testdemo.entity.User;
import com.example.testdemo.exception.UserNotFoundException;
import com.example.testdemo.repository.UserRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Testcontainers
@SpringBootTest
@DisplayName("用户服务集成测试")
public class UserServiceIntegrationTest {
   @Container
   @ServiceConnection
   static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.4.0");

   @Autowired
   private UserService userService;
   @Autowired
   private UserRepository userRepository;

   @Test
   @DisplayName("集成查询:数据库中存在的有效用户,能正确返回用户信息")
   void getUserById_ExistsUserInDb_ReturnsUser() {
       User user = new User();
       user.setUsername("service_test_user");
       user.setLocked(false);
       User savedUser = userRepository.save(user);

       User foundUser = userService.getUserById(savedUser.getId());

       assertThat(foundUser).isNotNull();
       assertThat(foundUser.getId()).isEqualTo(savedUser.getId());
       assertThat(foundUser.getUsername()).isEqualTo("service_test_user");
   }

   @Test
   @DisplayName("集成校验:数据库中不存在的用户,抛出用户不存在异常")
   void getUserById_NotExistsUserInDb_ThrowsException() {
       Long notExistsUserId = 8888L;

       assertThatThrownBy(() -> userService.getUserById(notExistsUserId))
               .isInstanceOf(UserNotFoundException.class)
               .hasMessage("用户不存在:8888")
;
   }
}

3.4 集成测试的常见误区与最佳实践

3.4.1 易踩坑的误区

  1. 把集成测试写成单元测试:在集成测试中Mock核心依赖,比如Mock Repository层,导致集成测试完全失去了验证模块交互的意义。
  2. 把集成测试写成端到端测试:一个集成测试覆盖了从Controller到数据库的全链路,甚至调用了第三方服务,导致测试执行速度极慢,失败后无法快速定位问题。
  3. 使用内存数据库(如H2)替代生产环境的数据库:H2和MySQL、PostgreSQL等生产数据库的语法、特性有很多差异,会导致测试通过了,生产环境却出问题,无法保证测试的真实性。
  4. 测试数据不隔离:多个用例共用同一份测试数据,前一个用例修改了数据,导致后一个用例执行失败,违反了可重复原则。
  5. 过度使用@SpringBootTest:不管测试什么内容,都用@SpringBootTest启动完整的Spring容器,导致测试启动速度极慢,开发效率低下。

3.4.2 最佳实践

  1. 聚焦测试范围,使用专用测试注解:测试Repository层用@DataJpaTest,测试Controller层用@WebMvcTest,只有需要测试多模块完整集成时,才使用@SpringBootTest,最小化测试的启动范围,提升执行速度。
  2. 使用TestContainers保证环境一致性:用Docker容器启动和生产环境版本一致的数据库、中间件实例,保证测试环境和生产环境完全一致,避免环境差异导致的bug。
  3. 测试数据自动隔离:每个测试用例执行前插入测试数据,执行后自动清理,或者使用事务回滚机制(@DataJpaTest默认会在测试结束后回滚事务),保证用例之间的数据完全隔离。
  4. 集成测试只验证核心交互场景:集成测试不需要覆盖所有的边界条件和异常场景,这些场景已经在单元测试中覆盖了,集成测试只需要验证模块间的核心交互流程是否正常。
  5. 第三方依赖使用契约测试:对于横向集成的第三方服务,不要直接调用第三方的测试环境,应该使用契约测试,保证服务间的契约一致,同时避免依赖第三方服务的可用性。

四、TDD 测试驱动开发:用测试驱动代码设计

很多开发者对TDD的认知停留在“先写测试,再写代码”,但这只是TDD的表面形式,TDD的核心是用测试驱动代码的设计,通过定义行为预期,倒逼代码实现低耦合、高内聚的设计

4.1 TDD的核心循环:红-绿-重构

TDD的整个过程,由三个步骤组成一个无限循环,每个步骤都有明确的目标和要求,不能跳过任何一步。

  1. 红(Red):先编写一个测试用例,定义代码要实现的行为预期,此时因为还没有实现业务代码,测试运行一定会失败。这一步的核心,是从使用者的角度,定义代码的接口和行为契约,而不是从实现者的角度思考怎么写代码。
  2. 绿(Green):编写最少的业务代码,刚好让刚才的测试用例通过,不需要考虑代码的优雅性、扩展性,只要能让测试通过就行。这一步的核心,是保证代码的行为符合预期,先实现功能,再优化结构。
  3. 重构(Refactor):在测试用例全部通过的安全网下,优化代码的结构,消除重复代码,提升代码的可读性、可维护性,整个过程中,测试用例必须一直保持通过。这一步的核心,是不改变代码行为的前提下,优化代码设计

4.2 完整实战示例

我们以一个订单折扣计算的核心业务功能为例,完整走一遍TDD的红-绿-重构循环,直观理解TDD的落地过程。

业务需求:

  • 订单金额小于100元,无折扣;
  • 订单金额大于等于100元,小于1000元,95折;
  • 订单金额大于等于1000元,小于5000元,9折;
  • 订单金额大于等于5000元,85折;
  • 订单金额必须为正数,否则抛出非法参数异常。

步骤1:红 - 编写第一个失败的测试

我们先从最简单的场景开始,编写第一个测试用例,定义折扣计算类的接口和行为预期。

package com.example.testdemo.service;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

import static org.assertj.core.api.Assertions.assertThat;

@DisplayName("订单折扣服务TDD测试")
public class OrderDiscountServiceTest {
   private final OrderDiscountService discountService = new OrderDiscountService();

   @Test
   @DisplayName("订单金额小于100元,无折扣,返回原金额")
   void calculateDiscount_AmountLessThan100_ReturnsOriginalAmount() {
       BigDecimal orderAmount = new BigDecimal("50.00");
       BigDecimal actualAmount = discountService.calculateDiscount(orderAmount);
       assertThat(actualAmount).isEqualByComparingTo(new BigDecimal("50.00"));
   }
}

此时,我们还没有编写OrderDiscountService类,测试运行会直接编译失败,也就是“红”的状态。

步骤2:绿 - 编写刚好让测试通过的代码

我们编写最少的代码,让测试通过,不需要考虑其他场景,只要满足当前的测试用例。

package com.example.testdemo.service;

import java.math.BigDecimal;

public class OrderDiscountService {
   public BigDecimal calculateDiscount(BigDecimal orderAmount) {
       return orderAmount;
   }
}

此时,运行测试用例,测试通过,进入“绿”的状态。

步骤3:红 - 编写第二个失败的测试

接下来,我们添加第二个场景的测试用例,订单金额大于等于100元,小于1000元,95折。

@Test
@DisplayName("订单金额100元到1000元,95折")
void calculateDiscount_AmountBetween100And1000_Returns95Discount() {
   BigDecimal orderAmount = new BigDecimal("200.00");
   BigDecimal actualAmount = discountService.calculateDiscount(orderAmount);
   assertThat(actualAmount).isEqualByComparingTo(new BigDecimal("190.00"));
}

运行测试,这个新的用例失败,回到“红”的状态。

步骤4:绿 - 编写刚好让测试通过的代码

修改业务代码,添加95折的逻辑,让两个测试用例都通过。

package com.example.testdemo.service;

import java.math.BigDecimal;

public class OrderDiscountService {
   public BigDecimal calculateDiscount(BigDecimal orderAmount) {
       if (orderAmount.compareTo(new BigDecimal("100")) >= 0) {
           return orderAmount.multiply(new BigDecimal("0.95"));
       }
       return orderAmount;
   }
}

运行测试,两个用例都通过,回到“绿”的状态。

步骤5:重复红-绿循环,覆盖所有场景

我们继续按照这个循环,依次添加剩余的场景:

  1. 订单金额1000元到5000元,9折;
  2. 订单金额大于等于5000元,85折;
  3. 订单金额为负数,抛出非法参数异常;
  4. 订单金额为0,抛出非法参数异常;
  5. 边界值:100元、1000元、5000元的折扣计算。

最终完成所有测试用例的编写,对应的业务代码也同步完成。

步骤6:重构 - 优化代码结构

所有测试用例都通过后,我们在安全网下,优化代码的结构,消除魔法值,提升代码的可读性和可维护性。

重构后的业务代码:

package com.example.testdemo.service;

import java.math.BigDecimal;

public class OrderDiscountService {
   private static final BigDecimal DISCOUNT_THRESHOLD_100 = new BigDecimal("100");
   private static final BigDecimal DISCOUNT_THRESHOLD_1000 = new BigDecimal("1000");
   private static final BigDecimal DISCOUNT_THRESHOLD_5000 = new BigDecimal("5000");
   private static final BigDecimal DISCOUNT_RATE_95 = new BigDecimal("0.95");
   private static final BigDecimal DISCOUNT_RATE_90 = new BigDecimal("0.90");
   private static final BigDecimal DISCOUNT_RATE_85 = new BigDecimal("0.85");
   private static final BigDecimal ZERO = BigDecimal.ZERO;

   public BigDecimal calculateDiscount(BigDecimal orderAmount) {
       if (orderAmount.compareTo(ZERO) <= 0) {
           throw new IllegalArgumentException("订单金额必须为正数");
       }
       if (orderAmount.compareTo(DISCOUNT_THRESHOLD_5000) >= 0) {
           return orderAmount.multiply(DISCOUNT_RATE_85);
       }
       if (orderAmount.compareTo(DISCOUNT_THRESHOLD_1000) >= 0) {
           return orderAmount.multiply(DISCOUNT_RATE_90);
       }
       if (orderAmount.compareTo(DISCOUNT_THRESHOLD_100) >= 0) {
           return orderAmount.multiply(DISCOUNT_RATE_95);
       }
       return orderAmount;
   }
}

重构完成后,运行所有测试用例,全部通过,保证重构没有改变代码的行为。

4.3 TDD的常见误区与最佳实践

4.3.1 易踩坑的误区

  1. 先写完整的业务代码,再补测试:这完全违背了TDD的核心思想,TDD是用测试驱动设计,而不是用测试验证已经写好的代码,补测试无法倒逼出好的设计。
  2. 一个测试用例覆盖太多场景:红阶段一次写多个场景的测试,导致绿阶段需要写大量的代码,无法保证小步快跑,一旦测试失败,无法快速定位问题。
  3. 重构阶段修改测试代码:重构阶段的核心是不改变代码的行为,测试代码是行为的契约,一旦修改测试代码,就无法保证重构没有改变行为,重构阶段绝对不能修改测试代码。
  4. 绿阶段过度设计代码:绿阶段只需要写刚好让测试通过的代码,不需要考虑未来的扩展、代码的优雅性,过早的设计会导致代码冗余,偏离需求。
  5. 认为TDD能替代所有测试:TDD主要驱动的是单元测试,无法替代集成测试、端到端测试,完整的测试体系需要多种测试类型配合。

4.3.2 最佳实践

  1. 小步快跑,一次只写一个场景的测试:每个红阶段只写一个最小的场景测试,绿阶段只写刚好让这个测试通过的代码,保证每一步都有明确的目标,快速反馈。
  2. 从使用者的角度编写测试:编写测试的时候,把自己当成这个类的使用者,思考你希望这个类提供什么样的接口,什么样的行为,而不是思考这个类内部要怎么实现,这是TDD驱动好设计的核心。
  3. 重构必须在绿阶段进行:只有所有测试用例都通过的情况下,才能进行重构,测试是重构的安全网,没有通过的测试,绝对不能进行重构。
  4. 测试用例必须覆盖所有业务规则:每个业务规则、边界条件、异常场景,都必须有对应的测试用例,保证业务代码完全符合需求。
  5. 选择合适的场景使用TDD:TDD最适合核心业务逻辑、有明确需求的功能、算法实现、工具类开发;对于探索性需求、UI开发、一次性脚本等场景,不适合使用TDD。

五、测试体系落地的核心准则

5.1 正确看待测试覆盖率

测试覆盖率是一个结果指标,不是目标指标。追求100%的代码覆盖率没有任何意义,很多代码(比如getter/setter、简单的DTO类)即使覆盖率100%,也无法保证业务逻辑的正确性。

正确的做法是:核心业务逻辑的代码覆盖率必须达到100%,非核心代码的覆盖率可以根据实际情况放宽,重点关注分支覆盖率、条件覆盖率,而不是简单的行覆盖率。

5.2 测试代码和业务代码同等重要

很多开发者只重视业务代码的质量,不重视测试代码的质量,写出的测试代码冗余、重复、难以维护,最终导致测试代码无法和业务代码同步迭代,变成无效测试。

测试代码必须遵循和业务代码一样的编码规范,消除重复代码,提升可读性和可维护性,业务代码重构时,测试代码也要同步重构,保证测试的有效性。

5.3 建立分层的测试执行策略

不同层级的测试,执行速度和执行频率不同,需要建立对应的执行策略:

  • 单元测试:开发过程中随时运行,每次代码提交到仓库时,必须全量执行,保证提交的代码不会破坏已有的逻辑。
  • 集成测试:开发阶段按需运行,代码提交PR/合并到主干分支时,必须全量执行,保证模块间的交互正常。
  • 端到端测试:每天定时全量执行,或者版本发布前执行,保证完整业务链路的正常。

5.4 把测试集成到CI/CD流水线中

测试体系的价值,只有在持续集成中才能完全发挥出来。把所有的测试用例集成到CI/CD流水线中,代码提交时自动执行单元测试,PR合并时自动执行集成测试,版本发布前自动执行端到端测试,一旦测试失败,直接阻断流程,保证有问题的代码不会进入生产环境。


写在最后

单元测试是基石,保证每个代码单元的行为符合预期;集成测试是桥梁,保证模块间的协作正常;TDD是设计方法,用测试驱动出更好的代码设计。三者结合,才能构建出一套完整、有效的Java测试体系,让你敢放心改代码,放心做重构,持续交付高质量的代码。

目录
相关文章
|
3天前
|
人工智能 JSON 机器人
让龙虾成为你的“公众号分身” | 阿里云服务器玩Openclaw
本文带你零成本玩转OpenClaw:学生认证白嫖6个月阿里云服务器,手把手配置飞书机器人、接入免费/高性价比AI模型(NVIDIA/通义),并打造微信公众号“全自动分身”——实时抓热榜、AI选题拆解、一键发布草稿,5分钟完成热点→文章全流程!
10446 46
让龙虾成为你的“公众号分身” | 阿里云服务器玩Openclaw
|
23天前
|
人工智能 JavaScript Ubuntu
5分钟上手龙虾AI!OpenClaw部署(阿里云+本地)+ 免费多模型配置保姆级教程(MiniMax、Claude、阿里云百炼)
OpenClaw(昵称“龙虾AI”)作为2026年热门的开源个人AI助手,由PSPDFKit创始人Peter Steinberger开发,核心优势在于“真正执行任务”——不仅能聊天互动,还能自动处理邮件、管理日程、订机票、写代码等,且所有数据本地处理,隐私完全可控。它支持接入MiniMax、Claude、GPT等多类大模型,兼容微信、Telegram、飞书等主流聊天工具,搭配100+可扩展技能,成为兼顾实用性与隐私性的AI工具首选。
23591 121
|
9天前
|
人工智能 JavaScript API
解放双手!OpenClaw Agent Browser全攻略(阿里云+本地部署+免费API+网页自动化场景落地)
“让AI聊聊天、写代码不难,难的是让它自己打开网页、填表单、查数据”——2026年,无数OpenClaw用户被这个痛点困扰。参考文章直击核心:当AI只能“纸上谈兵”,无法实际操控浏览器,就永远成不了真正的“数字员工”。而Agent Browser技能的出现,彻底打破了这一壁垒——它给OpenClaw装上“上网的手和眼睛”,让AI能像真人一样打开网页、点击按钮、填写表单、提取数据,24小时不间断完成网页自动化任务。
2213 5