An Interest In:
Web News this Week
- March 22, 2024
- March 21, 2024
- March 20, 2024
- March 19, 2024
- March 18, 2024
- March 17, 2024
- March 16, 2024
Spring Boot Testing Data and Services
Spring Boot Testing Data and Services
I think testing is an essential thing in software development. And Im not the only one. If you ask any developer whether tests are important or not, they would probably tell you the same thing.
But the reality is not so bright. Almost all projects that Ive seen lack either tests presence or their quality. Its not just one case. The problem is systematic.
Why does it happen? I consider that developers usually dont pay enough attention to improving the knowledge of testing frameworks' usage. So, when it comes to verifying the business logic programmers just dont know how to do it.
Lets fill the gaps and see what Spring Test has prepared for us.
The code snippets are taken from this repository.
You can clone it and run tests to see how it works.
Service Layer + Mocks
Mocks have become so widespread in testing environments that mocking and testing are almost considered synonyms.
Suppose that we have PersonCreateService
.
@Service@RequiredArgsConstructorpublic class PersonCreateServiceImpl implementes PersonCreateService { private final PersonValidateService personValidateService; private final PersonRepository personRepository; @Override @Transactional public PersonDTO createPerson(String firstName, String lastName) { personValidateService.checkUserCreation(firstName, lastName); final var createdPerson = personRepository.saveAndFlush( new Person() .setFirstName(firstName) .setLastName(lastName) ); return DTOConverters.toPersonDTO(createdPerson); }}
PersonValidateService
is our custom interface. PersonRepository
is a simple Spring Data JpaRepository.
Lets write a unit test using mocks.
class PersonCreateServiceImplMockingTest { private final PersonValidateService personValidateService = mock(PersonValidateService.class); private final PersonRepository personRepository = mock(PersonRepository.class); private final PersonCreateService service = new PersonCreateServiceImpl(personValidateService, personRepository); @Test void shouldFailUserCreation() { final var firstName = "Jack"; final var lastName = "Black"; doThrow(new ValidationFailedException("")) .when(personValidateService) .checkUserCreation(firstName, lastName); assertThrows( ValidationFailedException.class, () -> service.createPerson(firstName, lastName) ); }}
Ok, that one was pretty easy. Lets think about something more complicated. What if a users creation passes? That requires a bit more determination.
Firstly, we need to mock PersonRepository
so saveAndFlush
returns the new Person
instance with a filled id
field. Secondly, we need to test that the result PersonDTO
contains the expected information.
class PersonCreateServiceImplMockingTest { // initialization... @Test void shouldCreateNewUser() { final var firstName = "Lisa"; final var lastName = "Green"; when(personRepository.saveAndFlush(any())) .thenAnswer(invocation -> { Person person = invocation.getArgument(0); assert Objects.equals(person.getFirstName(), firstName); assert Objects.equals(person.getLastName(), lastName); return person.setId(1L); }); final var personDTO = service.createPerson(firstName, lastName); assertEquals(personDTO.getFirstName(), firstName); assertEquals(personDTO.getLastName(), lastName); assertNotNull(personDTO.getId()); }}
It has become tricky. But theres no time to rest yet. Assume that PersonCreateService
has been enhanced with createFamily
method.
@Service@RequiredArgsConstructorpublic class PersonCreateServiceImpl implements PersonCreateService { private final PersonValidateService personValidateService; private final PersonRepository personRepository; @Override @Transactional public List<PersonDTO> createFamily(Iterable<String> firstNames, String lastName) { final var people = new ArrayList<PersonDTO>(); firstNames.forEach(firstName -> people.add(createPerson(firstName, lastName))); return people; } @Override @Transactional public PersonDTO createPerson(String firstName, String lastName) { personValidateService.checkUserCreation(firstName, lastName); final var createdPerson = personRepository.saveAndFlush( new Person() .setFirstName(firstName) .setLastName(lastName) ); return DTOConverters.toPersonDTO(createdPerson); }}
It needs tests too. Lets try to write one.
class PersonCreateServiceImplMockingTest { // initialization... @Test void shouldCreateFamily() { final var firstNames = List.of("John", "Samantha", "Kyle"); final var lastName = "Purple"; final var idHolder = new AtomicLong(0); when(personRepository.saveAndFlush(any())) .thenAnswer(invocation -> { Person person = invocation.getArgument(0); assert firstNames.contains(person.getFirstName()); assert Objects.equals(person.getLastName(), lastName); return person.setId(idHolder.incrementAndGet()); }); final var people = service.createFamily(firstNames, lastName); for (int i = 0; i < people.size(); i++) { final var personDTO = people.get(i); assertEquals(personDTO.getFirstName(), firstNames.get(i)); assertEquals(personDTO.getLastName(), lastName); assertNotNull(personDTO.getId()); } verify(personValidateService, times(3)).checkUserCreation(any(), any()); verify(personRepository, times(3)).saveAndFlush(any()); }}
When I look at this code I see nothing but just nonsense. The data flow is so complicated that its almost impossible to get what the test is really doing. More than that, there is no testing but verifying that some particular methods were called the defined times. Whats the difference? you may ask. Imagine that saveAndFlush
method execution was replaced with the custom one that updates the entity and saves the previous state in the archive table (e.g. saveWithArchiving
). Although the business logic is the same the test would fail due to the fact the new method has not been mocked.
Perhaps the last statement was not convincing enough. Lets see the declaration of Person
entity.
@Entity@Table(name = "person")public class Person { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "first_name") private String firstName; @Column(name = "last_name") private String lastName; private ZonedDateTime dateCreated; @PrePersist void prePersist() { dateCreated = ZonedDateTime.now(); } // getters, setters}
It has PrePersist
callback that sets the date of creation just before inserting a new record in the database. The problem is that it cannot be tested with mocks. The logic is being invoked by the JPA provider internally. Mocks just cannot imitate this behavior.
So, lets draw the conclusions. Mocks are perfect for testing those functions that you have control for. These are usually user-defined services (e.g. PersonValidateService
). Spring Data and JPA generate lots of stuff in runtime. Mocks wont help you to test it.
Service Layer + H2 Database
If you have a service that is ought to interact with the database, the only way to truly test it is to run it against the real DB instance. H2 DB is the first thing that comes to mind.
Thankfully we dont need any complex configurations and tricky beans declaration to run a database in the test environment. Spring Boot takes care of it.
DataJpaTest
Where do we start? Firstly, we need to declare the test suite.
@DataJpaTestclass PersonCreateServiceImplDataJpaTest { @Autowired private PersonRepository personRepository; @MockBean private PersonValidateService personValidateService; @Autowired private PersonCreateService personCreateService;}
@DataJpaTest
annotation does the magic here. And specifically, there are 4 points.
- Launching the embedded instance of the H2 database.
- Creating the database schema according to declared entity classes.
- Adding all repositories beans to the application context.
- Wrapping the whole test suite with @Transactional annotation. So, each test execution becomes independent.
You have probably noticed the @MockBean
annotation. Thats the Spring feature that not only mocks the interface but also adds it to the application context. So, it can be auto-wired by other beans during the test run.
Now we need to instantiate the service that is about to test.
@DataJpaTestclass PersonCreateServiceImplDataJpaTest { @Autowired private PersonRepository personRepository; @MockBean private PersonValidateService personValidateService; @Autowired private PersonCreateService personCreateService; @TestConfiguration static class TestConfig { @Bean public PersonCreateService personCreateService( PersonRepository personRepository, PersonValidateService personValidateService ) { return new PersonCreateServiceImpl( personValidateService, personRepository ); } }}
In my opinion, the most flexible solution provides the @TestConfiguration
annotation. It allows us to modify the existing application context. When PersonCreateService
is added, it can be easily injected with @Autowired
.
Ok, lets start with a simple happy path test of createFamily
method.
@DataJpaTestclass PersonCreateServiceImplDataJpaTest { // initialization... @Test void shouldCreateOnePerson() { final var people = personCreateService.createFamily( List.of("Simon"), "Kirekov" ); assertEquals(1, people.size()); final var person = people.get(0); assertEquals("Simon", person.getFirstName()); assertEquals("Kirekov", person.getLastName()); assertTrue(person.getDateCreated().isBefore(ZonedDateTime.now())); }}
As you can see, this test is much cleaner, shorter, and easier to understand than one using mocks. More than that, we are now able to test Hibernate callbacks (e.g. @PrePersist
).
Well, this one was a cake. But what if ValidationFailedException
occurs? It means that the transaction should be rolled back. Lets find this out.
@DataJpaTestclass PersonCreateServiceImplDataJpaTest { // initialization... @Test void shouldRollbackIfAnyUserIsNotValidated() { doThrow(new ValidationFailedException("")) .when(personValidateService) .checkUserCreation("John", "Brown"); assertThrows(ValidationFailedException.class, () -> personCreateService.createFamily( List.of("Matilda", "Vasya", "John"), "Brown" )); assertEquals(0, personRepository.count()); }}
The execution should fail on "John"
creation. It means the total number of people has to be equal to 0
because the exception throwing rolls back the transaction.
expected: <0> but was: <2>Expected :0Actual :2
Something went wrong. Seems like the transaction has not been rolled back for some reason. And its true.
Ive mentioned that @DataJpaTest
wraps the suite with the @Transactional
. So, the test suite and the service are both transactional. The default propagation level for the annotation is REQUIRED
. It means that calling another transactional method does not start a new transaction. Instead, it continues to execute SQL statements in the current one. ValidationFailedException
occurring does not roll back the transaction because the exception does not leave the scope of it. So, the count returns 2
instead of 0
.
I have described this phenomenon in my article Spring Data Transactional Caveats.
What can we do about it? We could mark the PersonCreateService.createFamily
transaction propagation as REQUIRES_NEW. That solves the problem with the current test but adds new ones. You can find more examples in the repository that I tagged at the beginning of the article.
If @DataJpaTest
causes so weird problems then whats the purpose of it? Well, its name describes the goal. Its ought to be used with repository tests.
public interface PersonRepository extends JpaRepository<Person, Long> { @Query("select distinct p.lastName from Person p") Set<String> findAllLastNames();}
@DataJpaTestclass PersonRepositoryDataJpaTest { @Autowired private PersonRepository personRepository; @Test void shouldReturnAlLastNames() { personRepository.saveAndFlush(new Person().setFirstName("John").setLastName("Brown")); personRepository.saveAndFlush(new Person().setFirstName("Kyle").setLastName("Green")); personRepository.saveAndFlush(new Person().setFirstName("Paul").setLastName("Brown")); assertEquals(Set.of("Brown", "Green"), personRepository.findAllLastNames()); }}
See? That fits perfectly. The test executes a single SQL statement. In this case, default transactional behavior of @DataJpaTest
becomes convenient. But service layers are far more complicated. And we need a different tool for that.
SpringBootTest
Lets rewrite the test declaration a little bit.
@SpringBootTest(webEnvironment = WebEnvironment.NONE)@AutoConfigureTestDatabaseclass PersonCreateServiceImplSpringBootTest { @Autowired private PersonRepository personRepository; @MockBean private PersonValidateService personValidateService; @Autowired private PersonCreateService personCreateService; @BeforeEach void init() { personRepository.deleteAll(); }}
There are some differences with the @DataJpaTest
alternative.
The @SpringBootTest
annotation launches the whole Spring context and not only JPA repositories. Another important thing is that it does not wrap the test suite with the @Transactional
.The webEnvironment = WebEnvironment.NONE
parameterer is a slight optimization. We dont need the web layer in the test case. So, there is no need to spend resources on that.
The @AutoConfigureTestDatabase
annotation configures the embedded H2 database and creates the schema according to defined entities. @DataJpaTest
already includes it, so its redundant to declare them both (unless we want to parameterize @AutoConfigureTestDatabase
but thats out of scope).
You may also have noticed that we just auto-wired PersonCreateService
without any additional configurations. Due to the fact that @SpringBootTest
instantiate every bean by default the service is already present in the application context.
The database reset in @BeforeEach
callback is required since @SpringBootTest
does not provide transactional behavior. But we need to keep the table clean between tests run.
So, lets put the tests from the @DataJpaTest
example and see how it works.
@SpringBootTest(webEnvironment = WebEnvironment.NONE)@AutoConfigureTestDatabaseclass PersonCreateServiceImplSpringBootTest { @Autowired private PersonRepository personRepository; @MockBean private PersonValidateService personValidateService; @Autowired private PersonCreateService personCreateService; @BeforeEach void init() { personRepository.deleteAll(); } @Test void shouldCreateOnePerson() { final var people = personCreateService.createFamily( List.of("Simon"), "Kirekov" ); assertEquals(1, people.size()); final var person = people.get(0); assertEquals("Simon", person.getFirstName()); assertEquals("Kirekov", person.getLastName()); assertTrue(person.getDateCreated().isBefore(ZonedDateTime.now())); } @Test void shouldRollbackIfAnyUserIsNotValidated() { doThrow(new ValidationFailedException("")) .when(personValidateService) .checkUserCreation("John", "Brown"); assertThrows(ValidationFailedException.class, () -> personCreateService.createFamily( List.of("Matilda", "Vasya", "John"), "Brown" )); assertEquals(0, personRepository.count()); }}
Everything works like a charm.
All of our test cases assumed that PersonValidateService.checkUserCreation
has a simple logic of checking the input parameters. But in reality, this might not be true. The service may interact with the database as well in order to check preconditions. So, lets imitate the behavior.
Suppose that the validator does not allow create a new person if there is one with the same last name. To test this scenario we need to properly mock the PersonValidateService
and insert a family member in advance before calling the PersonCreateService.createFamily
method.
@SpringBootTest(webEnvironment = WebEnvironment.NONE)@AutoConfigureTestDatabaseclass PersonCreateServiceImplSpringBootTest { // initialization... @Test void shouldRollbackIfOneUserIsNotValidated() { doAnswer(invocation -> { final String lastName = invocation.getArgument(1); final var exists = personRepository.exists(Example.of(new Person().setLastName(lastName))); System.out.println("Person with " + lastName + " exists: " + exists); if (exists) { throw new ValidationFailedException("Person with " + lastName + " already exists"); } return null; }).when(personValidateService).checkUserCreation(any(), any()); personRepository.saveAndFlush(new Person().setFirstName("Alice").setLastName("Purple")); assertThrows(ValidationFailedException.class, () -> personCreateService.createFamily( List.of("Matilda"), "Purple" )); assertEquals(1, personRepository.count()); }}
It works!
By the way, you can also run
@DataJpaTest
suitesnon-transactionally
.
You just need to@Transactional(propagation = NOT_SUPPORTED)
annotation.
Conclusion
Thank you for reading! Thats a quite long article and Im glad that you made it through. Next time were going to discuss Testcontainers integration with Spring Test. If you have any questions or suggestions, please leave your comments down below. See you next time!
Original Link: https://dev.to/kirekov/spring-boot-testing-data-and-services-288f
Dev To
An online community for sharing and discovering great ideas, having debates, and making friendsMore About this Source Visit Dev To