如何结合约束验证器(部分需Mock)对CDI托管的业务方法进行单元测试
测试带自定义Bean Validation验证器的CDI服务层Bean方案
结合你使用的TomEE 8、Apache BVal、OpenWebBeans、JUnit 5和Mockito技术栈,最贴合生产环境的测试方式是用OpenEJB的ApplicationComposer启动嵌入式容器,它能自动处理CDI依赖注入、Bean Validation拦截器逻辑,同时支持Mock数据库依赖。下面是具体实现步骤:
1. 准备测试依赖
如果你的项目用Maven,需要添加这些测试依赖(TomEE自带的BVal和OpenWebBeans无需额外引入):
<dependency> <groupId>org.apache.tomee</groupId> <artifactId>openejb-junit</artifactId> <version>8.0.8</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-api</artifactId> <version>5.8.2</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> <version>5.8.2</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>4.5.1</version> <scope>test</scope> </dependency>
2. 编写测试类(ApplicationComposer方案)
这个方案会启动嵌入式OpenWebBeans和BVal容器,完全模拟生产环境的Bean管理和验证逻辑:
import org.apache.openejb.junit5.RunWithApplicationComposer; import org.apache.openejb.testing.Classes; import org.apache.openejb.testing.Mock; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import jakarta.inject.Inject; import jakarta.validation.ConstraintViolationException; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.when; @RunWithApplicationComposer // 启动嵌入式容器 @Classes(value = { PostService.class, SectionExistsValidator.class, PostExistsValidator.class }) // 指定需要容器托管的Bean:服务类+自定义验证器 public class PostServiceTest { @Inject private PostService postService; // 注入要测试的CDI服务Bean // 用OpenWebBeans的@Mock注解创建Mock实例,替换容器中的真实Repository @Mock private PostRepository postRepo; @Mock private SectionRepository sectionRepo; @Mock private UserRepository userRepo; @Mock private LoggedInUser loggedInUser; @Test void getPost_ValidId_ReturnsPostDto() { // 准备Mock数据:模拟存在的Post int validPostId = 1; Post mockPost = new Post(); mockPost.setId(validPostId); mockPost.setTitle("Test Post"); when(postRepo.findById(validPostId)).thenReturn(mockPost); // 调用方法:BVal拦截器会自动触发@PostExists验证 PostDto result = postService.getPost(validPostId); // 断言结果 assertNotNull(result); assertEquals(validPostId, result.getId()); assertEquals("Test Post", result.getTitle()); // 验证Repository方法被正确调用 Mockito.verify(postRepo).findById(validPostId); } @Test void getPost_InvalidId_ThrowsValidationError() { int invalidPostId = 999; // Mock返回null,模拟Post不存在 when(postRepo.findById(invalidPostId)).thenReturn(null); // 预期触发约束违例 assertThrows(ConstraintViolationException.class, () -> postService.getPost(invalidPostId)); // 验证验证器确实调用了Repository Mockito.verify(postRepo).findById(invalidPostId); } @Test void newPost_ValidData_ReturnsPostId() { String testUser = "testUser"; String testSection = "testSection"; String postTitle = "New Test Post"; String postBody = "Test content"; // 依次Mock依赖的返回值 when(loggedInUser.getUsername()).thenReturn(testUser); User mockUser = new User(); mockUser.setName(testUser); when(userRepo.getByName(testUser)).thenReturn(mockUser); Section mockSection = new Section(); mockSection.setName(testSection); when(sectionRepo.getByName(testSection)).thenReturn(mockSection); Post savedPost = new Post(); savedPost.setId(2); when(postRepo.insert(Mockito.any(Post.class))).thenReturn(savedPost); // 调用方法:BVal会自动验证@SectionExists、@NotBlank等约束 int postId = postService.newPost(postTitle, postBody, testSection); // 断言结果 assertEquals(2, postId); // 验证所有依赖方法都被正确调用 Mockito.verify(loggedInUser).getUsername(); Mockito.verify(userRepo).getByName(testUser); Mockito.verify(sectionRepo).getByName(testSection); Mockito.verify(postRepo).insert(Mockito.any(Post.class)); } @Test void newPost_NonExistentSection_ThrowsValidationError() { String invalidSection = "nonExistentSection"; // Mock返回null,模拟板块不存在 when(sectionRepo.getByName(invalidSection)).thenReturn(null); // 预期触发@SectionExists的约束违例 assertThrows(ConstraintViolationException.class, () -> postService.newPost("Title", "Body", invalidSection)); Mockito.verify(sectionRepo).getByName(invalidSection); } }
3. 关键细节解释
- @RunWithApplicationComposer:启动嵌入式TomEE容器,自动初始化CDI、Bean Validation和事务管理器,完全模拟生产环境的Bean生命周期。
- @Classes注解:告诉容器需要托管哪些Bean,必须包含你的服务类和自定义验证器,这样容器才能管理它们的依赖注入。
- @Mock注解:OpenWebBeans与Mockito集成的注解,自动创建Mock实例并替换容器中的真实Bean,这样验证器中注入的Repository就是Mock对象,我们可以自由控制返回值。
- 自动触发验证:BVal的
BValInterceptor会自动拦截标注了@Transactional的方法调用,触发参数验证,包括你的自定义@PostExists、@SectionExists约束,无需手动处理。 - 事务自动回滚:测试中的事务会在方法结束后自动回滚,不会污染真实数据库。
4. 轻量替代方案(无需嵌入式容器)
如果不想启动容器,也可以手动初始化验证器和服务,不过这种方式需要自己处理验证触发逻辑,适合简单场景:
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import jakarta.validation.Validation; import jakarta.validation.Validator; import jakarta.validation.ValidatorFactory; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; public class PostServiceManualTest { private PostService postService; private Validator validator; @Mock private PostRepository postRepo; @Mock private SectionRepository sectionRepo; @Mock private UserRepository userRepo; @Mock private LoggedInUser loggedInUser; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); // 手动给验证器注入Mock依赖 SectionExistsValidator sectionValidator = new SectionExistsValidator(); sectionValidator.sectionRepo = sectionRepo; PostExistsValidator postValidator = new PostExistsValidator(); postValidator.postRepo = postRepo; // 配置ValidatorFactory,注册自定义验证器 ValidatorFactory factory = Validation.byDefaultProvider() .configure() .constraintValidator(SectionExists.class, sectionValidator) .constraintValidator(PostExists.class, postValidator) .buildValidatorFactory(); validator = factory.getValidator(); // 手动初始化服务Bean,注入Mock依赖 postService = new PostService(); postService.postRepo = postRepo; postService.userRepo = userRepo; postService.sectionRepo = sectionRepo; postService.loggedInUser = loggedInUser; } @Test void getPost_InvalidId_FailsValidation() { int invalidId = 999; when(postRepo.findById(invalidId)).thenReturn(null); // 手动触发参数验证 var violations = validator.forExecutables().validateParameters( postService, PostService.class.getMethod("getPost", int.class), new Object[]{invalidId} ); // 断言验证失败 assertFalse(violations.isEmpty()); assertEquals(1, violations.size()); // 这里替换成你的@PostExists注解的message内容 assertEquals("指定的帖子不存在", violations.iterator().next().getMessage()); } // 其他测试方法类似,需要先手动验证参数,再调用业务方法 }
这种方式的缺点是无法模拟CDI的完整生命周期和拦截器自动触发逻辑,测试场景和生产环境有差异,所以更推荐第一种ApplicationComposer方案。
内容的提问来源于stack exchange,提问作者cidra
相关产品推荐
相关产品推荐

