Skip to main content

First Tests

Complete Code
The end result of the code developed in this document can be found in the GitHub monorepo springboot-demo-projects, under the tag testing.

Testing Strategy

Pick a Framework

  • For the Java Spring Boot App we are going to choose JUnit + Mockito: is the de-facto standard, the one always being asked in interviews, and the one you are expected to know.
  • For the Kotlin Spring Boot App we are going to choose JUnit + MockK: kotlin native tools + kotlin-first mocking.
  • For the Groovy Spring Boot Project we are going to choose Spock, as is the natural less friction choice.

JaCoCo

JaCoCo (Java Code Coverage) tells you which lines of code your tests execute. It is the standard coverage tool for JVM projects.

When running the test or jacocoTestReport task, JaCoCo will generate reports in HTML (for humans) and XML (for CI tools) at build/reports/jacoco/test.

What Files To Test

File Tree
spring_java/
├── SpringJavaApplication.java ← 🔴 @SpringBootTest (context loads)
├── config/
│ ├── advice/
│ │ └── ControllerAdvice.java ← 🔶 Standalone MockMvc
│ ├── log/
│ │ ├── LogAspect.java ┐
│ │ ├── LogFilter.java │ 🔴 @SpringBootTest (single test)
│ │ ├── MaskingPatternLayout.java │
│ │ └── TraceIdFilter.java ┘
│ └── mapper/
│ └── MapperSpringConfig.java ← ⚪ Ignored (Config)
└── sakila/
└── film/
├── adapter/in/rest/
│ ├── FilmRestMapper.java ← ⚪ Ignored (Inteface)
│ └── FilmRestController.java ← 🟡 @WebMvcTest
└── domain/
├── model/
│ └── Film.java ← ⚪ Ignored (POJO)
└── port/in/
├── FindByIdPortIn.java ← ⚪ Ignored (Interface)
└── FindByIdPortInImpl.java ← 🟢 Unit Test
SymbolTest TypeDescription
🔴@SpringBootTestFull context integration test
🟡@WebMvcTestWeb layer slice test
🔶Standalone MockMvcMockMvc without full Spring context
🟢Unit TestPure unit test
IgnoredNo test needed (config/interface/POJO)
◀─────────────────────── TEST SPECTRUM ───────────────────────▶

UNIT INTEGRATION
FAST SLOW
ISOLATED CONNECTED

🟢──────────🔶──────────🟡──────────🔴
│ │ │ │
│ │ │ └── Full Spring Boot
│ │ └── Web Layer Only
│ └── MockMvc Standalone
└── Pure Unit Test

⚪ = Not on spectrum (no test needed)
Files to Create/Modify
FileTree
├── build.gradle
└── src/
├── main/java/.../spring_java/
│ └── config/advice/
│ └── ControllerAdvice.java
└── test/java/.../spring_java/
├── SanityCheckSpringBootTest.java
├── config/advice/
│ └── ControllerAdviceMockMvcTest.java
├── sakila/film/
│ ├── adapter/in/rest/
│ │ └── FilmRestControllerMockMvcTest.java
│ └── domain/port/in/
│ └── FindByIdPortInImplTest.java
└── test/util/
└── MockMvcResultMatchers.java

Dependencies

build.gradle
plugins {
// ...
id 'jacoco'
}
// ...
jacoco {
toolVersion = "0.8.14"
}

jacocoTestReport {
dependsOn test
reports {
xml.required = true
html.required = true
}

classDirectories.setFrom(
files(classDirectories.files.collect {
fileTree(it) {
exclude(
// OpenAPI generated code
'**/generated/**',

// Application entry point
'**/*Application*',

// Domain models (POJOs)
'**/domain/model/**',

// MapStruct
'**/config/mapper/**',
'**/*MapperImpl*',
)
}
})
)
}

jacocoTestCoverageVerification {
dependsOn jacocoTestReport
classDirectories.setFrom(jacocoTestReport.classDirectories)
violationRules {
rule {
limit {
counter = 'LINE'
minimum = 0.8
}
limit {
counter = 'BRANCH'
minimum = 0.5
}
}
}
}

