Recently, I have learned a lot of new knowledge. Thank you for your excellent teacher's detailed explanation. This blog records what I think.
Want to learn more about functional testing in the Spring Boot project?This article takes you to learn more about using the Docker container in testing.
This article focuses on applying some best practices during the functional testing of the Spring Boot application.We will demonstrate an advanced method for testing services as black boxes without setting up a staging environment.(
theory
Let's start by defining what functional testing means:
Functional testing is a software testing process used in software development where the software is tested to ensure that it meets all requirements.Functional testing is a method of examining software to ensure that it has all the required functions specified in its functional requirements.
Although this is confusing, don't worry - the following definitions provide further explanation:
Functional testing is primarily used to verify that the output provided by a software is the same as that required by the end user or enterprise.Typically, functional testing involves evaluating each software capability and comparing it to business requirements.Tests the software by providing some relevant inputs so that you can evaluate the output to see if it matches, correlates, or changes to the basic requirements.In addition, functional tests check the availability of software, such as by ensuring that navigation functions work as required.
In our case, we use microservices as software and should provide some output based on end-user requirements.
objective
Functional testing should cover the following aspects of our application:
- Context Start - Ensure that the service has no conflicts in the context and can be successfully booted.
- Business Requirements/User Cases - This includes requested functionality.
Basically, each (or most) user story should have its own dedicated functional testing.If there is at least one functional test, we do not need to write a context start test because it will still test it.
practice
To demonstrate how best practices can be applied, we need to write some sample services.Let's start from scratch.
task
Our new service requirements meet the following requirements:
- REST API for storing and retrieving user details.
- REST API for getting rich contact details from contact services via REST.
architecture design
For this task, we will use the Spring Platform as the framework and the Spring Boot as the application bootstrapper.To store user details, we'll use MariaDB.
Since the service should store and retrieve user details, it is logical to name it the User Details service.
Before implementation, component diagrams should be made to better understand the main components of the system:
Practice
The following sample code contains many Lombok comments.You can find instructions for each comment in the docs file on the website.
Models
User Detail Model:
1 @Value(staticConstructor = "of") 2 public class UserDetails { 3 String firstName; 4 String lastName; 5 public static UserDetails fromEntity(UserDetailsEntity entity) { 6 return UserDetails.of(entity.getFirstName(), entity.getLastName()); 7 } 8 public UserDetailsEntity toEntity(long userId) { 9 return new UserDetailsEntity(userId, firstName, lastName); 10 } 11 }
User Contact Model:
1 @Value 2 public class UserContacts { 3 String email; 4 String phone; 5 } 6
Users with summary information:
1 @Value(staticConstructor = "of") 2 public class User { 3 UserDetails userDetails; 4 UserContacts userContacts; 5 }
REST API
1 @RestController 2 @RequestMapping("user") 3 @AllArgsConstructor 4 public class UserController { 5 private final UserService userService; 6 @GetMapping("/{userId}") //1 7 public User getUser(@PathVariable("userId") long userId) { 8 return userService.getUser(userId); 9 } 10 @PostMapping("/{userId}/details") //2 11 public void saveUserDetails(@PathVariable("userId") long userId, @RequestBody UserDetails userDetails) { 12 userService.saveDetails(userId, userDetails); 13 } 14 @GetMapping("/{userId}/details") //3 15 public UserDetails getUserDetails(@PathVariable("userId") long userId) { 16 return userService.getDetails(userId); 17 } 18 }
- Get user summary data by ID
- Publish user details by ID for users
- Get user details through ID
Contact Service Client
1 @Component 2 public class ContactsServiceClient { 3 private final RestTemplate restTemplate; 4 private final String contactsServiceUrl; 5 public ContactsServiceClient(final RestTemplateBuilder restTemplateBuilder, 6 @Value("${contacts.service.url}") final String contactsServiceUrl) { 7 this.restTemplate = restTemplateBuilder.build(); 8 this.contactsServiceUrl = contactsServiceUrl; 9 } 10 public UserContacts getUserContacts(long userId) { 11 URI uri = UriComponentsBuilder.fromHttpUrl(contactsServiceUrl + "/contacts") 12 .queryParam("userId", userId).build().toUri(); 13 return restTemplate.getForObject(uri, UserContacts.class); 14 } 15 }
Detail Entities and Their Repositories
1 @Entity 2 @Data 3 @NoArgsConstructor 4 @AllArgsConstructor 5 public class UserDetailsEntity { 6 @Id 7 private Long id; 8 @Column 9 private String firstName; 10 @Column 11 private String lastName; 12 } 13 @Repository 14 public interface UserDetailsRepository extends JpaRepository<UserDetailsEntity, Long> { 15 }
User Services
1 @Service 2 @AllArgsConstructor 3 public class UserService { 4 private final UserDetailsRepository userDetailsRepository; 5 private final ContactsServiceClient contactsServiceClient; 6 public User getUser(long userId) { 7 UserDetailsEntity userDetailsEntity = userDetailsRepository.getOne(userId); //1 8 UserDetails userDetails = UserDetails.fromEntity(userDetailsEntity); 9 UserContacts userContacts = contactsServiceClient.getUserContacts(userId); //2 10 return User.of(userDetails, userContacts); //3 11 } 12 public void saveDetails(long userId, UserDetails userDetails) { 13 UserDetailsEntity entity = userDetails.toEntity(userId); 14 userDetailsRepository.save(entity); 15 } 16 public UserDetails getDetails(long userId) { 17 UserDetailsEntity userDetailsEntity = userDetailsRepository.getOne(userId); 18 return UserDetails.fromEntity(userDetailsEntity); 19 } 20 }
- Retrieving user details from the database
- Retrieving User Address Book from Address Book Service
- Return summary data to the user
Application and its properties
UserDetailsServiceApplication.java
1 @SpringBootApplication 2 public class UserDetailsServiceApplication { 3 public static void main(String[] args) { 4 SpringApplication.run(UserDetailsServiceApplication.class, args); 5 } 6 }
application.properties:
1 #contact service 2 contacts.service.url=http://www.prod.contact.service.com 3 #database 4 user.details.db.host=prod.maria.url.com 5 user.details.db.port=3306 6 user.details.db.schema=user_details 7 spring.datasource.url=jdbc:mariadb://${user.details.db.host}:${user.details.db.port}/${user.details.db.schema} 8 spring.datasource.username=prod-username 9 spring.datasource.password=prod-password 10 spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
POM File
1 <?xml version="1.0" encoding="UTF-8"?> 2 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 3 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4 <modelVersion>4.0.0</modelVersion> 5 <artifactId>user-details-service</artifactId> 6 <version>0.0.1-SNAPSHOT</version> 7 <packaging>jar</packaging> 8 <name>User details service</name> 9 <parent> 10 <groupId>com.tdanylchuk</groupId> 11 <artifactId>functional-tests-best-practices</artifactId> 12 <version>0.0.1-SNAPSHOT</version> 13 </parent> 14 <dependencies> 15 <dependency> 16 <groupId>org.springframework.boot</groupId> 17 <artifactId>spring-boot-starter-data-jpa</artifactId> 18 </dependency> 19 <dependency> 20 <groupId>org.springframework.boot</groupId> 21 <artifactId>spring-boot-starter-web</artifactId> 22 </dependency> 23 <dependency> 24 <groupId>org.projectlombok</groupId> 25 <artifactId>lombok</artifactId> 26 <scope>provided</scope> 27 </dependency> 28 <dependency> 29 <groupId>org.mariadb.jdbc</groupId> 30 <artifactId>mariadb-java-client</artifactId> 31 <version>2.3.0</version> 32 </dependency> 33 </dependencies> 34 <build> 35 <plugins> 36 <plugin> 37 <groupId>org.springframework.boot</groupId> 38 <artifactId>spring-boot-maven-plugin</artifactId> 39 </plugin> 40 </plugins> 41 </build> 42 </project>
Note: The parent is a custom function test best practice project that inherits spring-boot-starter-parent.Its purpose will be described later.
Structure
This is almost everything we need to meet our initial needs: to save and retrieve user details and to retrieve user details that contain contacts.
functional testing
It's time to add functional tests!For TDD, you need to read this section before you can implement it.
place
Before we get started, we need to choose where to test functionality; there are two more appropriate places:
- In a separate folder with unit tests:
This is the easiest and fastest way to start adding functional tests, although it has one big disadvantage: if you want to run unit tests separately, you need to exclude the functional test folder.Why don't all tests run after every minor code change?Because in most cases, functional testing has a significant execution time compared to unit testing, it should be modified separately to save development time.
- In a separate project and in a 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 a functional test module isolated from service unit tests, so we can easily validate logic by running unit tests or functional tests separately.On the other hand, this method requires a multi-module project structure, which is more difficult than a single-module project.
As you may have guessed from the service pom.xml, we will choose the second option for our case.
Parent POM File
1 <?xml version="1.0" encoding="UTF-8"?> 2 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 3 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4 <modelVersion>4.0.0</modelVersion> 5 <groupId>com.tdanylchuk</groupId> 6 <artifactId>functional-tests-best-practices</artifactId> 7 <version>0.0.1-SNAPSHOT</version> 8 <packaging>pom</packaging> 9 <name>Functional tests best practices parent project</name> 10 <parent> <!--1--> 11 <groupId>org.springframework.boot</groupId> 12 <artifactId>spring-boot-starter-parent</artifactId> 13 <version>2.0.4.RELEASE</version> 14 <relativePath/> 15 </parent> 16 <modules> <!--2--> 17 <module>user-details-service</module> 18 <module>user-details-service-functional-tests</module> 19 </modules> 20 <properties> 21 <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> 22 <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> 23 <java.version>1.8</java.version> 24 </properties> 25 </project>
- spring-boot-starter-parent is the parent project of our parent POM.In this way, we provide dependency management for Spring.
- Module declaration.Note: Order is important and functional testing should always be at the bottom.
case
In order to select the cases covered by the functional tests, we need to consider two main aspects:
- Functional Requirements - Basically, each requirement should have its own functional tests.
- Long execution time - Focus on key parts of the application and, as opposed to unit testing, should cover every small case in a unit test.Otherwise, the build time will be enormous.
Architecture
Yes, testing also requires architecture, especially functional testing, where execution time is important and logic may become too complex over time.Moreover, they should be maintainable.This means that if there is a functional shift, functional testing will not be a headache for developers.
Steps
Steps, also known as fixtures, are a way to encapsulate the logic of each communication channel.Each channel should have its own step object, which is isolated from other steps.
For us, we have two channels of communication:
- User Detailed Information Services REST API (in channels)
- Contact Services REST API (Out of Channel)
For REST in channels, we will use a library called REST Assured.We use more black-box style tests here than our integrated tests using MockMvc for REST API validation to avoid confusing the Spring context with the test simulation object.
As for the REST out channel, WireMock will be used.We will not point out that Spring replaces the REST template with a simulated template.Instead, the dock servers WireMock uses behind the scenes will be booted with our services to simulate real external REST services.
User Details Steps
1 @Component 2 public class UserDetailsServiceSteps implements ApplicationListener<WebServerInitializedEvent> { 3 private int servicePort; 4 public String getUser(long userId) { 5 return given().port(servicePort) 6 .when().get("user/" + userId) 7 .then().statusCode(200).contentType(ContentType.JSON).extract().asString(); 8 } 9 public void saveUserDetails(long userId, String body) { 10 given().port(servicePort).body(body).contentType(ContentType.JSON) 11 .when().post("user/" + userId + "/details") 12 .then().statusCode(200); 13 } 14 public String getUserDetails(long userId) { 15 return given().port(servicePort) 16 .when().get("user/" + userId + "/details") 17 .then().statusCode(200).contentType(ContentType.JSON).extract().asString(); 18 } 19 @Override 20 public void onApplicationEvent(@NotNull WebServerInitializedEvent webServerInitializedEvent) { 21 this.servicePort = webServerInitializedEvent.getWebServer().getPort(); 22 } 23 }
As you can see from the step objects, each API endpoint has its own method.
By default, REST will call localhost securely, but you need to specify a port because our service will boot using a random port.To distinguish it, you should listen for WebServerInitializedEvent s.
Note: The @LocalServerPor comment cannot be used here because the step bean was created before the Spring Boot embedded container started.
Contact person service steps
1 @Component 2 public class ContactsServiceSteps { 3 public void expectGetUserContacts(long userId, String body) { 4 stubFor(get(urlPathMatching("/contacts")).withQueryParam("userId", equalTo(String.valueOf(userId))) 5 .willReturn(okJson(body))); 6 } 7 }
Here, we need to emulate the server in exactly the same way as when invoking a remote service from an application: endpoints, parameters, and so on.
data base
Our service is to store the data in Maria DB, but in terms of functional testing, the location of the data is irrelevant, so nothing should be mentioned in the test, as required by the black box test.
In the future, if we consider changing Maria DB to some NoSQL solution, the tests should remain the same.
But what is the solution?
Of course, we can use embedded solutions like we did with H2 databases in integrated testing, but in production, our services will use Maria DB, which can cause problems.
For example, we have a column named MAXVALUE and run tests against H2, everything works.However, in production, the service failed because this is a reserved word in MariaDB, which means our tests are not as good as expected, and may waste a lot of time solving problems, while the service will remain unpublished.
The only way to avoid this is to use real Maria DB in your tests.At the same time, we need to ensure that our tests can be executed locally without having to set up Maria DB in any other staging environment.
To solve this problem, we'll choose the testcontainers project, which provides a lightweight, one-time instance of a common database, Selenium Web browser, or anything else that can run in a Docker container.
However, the testcontainers library does not support out-of-the-box Spring Boots.Therefore, instead of writing a custom generic container for MariaDB and manually injecting it into Spring Boot, we will use another library called testcontainers-spring-boot.It supports the most common technologies that may be used in your services, such as MariaDB, Couchbase, Kafka, Aerospike, MemSQL, Redis, neo4j, Zookeeper, PostgreSQL, Elastic Search.
To inject real Maria DB into our tests, we only need to add the appropriate dependencies to our user-details-service-functional-tests project pom.xml file, as shown below:
1 <dependency> 2 <groupId>com.playtika.testcontainers</groupId> 3 <artifactId>embedded-mariadb</artifactId> 4 <version>1.9</version> 5 <scope>test</scope> 6 </dependency>
If your service does not use Spring Cloud, you should add the next dependency based on the above:
1 <dependency> 2 <groupId>org.springframework.cloud</groupId> 3 <artifactId>spring-cloud-context</artifactId> 4 <version>2.0.1.RELEASE</version> 5 <scope>test</scope> 6 </dependency>
Before the Spring Boot context starts, it needs to boot the dockerized resource.
This method obviously has many advantages.Since we have "real" resources, you don't need to write workarounds in your code if you can't test the real connections of the resources you need.Unfortunately, this solution has a huge drawback - testing can only run in environments where Docker is installed.This means that your workstation and CI tools should be on board Docker.Also, you should be ready for the test and it will take more time to execute.
Parent Tests Class
Because execution time is important, we need to avoid loading multiple contexts for each test, so the Docker container will only start all tests once.Spring has context caching enabled by default, but we need to be cautious because by simply adding a simple comment @MockBean, we force Spring to create a new context using simulated bean s instead of reusing the existing context.The solution to this problem is to create a single parent abstract class that will contain all the necessary Spring annotations to ensure that a single context is reused for all test suites:
1 @RunWith(SpringRunner.class) 2 @SpringBootTest( 3 classes = UserDetailsServiceApplication.class, //1 4 webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) //2 5 @ActiveProfiles("test") //3 6 public abstract class BaseFunctionalTest { 7 @Rule 8 public WireMockRule contactsServiceMock = new WireMockRule(options().port(8777)); //4 9 @Autowired //5 10 protected UserDetailsServiceSteps userDetailsServiceSteps; 11 @Autowired 12 protected ContactsServiceSteps contactsServiceSteps; 13 @TestConfiguration //6 14 @ComponentScan("com.tdanylchuk.user.details.steps") 15 public static class StepsConfiguration { 16 } 17 }
- Point to the Spring Boot test comment to load the main configuration classes for our service.
- Use the Bootstraps Web environment in a production environment (simulated by default)
- A test configuration file is required to load application-test.properties, which overrides production properties such as URL s, users, passwords, and so on.
- WireMockRule starts the dock server to stub on the provided port.(
- Steps are protected for automatic wiring, so they can be accessed in each test.
- @TestConfiguration loads steps into the context by scanning the package.
Here, instead of modifying the context, we will use it further in a production environment by adding util items to it, such as step and property overrides.
It is not a good practice to use the @MockBean comment, which replaces part of the application with emulation and will remain untested.
Inevitably - retrieving the current time in logic, such as System.currentTimeMillis(), this type of code should be refactored, so the Clock object: clock.millis() will be used instead.Also, in functional testing, Clock objects should be emulated so that the results can be verified.
Test Property
application-test.properties:
1 #contact service #1 2 contacts.service.url=http://localhost:8777 3 #database #2 4 user.details.db.host=${embedded.mariadb.host} 5 user.details.db.port=${embedded.mariadb.port} 6 user.details.db.schema=${embedded.mariadb.schema} 7 spring.datasource.username=${embedded.mariadb.user} 8 spring.datasource.password=${embedded.mariadb.password} 9 #3 10 spring.jpa.hibernate.ddl-auto=create-drop
- Use the WireMock dock server endpoint instead of the production contact service URL.
- Override of database properties.Note: These properties are provided by the spring-boo-test-containers library.
- In the test, the database schema will be created by Hibernate.
Self-Test
A lot of preparations have been made for this test, so let's take a look at how it looks:
1 public class RestUserDetailsTest extends BaseFunctionalTest { 2 private static final long USER_ID = 32343L; 3 private final String userContactsResponse = readFile("json/user-contacts.json"); 4 private final String userDetails = readFile("json/user-details.json"); 5 private final String expectedUserResponse = readFile("json/user.json"); 6 @Test 7 public void shouldSaveUserDetailsAndRetrieveUser() throws Exception { 8 //when 9 userDetailsServiceSteps.saveUserDetails(USER_ID, userDetails); 10 //and 11 contactsServiceSteps.expectGetUserContacts(USER_ID, userContactsResponse); 12 //then 13 String actualUserResponse = userDetailsServiceSteps.getUser(USER_ID); 14 //expect 15 JSONAssert.assertEquals(expectedUserResponse, actualUserResponse, false); 16 } 17 }
For stubbing and asserting, use the JSON file you created earlier.In this way, both request and response formats are validated.It is best not to use test data here, but to use a copy of the production request/response.
Since the entire logic is encapsulated in steps, configurations, and JSON files, this test will remain unchanged if changes are not functional.For example:
- Format that responds to changes - Only test JSON files should be modified.
- Contact Service Endpoint Change - ContactsServiceSteps object should be modified.
- Maria DB has been replaced by No SQL DB - pom.xml and test properties files should be modified.
Functional Test Projects
1 <?xml version="1.0" encoding="UTF-8"?> 2 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 3 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4 <modelVersion>4.0.0</modelVersion> 5 <artifactId>user-details-service-functional-tests</artifactId> 6 <version>0.0.1-SNAPSHOT</version> 7 <name>User details service functional tests</name> 8 <parent> 9 <groupId>com.tdanylchuk</groupId> 10 <artifactId>functional-tests-best-practices</artifactId> 11 <version>0.0.1-SNAPSHOT</version> 12 </parent> 13 <dependencies> 14 <dependency> <!--1--> 15 <groupId>com.tdanylchuk</groupId> 16 <artifactId>user-details-service</artifactId> 17 <version>${project.version}</version> 18 <scope>test</scope> 19 </dependency> 20 <dependency> 21 <groupId>org.springframework.boot</groupId> 22 <artifactId>spring-boot-starter-test</artifactId> 23 <scope>test</scope> 24 </dependency> 25 <dependency> 26 <groupId>org.springframework.cloud</groupId> 27 <artifactId>spring-cloud-context</artifactId> 28 <version>2.0.1.RELEASE</version> 29 <scope>test</scope> 30 </dependency> 31 <dependency> 32 <groupId>com.playtika.testcontainers</groupId> 33 <artifactId>embedded-mariadb</artifactId> 34 <version>1.9</version> 35 <scope>test</scope> 36 </dependency> 37 <dependency> 38 <groupId>com.github.tomakehurst</groupId> 39 <artifactId>wiremock</artifactId> 40 <version>2.18.0</version> 41 <scope>test</scope> 42 </dependency> 43 <dependency> 44 <groupId>io.rest-assured</groupId> 45 <artifactId>rest-assured</artifactId> 46 <scope>test</scope> 47 </dependency> 48 </dependencies> 49 </project>
- User Detailed Information Services have been added as a dependency and can therefore be loaded by SpringBootTest.
structural morphology
Together, we have the next structure.
Adding functionality to a service does not change the current structure; it only extends it.If you add more communication channels, you can do the following through other steps: add utils folders using common methods; new files with test data; and, of course, additional testing for each functional requirement.
conclusion
In this article, we build a new micro-service based on the given requirements and cover them through functional testing.In the test, we used a black-box type test in which we tried to communicate with the application externally as a normal client, without changing the internal parts of the application, to simulate production behavior as much as possible.At the same time, we have laid the foundation for the functional testing architecture, so future service changes will not require refactoring of existing tests, and adding new tests will be as easy as possible.