Using Testcontainers in Spring Boot Tests combined with JUnit5 for Selenium Tests
In this blog post, I'd like to show how to integrate Testcontainers in Spring Boot tests for running UI tests with Selenium.
Why Testcontainers?
Testcontainers is a library that helps to integrate infrastructure components like Selenium or databases in integration tests based on Docker Container. It helps to avoid writing integrated tests. These are kind of tests that will pass or fail based on the correctness of another system. In this case, Selenium. With Testcontainers we have the control over this dependent system.
If you want to learn how to write integration tests with database integration, please have a look on my blog post about Using Testcontainers in Spring Boot Tests For Database Integration Tests
Introducing the Subject Under Test
The sample project is a simple web application with a UI, based on Thymeleaf, Spring Boot and MySQL database.
Preparing a Test that starts the whole Spring Boot Web Application
We want to test the whole application including database. Therefore, we need a JUnit5 test that load the complete Spring Boot application context and a MySQL database.
The first step is to set up the dependencies to the needed test libraries.
In this case, they are JUnit5, Testcontainers and Spring Boot's test helper classes.
As build tool, we use Maven.
All test libraries provide so called BOM "bill of material", that helps to avoid a version mismatch in the used dependencies.
As database, we use MySQL.
Therefore, we use the Testcontainers' module mysql
additional to the core module testcontainers
.
It provides a predefined MySQL container.
For simplifying the container setup specifically in JUnit5 test code, Testcontainers provides a JUnit5 module junit-jupiter
.
For Spring Boot's test helper class, we need the test dependency spring-boot-starter-test
.
1 <dependencies>
2 <dependency>
3 <groupId>org.junit.jupiter</groupId>
4 <artifactId>junit-jupiter</artifactId>
5 <scope>test</scope>
6 </dependency>
7 <dependency>
8 <groupId>org.testcontainers</groupId>
9 <artifactId>testcontainers</artifactId>
10 <scope>test</scope>
11 </dependency>
12 <dependency>
13 <groupId>org.testcontainers</groupId>
14 <artifactId>junit-jupiter</artifactId>
15 <scope>test</scope>
16 </dependency>
17 <dependency>
18 <groupId>org.testcontainers</groupId>
19 <artifactId>mysql</artifactId>
20 <scope>test</scope>
21 </dependency>
22 <dependency>
23 <groupId>org.springframework.boot</groupId>
24 <artifactId>spring-boot-starter-test</artifactId>
25 <scope>test</scope>
26 </dependency>
27 </dependencies>
28 <dependencyManagement>
29 <dependencies>
30 <dependency>
31 <groupId>org.junit</groupId>
32 <artifactId>junit-bom</artifactId>
33 <version>${junit.jupiter.version}</version>
34 <type>pom</type>
35 <scope>import</scope>
36 </dependency>
37 <dependency>
38 <groupId>org.testcontainers</groupId>
39 <artifactId>testcontainers-bom</artifactId>
40 <version>${testcontainers.version}</version>
41 <type>pom</type>
42 <scope>import</scope>
43 </dependency>
44 <dependency>
45 <groupId>org.springframework.boot</groupId>
46 <artifactId>spring-boot-dependencies</artifactId>
47 <version>${spring-boot.version}</version>
48 <type>pom</type>
49 <scope>import</scope>
50 </dependency>
51 </dependencies>
52 </dependencyManagement>
Now, everything is prepared for setup the test.
1package com.github.sparsick.testcontainerspringboot.hero.universum;
2
3
4import ...
5
6@SpringBootTest
7@Testcontainers
8class HeroStartPageSeleniumIT {
9 @Container
10 private static MySQLContainer database = new MySQLContainer("mysql:5.7.34");
11
12 @Test
13 void contextLoad() {
14
15 }
16
17 @DynamicPropertySource
18 static void databaseProperties(DynamicPropertyRegistry registry) {
19 registry.add("spring.datasource.url",database::getJdbcUrl);
20 registry.add("spring.datasource.username", database::getUsername);
21 registry.add("spring.datasource.password", database::getPassword);
22 }
23
24
25}
The test class is annotated with some annotations.
The first one is @SpringBootTest
thereby the Spring application context is started during the test.
The last one is @Testcontainers
.
It is a JUnit5 extension provided by Testcontainers that manage starting and stopping the docker container during the test.
It checks if Docker is installed on the machine, starts and stops the container during the test.
But how Testcontainers knows which container it should start? Here, the annotation @Container
helps.
It marks the container that should manage by the Testcontainers' extension.
In this case, a MySQLContainer
provided by Testcontainers' module mysql
.
This class provides a MySQL Docker container and handles such things like setting up database user, recognizing when the database is ready to use etc.
As soon as the database is ready to use, the database schema has to be set up.
MySQLContainer
database is static, because the container has to start before the application context starts, so that we have a change to pass the database connection configuration to the application context.
For this, static method databaseProperties
is responsible.
Here, it is important that this method is annotated by @DynamicPropertySource
.
It overrides the application context configuration during the start phase.
Here, we want to override the database connection configuration with the database information that we get from database container object managed by Testcontainers.
The last step is to set up the database schema in the database. Here JPA can help. It can create a database schema automatically. we have to configure it with
1# src/test/resources/application.properties
2spring.jpa.hibernate.ddl-auto=update
Now, the whole application including a database is started if the test runs. This test set up is similar to the test set up, I showed in my previous blog post about Using Testcontainers in Spring Boot Tests For Database Integration Tests
In the next section, I will demonstrate how to add Selenium for the UI testing.
Add Selenium to the Test
First at all, we have to add new dependencies into the project POM.
1<dependencies>
2 <dependency>
3 <groupId>org.testcontainers</groupId>
4 <artifactId>selenium</artifactId>
5 <scope>test</scope>
6 </dependency>
7 <dependency>
8 <groupId>org.seleniumhq.selenium</groupId>
9 <artifactId>selenium-java</artifactId>
10 <version>${selenium.version}</version>
11 <scope>test</scope>
12 </dependency>
13 <dependency>
14 <groupId>org.seleniumhq.selenium</groupId>
15 <artifactId>selenium-remote-driver</artifactId>
16 <version>${selenium.version}</version>
17 <scope>test</scope>
18 </dependency>
19 <dependency>
20 <groupId>org.seleniumhq.selenium</groupId>
21 <artifactId>selenium-api</artifactId>
22 <version>${selenium.version}</version>
23 <scope>test</scope>
24 </dependency>
25</dependencies>
Testcontainers offers a module selenium
with Selenium-specific containers.
For Selenium, we need three dependencies, selenium-java
, selenium-remote-driver
and selenium-api
.
The dependency selenium-api
is needed if you want to use Selenium version higher than 4.1.4. Otherwise, you get java.lang.NoClassDefFoundError: org/openqa/selenium/AcceptedW3CCapabilityKeys
on runtime.
Unfortunately, for Selenium doesn't exist a BOM.
The next step is to add Selenium to the above test.
The whole test including Selenium looks like the following one:
1package com.github.sparsick.testcontainerspringboot.hero.universum;
2
3import ...
4
5@Testcontainers
6@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
7class HeroStartPageSeleniumIT {
8
9 @Container
10 private static final BrowserWebDriverContainer<?> seleniumContainer = new BrowserWebDriverContainer<>() // one browser for all tests
11 .withAccessToHost(true);
12
13 @Container
14 private static final MySQLContainer database = new MySQLContainer("mysql:5.7.34");
15
16 @LocalServerPort
17 private int heroPort;
18
19 private RemoteWebDriver browser;
20
21
22 @BeforeEach
23 void setUp(){
24 Testcontainers.exposeHostPorts(heroPort);
25 browser = new RemoteWebDriver(seleniumContainer.getSeleniumAddress(), new ChromeOptions());
26 browser.manage().timeouts().implicitlyWait(Duration.ofSeconds(30));
27 }
28 @AfterEach
29 void cleanUp() {
30 browser.quit();
31 }
32
33 @Test
34 void titleIsHeroSearchMachine(){
35 browser.get("http://host.testcontainers.internal:" + heroPort + "/hero");
36 WebElement title = browser.findElement(By.tagName("h1"));
37 assertThat(title.getText().trim())
38 .isEqualTo("Hero Search Machine");
39 }
40
41 @DynamicPropertySource
42 static void databaseProperties(DynamicPropertyRegistry registry) {
43 registry.add("spring.datasource.url",database::getJdbcUrl);
44 registry.add("spring.datasource.username", database::getUsername);
45 registry.add("spring.datasource.password", database::getPassword);
46 }
47
48}
Let's go through this test step by step.
As first step, we add BrowserWebDriverContainer
with the annotation @Container
, so that the Selenium container is managed by Testcontainers.
The next step is to configure the Selenium container can access to the Spring Boot application that is started on the host.
Therefore, we have to configure that the container has access to the host (.withAccessToHost(true)
).
But this is not enough.
We also have to expose the host port of the Spring Boot application to the Selenium container.
Testcontainers prepares a static helper method for this use case (Testcontainers.exposeHostPorts(heroPort)
).
But how to get the host port of the Spring Boot application?
Spring Boot test module has a test utility annotation @LocalServerPort
.
So it is sufficient that a class property have this annotation, so that Spring Boot can register the port number to this class property.
1 @LocalServerPort
2 private int heroPort;
Unfortunately, with the current test setup the Spring Boot application runs always on the host port 8080 (default port of Spring Boot applications).
So it is an integrated test and not an integration test.
Spring Boot has an easy way to change it.
We can configure the test to use random, but free, port when it starts the application context (@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
).
The last but one step is to configure the Selenium's remote web driver that we needed to navigate through the web page.
1browser = new RemoteWebDriver(seleniumContainer.getSeleniumAddress(), new ChromeOptions());
2browser.manage().timeouts().implicitlyWait(Duration.ofSeconds(30));
First at all, it needs the address of the Selenium container (seleniumContainer.getSeleniumAddress()
) and which browser it should simulate (here, a Chrome browser new ChromeOptions()
).
Also, we configure an explicit timeout to avoid TimeoutException
(see also Testcontainers' issue)
The last step is to write a test.
The most important part is to know how to access the web page, because Selenium is running in a container and the Spring Boot application runs on the host.
Testcontainers provides a dedicated host name host.testcontainers.internal
for this use case.
So the Spring Boot application is reachable under http://host.testcontainers.internal:" + heroPort + "/hero"
and we have everything together to write a test for the web page.
1@Test
2void titleIsHeroSearchMachine(){
3 browser.get("http://host.testcontainers.internal:" + heroPort + "/hero");
4 WebElement title = browser.findElement(By.tagName("h1"));
5 assertThat(title.getText().trim())
6 .isEqualTo("Hero Search Machine");
7}
Conclusion and Overview
This blog post shows how we can write tests in Spring Boot with Testcontainers and Selenium. All code snippet can be found on GitHub.
Do you have other ideas for writing end-to-end-tests? Please let me know and write a comment.