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, commit(s) Testing Java, Testing Kotlin, Testing Groovy

In the previous document we compared JVM testing frameworks and picked one for each language. Now we put those frameworks to work: write unit tests, slice tests, and check the results with JaCoCo.

Files to Create/Modify
File Tree
.
├── build.gradle
├── ...
└── src
├── main
│ └── java
│ └── dev
│ └── pollito
│ └── spring_java
│ ├── config
│ │ └── web
│ │ └── ControllerAdvice.java
│ └── ...
└── test
├── java
│ └── dev
│ └── pollito
│ └── spring_java
│ ├── common
│ │ └── util
│ │ └── EnumUtilsTest.java
│ ├── config
│ │ └── web
│ │ └── ControllerAdviceMockMvcTest.java
│ └── sakila
│ └── film
│ ├── adapter
│ │ └── in
│ │ └── rest
│ │ └── FilmRestControllerMockMvcTest.java
│ └── domain
│ └── service
│ └── FilmUseCasesImplTest.java
└── util
└── MockMvcResultMatchers.java

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

Should I create one test per every main package file? Can I group tests? Which kind of test is better? All these questions don't have one truth answer. It changes from team to team, and from developer to developer. Here I share my approach, feel free to follow it, adapt it, change it as much as needed.

Given a REST + Database CRUD project following hexagonal architecture:

Hexagonal Arch Simple Diagram 12d64fec5e72a98ed1fdf4a26df84e03
  • Adapter In related classes that adapt REST can be grouped and tested together under a @WebMvcTest Web layer slice test.
  • Adapter Out related classes that adapt JPA can be grouped and tested together under a @DataJpaTest JPA layer slice test.
  • Domain Use cases Implementations can be grouped and tested together under a unit test.
  • Configuration classes are tricky. Most teams just ignore them completely. My take on them are:
    • Web @RestControllerAdvice classes are tested with a MockMvc Standalone @WebMvcTest Web layer slice test.
    • I've seen developers test log related classes in integration tests. My take is to just avoid testing log related filters and aspects. Too much trouble for too little win.
◀─────────────────────── TEST SPECTRUM ───────────────────────▶

UNIT INTEGRATION
FAST SLOW
ISOLATED CONNECTED

⚪──────────⚪──────────⚪──────────⚪
│ │ │ │
│ │ │ └── Full Spring Boot
│ │ └── Web Layer Only
│ └── MockMvc Standalone
└── Pure Unit Test

Dependencies

build.gradle (plugin block)
id 'jacoco'
build.gradle (new jacoco blocks + updated test block)
jacoco {
toolVersion = "0.8.14"
}

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

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

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

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

// Log
'**/log/**',

// MapStruct
'**/*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.

Keep It Real

The tests shown here are intentionally simple. They demonstrate wiring and basic assertions, not exhaustive validation. How deep you go with testing is ultimately a team (and developer) philosophy. It is very easy to write trivial tests that paint every line green in a JaCoCo report without actually testing anything meaningful. Treat coverage as a guide, not a goal.

Use cases implementation test

FilmUseCasesImpl.getFilm() 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/service/FilmUseCasesImplTest.java
package dev.pollito.spring_java.sakila.film.domain.service;

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 FilmUseCasesImplTest {
@InjectMocks private FilmUseCasesImpl useCases;

@Test
void getFilmReturnsADomainModel() {
assertNotNull(useCases.getFilm(1));
}
}

Valued enum utility test

java/dev/pollito/spring_java/common/util/EnumUtilsTest.java
package dev.pollito.spring_java.common.util;

import static dev.pollito.spring_java.common.util.EnumUtils.fromValue;
import static org.junit.jupiter.api.Assertions.*;

import dev.pollito.spring_java.common.ValuedEnum;
import org.junit.jupiter.api.Test;

