使用验证的单元测试 Spring Boot 服务层 bean

时间:2021-07-15 03:46:01

标签: spring-boot unit-testing validation mockito

我真的很喜欢 Java 验证注释来检查方法参数的基本有效性。我的问题是如何在不启动非常慢的Spring应用程序上下文的情况下测试服务bean的这种方法。

我知道验证需要执行一些验证器。通常,当服务 bean 具有 @Validated 时,这是由 Spring Boot 基础设施设置的。有没有办法在不使用创建完整上下文的 @SpringBootTest 的情况下设置单元测试来设置验证?

例如,我有一个实体 Thing 和一个服务 ThingService。

@Entity
@Getter @Setter @AllArgsConstructor @NoArgsConstructor @Accessors(chain=true)
public class Thing {
    @Id
    private Long id;
    
    @NotNull
    @Min(value=5)
    private Long one;
    
    @NotNull @NotEmpty @NotBlank
    @Size(max = 3)
    private String two;
    
    public Thing(
            Long one,
            String two) {
        this.one = one;
        this.two = two;
    }
}

@Service
@Validated
public class ThingService {
    @Setter
    private ThingRepository repo;
    
    @Autowired
    public ThingService(ThingRepository repo) {
        this.repo = repo;
    };
    
    
    public Thing create(@NotNull Long arg, @NotNull @Valid Thing thing) {
        // Use arg to check that thing meets higher level constraints
        
        Thing savedThing = repo.save(thing);
        return savedThing;
    }
}

我可以使用@SpringBootTest 正确测试,但速度很慢

@SpringBootTest
@ExtendWith(MockitoExtension.class)
public class ThingServiceTestOverkill {
    @MockBean
    private ThingRepository repo;
        
    @Autowired
    private ThingService service;
    
    @Test
    public void createSuccess() {
        when(repo.save(any(Thing.class))).thenAnswer(returnsFirstArg());
        
        Thing thing = service.create(2L, new Thing(5L, "foo"));
        
        verify(repo, times(1)).save(thing);
        assertThat(thing.getOne(), is(5L));
        assertThat(thing.getTwo(), is ("foo"));
    }
    
    @Test
    public void createNullUser() {
        ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> {
            service.create(null, new Thing(5L, "foo"));
        });
        
        assertThat(ex.getConstraintViolations(), hasSize(1));
        ConstraintViolation<?> cv = ex.getConstraintViolations().stream().findFirst().get();
        assertThat(cv.getPropertyPath().toString(), is("create.arg"));
        assertThat(cv.getInvalidValue(), is(nullValue()));
        assertThat(cv.getMessage(), is("must not be null"));
    }
 }

如果我尝试使用更传统的直接 Mockito 方法进行测试,则不会进行任何验证。

@ExtendWith(MockitoExtension.class)
public class ThingServiceTestUnderkill {
    @Mock
    private ThingRepository repo;
    
    // Decided not to use @InjectMocks as it fails silently and can hide errors and bad smells
    private ThingService service;
    
    @BeforeEach
    public void initializeTest() {
        service = new ThingService(repo);  // No validation wrapper used.
    }
    
    @Test
    public void createSuccess() {
        when(repo.save(any(Thing.class))).thenAnswer(returnsFirstArg());
        
        Thing thing = service.create(2L, new Thing(5L, "foo"));
        
        verify(repo, times(1)).save(thing);
        assertThat(thing.getOne(), is(5L));
        assertThat(thing.getTwo(), is ("foo"));
    }
    
    @Test
    public void createNullUser() {
        ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> {
            service.create(null, new Thing(5L, "foo"));
        });
        
        // NEVER THROWS EXCEPTION
        
        assertThat(ex.getConstraintViolations(), hasSize(1));
        ConstraintViolation<?> cv = ex.getConstraintViolations().stream().findFirst().get();
        assertThat(cv.getPropertyPath().toString(), is("create.arg"));
        assertThat(cv.getInvalidValue(), is(nullValue()));
        assertThat(cv.getMessage(), is("must not be null"));
    }
 }

有没有办法访问 Spring Boot 的验证包装功能?还有其他解决办法吗?

Spring Boot 2.5.2,Java 11。

1 个答案:

答案 0 :(得分:0)

经过更多研究,我找到了一个使用 ProxyFactory、MethodBeforeAdvice 和 ExecutableValidator 的不错的解决方案。这是对使用方法验证的类进行单元测试的一种简单快捷的方法。

首先,创建一个被测试类的代理并添加一个建议类(稍后展示)。请参阅@BeforeEach。这是唯一需要的额外工作。

import org.springframework.aop.framework.ProxyFactory;

@ExtendWith(MockitoExtension.class)
public class ThingServiceTestAOP {
    @Mock
    private ThingRepository repo;
    
    private ThingService service;
    
    @BeforeEach
    public void initializeTest() {
        ProxyFactory factory = new ProxyFactory(new ThingService(repo));
        factory.addAdvice(new ValidationAdvice());
        service = (ThingService) factory.getProxy();
    }
    
    @Test
    public void createSuccess() {
        when(repo.save(any(Thing.class))).thenAnswer(returnsFirstArg());
        
        Thing thing = service.create(2L, new Thing(5L, "foo"));
        
        verify(repo, times(1)).save(thing);
        assertThat(thing.getOne(), is(5L));
        assertThat(thing.getTwo(), is ("foo"));
    }
    
    @Test
    public void createNullUser() {
        ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> {
            service.create(null, new Thing(5L, "foo"));
        });
        
        assertThat(ex.getConstraintViolations(), hasSize(1));
        ConstraintViolation<?> cv = ex.getConstraintViolations().stream().findFirst().get();
        assertThat(cv.getPropertyPath().toString(), is("create.userId"));
        assertThat(cv.getInvalidValue(), is(nullValue()));
        assertThat(cv.getMessage(), is("must not be null"));
    }

现在是建议课。静态初始化器块中的代码构造验证器。

注意使用 Spring 的 LocalValidatorFactoryBean 而不是 javax 的 buildDefaultValidatorFactory。前者在确定参数名称方面做得更好。后者只是调用参数“arg0”、“arg1”等。更重要的是,LocalValidatorFactoryBean 获得与 Spring Boot 处理验证时相同的名称,因此您的断言正在测试生产中生成的名称。

before 方法使用标准的 javax 验证代码进行方法验证。

import java.lang.reflect.Method;
import java.util.Set;

import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.validation.executable.ExecutableValidator;

import org.springframework.aop.MethodBeforeAdvice;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;

public class ValidationAdvice implements MethodBeforeAdvice {
    static private ExecutableValidator executableValidator;
    static {
        // Using the javax validation factory mostly works, but doesn't capture the parameter names
        // ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        // LocalValidatorFactoryBean uses DefaultParameterNameDiscoverer which does get the param names
        LocalValidatorFactoryBean factory = new LocalValidatorFactoryBean();
        factory.afterPropertiesSet();
        executableValidator = factory.getValidator().forExecutables();
        factory.close();
    }

    @Override
    public void before(Method method, Object[] args, Object target) throws Throwable {
        Set<ConstraintViolation<Object>> violations = executableValidator.validateParameters(target, method, args);
        if (!violations.isEmpty()) {
            throw new ConstraintViolationException(violations);
        }
    }
}