{{announcement.body}}
{{announcement.title}}

Testing Asynchronous Operations in Spring With JUnit 5 and Byteman

DZone 's Guide to

Testing Asynchronous Operations in Spring With JUnit 5 and Byteman

In this article, we will be discussing how to test operations in an application that uses a Spring context (with asynchronous operations enabled).

· Java Zone ·
Free Resource

This is already the third article that describes how to test asynchronous operations with the Byteman framework in an application using the Spring framework. The article focuses on how to do such testing with the JUnit 5 library.

The previous article focused on how such testing could be done with the usage of the JUnit 4 library (first article) and Spock framework (second article).

Just like in with the second article, It's not necessary to read the previous articles because all of the essentials parts are going to be repeated. Nevertheless, I encourage you to read the previous article, related to the Spock framework because, In my opinion, It is a good alternative for the JUnit 5 library.

Although the whole demo project has some changes comparing to that one which was used for the previous articles (among other the Spring Boot dependency was updated from version 1.5.3 to 2.2.4), the source code used for the test case presentation is the same. So if you have already read the previous article, and you still remember the test case concept, you can quickly jump to the section, "Test Code".

So, let's get started!

In this article, we will be discussing how to test operations in an application that uses a Spring context (with asynchronous operations enabled). We don’t have to change the production code to achieve this.

Tests are going to be run with the JUnit 5 library. If you are not familiar with JUnit 5, I would suggest you read its user guide.

Here are some other useful links that can give you a quick introduction about JUnit 5:

Our main test dependency is the BMUnit library. It is part of the Byteman project, and it has support for the JUnit 5 library. We also need to use the "utils" maven module from the BMUnit-extension project. 

Byteman is a tool that injects Java code into your application methods or into Java runtime methods without the need for you to recompile, repackage, or even redeploy your application.

You may also like: Using Byteman to Find Out Why the TimeZone Changed on a Java App Server

BMUnit is a package that makes it simple to use Byteman as a testing tool by integrating it into the two most popular Java test frameworks, JUnit and TestNG.

The Bmunit-extension is a small project on GitHub, which contains JUnit 4 rule, which allows integration with Byteman framework and JUnit and Spock tests. It also contains a few helper methods in the "utils" method, which is compatible with version 4.0.10 of Byteman project.

As was mentioned before in the article, we are going to use code from the demo application, which is part of the “Bmunit-extension” project. 

The source code can be found on page https://github.com/starnowski/bmunit-extension/tree/master/junit5-spring-demo.

Test Case

The test case assumes that we register a new user to our application (all transactions were committed) and send an email to him/her. The email is sent asynchronously. Now, the application contains a few tests that show how this case can be tested. There is no suggestion that the code implemented in the demo application for the Bmunit-extension is the only approach or even the best one. 

The primary purpose of this project is to show how such a case could be tested without any production code change while using Byteman.

In our example, we need to check the process of a new application user registration process. Let’s assume that the application allows user registration via a REST API. So, a client sends a request with user data. A controller then handles the request. 

When the database transaction has occurred, but before the response has been set, the controller invokes an Asynchronous Executor to send an email to a user with a registration link (to confirm their email address).

The whole process is presented in the sequence diagram below.


User sign up workflow


Now, I am guessing that this might not be the best approach to register users. It would probably be better to use some kind of scheduler component, which checks if there is an email to send — not to mention that for larger applications, a separate microservice would be more suitable. 

Let’s assume that it is acceptable for an application that does not have a problem with available threads.

Implementation for our REST Controller:

Java
 




x
13


1
@RestController
2
public class UserController {
3
 
          
4
    @Autowired
5
    private UserService service;
6
 
          
7
    @ResponseBody
8
    @PostMapping("/users")
9
    public UserDto post(@RequestBody UserDto dto)
10
    {
11
        return service.registerUser(dto);
12
    }
13
}



Service that handles the User object:

Java
 




xxxxxxxxxx
1
22


 
1
@Service
2
public class UserService {
3
 
          
4
    @Autowired
5
    private PasswordEncoder passwordEncoder;
6
    @Autowired
7
    private RandomHashGenerator randomHashGenerator;
8
    @Autowired
9
    private MailService mailService;
10
    @Autowired
11
    private UserRepository repository;
12
 
          
13
    @Transactional
14
    public UserDto registerUser(UserDto dto)
15
    {
16
        User user = new User().setEmail(dto.getEmail()).setPassword(passwordEncoder.encode(dto.getPassword())).setEmailVerificationHash(randomHashGenerator.compute());
17
        user = repository.save(user);
18
        UserDto response = new UserDto().setId(user.getId()).setEmail(user.getEmail());
19
        mailService.sendMessageToNewUser(response, user.getEmailVerificationHash());
20
        return response;
21
    }
22
}



A service that handles mail messages:

Java
 




xxxxxxxxxx
1
32


1
@Service
2
public class MailService {
3
 
          
4
    @Autowired
5
    private MailMessageRepository mailMessageRepository;
6
    @Autowired
7
    private JavaMailSender emailSender;
8
    @Autowired
9
    private ApplicationEventPublisher applicationEventPublisher;
10
 
          
11
    @Transactional
12
    public void sendMessageToNewUser(UserDto dto, String emailVerificationHash)
13
    {
14
        MailMessage mailMessage = new MailMessage();
15
        mailMessage.setMailSubject("New user");
16
        mailMessage.setMailTo(dto.getEmail());
17
        mailMessage.setMailContent(emailVerificationHash);
18
        mailMessageRepository.save(mailMessage);
19
        applicationEventPublisher.publishEvent(new NewUserEvent(mailMessage));
20
    }
21
 
          
22
    @Async
23
    @TransactionalEventListener
24
    public void handleNewUserEvent(NewUserEvent newUserEvent)
25
    {
26
        SimpleMailMessage message = new SimpleMailMessage();
27
        message.setTo(newUserEvent.getMailMessage().getMailTo());
28
        message.setSubject(newUserEvent.getMailMessage().getMailSubject());
29
        message.setText(newUserEvent.getMailMessage().getMailContent());
30
        emailSender.send(message);
31
    }
32
}



Test Code

To see how to attach all Byteman and Bmunit-extension dependencies, please check the section "How to attach project". Please also check the  project descriptor file for the demo application to specify the correct dependencies from the Byteman project with version 4.

Let’s go to the test some code:

Java
 




xxxxxxxxxx
1
50


 
1
@WithByteman
2
@SpringBootTest(webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT)
3
@Sql(value = CLEAR_DATABASE_SCRIPT_PATH,
4
        config = @SqlConfig(transactionMode = ISOLATED),
5
        executionPhase = BEFORE_TEST_METHOD)
6
@Sql(value = CLEAR_DATABASE_SCRIPT_PATH,
7
        config = @SqlConfig(transactionMode = ISOLATED),
8
        executionPhase = AFTER_TEST_METHOD)
9
@EnableAsync
10
public class UserControllerTest {
11
 
          
12
    @RegisterExtension
13
    static GreenMailExtension greenMail = new GreenMailExtension(ServerSetup.verbose(ServerSetup.ALL)).withConfiguration(new GreenMailConfiguration().withUser("no.such.mail@gmail.com", "no.such.password"));
14
    @Autowired
15
    UserRepository userRepository;
16
    @Autowired
17
    TestRestTemplate testRestTemplate;
18
    @LocalServerPort
19
    private int port;
20
 
          
21
    @DisplayName("Should send mail message after correct user sign-up and wait until the async operation which is e-mail sending is complete")
22
    @ParameterizedTest(name = "{index}. expected e-mail is {0}")
23
    @ValueSource(strings = {"szymon.doe@nosuch.domain.com", "john.doe@gmail.com","marry.doe@hotmail.com", "jack.black@aws.eu" })
24
    @BMUnitConfig(verbose = true, bmunitVerbose = true)
25
    @BMRules(rules = {
26
            @BMRule(name = "signal thread waiting for mutex \"UserControllerTest.shouldCreateNewUserAndSendMailMessageInAsyncOperation\"",
27
                    targetClass = "com.github.starnowski.bmunit.extension.junit5.spring.demo.services.MailService",
28
                    targetMethod = "handleNewUserEvent(com.github.starnowski.bmunit.extension.junit5.spring.demo.util.NewUserEvent)",
29
                    targetLocation = "AT EXIT",
30
                    action = "joinEnlist(\"UserControllerTest.shouldCreateNewUserAndSendMailMessageInAsyncOperation\")")
31
    })
32
    public void testShouldCreateNewUserAndSendMailMessageInAsyncOperation(String expectedEmail) throws IOException, URISyntaxException, MessagingException {
33
        // given
34
        assertThat(userRepository.findByEmail(expectedEmail)).isNull();
35
        UserDto dto = new UserDto().setEmail(expectedEmail).setPassword("XXX");
36
        RestTemplate restTemplate = new RestTemplate();
37
        createJoin("UserControllerTest.shouldCreateNewUserAndSendMailMessageInAsyncOperation", 1);
38
        assertEquals(0, greenMail.getReceivedMessages().length);
39
 
          
40
        // when
41
        UserDto responseEntity = restTemplate.postForObject(new URI("http://localhost:" + port + "/users/"), (Object) dto, UserDto.class);
42
        joinWait("UserControllerTest.shouldCreateNewUserAndSendMailMessageInAsyncOperation", 1, 15000);
43
 
          
44
        // then
45
        assertThat(userRepository.findByEmail(expectedEmail)).isNotNull();
46
        assertThat(greenMail.getReceivedMessages().length).isEqualTo(1);
47
        assertThat(greenMail.getReceivedMessages()[0].getSubject()).contains("New user");
48
        assertThat(greenMail.getReceivedMessages()[0].getAllRecipients()[0].toString()).contains(expectedEmail);
49
    }
50
}



Test class needs to contain an annotation of type  WithByteman  (line 1) to load Byteman rules. The BMRule annotation is part of the BMUnit project. All options  name (line 26), targetClass  (line 27), targetMethod (line 28), targetLocation  (line 29) and  action  (line 30) refers to the specific section in Byteman rule language section. Options  targetClass , targetMethod  and  targetLocation are used to a specified point in java code, after which the rule should be executed.

The  action option defines what should be done after reaching the rule point.

If you would like to know more about the Byteman rule language, then please check Programer’s guide.

The purpose of this test method is to confirm that the new application user can be registered via the rest API controller, and the application sends an email to the user with registration details. The last important thing, the test confirms that the method which triggers an Asynchronous Executor which sends an email is being triggered.

To do that, we need to use a “Joiner” mechanism. From “Developer Guide” for Byteman, we can find out that the joiners are useful in situations where it is necessary to ensure that a thread does not proceed until one or more related threads have exited.

Generally, when we create joiner, we need to specify the identification and number of thread which needs to join. In the  given (line 33) section we executes  BMUnitUtils#createJoin(Object, int)(line 37) to create  UserControllerTest.shouldCreateNewUserAndSendMailMessageInAsyncOperation joiner with one as expected number of threads. We expect that the thread responsible for sending is going to join.

To achieve this, we need to via BMRule annotation set that after method exit (targetLocation option with value “AT EXIT”) the specific action need be done which executes method  Helper#joinEnlist(Object key), this method does not suspend current thread in which it was called.

In the  when section (line 40), besides executing testes method, we invoke  BMUnitUtils#joinWait(Object, int, long)  to suspend test thread to wait until the number of joined threads for joiner  UserControllerTest.shouldCreateNewUserAndSendMailMessageInAsyncOperation reach expected value. In case when there won’t be expected number of joined threads, then execution is going to reach a timeout, and certain exceptions are going to be thrown.

In the  then (line 44) section, we check if the user was created, and email with correct content was sent. 

This test could be done without changing source code thanks to Byteman.

It also could be done with basic java mechanism, but it would also require changes in source code.

First, we have to create a component with  CountDownLatch.

Java
 




xxxxxxxxxx
1
29


1
@Component
2
public class DummyApplicationCountDownLatch implements IApplicationCountDownLatch{
3
 
          
4
    private CountDownLatch mailServiceCountDownLatch;
5
 
          
6
    @Override
7
    public void mailServiceExecuteCountDownInHandleNewUserEventMethod() {
8
        if (mailServiceCountDownLatch != null) {
9
            mailServiceCountDownLatch.countDown();
10
        }
11
    }
12
 
          
13
    @Override
14
    public void mailServiceWaitForCountDownLatchInHandleNewUserEventMethod(int milliseconds) throws InterruptedException {
15
        if (mailServiceCountDownLatch != null) {
16
            mailServiceCountDownLatch.await(milliseconds, TimeUnit.MILLISECONDS);
17
        }
18
    }
19
 
          
20
    @Override
21
    public void mailServiceResetCountDownLatchForHandleNewUserEventMethod() {
22
        mailServiceCountDownLatch = new CountDownLatch(1);
23
    }
24
 
          
25
    @Override
26
    public void mailServiceClearCountDownLatchForHandleNewUserEventMethod() {
27
        mailServiceCountDownLatch = null;
28
    }
29
}



There are also changes required in  MailService so that the specific methods for type  DummyApplicationCountDownLatch would be executed.

Java
 




xxxxxxxxxx
1
24


 
1
    private IApplicationCountDownLatch applicationCountDownLatch;
2
 
          
3
    @Transactional
4
    public void sendMessageToNewUser(UserDto dto, String emailVerificationHash)
5
    {
6
        MailMessage mailMessage = new MailMessage();
7
        mailMessage.setMailSubject("New user");
8
        mailMessage.setMailTo(dto.getEmail());
9
        mailMessage.setMailContent(emailVerificationHash);
10
        mailMessageRepository.save(mailMessage);
11
        applicationEventPublisher.publishEvent(new NewUserEvent(mailMessage));
12
    }
13
 
          
14
    @Async
15
    @TransactionalEventListener
16
    public void handleNewUserEvent(NewUserEvent newUserEvent)
17
    {
18
        SimpleMailMessage message = new SimpleMailMessage();
19
        message.setTo(newUserEvent.getMailMessage().getMailTo());
20
        message.setSubject(newUserEvent.getMailMessage().getMailSubject());
21
        message.setText(newUserEvent.getMailMessage().getMailContent());
22
        emailSender.send(message);
23
        applicationCountDownLatch.mailServiceExecuteCountDownInHandleNewUserEventMethod();
24
    }


  

After applying those changes, we can implement below test class:

Java
 




xxxxxxxxxx
1
49


1
@SpringBootTest(webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT)
2
@Sql(value = CLEAR_DATABASE_SCRIPT_PATH,
3
        config = @SqlConfig(transactionMode = ISOLATED),
4
        executionPhase = BEFORE_TEST_METHOD)
5
@Sql(value = CLEAR_DATABASE_SCRIPT_PATH,
6
        config = @SqlConfig(transactionMode = ISOLATED),
7
        executionPhase = AFTER_TEST_METHOD)
8
@EnableAsync
9
public class UserControllerTest {
10
 
          
11
    @RegisterExtension
12
    static GreenMailExtension greenMail = new GreenMailExtension(new ServerSetup(3025, (String)null, "smtp").setVerbose(true)).withConfiguration(new GreenMailConfiguration().withUser("no.such.mail@gmail.com", "no.such.password"));
13
    @Autowired
14
    UserRepository userRepository;
15
    @Autowired
16
    TestRestTemplate testRestTemplate;
17
    @LocalServerPort
18
    private int port;
19
    @Autowired
20
    private IApplicationCountDownLatch applicationCountDownLatch;
21
 
          
22
    @AfterEach
23
    public void tearDown()
24
    {
25
        applicationCountDownLatch.mailServiceClearCountDownLatchForHandleNewUserEventMethod();
26
    }
27
 
          
28
    @DisplayName("Should send mail message after correct user sign-up and wait until the async operation which is e-mail sending is complete")
29
    @ParameterizedTest(name = "{index}. expected e-mail is {0}")
30
    @ValueSource(strings = {"szymon.doe@nosuch.domain.com", "john.doe@gmail.com","marry.doe@hotmail.com", "jack.black@aws.eu" })
31
    public void testShouldCreateNewUserAndSendMailMessageInAsyncOperation(String expectedEmail) throws IOException, URISyntaxException, MessagingException, InterruptedException {
32
        // given
33
        assertThat(userRepository.findByEmail(expectedEmail)).isNull();
34
        UserDto dto = new UserDto().setEmail(expectedEmail).setPassword("XXX");
35
        RestTemplate restTemplate = new RestTemplate();
36
        applicationCountDownLatch.mailServiceResetCountDownLatchForHandleNewUserEventMethod();
37
        assertEquals(0, greenMail.getReceivedMessages().length);
38
 
          
39
        // when
40
        UserDto responseEntity = restTemplate.postForObject(new URI("http://localhost:" + port + "/users/"), (Object) dto, UserDto.class);
41
        applicationCountDownLatch.mailServiceWaitForCountDownLatchInHandleNewUserEventMethod(15000);
42
 
          
43
        // then
44
        assertThat(userRepository.findByEmail(expectedEmail)).isNotNull();
45
        assertThat(greenMail.getReceivedMessages().length).isEqualTo(1);
46
        assertThat(greenMail.getReceivedMessages()[0].getSubject()).contains("New user");
47
        assertThat(greenMail.getReceivedMessages()[0].getAllRecipients()[0].toString()).contains(expectedEmail);
48
    }
49
}


Full code for demo application can found on the Github.

Summary

The Byteman allows for testing asynchronous operations in an application without changing its source code. The same test cases can be tested without the Byteman, but it would require changes in source code.


Topics:
junit 5 ,junit ,byteman ,spring boot ,spring ,java

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}