class EnumUtilsTest {

private enum Color implements ValuedEnum<String> {
RED("red"),
GREEN("green"),
BLUE("blue");

private final String value;

Color(String value) {
this.value = value;
}

@Override
public String getValue() {
return value;
}
}

@Test
void fromValue_returnsMatchingConstant() {
assertEquals(Color.RED, fromValue(Color.class, "red"));
assertEquals(Color.GREEN, fromValue(Color.class, "green"));
assertEquals(Color.BLUE, fromValue(Color.class, "blue"));
}

@Test
void fromValue_throwsIllegalArgumentExceptionForUnknownValue() {
IllegalArgumentException ex =
assertThrows(IllegalArgumentException.class, () -> fromValue(Color.class, "yellow"));
assertTrue(ex.getMessage().contains("Color"));
assertTrue(ex.getMessage().contains("yellow"));
}
}

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 our API contract?

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.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import dev.pollito.spring_java.config.web.ControllerAdvice;
import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.port.in.FilmUseCases;
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 {

public static final String PATH = "/api/films";
public static final String CONTENT_BODY =
"""
{
"title": "ACADEMY DINOSAUR",
"language": "English",
"rentalDuration": 3,
"rentalRate": 4.99,
"replacementCost": 20.99
}
""";

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

@SuppressWarnings("unused")
@MockitoBean
private FilmUseCases filmUseCases;

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

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

when(filmUseCases.getFilm(anyInt())).thenReturn(film);

mockMvc
.perform(get(PATH + "/{id}", filmId).accept(APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentType(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(PATH + "/" + filmId, OK));
}

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

@Test
void createFilmReturnsINTERNAL_SERVER_ERROR() throws Exception {
HttpStatus status = INTERNAL_SERVER_ERROR;
mockMvc
.perform(
post(PATH).contentType(APPLICATION_JSON).content(CONTENT_BODY).accept(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(PATH, status))
.andExpect(hasErrorFields(status));
}

@Test
void deleteFilmReturnsINTERNAL_SERVER_ERROR() throws Exception {
Integer filmId = 1;
HttpStatus status = INTERNAL_SERVER_ERROR;
mockMvc
.perform(delete(PATH + "/{id}", filmId).accept(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(PATH + "/" + filmId, status))
.andExpect(hasErrorFields(status));
}

@Test
void updateFilmReturnsINTERNAL_SERVER_ERROR() throws Exception {
Integer filmId = 1;
HttpStatus status = INTERNAL_SERVER_ERROR;
mockMvc
.perform(
put(PATH + "/{id}", filmId)
.contentType(APPLICATION_JSON)
.content(CONTENT_BODY)
.accept(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(PATH + "/" + filmId, status))
.andExpect(hasErrorFields(status));
}
}

MockMvc standalone

The goal of these tests is to verify 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/web/ControllerAdviceMockMvcTest.java
package dev.pollito.spring_java.config.web;

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.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
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());
}

@GetMapping("/method-arg-not-valid")
@SuppressWarnings({"unused"})
public void throwMethodArgumentNotValidException() throws Exception {
throw new MethodArgumentNotValidException(
new MethodParameter(
FakeController.class.getMethod("throwMethodArgumentNotValidException"), -1),
mock(BindingResult.class));
}
}

@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),
Arguments.of("/fake/method-arg-not-valid", 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));
}
}

Don't forget to update ControllerAdvice to handle the exceptions tested.

java/dev/pollito/spring_java/config/web/ControllerAdvice.java (new exception handlers)
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Error> handle(ConstraintViolationException e) {
return buildProblemDetail(e, BAD_REQUEST);
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Error> handle(MethodArgumentNotValidException e) {
return buildProblemDetail(e, BAD_REQUEST);
}

Check the JaCoCo report

Package / ClassCoverageComplexityLinesMethodsClasses
Instr. %Branch %MissedTotalMissedTotalMissedTotalMissedTotal
dev.pollito.spring_java.common.util100%100%03040101
dev.pollito.spring_java.config.web93%66%181190601
dev.pollito.spring_java.sakila.film.adapter.in.rest100%n/a050100501
dev.pollito.spring_java.sakila.film.domain.service100%n/a020160201
Total97%85%11814901404

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

We now have unit tests for domain logic and enum utilities, slice tests for the web layer, and JaCoCo tracking coverage on every build.