Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Cover image for Spring Boot Testing — Data and Services
Semyon Kirekov
Semyon Kirekov

Posted on

     

Spring Boot Testing — Data and Services

Spring Boot Testing — Data and Services

I think testing is an essential thing in software development. And I’m 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 I’ve seen lack either tests’ presence or their quality. It’s not just one case. The problem is systematic.

Why does it happen? I consider that developers usually don’t pay enough attention to improving the knowledge of testing frameworks' usage. So, when it comes to verifying the business logic programmers just don’t know how to do it.

Let’s fill the gaps and see what Spring Test has prepared for us.

The code snippets are taken fromthis repository.
You can clone it and run tests to see how it works.

Service Layer + Mocks

Mocks have become so widespread in testing environments thatmocking andtesting are almost considered synonyms.

Suppose that we havePersonCreateService.

@Service@RequiredArgsConstructorpublicclassPersonCreateServiceImplimplementesPersonCreateService{privatefinalPersonValidateServicepersonValidateService;privatefinalPersonRepositorypersonRepository;@Override@TransactionalpublicPersonDTOcreatePerson(StringfirstName,StringlastName){personValidateService.checkUserCreation(firstName,lastName);finalvarcreatedPerson=personRepository.saveAndFlush(newPerson().setFirstName(firstName).setLastName(lastName));returnDTOConverters.toPersonDTO(createdPerson);}}
Enter fullscreen modeExit fullscreen mode

PersonValidateService is our custom interface.PersonRepository is a simpleSpring Data JpaRepository.

Let’s write a unit test using mocks.

classPersonCreateServiceImplMockingTest{privatefinalPersonValidateServicepersonValidateService=mock(PersonValidateService.class);privatefinalPersonRepositorypersonRepository=mock(PersonRepository.class);privatefinalPersonCreateServiceservice=newPersonCreateServiceImpl(personValidateService,personRepository);@TestvoidshouldFailUserCreation(){finalvarfirstName="Jack";finalvarlastName="Black";doThrow(newValidationFailedException("")).when(personValidateService).checkUserCreation(firstName,lastName);assertThrows(ValidationFailedException.class,()->service.createPerson(firstName,lastName));}}
Enter fullscreen modeExit fullscreen mode

Ok, that one was pretty easy. Let’s think about something more complicated. What if a user’s creation passes? That requires a bit more determination.

Firstly, we need to mockPersonRepository sosaveAndFlush returns the newPerson instance with a filledid field. Secondly, we need to test that the resultPersonDTO contains the expected information.

