With Spring Boot, you only need one dependency to have a solid testing infrastructure: Spring Boot Starter Test. Using this starter, you'll pull an opinionated set of testing libraries to your project. The Spring Boot team even ensures to upgrade these testing libraries and keep them compatible regularly. You can start writing tests right after bootstrapping your project.
This guide gives you hands-on insights into the Spring Boot Starter Test, or how I call it, the testing swiss-army knife. This includes an introduction to each testing library that this Spring Boot Starter transitively pulls into your project. For unit and integration testing Spring Boot applications in general, take a look at this overview.
Anatomy of the Spring Boot Starter Test
Every Spring Boot project we create with the Spring Initializr includes the following starter by default:
1 2 3 4 5 | <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> |
This starter includes Spring-specific dependencies and dependencies for auto-configuration and a set of testing libraries. This includes JUnit, Mockito, Hamcrest, AssertJ, JSONassert, and JsonPath.
These libraries all serve a specific purpose, and some can be replaced by each other, which we'll later see on.
Nevertheless, this opinionated selection of testing tools is all we need for unit testing. For writing integration tests, we might want to include additional dependencies (e.g., WireMock, Testcontainers, or Selenium) depending on our application setup.
With Maven, we can inspect all transitive dependencies coming with spring-boot-starter-test
using mvn dependency:tree
:
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 | [INFO] +- org.springframework.boot:spring-boot-starter-test:jar:2.5.5:test [INFO] | +- org.springframework.boot:spring-boot-test:jar:2.5.5:test [INFO] | +- org.springframework.boot:spring-boot-test-autoconfigure:jar:2.5.5:test [INFO] | +- com.jayway.jsonpath:json-path:jar:2.5.0:test [INFO] | | +- net.minidev:json-smart:jar:2.4.7:test [INFO] | | | \- net.minidev:accessors-smart:jar:2.4.7:test [INFO] | | | \- org.ow2.asm:asm:jar:9.1:test [INFO] | | \- org.slf4j:slf4j-api:jar:1.7.32:compile [INFO] | +- jakarta.xml.bind:jakarta.xml.bind-api:jar:2.3.3:test [INFO] | | \- jakarta.activation:jakarta.activation-api:jar:1.2.2:test [INFO] | +- org.assertj:assertj-core:jar:3.19.0:test [INFO] | +- org.hamcrest:hamcrest:jar:2.2:test [INFO] | +- org.junit.jupiter:junit-jupiter:jar:5.7.2:test [INFO] | | +- org.junit.jupiter:junit-jupiter-params:jar:5.7.2:test [INFO] | | \- org.junit.jupiter:junit-jupiter-engine:jar:5.7.2:test [INFO] | | \- org.junit.platform:junit-platform-engine:jar:1.7.2:test [INFO] | +- org.mockito:mockito-core:jar:3.9.0:test [INFO] | | +- net.bytebuddy:byte-buddy:jar:1.10.22:test [INFO] | | +- net.bytebuddy:byte-buddy-agent:jar:1.10.22:test [INFO] | | \- org.objenesis:objenesis:jar:3.2:test [INFO] | +- org.mockito:mockito-junit-jupiter:jar:3.9.0:test [INFO] | +- org.skyscreamer:jsonassert:jar:1.5.0:test [INFO] | | \- com.vaadin.external.google:android-json:jar:0.0.20131108.vaadin1:test [INFO] | +- org.springframework:spring-core:jar:5.3.10:compile [INFO] | | \- org.springframework:spring-jcl:jar:5.3.10:compile [INFO] | +- org.springframework:spring-test:jar:5.3.10:test [INFO] | \- org.xmlunit:xmlunit-core:jar:2.8.2:test |
Unless some of our tests still use JUnit 4, we can remove the JUnit Vintage engine from the starter. The Vintage engine allows running JUnit 3 and 4 tests alongside JUnit 5
1 2 3 4 5 6 7 8 9 10 11 | <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> |
UPDATE: Starting with Spring Boot 2.4, the JUnit Vintage engine is no longer part of the Spring Boot Starter Test. Hence, we don't need to exclude it explicitly.
For projects that are not yet fully migrated to JUnit 5 and using Spring Boot > 2.4, we have to bring back support for previous JUnit versions with the following import:
1 2 3 4 5 6 7 8 9 10 11 | <dependency> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>org.hamcrest</groupId> <artifactId>hamcrest-core</artifactId> </exclusion> </exclusions> </dependency> |
When using this starter, we don't need to update the versions of all the dependencies manually. The Spring Boot parent POM handles all dependency versions, and the Spring Boot team ensures the different testing dependencies work properly together.
If for some reason, we want a different version of a dependency coming from this starter, we can override it in our properties
section of our pom.xml
:
1 2 3 4 5 6 7 | <project> <properties> <mockito.version>3.1.0</mockito.version> </properties> </project> |
For now, this is the basic test setup every Spring Boot application uses by default. The following sections cover each test dependency coming with this starter.
Introduction to JUnit
JUnit is the most important library when it comes to testing our Java applications.
It's the de facto standard testing framework for Java. This introduction chapter won't cover all features of JUnit and rather focus on the basics.
Before we start with the basics, let's have a short look at the history of JUnit. For a long time, JUnit 4.12 was the main framework version.
In 2017 JUnit 5 was launched and is now composed of several modules:
JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage
The JUnit team invested a lot in this refactoring to have a more platform-based approach with a comprehensive extension model.
Nevertheless, migrating from JUnit 4 to 5 requires effort. All annotations, like @Test
, now reside in the package org.junit.jupiter.api
, and some annotations were renamed or dropped and have to be replaced.
A short overview of the differences between both framework versions is the following:
- Assertions reside in
org.junit.jupiter.api.Assertions
- Assumptions reside in
org.junit.jupiter.api.Assumptions
@Before
and@After
no longer exist; use@BeforeEach
and@AfterEach
instead.@BeforeClass
and@AfterClass
no longer exist; use@BeforeAll
and@AfterAll
instead.@Ignore
no longer exists, use@Disabled
or one of the other built-in execution conditions instead@Category
no longer exists, use@Tag
instead@Rule
and@ClassRule
no longer exist; superseded by@ExtendWith
and@RegisterExtension
@RunWith
no longer exists, superseded by the extension model using@ExtendWith
If our codebase uses JUnit 4, changing the annotations to the JUnit 5 ones is the first step. Most of the migration effort goes to migrating custom JUnit 4 rules to JUnit 5 extensions.
For our daily test development, we'll use the following annotations/methods most of the time:
@Test
to mark a method as a test
1 2 3 4 5 6 7 8 | public class FirstTest { @Test void testOne() { // our test } } |
assertEquals
or other assertions to verify the test outcome
1 2 3 4 5 6 7 8 9 | import static org.junit.jupiter.api.Assertions.*; @Test void testOne() { assertNotNull("NOT NULL"); assertNotEquals("John", "Duke"); assertThrows(NumberFormatException.class, () -> Integer.valueOf("duke")); assertEquals("hello world", "HELLO WORLD".toLowerCase()); } |
@BeforeEach
/@AfterEach
to do setup/teardown tasks before and after a test execution
1 2 3 4 5 6 7 8 9 | @BeforeEach void setup() { // setup tasks like populating sample data } @AfterEach void tearDown() { // cleanup tasks like deleting database rows } |
@ExtendWith
to include an extension like@ExtendWith(SpringExtension.class)
1 2 3 4 | @ExtendWith(SpringExtension.class) class OrderServiceTest { } |
@ParametrizedTest
to run a parameterized test based on different input sources (e.g., CSV file or a list)
For more information on JUnit 5 and migration tips, please take a look at the excellent user guide of JUnit 5.
While we use the basic JUnit features for almost every test, there are also great advanced features of JUnit 5 that not everybody is aware of.
Introduction to Mockito
Mockito is a …
Tasty mocking framework for unit tests in Java
The main reason to use Mockito is to stub methods calls and verify interaction on objects. The first is important if we write unit tests and our class under test has collaborators (other classes that this class depends on).
As our unit test should focus on just testing our class under test, we mock the behavior of any dependent collaborator.
An example might explain this even better. Let's say we want to write unit tests for the following PricingService
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | public class PricingService { private final ProductVerifier productVerifier; public PricingService(ProductVerifier productVerifier) { this.productVerifier = productVerifier; } public BigDecimal calculatePrice(String productName) { if (productVerifier.isCurrentlyInStockOfCompetitor(productName)) { return new BigDecimal("99.99"); } return new BigDecimal("149.99"); } } |
Our class requires an instance of the ProductVerifier
for the method calculatePrice(String productName)
to work.
While writing a unit test, we don't want to create an instance of ProductVerifier
and rather use a stub of this class.
This is because our unit test should focus on testing just one class and not multiple together. Furthermore, the ProductVerifier
might also need other objects/resources/network/a database to work properly, resulting in a test setup hell (without mocks).
With Mocktio, we can easily create a mock (also called stub) of the ProductVerifier
. This allows us to control the behavior of this class and make it return whatever we need during a specific test case.
A first test might require the ProductVerifier
object to return true
.
With Mockito, this is as simple as the following:
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 | import java.math.BigDecimal; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.when; // register the Mockito JUnit Jupiter extension @ExtendWith(MockitoExtension.class) class PricingServiceTest { @Mock // Instruct Mockito to mock this object private ProductVerifier mockedProductVerifier; @Test void shouldReturnCheapPriceWhenProductIsInStockOfCompetitor() { //Specify what boolean value to return for this test when(mockedProductVerifier.isCurrentlyInStockOfCompetitor("AirPods")).thenReturn(true); PricingService classUnderTest = new PricingService(mockedProductVerifier); assertEquals(new BigDecimal("99.99"), classUnderTest.calculatePrice("AirPods")); } } |
The example above should give a first idea of why we need Mockito.
A second use case for Mockito is to verify an interaction of an object during test execution.
Let's enhance the PricingService
to report the lower price whenever the competitor has the same product in stock:
1 2 3 4 5 6 7 8 | public BigDecimal calculatePrice(String productName) { if (productVerifier.isCurrentlyInStockOfCompetitor(productName)) { productRepoter.notify(productName); return new BigDecimal("99.99"); } return new BigDecimal("149.99"); } |
The notify(String productName)
method is void
Hence, we don't have to mock the return value of this call as it is not used for our implementation's execution flow. Still, we want to verify that our PricingService
reports a product.
Therefore, we can now use Mockito to verify that the notify(String productName)
method was called with the correct argument. For this to work, we also have to mock the ProductReporter
during our test execution and can then use Mockito's verify(...)
method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | @ExtendWith(MockitoExtension.class) class PricingServiceTest { // ... rest like above @Mock private ProductReporter mockedProductReporter; @Test void shouldReturnCheapPriceWhenProductIsInStockOfCompetitor() { when(mockedProductVerifier.isCurrentlyInStockOfCompetitor("AirPods")).thenReturn(true); PricingService cut = new PricingService(mockedProductVerifier, mockedProductReporter); assertEquals(new BigDecimal("99.99"), cut.calculatePrice("AirPods")); //verify the interaction verify(mockedProductReporter).notify("AirPods"); } } |
For a more deep-dive introduction to Mockito, consider enrolling for the Hands-On Mocking With Mockito Online Course.
Introduction to Hamcrest
Even though JUnit ships its own assertions within the package, org.junit.jupiter.api.Assertions
we can still use another assertion library. Hamcrest is such an assertion library.
The assertions we write with Hamcrest follow a more sentence-like approach which makes it more readable.
While we might write the following assertion with JUnit:
1 | assertEquals(new BigDecimal("99.99"), classUnderTest.calculatePrice("AirPods")); |
With Hamcrest, we achieve the same with the following:
1 2 3 4 | import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; assertThat(classUnderTest.calculatePrice("AirPods"), equalTo(new BigDecimal("99.99"))); |
Besides the fact that it reads more like an English sentence, the parameters' order is also different. The JUnit assertEquals
takes the expected value as the first argument and the actual value as the second argument.
Hamcrest does it the other way around:
1 2 | assertEquals(expected, actual); assertThat(actual, equalTo(expected)); |
The Hamcrest Matchers
class exposes feature-rich matchers like contains()
, isEmpty()
, hasSize()
, etc. we need for writing tests.
Whether we use JUnit's assertions, Hamcrest, or matchers of the assertions library in the next chapter, depends on our personal gusto. All assertion libraries achieve the same – they differ in the syntax and the number of supported assertions.
Nevertheless, I recommend sticking to one assertion library within the same project or at least the same test class.
Introduction to AssertJ
AssertJ is another assertion library that allows writing fluent assertions for Java tests. It follows a similar approach you already saw with Hamcrest as it makes the assertion more readable.
Let's retake our JUnit assertion as an example for comparison:
1 | assertEquals(new BigDecimal("99.99"), classUnderTest.calculatePrice("AirPods")); |
This would be written with AssertJ like the following:
1 2 3 | import static org.assertj.core.api.Assertions.assertThat; assertThat(classUnderTest.calculatePrice("AirPods")).isEqualTo(new BigDecimal("99.99")); |
The available assertions we get are also feature-rich and offer everything we need.
To not get confused during test development, as the Spring Boot Starter includes different libraries to assertions, make sure to import the correct assertion for the test cases.
Mixing them within one assertion is not possible, and as they are all named very similar, we should stick to one within the test class.
Introduction to JSONassert
JSONAssert helps writing unit tests for JSON data structures. This can be really helpful when testing the API endpoints of our Spring Boot application.
The library works both with JSON provided as String
or using the JSONObject
/ JSONArray
class from org.json
.
Most of the assertEquals()
methods expect a boolean
value to define the strictness of the assertion. When it's set to false
, the assertion won't fail if the JSON contains more fields as expected.
The official recommendation for the strictness is the following:
It is recommended that you leave strictMode off, so your tests will be less brittle. Turn it on if you need to enforce a particular order for arrays, or if you want to ensure that the actual JSON does not have any fields beyond what's expected.
An example helps to understand this:
1 2 3 4 5 | @Test void jsonAssertExample() throws JSONException { String result = "{\"name\": \"duke\", \"age\":\"42\"}"; JSONAssert.assertEquals("{\"name\": \"duke\"}", result, false); } |
A JUnit test with the assertion above will be green as the expected field name
contains the value duke
.
However, if we set the strictness to true
, the test above will fail with the following error:
1 2 3 4 5 6 7 | String result = "{\"name\": \"duke\", \"age\":\"42\"}"; JSONAssert.assertEquals("{\"name\": \"duke\"}", result, true); java.lang.AssertionError: Unexpected: age at org.skyscreamer.jsonassert.JSONAssert.assertEquals(JSONAssert.java:417) .... |
Introduction to JsonPath
Whereas JSONAssert helps writing assertions for entire JSON documents, JsonPath enables us to extract specific parts of our JSON while using a JsonPath expression.
The library itself does not provide any assertions, and we can use it with any of the assertion libraries already mentioned.
What XPath is for XML documents, JsonPath is for JSON payloads. JsonPath defines a set of operators and functions that we can use for our expressions.
As a first example, let's verify the length of an array and the value of an attribute:
1 2 3 4 5 6 7 8 | @Test void jsonPathExample() { String result = "{\"age\":\"42\", \"name\": \"duke\", \"tags\":[\"java\", \"jdk\"]}"; // Using JUnit 5 Assertions assertEquals(2, JsonPath.parse(result).read("$.tags.length()", Long.class)); assertEquals("duke", JsonPath.parse(result).read("$.name", String.class)); } |
Once we are familiar with the syntax of the JsonPath expression, we can read any property of our nested JSON object:
1 | assertEquals("your value", JsonPath.parse(result).read("$.my.nested.values[0].name", String.class)); |
Summary of the Spring Boot Starter Test
As a summary, these are the key takeaways of this guide:
- The Spring Boot Starter Test adds a solid testing foundation to each Spring Boot project.
- Test dependency versions are managed by Spring Boot but can be overridden.
- JUnit is the testing framework to launch tests on the JVM.
- Mockito is the de-facto standard mocking framework for Java projects.
- Pick one assertion library for writing tests: JUnit's built-in assertions, Hamcrest or AssertJ.
- Both JSONassert and JsonPath help writing tests for JSON data structures.
When it comes to integrations test, consider adding the following dependencies: WireMock, Testcontainers, or Selenium.
What's next is to explore Spring Boot's excellent test support. Read and bookmark the following articles for this purpose:
- Spring Boot Unit and Integration Testing Overview
- Spring Boot Test Slices Overview and Usage
- Guide to @SpringBootTest for Spring Boot Integration Tests
- Guide to Testing Spring Boot Applications With MockMvc
- Test Your Spring Boot JPA Persistence Layer With @DataJpaTest
- Fix No Qualifying Spring Bean Error For Spring Boot Tests
PS: For an overview of the entire Java Testing Landscape, take a look at the 30 Testing Tools And Libraries Every Java Developer Must Know eBook.
Have fun testing your Spring Boot application with the Spring Boot Starter Test,
Philip