In this post we will cover Integration Testing for the application we created in the previous post.
For the Integration Tests, we will use Test Containers running in docker, to ensure that we try and connect with real services instead of mocking them.
Testcontainers is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.
https://www.testcontainers.org/
Everything done using TestContainers could also be mocked whether using embedded/memory service instances, wiremock or simply using Mockito.
However with TestContainers you get an instance of the service and you can ensure your application will behave the same way once it goes live (Minus production traffic). For production traffic, you can look into load-testing.
Requirements:
- Verify JMS consume/produce workflow.
- Verify MongoDB repository
- Verify S3 putObject
- Use TestContainers (LocalStack, MongoDb, ActiveMQ)
LocalStack Module for the Atlassian’s LocalStack, ‘a fully functional local AWS cloud stack
MongoDb Module for creating a MongoDb Instance with a random port for you to use.
ActiveMQ does not have its own module but rather part of the main TestContainer’s dependency.
The dependencies below are for the TestContainers:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>localstack</artifactId>
<version>1.15.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mongodb</artifactId>
<version>1.15.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.15.3</version>
<scope>test</scope>
</dependency>
There are multiple ways of working with TestContainers in Springboot. You could use the annotation @TestContainer/@Container annotations, @Rule/@ClassRule, or in our case, we create static instances which are shared across all the Integration Tests.
Each has its own benefits, however for our case, we wanted to speed up the Integration Tests load time by having the containers load up only once. The issue with this approach however is that you are sharing container state between all your Integration Tests.
To combat this, simply clean up your data for each test class. As an example repository.deleteAll(), delete queue, etc.
We need to define a configuration class for our Integration Tests and for that we use @TestConfiguration annotation.
For @TestConfigurations, we could use @DynamicPropertySource or override the context beans. We will showcase the latter option.
@TestConfiguration
public class AppTestConfiguration {
@Value("${com.example.userPDFsBucket}")
private String userPDFsBucket;
private static final GenericContainer activeMQContainer;
private static final LocalStackContainer localStackContainer;
private static final MongoDBContainer mongoDBContainer;
static {
activeMQContainer = new GenericContainer<>("rmohr/activemq:latest")
.withExposedPorts(61616);
localStackContainer = new LocalStackContainer(DockerImageName.parse("localstack/localstack:0.12.14"))
.withServices(LocalStackContainer.Service.S3)
.withEnv("DEFAULT_REGION", "us-east-1");
mongoDBContainer = new MongoDBContainer("mongo:4.4.2");
activeMQContainer.start();
localStackContainer.start();
mongoDBContainer.start();
}
@Bean
public AmazonS3 amazonS3() {
AmazonS3 amazonS3 = AmazonS3ClientBuilder
.standard()
.withEndpointConfiguration(localStackContainer.getEndpointConfiguration(LocalStackContainer.Service.S3))
.withCredentials(localStackContainer.getDefaultCredentialsProvider())
.build();
if (!amazonS3.doesBucketExistV2(userPDFsBucket)) {
// Because the CreateBucketRequest object doesn't specify a region, the
// bucket is created in the region specified in the client.
amazonS3.createBucket(new CreateBucketRequest(userPDFsBucket));
}
return amazonS3;
}
@Bean
public JmsTemplate jmsTemplate() {
MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter();
converter.setTargetType(MessageType.TEXT);
converter.setTypeIdPropertyName("_type");
ActiveMQConnectionFactory activeMQConnectionFactory = new ActiveMQConnectionFactory();
activeMQConnectionFactory.setBrokerURL("vm://localhost:" + activeMQContainer.getMappedPort(61616));
JmsTemplate jmsTemplate = new JmsTemplate(activeMQConnectionFactory);
jmsTemplate.setMessageConverter(converter);
return jmsTemplate;
}
@Bean
public MongoTemplate mongoTemplate() throws Exception {
//ReplicaSetUrl will look similar to mongodb://localhost:33557/test
ConnectionString connectionString = new ConnectionString(mongoDBContainer.getReplicaSetUrl());
MongoClientSettings mongoClientSettings = MongoClientSettings.builder()
.applyConnectionString(connectionString)
.build();
MongoClient mongoClient = MongoClients.create(mongoClientSettings);
return new MongoTemplate(mongoClient, "test");
}
}
Keep in mind for MongoDb, Springboot autoconfigures an embedded instance however since we are using Test Containers, we need to point towards the Test Containers and disable auto configuration of EmbeddedMongoAutoConfiguration.
In application.properties under src/test/resources, we need to add:
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.mongo.embedded.EmbeddedMongoAutoConfiguration
For the Integration Test which verifies the API call, we will need to verify that a given User was inserted into MongoDb as well as a PDF file was uploaded into S3.
We use Awaitility to ensure that flow has been achieved before existing the test case.
@AutoConfigureMockMvc
@Import(AppTestConfiguration.class)
@SpringBootTest
public class UserControllerIT {
@Autowired
private MockMvc mvc;
@Autowired
private UserService userService;
@Autowired
private S3StorageService s3StorageService;
private ObjectMapper objectMapper = new ObjectMapper();
@Test
public void test_createUser_validateDBAndS3() throws Exception {
String id = UUID.randomUUID().toString();
User userToCreate = new User();
userToCreate.setId(id);
userToCreate.setFirstName("Mike");
userToCreate.setLastName("Fast");
userToCreate.setAge(50);
mvc.perform(post("/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(userToCreate)))
.andExpect(status().isOk());
Callable<Boolean> userHasBeenAddedToRepo = () -> {
return userService.getUser(id).isPresent();
};
Callable<Boolean> userPDFDocumentAddedToS3 = () -> {
return s3StorageService.confirmFileExistsInS3(id);
};
Awaitility.waitAtMost(20, TimeUnit.SECONDS).until(userHasBeenAddedToRepo);
Awaitility.waitAtMost(10, TimeUnit.SECONDS).until(userPDFDocumentAddedToS3);
}
//Lastname set to NULL, which is not valid as it has @NotNull on it
//We should expect BAD REQUEST with specific error messages
@Test
public void test_createUser_with_invalidPayload() throws Exception {
String id = UUID.randomUUID().toString();
User userToCreate = new User();
userToCreate.setId(id);
userToCreate.setFirstName("FirstName");
userToCreate.setAge(50);
MvcResult response = mvc.perform(post("/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(userToCreate)))
.andExpect(status().isBadRequest())
.andReturn();
//Ensure validation is triggered
Assertions.assertEquals("Validation Errors: lastName is not allowed to be null",
response.getResponse().getContentAsString());
}
}
For the JMS Consumer Integration Test, the best way to verify a message has been received is to validate its status in the database. If the User has been added into MongoDb, that means the message was processed successfully.
We could also verify using the logs printed out by using @ExtendWith(OutputCaptureExtension.class)
@ExtendWith(OutputCaptureExtension.class)
@Import(AppTestConfiguration.class)
@SpringBootTest
public class MessageConsumerServiceIT {
@Autowired
private MessageSenderService messageSenderService;
@Autowired
private UserService userService;
@Autowired
private S3StorageService s3StorageService;
@Test
void assertMessageConsumedSuccessfully(CapturedOutput logsGenerated) {
String id = UUID.randomUUID().toString();
User user = new User();
user.setFirstName("FirstName");
user.setLastName("LastName");
user.setId(id);
Callable<Boolean> userHasBeenAddedToRepo = () -> {
return userService.getUser(id).isPresent();
};
Callable<Boolean> userPDFDocumentAddedToS3 = () -> {
return s3StorageService.confirmFileExistsInS3(id);
};
assertDoesNotThrow(() -> messageSenderService.sendUserMessage(user));
Awaitility.waitAtMost(5, TimeUnit.SECONDS).until(userHasBeenAddedToRepo);
Awaitility.waitAtMost(5, TimeUnit.SECONDS).until(userPDFDocumentAddedToS3);
}
}
For the JMS Producer, the test is simpler and verifies whether sending a message threw an exception..
@ExtendWith(OutputCaptureExtension.class)
@Import(AppTestConfiguration.class)
@SpringBootTest
public class MessageSenderServiceIT {
@Autowired
private MessageSenderService messageSenderService;
@Test
void assertMessageSentSuccessfully(CapturedOutput logsGenerated){
User user = new User();
user.setFirstName("FirstName");
user.setLastName("LastName");
user.setAge(38);
Assertions.assertDoesNotThrow(() -> messageSenderService.sendUserMessage(user));
assertThat(logsGenerated.getOut()).contains("Sending the User: User(id=null, firstName=FirstName, lastName=LastName, age=38) to Queue: user.queue");
}
}
The code to the Integration Tests is in github.