First tests
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.
- Java
- Kotlin
- Groovy
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:
- Adapter In related classes that adapt REST can be grouped and tested together under a
@WebMvcTestWeb layer slice test. - Adapter Out related classes that adapt JPA can be grouped and tested together under a
@DataJpaTestJPA 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
@RestControllerAdviceclasses are tested with aMockMvcStandalone@WebMvcTestWeb 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.
- Web
◀─────────────────────── TEST SPECTRUM ───────────────────────▶
UNIT INTEGRATION
FAST SLOW
ISOLATED CONNECTED
⚪──────────⚪──────────⚪──────────⚪
│ │ │ │
│ │ │ └── Full Spring Boot
│ │ └── Web Layer Only
│ └── MockMvc Standalone
└── Pure Unit Test
Dependencies
- Java
- Kotlin
- Groovy
id 'jacoco'
jacoco
testImplementation("com.ninja-squad:springmockk:5.0.1")
testImplementation("io.mockk:mockk:1.14.9")
id 'jacoco'
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
testImplementation 'org.spockframework:spock-spring:2.4-groovy-5.0'
Unit tests
Unit tests don't involve Spring at all. These are faster to run and perfect for testing business logic in complete isolation.
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
- Kotlin
- Groovy
Valued enum utility test
- Java
- Kotlin
- Groovy
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
- Kotlin
- Groovy
By extending MockMvcResultMatchersDsl, our custom matchers integrate
seamlessly with Spring's Kotlin DSL. They can be called directly inside
the andExpect block, making the test code read like a native part of the
framework.
A trait is like an interface with implementation that can be mixed into classes. By implementing this trait in your Spock specifications, the matcher methods become available as if they were defined directly in your test class, no static imports needed. Unlike abstract base classes, you can implement multiple traits, keeping your test hierarchy flexible.
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
- Kotlin
- Groovy
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
- Kotlin
- Groovy
Don't forget to update ControllerAdvice to handle the exceptions tested.
- Java
- Kotlin
- Groovy
@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);
}
@ExceptionHandler(ConstraintViolationException::class)
fun handle(e: ConstraintViolationException): ResponseEntity<Error> {
return buildErrorResponse(e, BAD_REQUEST)
}
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handle(e: MethodArgumentNotValidException): ResponseEntity<Error> {
return buildErrorResponse(e, BAD_REQUEST)
}
@ExceptionHandler(ConstraintViolationException)
ResponseEntity<Error> handle(ConstraintViolationException e) {
buildErrorResponse(e, BAD_REQUEST)
}
@ExceptionHandler(MethodArgumentNotValidException)
ResponseEntity<Error> handle(MethodArgumentNotValidException e) {
buildErrorResponse(e, BAD_REQUEST)
}
Check the JaCoCo report
- Java
- Kotlin
- Groovy
| Package / Class | Coverage | Complexity | Lines | Methods | Classes | |||||
|---|---|---|---|---|---|---|---|---|---|---|
| Instr. % | Branch % | Missed | Total | Missed | Total | Missed | Total | Missed | Total | |
| dev.pollito.spring_java.common.util | 100% | 100% | 0 | 3 | 0 | 4 | 0 | 1 | 0 | 1 |
| dev.pollito.spring_java.config.web | 93% | 66% | 1 | 8 | 1 | 19 | 0 | 6 | 0 | 1 |
| dev.pollito.spring_java.sakila.film.adapter.in.rest | 100% | n/a | 0 | 5 | 0 | 10 | 0 | 5 | 0 | 1 |
| dev.pollito.spring_java.sakila.film.domain.service | 100% | n/a | 0 | 2 | 0 | 16 | 0 | 2 | 0 | 1 |
| Total | 97% | 85% | 1 | 18 | 1 | 49 | 0 | 14 | 0 | 4 |
| Package / Class | Coverage | Complexity | Lines | Methods | Classes | |||||
|---|---|---|---|---|---|---|---|---|---|---|
| Instr. % | Branch % | Missed | Total | Missed | Total | Missed | Total | Missed | Total | |
| dev.pollito.spring_kotlin.common.util | 100% | 100% | 0 | 2 | 0 | 2 | 0 | 1 | 0 | 1 |
| dev.pollito.spring_kotlin.config.web | 92% | 75% | 3 | 13 | 1 | 22 | 2 | 11 | 0 | 2 |
| dev.pollito.spring_kotlin.sakila.film.adapter.in.rest | 100% | n/a | 0 | 6 | 0 | 16 | 0 | 6 | 0 | 1 |
| dev.pollito.spring_kotlin.sakila.film.domain.service | 100% | n/a | 0 | 2 | 0 | 15 | 0 | 2 | 0 | 1 |
| Total | 96% | 83% | 3 | 23 | 1 | 55 | 2 | 20 | 0 | 5 |
| Package / Class | Coverage | Complexity | Lines | Methods | Classes | |||||
|---|---|---|---|---|---|---|---|---|---|---|
| Instr. % | Branch % | Missed | Total | Missed | Total | Missed | Total | Missed | Total | |
| dev.pollito.spring_groovy.common.util | 87% | 100% | 1 | 3 | 1 | 5 | 1 | 2 | 0 | 1 |
| dev.pollito.spring_groovy.config.web | 92% | 75% | 1 | 9 | 2 | 26 | 0 | 7 | 0 | 1 |
| dev.pollito.spring_groovy.sakila.film.adapter.in.rest | 100% | n/a | 0 | 8 | 0 | 19 | 0 | 8 | 0 | 2 |
| dev.pollito.spring_groovy.sakila.film.domain.service | 100% | n/a | 0 | 2 | 0 | 14 | 0 | 2 | 0 | 1 |
| Total | 95% | 83% | 2 | 22 | 3 | 64 | 1 | 19 | 0 | 5 |
JaCoCo works at the bytecode level, but Groovy generates significantly different bytecode than what you write in source code. For Groovy projects (especially with Spring Boot), you should generally ignore the "Instructions" and "Branches" columns and focus almost exclusively on Line Coverage.
Here's what each column in a JaCoCo coverage report means:
| Column | Meaning | Notes / When to Care |
|---|---|---|
| Name | The package, class, file, or method being measured | The Total row aggregates all entries |
| Instruction Coverage | Percentage of JVM bytecode instructions executed | Very fine‑grained; can be misleading for Groovy |
| Branch Coverage | Percentage of conditional branches executed (if, switch, loops) | Great for catching untested logic paths |
| Missed Complexity | Cyclomatic complexity not covered by tests | Indicates risky or untested control flow |
| Total Complexity | Total cyclomatic complexity of the code | Higher values mean more complex logic |
| Missed Lines | Number of executable source lines not run | Based on lines with bytecode |
| Total Lines | Total executable source lines | Most intuitive coverage metric |
| Missed Methods | Methods never invoked by tests | Good for spotting untested APIs |
| Total Methods | Total number of methods | Includes constructors and lambdas |
| Missed Classes | Classes never loaded during tests | High‑level test completeness indicator |
| Total Classes | Total number of classes | Includes 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.