tasks.named('check') {
dependsOn jacocoTestCoverageVerification
}

tasks.named('test') {
useJUnitPlatform()
jvmArgs '-XX:+EnableDynamicAgentLoading'
jvmArgumentProviders.add({
def mockitoAgent = configurations.testRuntimeClasspath.resolvedConfiguration
.resolvedArtifacts
.find { it.name == 'mockito-core' }
?.file
mockitoAgent ? ["-javaagent:${mockitoAgent}"] : []
} as CommandLineArgumentProvider)
finalizedBy jacocoTestReport
}
// ...

🟢 Unit Tests

Unit tests don't involve Spring at all. These are faster to run and perfect for testing business logic in complete isolation.

FindByIdPortInImpl is currently just returning a hardcoded film. There's not much point in doing deep testing here yet, but let's at least verify the service instantiates and returns something.

java/dev/pollito/spring_java/sakila/film/domain/port/in/FindByIdPortInImplTest.java
package dev.pollito.spring_java.sakila.film.domain.port.in;

import static org.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class FindByIdPortInImplTest {
@InjectMocks private FindByIdPortInImpl findByIdPortIn;

@Test
void findByIdReturnsADomainModel() {
assertNotNull(findByIdPortIn.findById(1));
}
}

🔶 🟡 Slice Tests

🟡 Web Layer

We're testing three things here:

  • REST endpoint behavior: Does the controller map requests correctly and return the expected responses?
  • Exception handling: When things go wrong, does our error response structure look right?
  • JSON response structure: Does the final JSON match what our API contract promises?

To make our assertions more readable, we'll first create some custom matchers for our API response structure:

java/dev/pollito/spring_java/test/util/MockMvcResultMatchers.java
package dev.pollito.spring_java.test.util;

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;

import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.ResultMatcher;

public final class MockMvcResultMatchers {

private MockMvcResultMatchers() {}

public static ResultMatcher hasStandardApiResponseFields(
String expectedInstance, HttpStatus expectedStatus) {
return result -> {
jsonPath("$.instance").value(expectedInstance).match(result);
jsonPath("$.status").value(expectedStatus.value()).match(result);
jsonPath("$.timestamp").exists().match(result);
jsonPath("$.trace").exists().match(result);
};
}

public static ResultMatcher hasErrorFields(HttpStatus expectedStatus) {
return result -> jsonPath("$.title").value(expectedStatus.getReasonPhrase()).match(result);
}
}

Now let's write the actual RestController test using a slice testing approach with @WebMvcTest, which creates a minimal Spring context focused on the web layer.

java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestControllerMockMvcTest.java
package dev.pollito.spring_java.sakila.film.adapter.in.rest;

