spring boot testing: Zero to Hero
Repository used during the talk: https://github.com/Kehrlann/spring-boot-testing
@SpringBootTest
Web testing
Abstract
WebEnvironment.MOCK
: “mock” servlet environment
- Good for most use-cases.
WebEnvironment.RANDOM_PORT
: full webserver (e.g. Tomcat)
- Good for deep testing (e.g. session persistence).
- Useful for debugging.
@LocalServerPort
(alsoWebTestClient
/TestRestTemplate
).MockMvc
for request-based testing (only inMOCK
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
Abstract
@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.
docker compose support
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
Abstract
- 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"));
}
}
More information about
ServiceConnection
: https://docs.spring.io/spring-boot/reference/testing/testcontainers.html#testing.testcontainers.service-connections.
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
Abstract
- 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
Abstract
- Compatible with
@WebMvcTest
@WithMockUser
for injecting simple users.
@WithUserDetailsService
SecurityMockMvcRequestPostProcessors
forMockMvc(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
.
Testing toolbox
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>")));
}
}
awaitility
@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();
}
mockito
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");
}