Ensuring your application is working properly is a critical part of continuous integration and delivery. Unit tests help you to test your methods' business logic for both normal & edge cases. When it comes to guaranteeing that your users can correctly work with your application, we need something different. If your application exposes a frontend with user interaction, we can write functional tests to ensure different use cases are working. With this blog post, I'll provide an example of writing functional tests (also called end-to-end tests) for Spring Boot applications using Selenium and Testcontainers.
Spring Boot Selenium with Testcontainers Project Setup
The sample Maven application uses the following dependencies:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | <?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 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.5.0</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>de.rieckpil.blog</groupId> <artifactId>spring-boot-selenium-integration-tests</artifactId> <version>0.0.1-SNAPSHOT</version> <name>spring-boot-selenium-integration-tests</name> <description>Spring Boot Integration Test with Selenium</description> <properties> <java.version>11</java.version> <testcontainers.version>1.16.2</testcontainers.version> <selenium.version>3.141.59</selenium.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers-bom</artifactId> <version>${testcontainers.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>selenium</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.seleniumhq.selenium</groupId> <artifactId>selenium-remote-driver</artifactId> <version>${selenium.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.seleniumhq.selenium</groupId> <artifactId>selenium-chrome-driver</artifactId> <version>${selenium.version}</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project> |
The application to test for this example is rather simple. We're using Thymeleaf and Spring MVC and want to write a test for the following view:
1 2 3 4 5 6 7 8 9 | <!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org"> <head> <title>Spring Boot Application</title> </head> <body> <h4 id="message" th:text="${message}"></h4> </body> </html> |
As a counterpart to the view above, the following controller provides the model:
1 2 3 4 5 6 7 8 9 | @Controller public class IndexController { @GetMapping("/index") public String getIndexPage(Model model) { model.addAttribute("message", "Integration Test with Selenium"); return "index"; } } |
While this is a rather basic application setup that doesn't reflect real-world applications, the following test setup works for any Spring Boot application which serves the frontend (e.g., Spring MVC or bundling SPA within the application).
Selenium Test Setup With Testcontainers
To use Selenium to access the frontend of our application and write functional tests, we need a WebDriver
. We can either manually download such a driver for our browser of choice (e.g., Chrome or Firefox) or use Testcontainers.
The Testcontainers project provides a module to launch container-based WebDriver
to avoid any manual setup efforts using the BrowserWebDriverContainer
class.
As we'll write an end-to-end test, we have to bootstrap the whole Spring Boot application (including Tomcat) with @SpringBootTest
. Once everything is up and running, we can request the RemoteWebDriver
instance from the container and perform any action with Selenium.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | @ExtendWith({ScreenshotOnFailureExtension.class}) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class IndexControllerIT { static BrowserWebDriverContainer<?> container = new BrowserWebDriverContainer<>() .withCapabilities(new ChromeOptions()); @LocalServerPort private int port; // ... @Test void shouldDisplayMessage() { container .getWebDriver() .get(String.format("http://host.testcontainers.internal:%d/index", port)); WebElement messageElement = container.getWebDriver().findElementById("message"); assertEquals("Integration Test with Selenium", messageElement.getText()); } } |
As the web driver runs within a Docker container and its own network, accessing the Spring Boot application using localhost
doesn't work. Instead, we have to first expose the host port we want to access before starting the WebDriverContainer
.
Timing-wise this has to happen after we start our Tomcat server but before we start the WebDriverContainer
with Testcontainers. As we're using a random port, we have to make this dynamic and can use the @BeforeAll
lifecycle of JUnit Jupiter:
1 2 3 4 5 | @BeforeAll static void beforeAll(@Autowired Environment environment) { Testcontainers.exposeHostPorts(environment.getProperty("local.server.port", Integer.class)); container.start(); } |
Once the port is exposed, we start the Docker container.
Custom JUnit Jupiter Screenshot Extension
When running functional tests (and especially when the browser runs inside a container), it's important to make screenshots of test failures. As the functional tests are usually executed within a CI/CD pipeline, we need a visual output once a test fails to understand the root cause of the failure.
This is a perfect use case for a custom JUnit Jupiter extension. We can write an extension that implements the AfterEachCallback
to perform actions after each test. The ExtensionContext
contains the information on whether an exception was thrown during test execution.
We can use this information and request a screenshot from the RemoteWebDriver
once an exception occurred.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | public class ScreenshotOnFailureExtension implements AfterEachCallback { @Override public void afterEach(ExtensionContext extensionContext) throws Exception { if (extensionContext.getExecutionException().isPresent()) { Object testInstance = extensionContext.getRequiredTestInstance(); Field containerField = testInstance.getClass().getDeclaredField("container"); containerField.setAccessible(true); BrowserWebDriverContainer browserContainer = (BrowserWebDriverContainer) containerField.get(testInstance); byte[] screenshot = browserContainer.getWebDriver().getScreenshotAs(OutputType.BYTES); try { Path path = Paths .get("target/selenium-screenshots") .resolve(String.format("%s-%s-%s.png", LocalDateTime.now(), extensionContext.getRequiredTestClass().getName(), extensionContext.getRequiredTestMethod().getName())); Files.createDirectories(path.getParent()); Files.write(path, screenshot); } catch (IOException e) { e.printStackTrace(); } } } } |
We register this custom extension with @ExtendWith
:
1 2 3 4 5 6 | @ExtendWith({ScreenshotOnFailureExtension.class}) // ... other annotations class IndexControllerIT { // ... } |
The screenshots are stored within target/selenium-screenshots
and we can archive this folder within our CI/CD pipeline to investigate failed builds.
The source code for this Selenium, Testcontainers, and Spring Boot example is available on GitHub.
For more content on writing integration tests for your Spring Boot application, have a look at the following blog posts:
- Spring Boot Integration Tests with WireMock and JUnit 5
- Write Spring Boot Integration Tests With Testcontainers
- Write Concise Web Tests with Selenide for Java Projects
Have fun testing your application with Testcontainers, Selenium and Spring Boot,
Phil