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 are able to 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 how to write Functional Tests for Spring Boot applications using Selenium and Testcontainers.
Spring Boot project setup
The 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.3.0.RELEASE</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.14.3</testcontainers.version> <selenium.version>3.141.59</selenium.version> </properties> <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> <exclusions> <exclusion> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <version>${testcontainers.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>selenium</artifactId> <version>${testcontainers.version}</version> <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. I'm 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"; } } |
I agree that this example does not reflect a typical enterprise application. Nevertheless, the following test setup works for any application which serves the frontend (e.g. Spring MVC or bundling SPA within the application).
Functional tests with Selenium and Testcontainers
To properly use Selenium to access the frontend of our application and write functional tests, we need a WebDriver
. You can either manually download such a driver for the browser you want to test (e.g. Chrome or Firefox) or use Testcontainers.
The Testcontainers project provides a module to launch container-based WebDriver
to avoid manual effort. Testcontainers also comes with a JUnit 5 Jupiter module to use JUnit extensions during the tests. This is quite useful as we otherwise would have to manage the lifecycle of the containers for our own.
Enabling the TestcontainersExtension
is as simple as adding @Testcontainers
to our class. Next, we can define a BrowserWebDriverContainer
and add all required configurations. The JUnit extension from Testcontainers will now detect each container annotated with @Container
and manage its lifecycle:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | @Testcontainers @ExtendWith({ScreenshotOnFailureExtension.class}) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class IndexControllerIT { @LocalServerPort private int port; @Container private BrowserWebDriverContainer container = new BrowserWebDriverContainer() .withCapabilities(new ChromeOptions()); @Test public void shouldDisplayMessage() { // depending on your operation system try 172.17.0.1 as IP or container.getTestHostIpAddress() this.container.getWebDriver().get("http://host.docker.internal:" + port + "/index"); WebElement messageElement = this.container.getWebDriver().findElementById("message"); assertEquals("Integration Test with Selenium", messageElement.getText()); } } |
As we'll write a functional test, we have to bootstrap the whole Spring Boot application with @SpringBootTest
. Once everything is up- and running, we can request the RemoteWebDriver
instance from the container and perform any action with Selenium.
As the web driver runs within a Docker container and its own network, accessing the Spring Boot application using localhost
does not work. Instead, we can have to refer to http://host.docker.internal:" + port + "/index
to be able to access our Thymeleaf application.
Please note: Depending on your operating system or Docker setup, host.docker.internal
might not work. In that case, try 172.17.0.1
or container.getTestHostIpAddress()
.
Custom JUnit Jupiter Screenshot extension
Another useful functionality is to make screenshots once a test fails. As the functional tests are usually executed within a CI/CD pipeline, we need a visual output once a test fails.
This is a perfect use case for a custom JUnit 5 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 can store the output of the screenshot to target/selenium-screenshots
and archive this folder within our CI/CD pipeline.
You can find the source code for this example 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 (JUnit 4.12 & 5)
Have fun testing your application with Testcontainers, Selenium and Spring Boot,
Phil
[…] tests, you might want to include additional dependencies (e.g. WireMock, Testcontainers or Selenium) depending on your application […]