You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何结合约束验证器(部分需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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.28 10:32:41