Testing Asynchronous Operations in Spring With Spock and Byteman
This is the second article that describes how to test asynchronous operations with the Byteman framework in an application using the Spring framework. The article discuss how to implement the Spock framework, while the first, "Testing Asynchronous Operations in Spring With JUnit and Byteman", focuses on implementing tests with JUnit4.
It's not necessary to read the previous article because all of the essentials are going to be repeated. Nevertheless, I encourage you to read that article if you are interested in testing asynchronous operations with JUnit.
If you have already read the previous article, and you still remember the test case concept, you can quickly jump to the section, "Testing Code".
So, let's get started!
In this article, we will discuss how to test operations in an application that uses a Spring context (with asynchronous operations enabled). We don’t have to change production code to achieve this.
Tests are going to be run in Spock. If you are not familiar with the Spock, I would suggest you read its documentation.
Here are some other useful links that can give you a quick introduction about Spock:
- Testing Your Code With Spock.
- An Introduction to Spock.
For tests, we are going to use features from the Byteman library.
We also need to attach the “Bmunit-extension” library, which contains JUnit rules and some helper methods used during our tests. Although the BMUnitMethodRule
rule was implemented for the JUnit4, it can be used in Spock as well.
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 Byteman framework and JUnit and Spock tests. It also 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.
The source code can be found on https://github.com/starnowski/bmunit-extension/tree/feature/article_examples.
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 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.
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 for an application that does not have a problem with available threads.
Implementation for our REST Controller:
xxxxxxxxxx
public class UserController {
private UserService service;
"/users") (
public UserDto post( UserDto dto)
{
return service.registerUser(dto);
}
}
Service that handles the 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 that 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 test some code:
xxxxxxxxxx
value = CLEAR_DATABASE_SCRIPT_PATH, ([ (
config = (transactionMode = ISOLATED),
executionPhase = BEFORE_TEST_METHOD),
value = CLEAR_DATABASE_SCRIPT_PATH, (
config = (transactionMode = ISOLATED),
executionPhase = AFTER_TEST_METHOD)])
webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT) (
class UserControllerSpockItTest extends Specification {
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 \"UserControllerSpockItTest.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(\"UserControllerSpockItTest.shouldCreateNewUserAndSendMailMessageInAsyncOperation\")")]
)
def "should send mail message and wait until async operation is completed"() throws IOException, MessagingException {
given:
assertThat(userRepository.findByEmail(expectedEmail)).isNull();
UserDto dto = new UserDto().setEmail(expectedEmail).setPassword("XXX");
createJoin("UserControllerSpockItTest.shouldCreateNewUserAndSendMailMessageInAsyncOperation", 1);
assertEquals(0, greenMail.getReceivedMessages().length);
when:
UserDto responseEntity = restTemplate.postForObject(new URI("http://localhost:" + port + "/users"), (Object) dto, UserDto.class);
joinWait("UserControllerSpockItTest.shouldCreateNewUserAndSendMailMessageInAsyncOperation", 1, GROOVY_TEST_ASYNC_OPERATION_TIMEOUT);
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);
where:
expectedEmail << ["szymon.doe@nosuch.domain.com", "john.doe@gmail.com","marry.doe@hotmail.com", "jack.black@aws.eu"]
}
}
The test class needs to contain an object of type BMUnitMethodRule
(line 12) to load Byteman rules.
The BMRule annotation is part of the BMUnit project. All options, including name
, targetClass
(line 27), targetMethod
(line 28), targetLocation
(line 29), and action
(line 30) refers to the specific section in the Byteman rule language section. The options, targetClass
, targetMethod
, and targetLocation
are used at 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 their Developer guide.
The purpose of this test method is to confirm that the new application user can be registered via the REST API controller and that the application sends an email to the user with registration details. The last thing the test confirms is that the method triggers an Asynchronous Executor that sends an email.
To do that, we need to use a "Joiner” mechanism. From the 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 a Joiner, we need to specify the identification and number of thread which needs to join. In the given
(line 33) section we execute BMUnitUtils#createJoin(Object, int)
to create:UserControllerSpockItTest.shouldCreateNewUserAndSendMailMessageInAsyncOperation
joiner with one as the 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 the method exits (targetLocation
option with the value, “AT EXIT”). Then, specific action need be done which executes Helper#joinEnlist(Object key)
. This method does not suspend the current thread in which it was called.
In the when
section (line 39), besides executing testes method, we invoke BMUnitUtils#joinWait(Object, int, long)
to suspend test thread to wait until the number of joined threads for the Joiner, UserControllerSpockItTest.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 43) 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 the 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 the following test class:
xxxxxxxxxx
value = CLEAR_DATABASE_SCRIPT_PATH, ([ (
config = (transactionMode = ISOLATED),
executionPhase = BEFORE_TEST_METHOD),
value = CLEAR_DATABASE_SCRIPT_PATH, (
config = (transactionMode = ISOLATED),
executionPhase = AFTER_TEST_METHOD)])
webEnvironment= RANDOM_PORT) (
class UserControllerSpockItTest extends Specification {
public final GreenMailRule greenMail = new GreenMailRule(ServerSetupTest.SMTP_IMAP)
UserRepository userRepository
TestRestTemplate restTemplate
private int port
private IApplicationCountDownLatch applicationCountDownLatch
def cleanup() {
applicationCountDownLatch.mailServiceClearCountDownLatchForHandleNewUserEventMethod()
}
def "should send mail message and wait until async operation is completed, the test e-mail is #expectedEmail"() throws IOException, MessagingException {
given:
assertThat(userRepository.findByEmail(expectedEmail)).isNull()
UserDto dto = new UserDto().setEmail(expectedEmail).setPassword("XXX")
applicationCountDownLatch.mailServiceResetCountDownLatchForHandleNewUserEventMethod()
assertEquals(0, greenMail.getReceivedMessages().length)
when:
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)
where:
expectedEmail << ["szymon.doe@nosuch.domain.com", "john.doe@gmail.com","marry.doe@hotmail.com", "jack.black@aws.eu"]
}
}
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 to source code.
Further Reading
Unit Testing With Mockito