
在开发基于Spring Boot的应用时,我们经常会遇到需要对业务逻辑层(Service)和数据访问层(Repository)进行测试的场景。特别是对于执行“软删除”操作的void方法,其测试覆盖率的实现常常会引发疑问。本文将深入探讨如何为这类方法构建健壮的测试,涵盖单元测试和集成测试两种策略,确保代码的每个环节都得到充分验证。
1. 理解软删除与测试覆盖的挑战
在提供的示例中,userService.deleteUser方法负责业务逻辑,它首先查找用户,然后进行权限检查,最后调用userRepository.delete执行软删除。userRepository.delete方法通过@Modifying和@Query注解,将UserEntity的deleted字段设置为true,而非物理删除。
原始的测试代码deleteUserTest虽然能够验证userService中的异常逻辑,但它并未覆盖到userRepository.delete(userEntity)这一行的实际执行。这主要是因为:
- 模拟对象行为而非代码执行: 当我们使用Mockito等框架模拟userRepository时,我们只是模拟了它的接口行为(例如,当findById被调用时返回什么),而不是执行userRepository中 @Query 注解所定义的实际SQL更新逻辑。因此,模拟对象的内部实现代码不会被触发,也就不会计入测试覆盖率。
- 测试范围的界定: 单元测试旨在隔离并验证单个组件的功能。对于userService的单元测试,我们应该模拟其依赖(如userRepository),以确保只测试userService自身的逻辑。如果希望测试userRepository的实际数据库操作,则需要进行集成测试。
为了实现全面的测试覆盖,我们需要采取分层测试的策略。
立即学习“Java免费学习笔记(深入)”;
2. 单元测试服务层(Service Layer)
对服务层的单元测试,其核心目标是验证业务逻辑的正确性,包括:
- 用户查找逻辑。
- 权限检查(lastAccessDate是否为空)。
- 在满足条件时,是否正确调用了数据访问层的方法。
在这种情况下,我们应该模拟userRepository,并使用Mockito.verify()来确认userRepository.delete()方法是否被正确调用。
示例代码:改进的userService单元测试
import org.junit.jupiter.api.BeforeEach;
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.Date;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository; // 模拟UserRepository
@InjectMocks
private UserService userService; // 注入被测试的UserService
private UserEntity testUser;
private final String TEST_USER_ID = "1";
@BeforeEach
void setUp() {
testUser = new UserEntity();
testUser.setId(Integer.valueOf(TEST_USER_ID));
testUser.setName("Test User");
// 初始状态,通常为未删除
testUser.setDeleted(false);
}
/**
* 测试:当用户有效且满足访问策略时,userService应成功调用userRepository的delete方法。
*/
@Test
void deleteUser_shouldCallRepositoryDelete_whenValidUserAndAccessDate() {
// Arrange
testUser.setLastAccessDate(new Date()); // 设置lastAccessDate以满足策略
// 模拟userRepository.findById行为
when(userRepository.findById(Integer.valueOf(TEST_USER_ID)))
.thenReturn(Optional.of(testUser));
// Act
userService.deleteUser(TEST_USER_ID);
// Assert
// 验证userRepository.findById是否被调用了一次
verify(userRepository, times(1)).findById(Integer.valueOf(TEST_USER_ID));
// 验证userRepository.delete是否被调用了一次,并且传入的是正确的userEntity对象
verify(userRepository, times(1)).delete(testUser);
}
/**
* 测试:当用户不存在时,userService应抛出UserNotFoundException。
*/
@Test
void deleteUser_shouldThrowUserNotFoundException_whenUserNotFound() {
// Arrange
when(userRepository.findById(Integer.valueOf(TEST_USER_ID)))
.thenReturn(Optional.empty()); // 模拟用户不存在
// Act & Assert
assertThrows(UserNotFoundException.class, () -> userService.deleteUser(TEST_USER_ID));
// 验证userRepository.delete方法没有被调用
verify(userRepository, never()).delete(any(UserEntity.class));
}
/**
* 测试:当用户不满足访问策略(lastAccessDate为null)时,userService应抛出ProhibitedAccessException。
*/
@Test
void deleteUser_shouldThrowProhibitedAccessException_whenNoLastAccessDate() {
// Arrange
testUser.setLastAccessDate(null); // 设置lastAccessDate为null以违反策略
when(userRepository.findById(Integer.valueOf(TEST_USER_ID)))
.thenReturn(Optional.of(testUser));
// Act & Assert
assertThrows(ProhibitedAccessException.class, () -> userService.deleteUser(TEST_USER_ID));
// 验证userRepository.delete方法没有被调用
verify(userRepository, never()).delete(any(UserEntity.class));
}
}注意事项:
- @ExtendWith(MockitoExtension.class):启用Mockito注解。
- @Mock:创建模拟对象。
- @InjectMocks:将模拟对象注入到被测试对象中。
- when(...).thenReturn(...):定义模拟对象的行为。
- verify(mockObject, times(N)).method(...):验证模拟对象的方法被调用了多少次。
- verify(mockObject, never()).method(...):验证模拟对象的方法从未被调用。
- any(UserEntity.class):匹配任何UserEntity类型的参数。
3. 集成测试数据访问层(Repository Layer)
服务层的单元测试验证了业务逻辑和方法调用,但它没有验证userRepository.delete中@Query注解定义的SQL语句是否正确执行并更新了数据库。为了验证这一点,我们需要编写集成测试。
集成测试会启动一个部分或完整的Spring上下文,并与真实的(通常是内存中的)数据库进行交互。
示例代码:userRepository集成测试
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import java.util.Date;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
// @DataJpaTest注解用于测试JPA组件。它会配置一个in-memory数据库(如H2)
// 并自动扫描@Entity和Spring Data JPA仓库。
@DataJpaTest
class UserRepositoryIntegrationTest {
@Autowired
private UserRepository userRepository; // 注入真实的UserRepository
@Autowired
private TestEntityManager entityManager; // 用于在测试中管理实体(持久化、查找、刷新等)
/**
* 测试:验证userRepository的delete方法是否正确执行软删除操作。
*/
@Test
void delete_shouldSoftDeleteUser() {
// Arrange
// 创建一个UserEntity实例并设置初始状态
UserEntity user = new UserEntity();
user.setName("Integration Test User");
user.setDeleted(false); // 初始状态为未删除
user.setLastAccessDate(new Date()); // 设置一个日期,虽然对于仓库测试不严格需要,但保持一致性
// 使用TestEntityManager持久化用户到in-memory数据库
UserEntity persistedUser = entityManager.persistAndFlush(user);
// 清除EntityManager缓存,确保后续从数据库中获取的是最新状态
entityManager.clear();
// Act
// 从数据库中重新获取用户,确保操作的是一个受管理的实体
Optional userOptional = userRepository.findById(persistedUser.getId());
assertThat(userOptional).isPresent();
UserEntity userToDelete = userOptional.get();
// 调用userRepository的delete方法执行软删除
userRepository.delete(userToDelete);
// 刷新EntityManager,确保SQL语句被执行到数据库
entityManager.flush();
// Assert
// 再次从数据库中查找用户,验证其deleted字段是否已更新为true
UserEntity softDeletedUser = entityManager.find(UserEntity.class, persistedUser.getId());
assertThat(softDeletedUser).isNotNull();
assertThat(softDeletedUser.isDeleted()).isTrue(); // 断言软删除成功
assertThat(softDeletedUser.getName()).isEqualTo("Integration Test User"); // 其他字段应保持不变
}
/**
* 额外测试:验证软删除的用户是否可以被正确查询(如果你的查询排除了软删除用户)。
* (这取决于你的findById或findAll方法是否考虑deleted字段)
*/
@Test
void findById_shouldReturnSoftDeletedUser_whenNotFiltered() {
// Arrange
UserEntity user = new UserEntity();
user.setName("Another Test User");
user.setDeleted(false);
user.setLastAccessDate(new Date());
UserEntity persistedUser = entityManager.persistAndFlush(user);
entityManager.clear();
UserEntity userToDelete = userRepository.findById(persistedUser.getId()).orElseThrow();
userRepository.delete(userToDelete);
entityManager.flush();
entityManager.clear();
// Act
Optional foundUser = userRepository.findById(persistedUser.getId());
// Assert
// 默认的findById不会过滤deleted=true的,所以应该还能找到
assertThat(foundUser).isPresent();
assertThat(foundUser.get().isDeleted()).isTrue();
}
} 注意事项:
- @DataJpaTest:这是一个特殊的Spring Boot测试注解,它只加载与JPA相关的组件(如DataSource、EntityManager和Spring Data JPA仓库),并默认配置一个嵌入式数据库(如H2),非常适合测试Repository层。
- TestEntityManager:由@DataJpaTest提供,它是一个用于管理JPA实体的工具,可以在测试中方便地进行持久化、查找和刷新操作,绕过Repository接口直接与数据库交互,以验证Repository方法的实际效果。
- entityManager.persistAndFlush(user):将实体持久化到数据库并立即同步到数据库,确保数据立即可用。
- entityManager.clear():清除EntityManager的缓存,确保后续的查找操作是从数据库中加载最新数据,而不是从缓存中获取旧数据。
- entityManager.find(UserEntity.class, persistedUser.getId()):直接通过EntityManager从数据库中查找实体,用于验证Repository操作后的数据库状态。
4. 总结
为了全面测试Spring Boot中执行软删除的void方法,我们应该采用分层测试的策略:
-
单元测试服务层(Service Layer):
- 目的:验证业务逻辑和方法调用流程。
- 方法:使用Mockito模拟所有外部依赖(如UserRepository),并通过Mockito.verify()来确认关键方法(如userRepository.delete())是否被正确调用。
- 覆盖:这会覆盖userService内部的逻辑分支,但不会覆盖userRepository中@Query的实际SQL执行。
-
集成测试数据访问层(Repository Layer):
- 目的:验证数据访问层(UserRepository)的实际数据库操作是否正确,包括@Query注解定义的SQL语句。
- 方法:使用@DataJpaTest注解启动一个轻量级的Spring上下文,连接到真实的(通常是嵌入式)数据库,并通过TestEntityManager或直接调用UserRepository方法来验证数据库状态。
- 覆盖:这会覆盖userRepository中@Query定义的SQL语句的执行,确保数据在数据库中的正确变更。
通过结合这两种测试方法,我们可以确保deleteUser方法的业务逻辑和底层数据库操作都得到了充分的验证,从而构建出更健壮、更可靠的应用程序。










