user1778855
user1778855

Reputation: 669

Dependency inside a Validator class not initialize in unit test

Coming from Spring unit test issue with Validator where part of the issue has been resolved.

I am trying to perform a unit test on a Validator class which has a dependency inside the class.

@NoArgsConstructor
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {

    @Autowired
    private AccountService accountService;

    @Override
    public void initialize(final UniqueEmail constraintAnnotation) {
    }

    @Override
    public boolean isValid(final String email, final ConstraintValidatorContext context) {
        return !this.accountService.findByEmail(email).isPresent();
    }
}

Here's the stack where UniqueEmailValidator.java:47 is return !this.accountService.findByEmail(email).isPresent();

javax.validation.ValidationException: HV000028: Unexpected exception during isValid call.
    at org.hibernate.validator.internal.engine.constraintvalidation.ConstraintTree.validateSingleConstraint(ConstraintTree.java:177)
    at org.hibernate.validator.internal.engine.constraintvalidation.SimpleConstraintTree.validateConstraints(SimpleConstraintTree.java:68)
    at org.hibernate.validator.internal.engine.constraintvalidation.ConstraintTree.validateConstraints(ConstraintTree.java:73)
    at org.hibernate.validator.internal.metadata.core.MetaConstraint.doValidateConstraint(MetaConstraint.java:127)
    at org.hibernate.validator.internal.metadata.core.MetaConstraint.validateConstraint(MetaConstraint.java:120)
    at org.hibernate.validator.internal.engine.ValidatorImpl.validateMetaConstraint(ValidatorImpl.java:533)
    at org.hibernate.validator.internal.engine.ValidatorImpl.validateConstraintsForSingleDefaultGroupElement(ValidatorImpl.java:496)
    at org.hibernate.validator.internal.engine.ValidatorImpl.validateConstraintsForDefaultGroup(ValidatorImpl.java:465)
    at org.hibernate.validator.internal.engine.ValidatorImpl.validateConstraintsForCurrentGroup(ValidatorImpl.java:430)
    at org.hibernate.validator.internal.engine.ValidatorImpl.validateInContext(ValidatorImpl.java:380)
    at org.hibernate.validator.internal.engine.ValidatorImpl.validate(ValidatorImpl.java:169)
    at com.x.x.AccountValidatorTest.shouldDetectDuplicatedEmailAddress(AccountValidatorTest.java:95)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(Unknown Source)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
    at java.lang.reflect.Method.invoke(Unknown Source)
    at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
    at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
    at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
    at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
    at org.springframework.test.context.junit4.statements.RunBeforeTestExecutionCallbacks.evaluate(RunBeforeTestExecutionCallbacks.java:73)
    at org.springframework.test.context.junit4.statements.RunAfterTestExecutionCallbacks.evaluate(RunAfterTestExecutionCallbacks.java:83)
    at org.springframework.test.context.junit4.statements.RunBeforeTestMethodCallbacks.evaluate(RunBeforeTestMethodCallbacks.java:75)
    at org.springframework.test.context.junit4.statements.RunAfterTestMethodCallbacks.evaluate(RunAfterTestMethodCallbacks.java:86)
    at org.springframework.test.context.junit4.statements.SpringRepeat.evaluate(SpringRepeat.java:84)
    at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
    at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:251)
    at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:97)
    at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
    at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
    at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
    at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
    at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
    at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
    at org.springframework.test.context.junit4.statements.RunBeforeTestClassCallbacks.evaluate(RunBeforeTestClassCallbacks.java:61)
    at org.springframework.test.context.junit4.statements.RunAfterTestClassCallbacks.evaluate(RunAfterTestClassCallbacks.java:70)
    at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
    at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.run(SpringJUnit4ClassRunner.java:190)
    at org.eclipse.jdt.internal.junit4.runner.JUnit4TestReference.run(JUnit4TestReference.java:86)
    at org.eclipse.jdt.internal.junit.runner.TestExecution.run(TestExecution.java:38)
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:538)
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:760)
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run(RemoteTestRunner.java:460)
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:206)
Caused by: java.lang.NullPointerException
    at com.x.x.validator.UniqueEmailValidator.isValid(UniqueEmailValidator.java:47)
    at com.x.x.validator.UniqueEmailValidator.isValid(UniqueEmailValidator.java:1)
    at org.hibernate.validator.internal.engine.constraintvalidation.ConstraintTree.validateSingleConstraint(ConstraintTree.java:171)
    ... 43 more

My question is if the Validator is init as such in unit test, how can I provide the inject of accountService during unit test? As it seem to me that accountService isn't injected or something, hence the NPE.

@RunWith(SpringRunner.class)
@DataJpaTest
public class AccountValidatorTest {

    private static Validator validator;