classPersonCreateServiceImplMockingTest{// initialization...@TestvoidshouldCreateNewUser(){finalvarfirstName="Lisa";finalvarlastName="Green";when(personRepository.saveAndFlush(any())).thenAnswer(invocation->{Personperson=invocation.getArgument(0);assertObjects.equals(person.getFirstName(),firstName);assertObjects.equals(person.getLastName(),lastName);returnperson.setId(1L);});finalvarpersonDTO=service.createPerson(firstName,lastName);assertEquals(personDTO.getFirstName(),firstName);assertEquals(personDTO.getLastName(),lastName);assertNotNull(personDTO.getId());}}
Enter fullscreen modeExit fullscreen mode

It has become tricky. But there’s no time to rest yet. Assume thatPersonCreateService has been enhanced withcreateFamily method.

@Service@RequiredArgsConstructorpublicclassPersonCreateServiceImplimplementsPersonCreateService{privatefinalPersonValidateServicepersonValidateService;privatefinalPersonRepositorypersonRepository;@Override@TransactionalpublicList<PersonDTO>createFamily(Iterable<String>firstNames,StringlastName){finalvarpeople=newArrayList<PersonDTO>();firstNames.forEach(firstName->people.add(createPerson(firstName,lastName)));returnpeople;}@Override@TransactionalpublicPersonDTOcreatePerson(StringfirstName,StringlastName){personValidateService.checkUserCreation(firstName,lastName);finalvarcreatedPerson=personRepository.saveAndFlush(newPerson().setFirstName(firstName).setLastName(lastName));returnDTOConverters.toPersonDTO(createdPerson);}}
Enter fullscreen modeExit fullscreen mode

It needs tests too. Let’s try to write one.

classPersonCreateServiceImplMockingTest{// initialization...@TestvoidshouldCreateFamily(){finalvarfirstNames=List.of("John","Samantha","Kyle");finalvarlastName="Purple";finalvaridHolder=newAtomicLong(0);when(personRepository.saveAndFlush(any())).thenAnswer(invocation->{Personperson=invocation.getArgument(0);assertfirstNames.contains(person.getFirstName());assertObjects.equals(person.getLastName(),lastName);returnperson.setId(idHolder.incrementAndGet());});finalvarpeople=service.createFamily(firstNames,lastName);for(inti=0;i<people.size();i++){finalvarpersonDTO=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());}}
Enter fullscreen modeExit fullscreen mode

When I look at this code I see nothing but just nonsense. The data flow is so complicated that it’s almost impossible to get what the test is really doing. More than that, there is notesting butverifying that some particular methods were called the defined times. “What’s the difference?” you may ask. Imagine thatsaveAndFlush 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. Let’s see the declaration ofPerson entity.

@Entity@Table(name="person")publicclassPerson{@Id@GeneratedValue(strategy=GenerationType.IDENTITY)privateLongid;@Column(name="first_name")privateStringfirstName;@Column(name="last_name")privateStringlastName;privateZonedDateTimedateCreated;@PrePersistvoidprePersist(){dateCreated=ZonedDateTime.now();}// getters, setters}
Enter fullscreen modeExit fullscreen mode

It hasPrePersist 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, let’s 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 won’t 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 don’t 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.

@DataJpaTestclassPersonCreateServiceImplDataJpaTest{@AutowiredprivatePersonRepositorypersonRepository;@MockBeanprivatePersonValidateServicepersonValidateService;@AutowiredprivatePersonCreateServicepersonCreateService;}
Enter fullscreen modeExit fullscreen mode

@DataJpaTest annotation does the magic here. And specifically, there are 4 points.

  1. Launching the embedded instance of the H2 database.
  2. Creating the database schema according to declared entity classes.
  3. Adding all repositories beans to the application context.
  4. Wrapping the whole test suite with @Transactional annotation. So, each test execution becomes independent.

You have probably noticed the@MockBean annotation. That’s 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.

@DataJpaTestclassPersonCreateServiceImplDataJpaTest{@AutowiredprivatePersonRepositorypersonRepository;@MockBeanprivatePersonValidateServicepersonValidateService;@AutowiredprivatePersonCreateServicepersonCreateService;@TestConfigurationstaticclassTestConfig{@BeanpublicPersonCreateServicepersonCreateService(PersonRepositorypersonRepository,PersonValidateServicepersonValidateService){returnnewPersonCreateServiceImpl(personValidateService,personRepository);}}}
Enter fullscreen modeExit fullscreen mode

In my opinion, the most flexible solution provides the@TestConfiguration annotation. It allows us to modify the existing application context. WhenPersonCreateService is added, it can be easily injected with@Autowired.

Ok, let’s start with a simple happy path test ofcreateFamily method.

@DataJpaTestclassPersonCreateServiceImplDataJpaTest{// initialization...@TestvoidshouldCreateOnePerson(){finalvarpeople=personCreateService.createFamily(List.of("Simon"),"Kirekov");assertEquals(1,people.size());finalvarperson=people.get(0);assertEquals("Simon",person.getFirstName());assertEquals("Kirekov",person.getLastName());assertTrue(person.getDateCreated().isBefore(ZonedDateTime.now()));}}
Enter fullscreen modeExit fullscreen mode

image

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 ifValidationFailedException occurs? It means that the transaction should be rolled back. Let’s find this out.

@DataJpaTestclassPersonCreateServiceImplDataJpaTest{// initialization...@TestvoidshouldRollbackIfAnyUserIsNotValidated(){doThrow(newValidationFailedException("")).when(personValidateService).checkUserCreation("John","Brown");assertThrows(ValidationFailedException.class,()->personCreateService.createFamily(List.of("Matilda","Vasya","John"),"Brown"));assertEquals(0,personRepository.count());}}
Enter fullscreen modeExit fullscreen mode

The execution should fail on"John" creation. It means the total number of people has to be equal to0 because the exception throwing rolls back the transaction.

image

expected: <0> but was: <2>Expected :0Actual   :2
Enter fullscreen modeExit fullscreen mode

Something went wrong. Seems like the transaction has not been rolled back for some reason. And it’s true.

I’ve 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 isREQUIRED. It means that callinganother 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 returns2 instead of0.

image

I have described this phenomenon in my article“Spring Data — Transactional Caveats”.

What can we do about it? We could mark thePersonCreateService.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 what’s the purpose of it? Well, its name describes the goal. It’s ought to be used withrepository tests.

publicinterfacePersonRepositoryextendsJpaRepository<Person,Long>{@Query("select distinct p.lastName from Person p")Set<String>findAllLastNames();}
Enter fullscreen modeExit fullscreen mode
@DataJpaTestclassPersonRepositoryDataJpaTest{@AutowiredprivatePersonRepositorypersonRepository;@TestvoidshouldReturnAlLastNames(){personRepository.saveAndFlush(newPerson().setFirstName("John").setLastName("Brown"));personRepository.saveAndFlush(newPerson().setFirstName("Kyle").setLastName("Green"));personRepository.saveAndFlush(newPerson().setFirstName("Paul").setLastName("Brown"));assertEquals(Set.of("Brown","Green"),personRepository.findAllLastNames());}}
Enter fullscreen modeExit fullscreen mode

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

Let’s rewrite the test declaration a little bit.

@SpringBootTest(webEnvironment=WebEnvironment.NONE)@AutoConfigureTestDatabaseclassPersonCreateServiceImplSpringBootTest{@AutowiredprivatePersonRepositorypersonRepository;@MockBeanprivatePersonValidateServicepersonValidateService;@AutowiredprivatePersonCreateServicepersonCreateService;@BeforeEachvoidinit(){personRepository.deleteAll();}}
Enter fullscreen modeExit fullscreen mode

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.ThewebEnvironment = WebEnvironment.NONE parameterer is a slight optimization. We don’t 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 it’s redundant to declare them both (unless we want to parameterize@AutoConfigureTestDatabase but that’s out of scope).

You may also have noticed that we just auto-wiredPersonCreateService 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, let’s put the tests from the@DataJpaTest example and see how it works.

@SpringBootTest(webEnvironment=WebEnvironment.NONE)@AutoConfigureTestDatabaseclassPersonCreateServiceImplSpringBootTest{@AutowiredprivatePersonRepositorypersonRepository;@MockBeanprivatePersonValidateServicepersonValidateService;@AutowiredprivatePersonCreateServicepersonCreateService;@BeforeEachvoidinit(){personRepository.deleteAll();}@TestvoidshouldCreateOnePerson(){finalvarpeople=personCreateService.createFamily(List.of("Simon"),"Kirekov");assertEquals(1,people.size());finalvarperson=people.get(0);assertEquals("Simon",person.getFirstName());assertEquals("Kirekov",person.getLastName());assertTrue(person.getDateCreated().isBefore(ZonedDateTime.now()));}@TestvoidshouldRollbackIfAnyUserIsNotValidated(){doThrow(newValidationFailedException("")).when(personValidateService).checkUserCreation("John","Brown");assertThrows(ValidationFailedException.class,()->personCreateService.createFamily(List.of("Matilda","Vasya","John"),"Brown"));assertEquals(0,personRepository.count());}}
Enter fullscreen modeExit fullscreen mode

image

Everything works like a charm.

All of our test cases assumed thatPersonValidateService.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, let’s 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 thePersonValidateService and insert a family member in advance before calling thePersonCreateService.createFamily method.

@SpringBootTest(webEnvironment=WebEnvironment.NONE)@AutoConfigureTestDatabaseclassPersonCreateServiceImplSpringBootTest{// initialization...@TestvoidshouldRollbackIfOneUserIsNotValidated(){doAnswer(invocation->{finalStringlastName=invocation.getArgument(1);finalvarexists=personRepository.exists(Example.of(newPerson().setLastName(lastName)));System.out.println("Person with "+lastName+" exists: "+exists);if(exists){thrownewValidationFailedException("Person with "+lastName+" already exists");}returnnull;}).when(personValidateService).checkUserCreation(any(),any());personRepository.saveAndFlush(newPerson().setFirstName("Alice").setLastName("Purple"));assertThrows(ValidationFailedException.class,()->personCreateService.createFamily(List.of("Matilda"),"Purple"));assertEquals(1,personRepository.count());}}
Enter fullscreen modeExit fullscreen mode

image

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! That’s a quite long article and I’m glad that you made it through. Next time we’re 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!

Top comments(1)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss
CollapseExpand
 
vilgodskiy_sergey profile image
Vilgodskiy Sergey
  • Location
    Nicosia, Cyprus
  • Work
    Java Developer
  • Joined

Thank you a lot for this article! I got a lot of interesting tips and tricks)

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

Java team lead, conference speaker, and technical author.Telegram for contact: @kirekov
  • Location
    Russia, Moscow
  • Education
    Polzunov Altai State Technical University
  • Work
    Java team lead, a conference speaker, and a lecturer
  • Joined

More fromSemyon Kirekov

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp