Testing Asynchronous Operations in Spring With JUnit and Byteman
Learn how to test such operations in an application that uses spring context (with asynchronous operations enabled).
Join the DZone community and get the full member experience.
Join For FreeTesting asynchronous operations might cause some troubles and usually requires few challenges and also code changes (even in production code).
In this article, we can find how to test such operations in an application that uses spring context (with asynchronous operations enabled). We don’t have to change the production code to achieve this.
Tests are going to be run in JUnit 4. For tests, we are going to use features from the Byteman library. We also gone have to attach the “Bmunit-extension” library, which gives contains JUnit rule and some helper methods used during our tests.
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.
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 junit4 rule, which allows integration with the Byteman framework and uses it in JUnit and Spock tests. And It contains a few helper methods.
In this article, we are going to use code from the demo application, which is part of the “Bmunit-extension” project. Source code can be found on https://github.com/starnowski/bmunit-extension/tree/feature/article_examples.
Test case
Tests case assumes that we register a new application user (all transactions were committed) and sends an email message to him. The email message sending operation is asynchronous.
Now the application contains few tests which 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 and 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 with usage of the Byteman library.
In our example test, we would like to check the process of a new application user registration process. Let’s assume that the application allows user registration via Rest API. So Rest API client sends a request with user data. The Rest API controller is proceeding the request. After when the database, transaction is being committed, but before returning Rest API response, the controller invokes an Asynchronous Executor to send an email to a user with a registration link (to confirm email address).
The whole process is presented in the sequence diagram below.
Now I am guessing that this might not be the best approach to register users. Probably better would be to use some kind of scheduler component, which checks if there is an email to send. Not to mention that for larger applications, the separate microservice would be more suitable. Let’s assume that for an application that does not have a problem with available threads is okay.
Implementation contains Rest Controller:
public class UserController {
private UserService service;
"/users") (
public UserDto post( UserDto dto)
{
return service.registerUser(dto);
}
}
Service which handles “User” object:
xxxxxxxxxx
public class UserService {
private PasswordEncoder passwordEncoder;
private RandomHashGenerator randomHashGenerator;
private MailService mailService;
private UserRepository repository;
public UserDto registerUser(UserDto dto)
{
User user = new User().setEmail(dto.getEmail()).setPassword(passwordEncoder.encode(dto.getPassword())).setEmailVerificationHash(randomHashGenerator.compute());
user = repository.save(user);
UserDto response = new UserDto().setId(user.getId()).setEmail(user.getEmail());
mailService.sendMessageToNewUser(response, user.getEmailVerificationHash());
return response;
}
}
A service which handles mail messages:
xxxxxxxxxx
public class MailService {
private MailMessageRepository mailMessageRepository;
private JavaMailSender emailSender;
private ApplicationEventPublisher applicationEventPublisher;
public void sendMessageToNewUser(UserDto dto, String emailVerificationHash)
{
MailMessage mailMessage = new MailMessage();
mailMessage.setMailSubject("New user");
mailMessage.setMailTo(dto.getEmail());
mailMessage.setMailContent(emailVerificationHash);
mailMessageRepository.save(mailMessage);
applicationEventPublisher.publishEvent(new NewUserEvent(mailMessage));
}
public void handleNewUserEvent(NewUserEvent newUserEvent)
{
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(newUserEvent.getMailMessage().getMailTo());
message.setSubject(newUserEvent.getMailMessage().getMailSubject());
message.setText(newUserEvent.getMailMessage().getMailContent());
emailSender.send(message);
}
}
Test code
To see how to attach all Byteman and Bmunit-extension dependencies, please check the section "How to attach project".
Let’s go to test code:
xxxxxxxxxx
SpringRunner.class) (
webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT) (
value = CLEAR_DATABASE_SCRIPT_PATH, (
config = (transactionMode = ISOLATED),
executionPhase = BEFORE_TEST_METHOD)
value = CLEAR_DATABASE_SCRIPT_PATH, (
config = (transactionMode = ISOLATED),
executionPhase = AFTER_TEST_METHOD)
public class UserControllerTest {
public BMUnitMethodRule bmUnitMethodRule = new BMUnitMethodRule();
public final GreenMailRule greenMail = new GreenMailRule(ServerSetupTest.SMTP_IMAP);
UserRepository userRepository;
TestRestTemplate restTemplate;
private int port;
verbose = true, bmunitVerbose = true) (
rules = { (
name = "signal thread waiting for mutex \"UserControllerTest.shouldCreateNewUserAndSendMailMessageInAsyncOperation\"", (
targetClass = "com.github.starnowski.bmunit.extension.junit4.spock.spring.demo.services.MailService",
targetMethod = "handleNewUserEvent(com.github.starnowski.bmunit.extension.junit4.spock.spring.demo.util.NewUserEvent)",
targetLocation = "AT EXIT",
action = "joinEnlist(\"UserControllerTest.shouldCreateNewUserAndSendMailMessageInAsyncOperation\")")
})
public void shouldCreateNewUserAndSendMailMessageInAsyncOperation() throws IOException, URISyntaxException, MessagingException {
// given
String expectedEmail = "szymon.doe@nosuch.domain.com";
assertThat(userRepository.findByEmail(expectedEmail)).isNull();
UserDto dto = new UserDto().setEmail(expectedEmail).setPassword("XXX");
createJoin("UserControllerTest.shouldCreateNewUserAndSendMailMessageInAsyncOperation", 1);
assertEquals(0, greenMail.getReceivedMessages().length);
// when
UserDto responseEntity = restTemplate.postForObject(new URI("http://localhost:" + port + "/users"), (Object) dto, UserDto.class);
joinWait("UserControllerTest.shouldCreateNewUserAndSendMailMessageInAsyncOperation", 1, 15000);
// then
assertThat(userRepository.findByEmail(expectedEmail)).isNotNull();
assertThat(greenMail.getReceivedMessages().length).isEqualTo(1);
assertThat(greenMail.getReceivedMessages()[0].getSubject()).contains("New user");
assertThat(greenMail.getReceivedMessages()[0].getAllRecipients()[0].toString()).contains(expectedEmail);
}
}
Test class needs to contain an object of type “BMUnitMethodRule” (line 13) to load Byteman rules.
The BMRule annotation is part of the BMUnit project. All options “name”, “targetClass“ (line 28), “targetMethod“ (line 29), “targetLocation“ (line 30) and “action“ (line 31) 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 34) section we executes “BMUnitUtils#createJoin(Object, int)” 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 41), 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 is going to be thrown.
In the “then” (line 45) 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”.
xxxxxxxxxx
public class DummyApplicationCountDownLatch implements IApplicationCountDownLatch{
private CountDownLatch mailServiceCountDownLatch;
public void mailServiceExecuteCountDownInHandleNewUserEventMethod() {
if (mailServiceCountDownLatch != null) {
mailServiceCountDownLatch.countDown();
}
}
public void mailServiceWaitForCountDownLatchInHandleNewUserEventMethod(int milliseconds) throws InterruptedException {
if (mailServiceCountDownLatch != null) {
mailServiceCountDownLatch.await(milliseconds, TimeUnit.MILLISECONDS);
}
}
public void mailServiceResetCountDownLatchForHandleNewUserEventMethod() {
mailServiceCountDownLatch = new CountDownLatch(1);
}
public void mailServiceClearCountDownLatchForHandleNewUserEventMethod() {
mailServiceCountDownLatch = null;
}
}
There are also changes required in “MailService” so that the specific methods for type DummyApplicationCountDownLatch would be executed.
xxxxxxxxxx
private IApplicationCountDownLatch applicationCountDownLatch;
public void sendMessageToNewUser(UserDto dto, String emailVerificationHash)
{
MailMessage mailMessage = new MailMessage();
mailMessage.setMailSubject("New user");
mailMessage.setMailTo(dto.getEmail());
mailMessage.setMailContent(emailVerificationHash);
mailMessageRepository.save(mailMessage);
applicationEventPublisher.publishEvent(new NewUserEvent(mailMessage));
}
public void handleNewUserEvent(NewUserEvent newUserEvent)
{
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(newUserEvent.getMailMessage().getMailTo());
message.setSubject(newUserEvent.getMailMessage().getMailSubject());
message.setText(newUserEvent.getMailMessage().getMailContent());
emailSender.send(message);
applicationCountDownLatch.mailServiceExecuteCountDownInHandleNewUserEventMethod();
}
After applying those changes we can implement below test class:
xxxxxxxxxx
SpringRunner.class) (
webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT) (
value = CLEAR_DATABASE_SCRIPT_PATH, (
config = (transactionMode = ISOLATED),
executionPhase = BEFORE_TEST_METHOD)
value = CLEAR_DATABASE_SCRIPT_PATH, (
config = (transactionMode = ISOLATED),
executionPhase = AFTER_TEST_METHOD)
public class UserControllerTest {
public final GreenMailRule greenMail = new GreenMailRule(ServerSetupTest.SMTP_IMAP);
UserRepository userRepository;
TestRestTemplate restTemplate;
private int port;
private IApplicationCountDownLatch applicationCountDownLatch;
public void tearDown()
{
applicationCountDownLatch.mailServiceClearCountDownLatchForHandleNewUserEventMethod();
}
public void shouldCreateNewUserAndSendMailMessageInAsyncOperation() throws IOException, URISyntaxException, MessagingException, InterruptedException {
// given
String expectedEmail = "szymon.doe@nosuch.domain.com";
assertThat(userRepository.findByEmail(expectedEmail)).isNull();
UserDto dto = new UserDto().setEmail(expectedEmail).setPassword("XXX");
applicationCountDownLatch.mailServiceResetCountDownLatchForHandleNewUserEventMethod();
assertEquals(0, greenMail.getReceivedMessages().length);
// when
UserDto responseEntity = restTemplate.postForObject(new URI("http://localhost:" + port + "/users"), (Object) dto, UserDto.class);
applicationCountDownLatch.mailServiceWaitForCountDownLatchInHandleNewUserEventMethod(15000);
// then
assertThat(userRepository.findByEmail(expectedEmail)).isNotNull();
assertThat(greenMail.getReceivedMessages().length).isEqualTo(1);
assertThat(greenMail.getReceivedMessages()[0].getSubject()).contains("New user");
assertThat(greenMail.getReceivedMessages()[0].getAllRecipients()[0].toString()).contains(expectedEmail);
}
}
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.
Further Reading
Opinions expressed by DZone contributors are their own.
Comments