spring boot testing: Zero to Hero
VIDEO
Repository used during the talk: https://github.com/Kehrlann/spring-boot-testing
@SpringBootTest
Web testing
WebEnvironment.MOCK
: “mock” servlet environment
WebEnvironment.RANDOM_PORT
: full webserver (e.g. Tomcat)
Good for deep testing (e.g. session persistence).
Useful for debugging.
@LocalServerPort
(also WebTestClient
/ TestRestTemplate
).
MockMvc
for request-based testing (only in MOCK
mode).
HtmlUnit’s WebClient
for (light) browser-based testing
Use Selenium for driving a real browser.
Use proper integration testing with JS tools (Playwright, Cypress).
@SpringBootTest
to start up your spring boot application in your tests.
You can inject MockMvc
in your test fields or as your test method argument:
@ SpringBootTest
class TodoMockMvcTest {
// here
@ Autowired
MockMvc mvc;
@ Test
void testTodo (@ Autowired MockMvc mvc ) { // or here
}
}
You can use a mocked web server (which is the default):
@ SpringBootTest ( webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@ AutoConfigureMockMvc
class TodoMockMvcTest {}
You can use a real web server with a random port:
@ SpringBootTest ( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class TodoMockMvcTest {
// you can inject the port with this annotation
@ LocalServerPort
int port;
}
You can add a break point in your test, your web application will still run, and you can use it directly with your browser / curl .
HtlmUnit
You can use HtmlUnit to test simple HTML page:
import org.htlmunit.WebClient;
@ SpringBootTest
@ AutoConfigureMockMvc
class TodoMockMvcTest {
@ Autowired
WebClient webClient; // This is not WebFlux WebClient
@ Test
void addTodos () {
HtmlPage page = webClient. getPage ( "/" );
HtmlInput text = page. querySelector ( "#new-todo" );
HtmlButton addTodoButton = page. querySelector ( "#add-todo" );
text. type ( "Hello, world" );
page = addTodoButton. click ();
// ...
}
}
RestClient
You can configure RestClient
to use your MockMvc
:
@ Test
void restClient () {
var client = RestClient. builder ()
. requestFactory ( new MockMvcClientHttpRequestFactory (mvc))
. build ();
var resp = client. get ()
. uri ( "/" )
. retrieve ()
. body (String.class);
}
TestContext
caching
@SpringBootTest
are slow (~second).
Avoid @ActiveProfiles
, @MockitoBean
, @DirtiesContext
, …
Reuse the cached context to avoid paying app startup price every time.
SpringBootTest
uses SpringExtension
which is “smart” and caches the spring-boot configuration used in the tests.
But if you change some configuration, e.g. use mock, use webserver, use specific spring profile, custom properties, etc… spring -test will re-create a new spring-boot application, which can be slow.
⇒ avoid creating many instances of your spring test context.
It’s configured in the DefaultContextCache
class, which caches 32 elements by default.
Slice tests: slimmer ApplicationContext
Web slice
Instead of using SpringBootTest
, you can use WebMvcTest
which will only load the web slice:
@ WebMvcTest
class TodoControllerTest {
// you might need to use your own test configuration if you want to use fakes
@ TestConfiguration
static class TestConfig {
@ Bean
TodoService todoService () {
return new TodoService ( null );
}
}
// or you can mock with new SB annotation
@ MockitoBean
TodoService todoService;
@ Autowired
MockMvcTester tester;
// ...
}
You can also import the service and mock the repository instead:
@ WebMvcTest
@ Import (TodoService.class)
class TodoControllerTest {
@ MockitoBean
TodoRepository repository;
@ Autowired
MockMvcTester tester;
// ...
}
You can also scope on a single controller:
@ WebMvcTest ( controllers = { TodoController.class })
More info: Testing Spring Boot Applications .
You can also create your own custom slice:
@ SpringBootTest ( classes = { TodoController.class, TodoService.class })
@ AutoConfigureMockMvc
// however, you might need to add the autoconfigurations yourself
@ ImportAutoConfiguration (ThymeleafAutoConfiguration.class)
class CustomSliceTest {
@ MockitoBean
TodoRepository repository;
@ Autowired
MockMvcTester tester;
// ...
}
Database slice
@ DataJpaTest
class TodoRepositoryTest {
@ Autowired
TodoRepository repository;
// ...
}
This is useful as it can test your entities (expect the JPA annotations are rightly used) and your custom SQL queries.
You can start your spring-boot application with docker compose support by adding the dependency to spring-boot-docker-compose
.
The starter website will put the compose.yml
file at the root folder of the project, but you can put in the class path instead, i.e. application/src/main/resources/compose.yml
:
name : todo-app
services :
postgres :
image : postgres:latest
environment :
- 'POSTGRES_DB=mydatabase'
- 'POSTGRES_PASSWORD=secret'
- 'POSTGRES_USER=myuser'
ports :
- '5432'
Then you can configure your application.yml
to use it:
spring :
sql :
init :
mode : always
schema-locations : classpath:/schema-postgres.sql
docker :
compose :
# no need to set the db creds, spring-boot-docker-compose will handle all of those
file : classpath:/compose.yml
The schema SQL file is also located in the classpath, i.e. application/src/main/resources/schema-postgres.sql
:
CREATE TABLE IF NOT EXISTS todo_item (
-- ...
);
This works for running your application locally, but your tests fail. To mitigate this, you can use testcontainers .
Testcontainers
Great setup at https://start.spring.io .
Auto-configuration support with @ServiceConnection
.
Dynamic configuration support with @DynamicPropertySource
.
Singleton pattern with static
containers!
You can add spring-boot-testcontainers
for testcontainers support.
You can use the starter website that will generate the following test configuration:
package com.example.demo;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
@ TestConfiguration ( proxyBeanMethods = false )
class TestcontainersConfiguration {
@ Bean
@ ServiceConnection
PostgreSQLContainer< ? > postgresContainer () {
return new PostgreSQLContainer<>(DockerImageName. parse ( "postgres:latest" ));
}
}
Then in your tests:
@ DataJpaTest
@ Import (TestcontainersConfiguration.class)
// you must add this because DataJpaTest will try to use in-memoy db, which we don't want
@ AutoConfigureTestDatabase ( replace = AutoConfigureTestDatabase.Replace.NONE)
class TodoRepositoryTest {
// ...
}
You can create your own generic container:
@ TestConfiguration ( proxyBeanMethods = false )
class TestcontainersConfiguration {
@ Bean
@ DynamicPropertySource
GenericContainer< ? > nginxContainer (DynamicPropertyRegistry registry ) {
var container = new GenericContainer<>(DockerImageName. parse ( "nginx:latest" ))
. withExportPorts ( 80 );
registry. add ( "external-service.port" , () -> cotnainer. getFirstMappedPort ());
return container;
}
}
@ SpringBootTest
@ Import (TestcontainersConfiguration.class)
class ExternalServiceTest {
@ Value ( "${external-service.port:-1})
int port;
@Test
void hasExternalServicePort() {
assertThat(port).isGreaterThan(0);
}
}
However, using this approach will create a new container for each test.
A suggestion is to use a singleton container:
@ TestConfiguration ( proxyBeanMethods = false )
@ TestContainers
class TestcontainersConfiguration {
@ Container
private static PostgreSQLContainer< ? > postgresContainer = new PostgreSQLContainer (
DockerImageName. parse ( "postgres:latest)
);
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return postgresContainer;
}
}
However, that means you have one container shared for multiple tests.
Testing @ConfigurationProperties
Construct property objects and validate those
Validation.buildDefaultValidatorFactory().getValidator()
Consider using YAML.
For integration testing:
Use @SpringBootTest(classes = { ... })
.
For failures, SpringApplicationBuilder#run
.
@ Test
void testProperties () {
// a bit cumbersome
var props = new CustomProperties (
// ...
);
var validator = Validation. buildDefaultValidatorFactory (). getValidator ();
Set< ConstraintViolation > actual = validator. validate (props);
ValidationResultAssert. assertThat (actual). isEmpty ();
ValidationResultAssert. assertThat (actual)
. hasViolationForProperty ( "profiles[0].internalUser.password" , "must not be blank" );
}
You can also in YAML instead:
@ Test
void yaml () {
var objectMapper = YAMLMapper. builder ()
. propertyNamingStategy (PropertyNamingStrategies.KEBAB_CASE)
. addModule ( new ParameterNamesModule ())
. build ();
var props = objectMapper. readValue ( """
profiles:
- name: first
github:
id: foobar
""" , TodoProperties.class);
var validator = Validation. buildDefaultValidatorFactory (). getValidator ();
Set< ConstraintViolation > actual = validator. validate (props);
ValidationResultAssert. assertThat (actual). isEmpty ();
ValidationResultAssert. assertThat (actual)
. hasViolationForProperty ( "profiles[0].internalUser.password" , "must not be blank" );
}
To test conditional properties:
@ SpringBootTest (
classes = TodoPropertiesConfiguration.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE,
// add custom properties
properties = {
"todo.profiles[0].internal-user.email=contact@email.com"
}
)
// or use a spring profile with the applicaton-integ-test.yaml in the test classpath
@ ActiveProfiles ( "integ-test" )
class IntegTest {
@ Autowired
TodoProperties props;
@ Test
void isValid () {
assertThat (props. getProfiles ()
. getFirst ()
. internalUser ()
. email ()
). isEqualTo ( "contact@email.com" );
}
}
To test failure, you can use SpringApplicationBuilder
:
@ Test
void failures () {
var props = """
todo:
profiles:
- name: foobar
github:
id: foobar
""" ;
var loader = new YamlPropertySourceLoader (). load (
"inline" ,
new ByteArrayResource (props. getBytes ())
);
var env = new StandardEnvironment ();
env. getPropertySources (). addFirst (ps. get ( 0 ));
var appBuilder = new SpringApplicationBuilder (TodoPropertiesConfiguration.class)
. web (WebApplicationType.NONE)
. environment (env);
assertThatThrownBy (appBuilder :: run);
}
Security testing: utility methods
Compatible with @WebMvcTest
@WithMockUser
for injecting simple users.
SecurityMockMvcRequestPostProcessors
for MockMvc(Tester)
.csrf()
, .opaqueToken()
, .oidcLogin()
, …
@ WebMvcTest
// import your security configuration
@ Import (SecurityConfiguration.class)
class SecurityTests {
@ MockitoBean
TodoService todoService;
@ Autowired
MockMvcTester mvc;
@ Test
void index () {
mvc. get ()
. uri ( "/" )
. exchange ()
. assertThat ()
. hasStatus (HttpStatus.FOUND)
. redirectedUrl ()
. endsWith ( "/login" );
}
@ Test
@ WithMockUser ( "foobar" )
void loggedIn () {
mvc. get ()
. uri ( "/" )
. exchange ()
. assertThat ()
. hasStatus (HttpStatus.OK)
. bodyText ()
. contains ( "<h1>TODO</h1>" );
}
@ Test
@ WithMockUser ( "foobar" )
void addTodo () {
mvc. post ()
. uri ( "/todo" )
. param ( "text" , "Hello" )
// if CSRF is enabled (which is by default), then you will need to add this to inject the CSRF token
. with (SecurityMockMvcRequestPostProcessors. csrf ())
. exchange ()
. assertThat ()
. hasStatus (HttpStatus.FOUND);
}
@ Test
void oidcLogin () {
mvc. post ()
. uri ( "/todo" )
. param ( "text" , "Hello" )
. with (SecurityMockMvcRequestPostProcessors. csrf ())
// simulate login in OIDC
. with (SecurityMockMvcRequestPostProcessors. oidcLogin ()
. idToken (idToken -> idToken. claim ( "email" , "contact@email.com" ))
)
. exchange ()
. assertThat ()
. hasStatus (HttpStatus.FOUND);
}
}
You can debug spring-security by reducing the log level:
logging :
level :
org.springframework.security : TRACE
This will display the invoked FilterChainProxy
.
spring-boot-starter-test comes with JUnit Jupiter , mockito and assertj .
OutputCaptureExtension
You can capture the stdout/stderr outputs with the OutputCaptureExtension
:
@ SpringBootTest ( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ ExtendWith (OutputCaptureExtension.class)
class TodoTomcatTests {
// inject as method param
@ Test
void addTodods (CaptureOutput output ) {
// ...
var lastLogLine = output. getOut (). lines (). toList (). getLast ();
assertThat (lastLogLine). contains ( "foobar" );
}
}
AssertJ
You can create your own assertion classes by extending AbstractAssert
.
More info: https://assertj.github.io/doc/#assertj-core-custom-assertions-creation .
MockMvcTester
: MockMvc
with assertj (spring-boot >= 3.4):
@ SpringBootTest
class TodoTest {
@ Autowired
MockMvc mvc;
@ Autowired
MockMvcTester tester;
@ Test
void test () {
// uses AssertJ
tester. get ()
. uri ( "/" )
. exchange ()
. assertThat ()
. hasStatus (HttpStatus.OK)
. bodyText ()
. contains ( "<h1>TODO</h1>" );
// instead of the following, which use harmcrest
mvc. perform ( get ( "/))
.andExpect(status().isOk())
.andExpect(content().string(containsString(" < h1 > TODO </ h1 > ")));
}
}
@ Test
voit testProcess () {
var p = new AsyncProcess ();
p. start ();
await ()
. atMost (Duration. ofSeconds ( 2 ))
. pollInterval (Duration. ofMillis ( 100 ))
. untilAsserted (() -> {
var complete = p.complete;
assertThat (complete). isTrue ();
});
assertThat (p.complete). isTrue ();
}
Instead of mocking every nested objects, you can use deep stubs:
@ Test
void testStubs () {
var client = mock (RestClient.class, RETURNS_DEEP_STUBS);
when (client. get ()
. uri ( anyString ())
. retrieve ()
. body ( eq (String.class))
). thenReturn ( "This is fine" );
var resp = client. get ()
. uri ( "http://example.com/foo/bar)
.retrieve()
.body(String.class);
assertThat(resp).isEqualTo(" This is fine ");
}