Advanced Functional Testing in Spring Boot Using Docker in Tests
Want to learn more about functional testing in Spring Boot projects? Check out this post to learn more about using Docker containers in tests.
Join the DZone community and get the full member experience.
Join For FreeOverview
This article focuses on applying some of the best practices during the functional testing of a Spring Boot application. We will demonstrate an advanced approach on how to test a service as a black box without setting up the staging environment. This article is the continuation of my previous post, Native Integration Testing in Spring Boot, so I will refer to it to show the difference between these two types of testing. I recommend taking a look at that before reading this post.
Theory
Let's start with defining what functional testing means:
Functional testing is a software testing process used within software development in which software is tested to ensure that it conforms with all requirements. Functional testing is a way of checking software to ensure that it has all the required functionality that's specified within its functional requirements. - Techopedia
While this is a bit confusing, don't worry — the following definition provided further explanation:
Functional testing is primarily used to verify that a piece of software is providing the same output as required by the end-user or business. Typically, functional testing involves evaluating and comparing each software function with the business requirements. Software is tested by providing it with some related input so that the output can be evaluated to see how it conforms, relates or varies compared to its base requirements. Moreover, functional testing also checks the software for usability, such as by ensuring that the navigational functions are working as required. - Techopedia
In our case, we have microservices as a piece of software that should provide some output as required by the end-user.
Purpose
Functional testing should cover the following aspects of our application:
Context startup — this ensures that the service has no conflicts in context and can be bootstrapped without issues.
Business requirements/user stories — this includes the requested features.
Basically, every (or most) users' story should have its own dedicated functional test. We don't need to write a context startup test if there is at least one functional test since it will test it anyway.
Practice
In order to demonstrate how to apply the best practices, we need to have some example service written. Let's make it from scratch.
Task
The following requirements have been requested for our new service:
A REST API for storing and retrieving user details.
A REST API for retrieving user details enriched with contact details from Contact service via REST.
Architecture Design
For this task, we will use the Spring platform as the framework and Spring Boot as the app bootstrapper. For storing user details, we will use MariaDB.
Since the service should store and retrieve user details, it's logical to name it as the User details service.
Before implementation, the component diagram should be made to better understand the main components of the system:
Implementation
The following example code contains a lot of Lombok annotations. You may find an explanation for every annotation in the docs file on the site.
Models
User details model:
@Value(staticConstructor = "of")
public class UserDetails {
String firstName;
String lastName;
public static UserDetails fromEntity(UserDetailsEntity entity) {
return UserDetails.of(entity.getFirstName(), entity.getLastName());
}
public UserDetailsEntity toEntity(long userId) {
return new UserDetailsEntity(userId, firstName, lastName);
}
}
User contacts model:
@Value
public class UserContacts {
String email;
String phone;
}
User with aggregated information:
@Value(staticConstructor = "of")
public class User {
UserDetails userDetails;
UserContacts userContacts;
}
REST API
@RestController
@RequestMapping("user")
@AllArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/{userId}") //1
public User getUser(@PathVariable("userId") long userId) {
return userService.getUser(userId);
}
@PostMapping("/{userId}/details") //2
public void saveUserDetails(@PathVariable("userId") long userId, @RequestBody UserDetails userDetails) {
userService.saveDetails(userId, userDetails);
}
@GetMapping("/{userId}/details") //3
public UserDetails getUserDetails(@PathVariable("userId") long userId) {
return userService.getDetails(userId);
}
}
Get user aggregated data by id
Post user details for user by id
Get user details by id
Contacts Service Client
@Component
public class ContactsServiceClient {
private final RestTemplate restTemplate;
private final String contactsServiceUrl;
public ContactsServiceClient(final RestTemplateBuilder restTemplateBuilder,
@Value("${contacts.service.url}") final String contactsServiceUrl) {
this.restTemplate = restTemplateBuilder.build();
this.contactsServiceUrl = contactsServiceUrl;
}
public UserContacts getUserContacts(long userId) {
URI uri = UriComponentsBuilder.fromHttpUrl(contactsServiceUrl + "/contacts")
.queryParam("userId", userId).build().toUri();
return restTemplate.getForObject(uri, UserContacts.class);
}
}
Details Entity and its Repository
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserDetailsEntity {
@Id
private Long id;
@Column
private String firstName;
@Column
private String lastName;
}
@Repository
public interface UserDetailsRepository extends JpaRepository<UserDetailsEntity, Long> {
}
User Service
@Service
@AllArgsConstructor
public class UserService {
private final UserDetailsRepository userDetailsRepository;
private final ContactsServiceClient contactsServiceClient;
public User getUser(long userId) {
UserDetailsEntity userDetailsEntity = userDetailsRepository.getOne(userId); //1
UserDetails userDetails = UserDetails.fromEntity(userDetailsEntity);
UserContacts userContacts = contactsServiceClient.getUserContacts(userId); //2
return User.of(userDetails, userContacts); //3
}
public void saveDetails(long userId, UserDetails userDetails) {
UserDetailsEntity entity = userDetails.toEntity(userId);
userDetailsRepository.save(entity);
}
public UserDetails getDetails(long userId) {
UserDetailsEntity userDetailsEntity = userDetailsRepository.getOne(userId);
return UserDetails.fromEntity(userDetailsEntity);
}
}
Retrieves user details from DB
Retrieves user contacts from contacts service
Returns user with aggregated data
Application and its Properties
UserDetailsServiceApplication.java
@SpringBootApplication
public class UserDetailsServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserDetailsServiceApplication.class, args);
}
}
application.properties:
#contact service
contacts.service.url=http://www.prod.contact.service.com
#database
user.details.db.host=prod.maria.url.com
user.details.db.port=3306
user.details.db.schema=user_details
spring.datasource.url=jdbc:mariadb://${user.details.db.host}:${user.details.db.port}/${user.details.db.schema}
spring.datasource.username=prod-username
spring.datasource.password=prod-password
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
POM File
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>user-details-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>User details service</name>
<parent>
<groupId>com.tdanylchuk</groupId>
<artifactId>functional-tests-best-practices</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>2.3.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
NOTE: The parent is the custom functional-tests-best-practices project, which inherits spring-boot-starter-parent. The purpose of this will be covered later.
Structure
And that is pretty much everything we need in order to meet initial requirements: save and retrieve user details and retrieve user details enriched with contacts.
Functional Tests
It's time to add the functional tests! In the case of TDD, you need to read this section before implementation.
Location
Before starting, we need to pick the location for our functional tests; there two more appropriate places:
Along with the unit-test in a separate folder:
This is the easiest and fastest approach to start adding functional tests, although it has a big disadvantage: in case you want to run unit tests alone, you need to exclude the functional test folder. Why not run all tests each time a minor code modification is applied? Because functional tests, in most cases, have a huge execution time in comparison to the unit tests and should be modified separately in order to save development time.
In a separate project along with service project under a common parent:
Parent POM (aggregative project)
Service project
Functional tests project
This approach has an advantage over the previous one — we have an isolated functional tests module from service unit tests, so we can easily verify logic by running either unit-tests or functional tests separately. On the other hand, this approach requires a multi-module project structure, which is more difficult in comparison to the single-module project.
You've probably guessed from the service pom.xml that, for our case, we will pick the second approach.
Parent POM File
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.tdanylchuk</groupId>
<artifactId>functional-tests-best-practices</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>pom</packaging>
<name>Functional tests best practices parent project</name>
<parent> <!--1-->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.4.RELEASE</version>
<relativePath/>
</parent>
<modules> <!--2-->
<module>user-details-service</module>
<module>user-details-service-functional-tests</module>
</modules>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>
</project>
The spring-boot-starter-parent is a parent project for our parent POM. In this way, we keep Spring provided with dependency management.
Modules declaration. NOTE: The order matters and functional tests should always be last.
Cases
For picking cases to cover with functional tests, we need to consider two major things:
- Functional requirements — Basically, each requested requirement should have its own functional test.
Long execution time — this focuses on the crucial parts of the application, which is the opposite to unit tests where each minor case should be covered. Otherwise, the build time will be enormous.
Architecture
Yes, tests also require architecture, especially functional tests where execution time matters and logic might become overcomplicated with time. Also, they should be maintainable. This means that, in case of a functional shift, functional tests won't be a headache for developers.
Steps
Steps (also called a fixture) is a way to encapsulate the logic of each communication channel. Each channel should have its own steps object, which is isolated from other steps.
In our case, we have two communication channels:
User details service REST API (in channel)
Contacts service REST API (out channel)
For the REST in channel, we will use the library called REST Assured. In comparison to integration tests where we used MockMvc for REST API verification, here we use more black-box style testing to not mess up the Spring context with test mock objects.
As for the REST out channel, WireMock will be used. We won't point Spring to substitute our REST template with the mocked one. Instead, the jetty server, which WireMock uses under the hood, will be bootstrapped along with our service to emulate a real external REST service.
User Details Steps
@Component
public class UserDetailsServiceSteps implements ApplicationListener<WebServerInitializedEvent> {
private int servicePort;
public String getUser(long userId) {
return given().port(servicePort)
.when().get("user/" + userId)
.then().statusCode(200).contentType(ContentType.JSON).extract().asString();
}
public void saveUserDetails(long userId, String body) {
given().port(servicePort).body(body).contentType(ContentType.JSON)
.when().post("user/" + userId + "/details")
.then().statusCode(200);
}
public String getUserDetails(long userId) {
return given().port(servicePort)
.when().get("user/" + userId + "/details")
.then().statusCode(200).contentType(ContentType.JSON).extract().asString();
}
@Override
public void onApplicationEvent(@NotNull WebServerInitializedEvent webServerInitializedEvent) {
this.servicePort = webServerInitializedEvent.getWebServer().getPort();
}
}
As you may see from the steps object, each API endpoint has its own method.
By default, REST Assured will call the localhost
, but the port needs to be specified since our service will be bootstrapped with a random port. To distinguish it, WebServerInitializedEvent
should be listened.
NOTE: The @LocalServerPort
annotation cannot be used here since the steps bean is being created before the Spring Boot-embedded container starts.
Contacts Service Steps
@Component
public class ContactsServiceSteps {
public void expectGetUserContacts(long userId, String body) {
stubFor(get(urlPathMatching("/contacts")).withQueryParam("userId", equalTo(String.valueOf(userId)))
.willReturn(okJson(body)));
}
}
Here, we need to stub mock the server exactly in the same way as when we called the remote service from our app: endpoint, parameters, etc.
Database
Our service is storing data in Maria DB, but in terms of functional tests, it doesn't matter where data is being stored, so no mentions should be met in tests, as black box testing requires.
In this future, if we consider changing Maria DB to some NoSQL solution, the tests should remain unchanged.
But, what is the solution?
Of course, we can use an embedded solution as we did with the H2 database in integration tests, but on production, our service will use Maria DB, and this could cause something to go wrong.
For example, we have a column named as MAXVALUE
and run tests against H2 and everything goes okay. But, in production, the service fails, because this is a reserved word in MariaDB that means that our tests are not as good as expected and plenty of time could be wasted for fixing the issue while the service will remain unreleased.
The only way to avoid this is to use the real Maria DB in tests. At the same time, we need to be sure that our tests can be executed locally without any additional staging environment where Maria DB is set up.
To solve this, we will pick the testcontainers project, which provides lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can ran in a Docker container.
But the testcontainers library doesn't support Spring Boot out of the box. So, instead of writing the custom Generic Container for MariaDB and injecting it into Spring Boot manually, we will use another library called testcontainers-spring-boot. It supports the most common technologies, which might be used in your service, like: MariaDB, Couchbase, Kafka, Aerospike, MemSQL, Redis, neo4j, Zookeeper, PostgreSQL, ElasticSearch.
To inject real Maria DB into our tests, we just need to add the appropriate dependency to our user-details-service-functional-tests project pom.xml file like this:
<dependency>
<groupId>com.playtika.testcontainers</groupId>
<artifactId>embedded-mariadb</artifactId>
<version>1.9</version>
<scope>test</scope>
</dependency>
If your service doesn't use Spring Cloud, the next dependency should be added along with the above one:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-context</artifactId>
<version>2.0.1.RELEASE</version>
<scope>test</scope>
</dependency>
It needs bootstrapping for the dockerized resources before the Spring Boot context starts.
This approach obviously has a lot of pros. Since we have a "real" resource, we don't need to write workarounds in the code if a real connection cannot be tested to the needed resource. Unfortunately, this solution brings one huge drawback— tests can be run only on an environment where Docker is installed. It means that your workstation and CI tool should have Docker onboard. Also, you should be prepared that your tests will require more time to execute.
Parent Tests Class
Since execution time matters, we need to avoid multiple context load for each test, so Docker containers will start only once for all tests. Spring has context caching feature enabled by default, but we need to be careful since, by just adding simple annotation @MockBean
, we force Spring to create a new context with the mocked bean instead of reusing an existing one. The solution to this problem is to create a single parent abstract class, which will contain all the needed Spring annotations in order to ensure that a single context will be reused for all test suits:
@RunWith(SpringRunner.class)
@SpringBootTest(
classes = UserDetailsServiceApplication.class, //1
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) //2
@ActiveProfiles("test") //3
public abstract class BaseFunctionalTest {
@Rule
public WireMockRule contactsServiceMock = new WireMockRule(options().port(8777)); //4
@Autowired //5
protected UserDetailsServiceSteps userDetailsServiceSteps;
@Autowired
protected ContactsServiceSteps contactsServiceSteps;
@TestConfiguration //6
@ComponentScan("com.tdanylchuk.user.details.steps")
public static class StepsConfiguration {
}
}
Points the Spring Boot test annotation to load our service's main configuration class
Bootstraps web environment as in production (the mocked one is used by default).
Test profile is needed to load application-test.properties where production properties will be overridden, like URLs, users, passwords, etc.
WireMockRule
starts the jetty server for stubbing on the provided port.The protected auto wiring of steps, so they will be accessible in every test.
@TestConfiguration
loads steps to context by scanning their package.
Here, we are trying to not modify the context, which will be further used in production by adding some util items to it, like steps and properties overriding.
It is bad practice to use the @MockBean
annotation, which substitutes part of the application with mock, and that part will remain untested.
The case where it is unavoidable — i.e. retrieving the current time in the logic, like System.currentTimeMillis()
, such code should be refactored, so the Clock object will be used instead: clock.millis()
. And, in functional tests, the Clock
object should be mocked so results could be verified.
Test Properties
application-test.properties:
#contact service #1
contacts.service.url=http://localhost:8777
#database #2
user.details.db.host=${embedded.mariadb.host}
user.details.db.port=${embedded.mariadb.port}
user.details.db.schema=${embedded.mariadb.schema}
spring.datasource.username=${embedded.mariadb.user}
spring.datasource.password=${embedded.mariadb.password}
#3
spring.jpa.hibernate.ddl-auto=create-drop
Use the WireMock jetty server endpoint instead of production contacts service URL.
The overridings of database properties. NOTE: these properties are provided by the spring-boot-test-containers library.
In tests, the database schema will be created by Hibernate.
Test Itself
A lot of preparations were made just to have this test, so let's see how it looks:
public class RestUserDetailsTest extends BaseFunctionalTest {
private static final long USER_ID = 32343L;
private final String userContactsResponse = readFile("json/user-contacts.json");
private final String userDetails = readFile("json/user-details.json");
private final String expectedUserResponse = readFile("json/user.json");
@Test
public void shouldSaveUserDetailsAndRetrieveUser() throws Exception {
//when
userDetailsServiceSteps.saveUserDetails(USER_ID, userDetails);
//and
contactsServiceSteps.expectGetUserContacts(USER_ID, userContactsResponse);
//then
String actualUserResponse = userDetailsServiceSteps.getUser(USER_ID);
//expect
JSONAssert.assertEquals(expectedUserResponse, actualUserResponse, false);
}
}
For stubbing and asserting JSON files, which was previously created, are used. In this way, both request and response formats are being verified. It is better to not use a test data here, but a copy of the production request/response.
Since the whole logic is encapsulated in steps, configurations, and JSON files, in case of changes that are not related to functionality, this test will remain unchanged. For example:
The format of response changes — only test JSON files should be modified.
Contacts service endpoint changes —
ContactsServiceSteps
objects should be modified.Maria DB is replaced with No SQL DB — pom.xml and test properties file should be modified.
Functional Test Project POM
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>user-details-service-functional-tests</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>User details service functional tests</name>
<parent>
<groupId>com.tdanylchuk</groupId>
<artifactId>functional-tests-best-practices</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<dependencies>
<dependency> <!--1-->
<groupId>com.tdanylchuk</groupId>
<artifactId>user-details-service</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-context</artifactId>
<version>2.0.1.RELEASE</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.playtika.testcontainers</groupId>
<artifactId>embedded-mariadb</artifactId>
<version>1.9</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock</artifactId>
<version>2.18.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
User details service as a dependency is added, so it could be loaded by the
SpringBootTest
.
Structure
Putting it all together, we have the next structure.
Adding features to a service will not change the current structure, it just will extend it. With additional steps, in case more communication channels are added, the utils
folder could be added with common methods; new files with test data; and of course, additional tests for each functional requirement.
Conclusion
In this article, we've built a new microservices based on the given requirement and covered those requirements by functional tests. In tests, we used black-box type of testing where we tried not to change internal parts of the application, but instead, communicated with it from outside as an ordinary client to emulate production behavior as much as we can. At the same time, we laid the foundation of functional tests architecture, so future service changes won't require refactoring of existing tests and adding new tests will be as easy as possible.
The source code for this project can all be found on GitHub.
Opinions expressed by DZone contributors are their own.
Comments