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 (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

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"));
	}
 
}

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 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.

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");
}