    @BeforeClass
    public static void setUp() {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        validator = factory.getValidator();
    }

    @Autowired
    private AccountService accountService;

    @Test
    public void shouldDetectDuplicatedEmailAddress() {

        User user = new User(); 
        // Setters omit

        // accountRepository.save(user);

        Set<ConstraintViolation<AccountRegistrationForm>> violations = validator.validate(user);

        assertEquals(1, violations.size());
    }

}

Upvotes: 0

Views: 4496

Answers (4)

Bruno 82
Bruno 82

Reputation: 519

If you want to go the path of the pure unit test you need a custom validator factory. I'll show you how I solved this same problem a while ago.

The problem is basically that Hibernate's standard Validator implementation that you get by calling Validation.buildDefaultValidatorFactory().getValidator() does not know anything about Spring's application context so it cannot inject dependencies in your custom constraint validators.

In a Spring application the implementation of both the Validator and the ValidatorFactory interface is the class LocalValidatorFactoryBean, which can delegate to the ApplicationContext to instantiate constraint validators with dependencies injected.

The first thing to do is replace field injection with constructor injection

public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {

    private final AccountService accountService;
    
    @Autowired
    public UniqueEmailValidator(AccountService accountService) {
        this.accountService = accountService;
    }

    @Override
    public void initialize(final UniqueEmail constraintAnnotation) {
    }

    @Override
    public boolean isValid(final String email, final ConstraintValidatorContext context) {
        return !this.accountService.findByEmail(email).isPresent();
    }
}

What you need to do is

  1. Instantiate your constraint validators with their (mocked, I presume) dependencies
  2. Create your own ValidatorFactory that holds all the constraint validators from bulletpoint 1
  3. Instantiate your Validator from such factory

This is the custom validator factory

public class CustomLocalValidatorFactoryBean extends LocalValidatorFactoryBean {

    private final List<ConstraintValidator<?, ?>> customConstraintValidators;

    public CustomLocalValidatorFactoryBean(List<ConstraintValidator<?, ?>> customConstraintValidators) {
        this.customConstraintValidators = customConstraintValidators;
        setProviderClass(HibernateValidator.class);
        afterPropertiesSet();
    }

    @Override
    protected void postProcessConfiguration(Configuration<?> configuration) {
        super.postProcessConfiguration(configuration);
        ConstraintValidatorFactory defaultConstraintValidatorFactory =
                configuration.getDefaultConstraintValidatorFactory();
        configuration.constraintValidatorFactory(
                new ConstraintValidatorFactory() {
                    @Override
                    public <T extends ConstraintValidator<?, ?>> T getInstance(Class<T> key) {
                        for (ConstraintValidator<?, ?> constraintValidator : customConstraintValidators) {
                            if (key.equals(constraintValidator.getClass())) //noinspection unchecked
                                return (T) constraintValidator;
                        }
                        return defaultConstraintValidatorFactory.getInstance(key);
                    }

                    @Override
                    public void releaseInstance(ConstraintValidator<?, ?> instance) {
                        defaultConstraintValidatorFactory
                                .releaseInstance(instance);
                    }
                }
        );
    }

}

then in your test class you'd just do something like this:

class AccountValidatorTest {
    
    private final AccountService mockAccountService = Mockito.mock(AccountService.class);
    private final List<ConstraintValidator<?,?>> customConstraintValidators = 
            Collections.singletonList(new UniqueEmailValidator(mockAccountService));
    private final ValidatorFactory customValidatorFactory = 
            new CustomLocalValidatorFactoryBean(customConstraintValidators);
    private final Validator validator = customValidatorFactory.getValidator();
        
        @Test
        public void shouldDetectDuplicatedEmailAddress() {
        // mock the dependency: Mockito.when(mockAccountService.findByEmail...)
        User user = new User(); 

        Set<ConstraintViolation<?>> violations = validator.validate(user);

        assertEquals(1, violations.size());
    }


}

Hope that helps. You can check out this post of mine for more details: https://codemadeclear.com/index.php/2021/01/26/how-to-mock-dependencies-when-unit-testing-custom-validators/

Upvotes: 1

Firelegacy
Firelegacy

Reputation: 21

I know that the question is quite old but I just spend a day without finding a real solution to the problem with complete explanations.

So here we go. I'm going to detail things as much as I can.

At the end of this process, you should have :

  • annotation interface: UniqueEmail
  • custom constraint validator: UniqueEmailValidator
  • validator test helper configuration: ValidatorTestHelperConfig
  • validator test helper (this is for clarity): ValidatorTestHelper
  • your test class: AccountValidatorTest

Here's is the code:

@Documented
@Target({TYPE, FIELD, ANNOTATION_TYPE}) 
@Retention(RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {

    String message() default "{com.x.x.validator.UniqueEmail.message}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

In your validator, you don't need to put @NoArgsConstructor.

public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {

    @Autowired
    private AccountService accountService;

    @Override
    public void initialize(final UniqueEmail constraintAnnotation) { }

    @Override
    public boolean isValid(final String email, final ConstraintValidatorContext context) {
        return !this.accountService.findByEmail(email).isPresent();
     }
}

Next, you'll need the config class

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;

@Configuration
public class ValidatorTestHelperConfiguration {

    @Bean
    public LocalValidatorFactoryBean validator() {
        return new LocalValidatorFactoryBean();
    }
}

For cleaner tests, we wrote a test helper (I'm only putting the relevant imports)

import javax.validation.ConstraintViolation;
import javax.validation.Validator;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;

@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = { ValidatorTestHelperConfiguration.class })
public abstract class ValidatorTestHelper {

    @Autowired
    protected Validator validator;

    protected List<String> getPropertyPaths(Set<? extends ConstraintViolation<?>> violations) {
        return violations.stream().map(ConstraintViolation::getPropertyPath).map(Object::toString).collect(Collectors.toList());
    }

    protected List<String> getMessageTemplate(Set<? extends ConstraintViolation<?>> violations) {
        return violations.stream().map(ConstraintViolation::getMessageTemplate).map(msg -> msg.replaceAll("([{}])", "")).collect(Collectors.toList());
    }
}

And here's the last piece, your test. I'm using JUnit5 hence the @ExtendWith (just so you know, it's not mandatory to have this line). Notice that I'm extending the helper here.

import org.junit.jupiter.api.Test;
import javax.validation.ConstraintViolation;
import org.springframework.boot.test.mock.mockito.MockBean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
public class AccountValidatorTest extends ValidatorTestHelper {

    @MockBean
    private AccountService accountService;

    @Test
    public void shouldDetectDuplicatedEmailAddress() {
        User user = new User(); 
        // other things

        Optional<User> userOptional = Optional.of(mock(User.class));
        when(this.accountService.findByEmail(user.getEmail())).thenReturn(userOptional);


        Set<ConstraintViolation<AccountRegistrationForm>> violations = validator.validate(user);

        assertEquals(1, violations.size());

        assertThat(getMessageTemplate(validate)).containsOnlyElementsOf(asList(
            "{com.x.x.validator.UniqueEmail.message}")
        );

        assertThat(getPropertyPaths(validate)).containsExactlyInAnyOrder(
            "accountRegistrationForm.email"
        );
   }
}

And that's it, hope this helps.

Upvotes: 2

user1778855
user1778855

Reputation: 669

With help of David, I think I realize I had unit test and integration test mixed up. So basically with unit test, the below should be kind of sufficient, of course more test is needed but this is the idea.

@RunWith(MockitoJUnitRunner.class)
public class AccountValidatorTest {

    private UniqueEmailValidator uniqueEmailValidator;

    @Mock
    private AccountService accountService;

    @Before
    public void setUp() {
        this.uniqueEmailValidator = new UniqueEmailValidator(this.accountService);
    }

    @Test
    public void shouldDetectDuplicatedEmailAddress() {

        // create user object with email "[email protected]"
        when(accountService.findByEmail(Mockito.anyString())).thenReturn(user);

        boolean violations = uniqueEmailValidator.isValid("[email protected]", null);

        assertFalse(violations);
    }

    @Test
    public void shouldNotDetectDuplicatedEmailAddress() {
        when(accountService.findByEmail(Mockito.anyString())).thenReturn(Optional.empty());

        boolean violations = uniqueEmailValidator.isValid("[email protected]", null);

        assertTrue(violations);
    }
}

Upvotes: 1

davidxxx
davidxxx

Reputation: 131346

1) If you want to write an unit test for the validator class, isolate the dependencies by mocking all of them.

You have two ways :

  • a) Inject the UniqueEmailValidator bean such as:

    @Autowired UniqueEmailValidator UniqueEmailValidator;

And use a mocking framework (Mockito is fine for) to mock the accountService dependency.

  • b) Create the UniqueEmailValidator with new operator and replace the Spring runner by the mockito Runner.
    It will fast up the test execution.

2) Whereas if you want to write an integration test, be aware that the @DataJpaTest annotation used in your test class limits Spring to load a restricted context containing mainly JPA components.
The @DataJpaTest states :

Annotation that can be used in combination with @RunWith(SpringRunner.class) for a typical JPA test. Can be used when a test focuses only on JPA components.

And your service is not a JPA component so the dependency is not wired by spring in the Validator bean.

So either @Autowired the service and the validator and set the service to the validator or makes things simpler : use @SpringBootTest instead of @DataJpaTest.

Upvotes: 1

Related Questions