import static dev.pollito.spring_java.test.util.MockMvcResultMatchers.hasErrorFields;
import static dev.pollito.spring_java.test.util.MockMvcResultMatchers.hasStandardApiResponseFields;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.http.HttpStatus.*;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import dev.pollito.spring_java.config.advice.ControllerAdvice;
import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.port.in.FindByIdPortIn;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(FilmRestController.class)
@Import({ControllerAdvice.class, FilmRestMapperImpl.class})
class FilmRestControllerMockMvcTest {

private static final String FILMS_PATH = "/api/films";
private static final String FILM_BY_ID_TEMPLATE = FILMS_PATH + "/{id}";

@SuppressWarnings("unused")
@Autowired
private MockMvc mockMvc;

@SuppressWarnings("unused")
@MockitoBean
private FindByIdPortIn findByIdPortIn;

@SuppressWarnings("unused")
@MockitoSpyBean
private FilmRestMapper mapper;

private static String filmPath(Integer id) {
return FILMS_PATH + "/" + id;
}

@Test
void findByIdReturnsOK() throws Exception {
Integer filmId = 1;
Film film = mock(Film.class);
when(film.getId()).thenReturn(filmId);

when(findByIdPortIn.findById(anyInt())).thenReturn(film);

mockMvc
.perform(get(FILM_BY_ID_TEMPLATE, filmId).accept(APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentType(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(filmPath(filmId), OK))
.andExpect(jsonPath("$.data.id").value(filmId));
}

@Test
void findByIdWithInvalidIdReturnsBAD_REQUEST() throws Exception {
Integer invalidId = 0;
HttpStatus status = BAD_REQUEST;
mockMvc
.perform(get(FILM_BY_ID_TEMPLATE, invalidId).accept(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(filmPath(invalidId), status))
.andExpect(hasErrorFields(status));
}

@Test
void findAllReturnsINTERNAL_SERVER_ERROR() throws Exception {
HttpStatus status = INTERNAL_SERVER_ERROR;
mockMvc
.perform(get(FILMS_PATH).accept(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(FILMS_PATH, status))
.andExpect(hasErrorFields(status));
}
}

When we run the tests, we find this error:

Terminal
JSON path "$.status"
Expected :400
Actual :500
<Click to see difference>

java.lang.AssertionError: JSON path "$.status" expected:<400> but was:<500>
at org.springframework.test.util.AssertionErrors.fail(AssertionErrors.java:62)
at org.springframework.test.util.AssertionErrors.assertEquals(AssertionErrors.java:129)
at org.springframework.test.util.JsonPathExpectationsHelper.assertValue(JsonPathExpectationsHelper.java:172)
at org.springframework.test.web.servlet.result.JsonPathResultMatchers.lambda$value$2(JsonPathResultMatchers.java:111)
at dev.pollito.spring_java.test.util.MockMvcResultMatchers.lambda$hasStandardApiResponseFields$0(MockMvcResultMatchers.java:19)
at org.springframework.test.web.servlet.MockMvc$1.andExpect(MockMvc.java:212)
at dev.pollito.spring_java.sakila.film.adapter.in.rest.FilmControllerTest.whenFindByIdWithInvalidId_thenReturnsBadRequest(FilmControllerTest.java:72)

We expected a 400 (Bad Request) when passing an invalid ID (0), but got a 500 instead. This tells us our ControllerAdvice isn't handling ConstraintViolationException properly. It's being caught by the generic Exception handler and returning a 500 status.

Let's fix the ControllerAdvice by adding an explicit handler for ConstraintViolationException that returns BAD_REQUEST (400) instead of letting it fall through to the generic 500 handler:

java/dev/pollito/spring_java/config/advice/ControllerAdvice.java
// ...
import jakarta.validation.ConstraintViolationException;
// ...
public class ControllerAdvice {
// ...
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Error> handle(ConstraintViolationException e) {
// ...
}
}

After this fix, we now have confidence that our controller handles happy paths, validation errors, and unexpected exceptions correctly.

🔶 MockMvc Standalone

The goal of these tests is to verify our ControllerAdvice handles different exception scenarios correctly. We test that validation errors return appropriate HTTP status codes and response structures, ensuring our API provides consistent error responses across different failure modes.

java/dev/pollito/spring_java/config/advice/ControllerAdviceMockMvcTest.java
package dev.pollito.spring_java.config.advice;

import static dev.pollito.spring_java.test.util.MockMvcResultMatchers.hasErrorFields;
import static dev.pollito.spring_java.test.util.MockMvcResultMatchers.hasStandardApiResponseFields;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.http.HttpMethod.GET;
import static org.springframework.http.HttpStatus.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import java.util.Set;
import java.util.stream.Stream;
import org.jspecify.annotations.NonNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.resource.NoResourceFoundException;

class ControllerAdviceMockMvcTest {

private MockMvc mockMvc;
private final HttpServletRequest request = mock(HttpServletRequest.class);

@RestController
@RequestMapping("/fake")
private static class FakeController {

@GetMapping("/not-found")
@SuppressWarnings("unused")
public void throwNoResourceFoundException() throws NoResourceFoundException {
throw new NoResourceFoundException(GET, "/fake", "no-resource-found");
}

@GetMapping("/error")
@SuppressWarnings("unused")
public void throwException() throws Exception {
throw new Exception("Test exception");
}

@GetMapping("/bad-request")
@SuppressWarnings("unused")
public void throwConstraintViolationException() {
throw new ConstraintViolationException("Constraint violation", Set.of());
}
}

@BeforeEach
void setUp() {
mockMvc =
standaloneSetup(new FakeController())
.setControllerAdvice(new ControllerAdvice(request))
.build();
}

static @NonNull Stream<Arguments> testCases() {
return Stream.of(
Arguments.of("/fake/not-found", NOT_FOUND),
Arguments.of("/fake/error", INTERNAL_SERVER_ERROR),
Arguments.of("/fake/bad-request", BAD_REQUEST));
}

@ParameterizedTest
@MethodSource("testCases")
void exceptionHandlingReturnsCorrectStatus(String path, @NonNull HttpStatus expectedStatus)
throws Exception {
when(request.getRequestURI()).thenReturn(path);
mockMvc
.perform(get(path))
.andExpect(status().is(expectedStatus.value()))
.andExpect(hasStandardApiResponseFields(path, expectedStatus))
.andExpect(hasErrorFields(expectedStatus));
}
}

🔴 Integration Tests

This integration test boots up the entire Spring Boot application context to verify that all the pieces fit together and work as expected in a real runtime environment.

Unlike the slice tests (@WebMvcTest) which only load specific parts of the application, this test with @SpringBootTest loads everything: all beans, configurations, filters, aspects, and wiring. It's our sanity check that the application actually starts and behaves correctly end-to-end.

For now, we're using this integration test to verify our logging infrastructure works across the entire request lifecycle: from the moment a request arrives (LogFilter), through method execution (LogAspect), including security concerns like masking sensitive headers (MaskingPatternLayout), and distributed tracing setup (TraceIdFilter).

java/dev/pollito/spring_java/SanityCheckSpringBootTest.java
package dev.pollito.spring_java;

import static java.util.regex.Pattern.compile;
import static java.util.regex.Pattern.quote;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.HttpMethod;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;

@SpringBootTest
@AutoConfigureMockMvc
@ExtendWith(OutputCaptureExtension.class)
class SanityCheckSpringBootTest {

@SuppressWarnings("unused")
@Autowired
private MockMvc mockMvc;

record TestCase(
@NonNull HttpMethod method,
@NonNull String url,
@NonNull List<Object> pathParams,
@NonNull Map<String, String> headers,
@NonNull Map<String, String> queryParams,
@Nullable String requestBody) {}

static @NonNull Stream<TestCase> sanityCheckTestCases() {
return Stream.of(
new TestCase(
HttpMethod.GET,
"/api/films/{id}",
List.of(1),
/* we don't have yet any endpoint with sensible headers to mask, so let's use this one for now */
Map.of("Authorization", "Bearer secret-token", "X-Api-Key", "my-secret-key"),
Collections.emptyMap(),
null));
}

private MockHttpServletRequestBuilder buildRequest(
@NonNull HttpMethod method, @NonNull String url, @NonNull List<Object> pathParams) {
Object[] params = pathParams.toArray();
return switch (method.name()) {
case "GET" -> get(url, params);
case "POST" -> post(url, params);
case "PUT" -> put(url, params);
case "PATCH" -> patch(url, params);
case "DELETE" -> delete(url, params);
default -> throw new IllegalArgumentException("Unsupported HTTP method: " + method);
};
}

private String resolvePathParameters(@NonNull String url, @NonNull List<Object> pathParams) {
String resolved = url;
for (Object param : pathParams) {
resolved = resolved.replaceFirst("\{[^}]+}", String.valueOf(param));
}
return resolved;
}

private long countMatches(@NonNull String text, @NonNull String regex) {
return compile(regex).matcher(text).results().count();
}

private void assertLogFilterOutput(
@NonNull String logOutput, @NonNull HttpMethod method, @NonNull String url) {
String methodAndUri = String.format(">>>> Method: %s; URI: %s", method.name(), url);
assert countMatches(
logOutput, quote(methodAndUri) + "; QueryString: [^;\n]*; Headers: \{[^\n]*}")
== 1
: "LogFilter should log request details with method, URI, QueryString, and Headers exactly once";
assert countMatches(logOutput, "<<<< Response Status: \d+") == 1
: "LogFilter should log response status exactly once";
}

private void assertLogAspectOutput(@NonNull String logOutput) {
assert countMatches(logOutput, "\[[\w.]+\([..]*\)] Args: \[") == 1
: "LogAspect should log args with format [ClassName.methodName(..)] Args: [...] exactly once";
assert countMatches(logOutput, "\[[\w.]+\([..]*\)] Response: <") == 1
: "LogAspect should log response with format [ClassName.methodName(..)] Response: <...> exactly once";
}

private void assertMaskingPatternLayoutOutput(@NonNull String logOutput) {
if (logOutput.contains("Authorization:") || logOutput.contains("X-Api-Key:")) {
assert !logOutput.contains("secret-token")
: "MaskingPatternLayout should mask Authorization value";
assert !logOutput.contains("my-secret-key")
: "MaskingPatternLayout should mask X-Api-Key value";
assert logOutput.contains("Authorization: ****")
: "MaskingPatternLayout should show masked Authorization";
assert logOutput.contains("X-Api-Key: ****")
: "MaskingPatternLayout should show masked X-Api-Key";
}
}

private void assertTraceIdFilterOutput(@NonNull String logOutput) {
assert logOutput.matches("(?s).*(trace_id=|trace_id=[a-f0-9]{32}).*")
: "TraceIdFilter should add trace_id to MDC (if present, must be exactly 32 hex characters)";
assert logOutput.matches("(?s).*(span_id=|span_id=[a-f0-9]{16}).*")
: "TraceIdFilter should add span_id to MDC (if present, must be exactly 16 hex characters)";
assert logOutput.matches("(?s).*trace_flags=(|00|01).*")
: "TraceIdFilter should add trace_flags to MDC (empty, 00, or 01)";
}

@ParameterizedTest
@MethodSource("sanityCheckTestCases")
void sanityCheck(@NonNull TestCase testCase, @NonNull CapturedOutput output) throws Exception {
MockHttpServletRequestBuilder requestBuilder =
buildRequest(testCase.method(), testCase.url(), testCase.pathParams());

testCase.headers().forEach(requestBuilder::header);
testCase.queryParams().forEach(requestBuilder::param);
if (testCase.requestBody() != null) {
requestBuilder.content(testCase.requestBody()).contentType(APPLICATION_JSON);
}

mockMvc.perform(requestBuilder.accept(APPLICATION_JSON));
String logOutput = output.getOut();

assertLogFilterOutput(
logOutput, testCase.method(), resolvePathParameters(testCase.url(), testCase.pathParams()));
assertLogAspectOutput(logOutput);
assertMaskingPatternLayoutOutput(logOutput);
assertTraceIdFilterOutput(logOutput);
}
}

Check the JaCoCo Report

Package / ClassCoverageComplexityLinesMethodsClasses
Instr. %Branch %MissedTotalMissedTotalMissedTotalMissedTotal
dev.pollito.spring_java.config.advice93%66%171180501
dev.pollito.spring_java.config.log93%52%93146212204
dev.pollito.spring_java.sakila.film.adapter.in.rest100%n/a02070201
dev.pollito.spring_java.sakila.film.domain.port.in100%n/a020100201
Total94%55%104259713107

Here's what each column in a JaCoCo coverage report means:

ColumnMeaningNotes / When to Care
NameThe package, class, file, or method being measuredThe Total row aggregates all entries
Instruction CoveragePercentage of JVM bytecode instructions executedVery fine‑grained; can be misleading for Groovy
Branch CoveragePercentage of conditional branches executed (if, switch, loops)Great for catching untested logic paths
Missed ComplexityCyclomatic complexity not covered by testsIndicates risky or untested control flow
Total ComplexityTotal cyclomatic complexity of the codeHigher values mean more complex logic
Missed LinesNumber of executable source lines not runBased on lines with bytecode
Total LinesTotal executable source linesMost intuitive coverage metric
Missed MethodsMethods never invoked by testsGood for spotting untested APIs
Total MethodsTotal number of methodsIncludes constructors and lambdas
Missed ClassesClasses never loaded during testsHigh‑level test completeness indicator
Total ClassesTotal number of classesIncludes inner/anonymous classes