Java单元测试最佳实践案例
基础单元测试示例
使用JUnit 5编写最基本的单元测试
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Assertions;
import java.time.LocalDate;
public class UserServiceTest {
private UserService userService;
private UserRepository userRepository;
@BeforeEach
void setUp() {
// 创建真实依赖(或使用Mockito创建mock)
userRepository = new InMemoryUserRepository();
userService = new UserService(userRepository);
}
@Test
@DisplayName("创建用户成功场景")
void shouldCreateUserSuccessfully() {
// 准备数据
User user = new User("张三", 25, "zhangsan@example.com");
// 执行操作
User result = userService.createUser(user);
// 验证结果
Assertions.assertNotNull(result);
Assertions.assertEquals("张三", result.getName());
Assertions.assertNotNull(result.getId());
Assertions.assertEquals(UserStatus.ACTIVE, result.getStatus());
}
@Test
@DisplayName("创建用户时邮箱格式校验")
void shouldRejectInvalidEmail() {
User user = new User("李四", 30, "invalid-email");
IllegalArgumentException exception = Assertions.assertThrows(
IllegalArgumentException.class,
() -> userService.createUser(user)
);
Assertions.assertEquals("Invalid email format", exception.getMessage());
}
@Nested
@DisplayName("用户查询功能测试")
class UserQuerying {
@Test
@DisplayName("按ID查询存在的用户")
void shouldFindUserById() {
User user = new User("王五", 28, "wangwu@example.com");
userService.createUser(user);
User found = userService.getUserById(user.getId());
Assertions.assertNotNull(found);
Assertions.assertEquals(user.getId(), found.getId());
Assertions.assertEquals("王五", found.getName());
}
@Test
@DisplayName("查询不存在的用户返回null")
void shouldReturnNullForNonExistentUser() {
User found = userService.getUserById(999L);
Assertions.assertNull(found);
}
}
}
使用Mockito进行Mock测试
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 static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
@ExtendWith(MockitoExtension.class)
public class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentGateway paymentGateway;
@Mock
private NotificationService notificationService;
@InjectMocks
private OrderService orderService;
@Test
@DisplayName("创建订单并通知")
void shouldCreateOrderAndNotifyUser() {
// 准备mock行为
Order order = new Order("product123", 99.99, "user123");
when(orderRepository.save(any(Order.class)))
.thenAnswer(invocation -> {
Order savedOrder = invocation.getArgument(0);
savedOrder.setId(1L);
return savedOrder;
});
when(paymentGateway.prepare(anyDouble())).thenReturn("payment_token_123");
// 执行测试
Order result = orderService.createOrder(order);
// 验证结果
org.junit.jupiter.api.Assertions.assertEquals(1L, result.getId());
org.junit.jupiter.api.Assertions.assertEquals("payment_token_123", result.getPaymentToken());
// 验证交互
verify(orderRepository).save(order);
verify(paymentGateway).prepare(99.99);
verify(notificationService).sendOrderConfirmation(any(Order.class));
verifyNoMoreInteractions(notificationService);
}
@Test
@DisplayName("订单支付成功后更新状态")
void shouldUpdateOrderStatusOnPaymentSuccess() {
// 准备
Order existingOrder = new Order("product456", 150.00, "user456");
existingOrder.setId(10L);
when(orderRepository.findById(10L)).thenReturn(java.util.Optional.of(existingOrder));
// 执行
orderService.processPayment(10L);
// 验证
verify(orderRepository).findById(10L);
verify(orderRepository).save(org.mockito.ArgumentMatchers.argThat(
order -> order.getStatus() == OrderStatus.PAID
));
verify(paymentGateway).processPayment(existingOrder.getPaymentToken());
}
@Test
@DisplayName("订单不存在时抛出异常")
void shouldThrowExceptionWhenOrderNotFound() {
when(orderRepository.findById(999L)).thenReturn(java.util.Optional.empty());
org.junit.jupiter.api.Assertions.assertThrows(
OrderNotFoundException.class,
() -> orderService.processPayment(999L)
);
verify(paymentGateway, never()).processPayment(anyString());
}
}
参数化测试
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
import java.util.stream.Stream;
public class CalculationServiceParameterizedTest {
@ParameterizedTest
@ValueSource(ints = {2, 4, 6, 8, 10})
@DisplayName("偶数判断")
void shouldIdentifyEvenNumbers(int input) {
CalculationService calcService = new CalculationService();
Assertions.assertTrue(calcService.isEven(input));
}
@ParameterizedTest
@CsvSource({
"10, 5, 15",
"20, 30, 50",
"0, 0, 0",
"-5, 5, 0"
})
@DisplayName("加法运算参数化测试")
void shouldAddNumbersCorrectly(int a, int b, int expected) {
CalculationService calcService = new CalculationService();
Assertions.assertEquals(expected, calcService.add(a, b));
}
@ParameterizedTest
@EnumSource(ErrorCode.class)
@DisplayName("每个枚举值都有对应的错误消息")
void shouldHaveErrorMessageForEachCode(ErrorCode errorCode) {
MessageUtil messageUtil = new MessageUtil();
Assertions.assertNotNull(messageUtil.getMessage(errorCode));
Assertions.assertFalse(messageUtil.getMessage(errorCode).isEmpty());
}
@ParameterizedTest
@MethodSource("provideDataForPrimeCheck")
@DisplayName("素数判断参数化测试")
void shouldCorrectlyCheckPrimeNumbers(int number, boolean expected) {
CalculationService calcService = new CalculationService();
Assertions.assertEquals(expected, calcService.isPrime(number));
}
private static Stream<Arguments> provideDataForPrimeCheck() {
return Stream.of(
Arguments.of(2, true),
Arguments.of(3, true),
Arguments.of(4, false),
Arguments.of(17, true),
Arguments.of(20, false),
Arguments.of(97, true)
);
}
}
测试隔离与Mock Bean(Spring Boot)
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
@SpringBootTest
@ExtendWith(SpringExtension.class) // 如果使用JUnit 4
public class SpringBootUserControllerTest {
@Autowired
private UserController userController;
@MockBean
private UserService userService;
@MockBean
private CacheManager cacheManager;
@Test
@DisplayName("测试用户注册接口")
void testUserRegistration() {
// 准备mock数据
UserDTO userDTO = new UserDTO("test@example.com", "password123", "张三");
User mockUser = new User(1L, "test@example.com", "张三");
when(userService.register(any(UserDTO.class))).thenReturn(mockUser);
// 执行测试
ResponseEntity<User> response = userController.register(userDTO);
// 验证
Assertions.assertEquals(HttpStatus.CREATED, response.getStatusCode());
Assertions.assertNotNull(response.getBody());
Assertions.assertEquals(1L, response.getBody().getId());
verify(userService, times(1)).register(any(UserDTO.class));
}
@Test
@DisplayName("测试缓存失效场景")
void testUserCacheEviction() {
Long userId = 100L;
when(userService.getUserById(userId)).thenReturn(new User(userId, "test@example.com", "李四"));
User result1 = userController.getUser(userId);
User result2 = userController.getUser(userId);
// 验证缓存被调用
verify(userService, atMost(1)).getUserById(userId); // 如果缓存实现正确,应该只调用一次
Assertions.assertEquals(result1, result2);
}
}
单元测试通用最佳实践模式
AAA模式(Arrange-Act-Assert)
@Test
@DisplayName("用户年龄成年判断")
void shouldCheckIfUserIsAdult() {
// Arrange - 准备
User user = new User("测试用户", 25, "test@example.com");
AgeValidator validator = new AgeValidator();
// Act - 执行
boolean isAdult = validator.isAdult(user);
// Assert - 验证
Assertions.assertTrue(isAdult);
}
使用自定义断言或断言库
import org.assertj.core.api.Assertions;
@Test
@DisplayName("使用AssertJ进行更灵活的断言")
void shouldUseAssertJForFlexibleAssertions() {
UserService userService = new UserService(new InMemoryUserRepository());
User user = userService.createUser(new User("用户A", 30, "usera@example.com"));
// 链式断言
Assertions.assertThat(user)
.isNotNull()
.hasFieldOrPropertyWithValue("name", "用户A")
.hasFieldOrPropertyWithValue("age", 30)
.extracting(User::getEmail)
.asString()
.contains("@example.com");
// List断言
List<User> allUsers = userService.getAllUsers();
Assertions.assertThat(allUsers)
.hasSize(1)
.extracting(User::getName)
.containsExactly("用户A");
// 异常断言
Assertions.assertThatThrownBy(() -> userService.getUserById(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("ID cannot be null");
}
测试数据工厂
public class TestDataFactory {
public static User createDefaultUser() {
return createUser("测试用户", 25, "test@example.com");
}
public static User createUser(String name, int age, String email) {
User user = new User();
user.setName(name);
user.setAge(age);
user.setEmail(email);
user.setStatus(UserStatus.NEW);
return user;
}
public static Order createOrderWithDefaultUser() {
return Order.builder()
.user(createDefaultUser())
.totalAmount(99.99)
.status(OrderStatus.PENDING)
.items(List.of(
new OrderItem("product_1", "商品1", 2),
new OrderItem("product_2", "商品2", 1)
))
.build();
}
}
// 在测试中使用
@Test
void shouldCreateOrderSuccessfully() {
Order order = TestDataFactory.createOrderWithDefaultUser();
OrderStatus status = orderService.placeOrder(order);
Assertions.assertEquals(OrderStatus.CONFIRMED, status);
verify(orderRepository).save(order);
}
测试命名规范
public class PaymentServiceTest {
@Test
@DisplayName("有效支付方式时处理支付成功")
void handlePayment_withValidPaymentMethod_shouldSucceed() {
// 可以使用 given_when_then 或者 When_Then 命名
}
@Test
@DisplayName("无效支付方式时抛出异常")
void handlePayment_withInvalidPaymentMethod_shouldThrowException() {
// 测试代码
}
// 另一种规范
@Test
@DisplayName("should_fail_when_payment_amount_is_negative")
void should_fail_when_payment_amount_is_negative() {
// 测试代码
}
}
使用Testcontainers进行外部依赖测试(可选)
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
@SpringBootTest
public class UserRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureDatabase(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository userRepository;
@Test
@DisplayName("用户保存和查询功能")
void shouldSaveAndRetrieveUser() {
User user = TestDataFactory.createDefaultUser();
userRepository.save(user);
Optional<User> found = userRepository.findById(user.getId());
Assertions.assertTrue(found.isPresent());
Assertions.assertEquals(user.getEmail(), found.get().getEmail());
}
}
测试覆盖率与常用工具
// 使用覆盖率工具插件(如JaCoCo)后,可生成HTML报告
// 重要:高覆盖率不代表测试质量,应关注关键路径
// 使用LocalDateTime测试时间相关功能
@Test
@DisplayName("测试用户注册时间记录")
void shouldRecordRegistrationTime() {
LocalDateTime fixedTime = LocalDateTime.of(2025, 1, 15, 10, 30);
TimeProvider timeProvider = () -> fixedTime; // 注入可以mock的时间提供器
UserService userService = new UserService(new InMemoryUserRepository(), timeProvider);
User user = userService.register("test@example.com", "password");
Assertions.assertEquals(fixedTime, user.getRegistrationTime());
}
并发和异常测试
@RepeatedTest(5)
@DisplayName("并发注册100个用户不应崩溃")
void concurrentUserRegistration() {
ExecutorService executor = Executors.newFixedThreadPool(10);
CountDownLatch latch = new CountDownLatch(100);
for (int i = 0; i < 100; i++) {
executor.submit(() -> {
try {
int userId = ThreadLocalRandom.current().nextInt(0, 1000);
userService.register("user" + userId + "@example.com", "password");
} catch (Exception e) {
// 记录异常
} finally {
latch.countDown();
}
});
}
try {
latch.await(10, TimeUnit.SECONDS);
Assertions.assertTrue(userService.getUserCount() >= 100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
Assertions.fail("Test interrupted");
}
}
测试代码质量检查清单
| 检查项 | 示例/说明 |
|---|---|
| 测试独立性 | 每个测试互不影响,不共享状态 |
| 测试可靠性 | 不依赖网络、时间、随机数(除非mock) |
| 测试表述性 | 使用@DisplayName描述测试意图 |
| 数据清理 | 在@AfterEach中清理测试数据 |
| 边界条件 | 测试空值、最大值、负数等边界值 |
| 异常路径 | 不仅测试成功场景,还要测试失败和异常 |
| 不测试私有方法 | 通过公有API间接测试私有逻辑 |
好的单元测试应该是:

- 快速 - 可单独运行,不依赖外部服务
- 可靠 - 结果稳定,不含随机性
- 可读 - 他人能快速理解测试意图
- 聚焦 - 一个测试只验证一个行为
- 覆盖关键 - 关注业务规则和关键分支
实际项目中,建议结合 TDD(测试驱动开发) 或 BDD(行为驱动开发) 方式开展工作,使测试产出最大化价值。