Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
Lesson 37 · Secure, Tested, Production-Ready
Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
Turn the tests from the earlier articles into a strategy: unit tests, slice tests (@WebMvcTest, @DataJpaTest, @JsonTest), full integration tests on a real PostgreSQL, measured run times, coverage with JaCoCo, and a mutation experiment that shows which tests really protect you.
For
Learners who have written some tests and need to choose the right kind of test for each part of a Spring Boot application.
Level
Before you start
Testing with JUnit and Mockito (article 25) and the Job Application Tracker articles 33 to 36. Verified with Spring Boot 4.1.1, JUnit Jupiter 6.1.3, Mockito 5.23.0, AssertJ 3.27.7 and JaCoCo 0.8.15 on JDK 21 and JDK 25.
On this page
Key takeaways
- Test each layer in the cheapest way that still proves its behaviour: plain unit tests for rules, slice tests for the web, JSON and data layers, and a few full integration tests that use the real database.
- A slice test starts only part of the application. @WebMvcTest loads controllers, security and JSON, @DataJpaTest loads JPA, and @MockitoBean replaces a bean with a mock. In Spring Boot 4, @MockBean and @SpyBean no longer exist.
- Test against the database you deploy to. The project shares one real PostgreSQL server across all database tests.
- Coverage shows what is not tested, not what is well tested. Using it found untested controller methods and the error handler, and raised line coverage from 92% to 96%.
- A mutation experiment (removing the owner check) was caught by 10 tests, and the tests that caught it best were the behavioural ones, not the mock-based ones.
By article 36 the Job Application Tracker had many tests of different kinds, written one at a time. This article steps back and organises them into a strategy: what each kind of test is for, what it costs, and what the numbers from this project say.
What you will learn
#- The test pyramid and the Spring Boot test types.
- What each slice annotation loads, and why that matters for speed.
- How to test against a real database, security and error handling.
- What coverage and a mutation experiment really tell you.
The test pyramid, measured
#The usual advice is a pyramid: many fast tests at the bottom, fewer slower ones above, very few end-to-end tests at the top. This is the real distribution of the project's 125 tests, with the time each group took on one machine (the numbers include one-off start-up costs such as starting PostgreSQL and the first Spring context, and will differ on yours):
Kind of test Tests Seconds ClassesUnit (plain JUnit) 47 0.3 ApplicationStatusTest, TokenServiceTestUnit (Mockito) 17 3.9 AuthServiceTest, JobApplicationServiceTestSlice: JSON (@JsonTest) 5 6.8 ApiJsonShapeTestSlice: web (@WebMvcTest) 25 6.8 JobApplicationControllerTest, SecurityRulesTestSlice: data (@DataJpaTest, PostgreSQL) 20 22.4 JobApplicationRepositoryTest, RelationshipTest, TransactionTestIntegration (@SpringBootTest, PostgreSQL) 11 5.4 JobApplicationServiceIntegrationTest, SecurityIntegrationTestTotal 125The shape holds: 64 plain unit tests ran in about four seconds, while 20 data tests took more than twenty, because they start a database and a Spring context. That is why the bottom of the pyramid should be wide.
Test type | Starts | Use it for |
|---|---|---|
Plain JUnit | Nothing | Pure logic: the status rules, token handling |
JUnit with Mockito | Nothing | A class with collaborators replaced by mocks: the services |
| Only the JSON mapper | The exact JSON that clients see and send |
| Controllers, security, JSON, error handling. No services, no database | HTTP behaviour: status codes, headers, validation, errors |
| JPA, repositories, the database, Flyway | Queries, mappings, transactions |
| The whole application | A few end-to-end flows |
Spring caches a started context and reuses it for every test class with the same configuration, so the first test of a kind pays the start-up cost and later ones are cheaper. Avoid @DirtiesContext, which throws the cache away, and avoid giving every test class a slightly different configuration.
Older style. Spring Boot 3 and earlier used
@MockBeanand@SpyBeanto replace beans in a test. In Spring Boot 4.1.1 those classes are gone. Use@MockitoBeanand@MockitoSpyBeanfrom Spring Framework'sorg.springframework.test.context.bean.override.mockito, as the tests in this course do.
Unit tests: exhaustive and fast
#Pure logic deserves exhaustive tests, because they cost almost nothing. The status rules have 36 possible moves. This test checks every one against a second, independently written table, using a parameterised test and AssertJ's fluent assertions (assertThat(...), which read like sentences and give good failure messages and ship with the Spring Boot test starter):
// File: src/test/java/com/example/jobtracker/ApplicationStatusTest.javapackage com.example.jobtracker;import static org.assertj.core.api.Assertions.assertThat;import java.util.EnumSet;import java.util.Map;import java.util.Set;import java.util.stream.Stream;import org.junit.jupiter.api.Test;import org.junit.jupiter.params.ParameterizedTest;import org.junit.jupiter.params.provider.Arguments;import org.junit.jupiter.params.provider.EnumSource;import org.junit.jupiter.params.provider.MethodSource;import static com.example.jobtracker.ApplicationStatus.APPLIED;import static com.example.jobtracker.ApplicationStatus.INTERVIEWING;import static com.example.jobtracker.ApplicationStatus.OFFER;import static com.example.jobtracker.ApplicationStatus.REJECTED;import static com.example.jobtracker.ApplicationStatus.WISHLIST;import static com.example.jobtracker.ApplicationStatus.WITHDRAWN;/** The whole transition table, checked exhaustively. Fast, pure logic: the best kind of test to have many of. */class ApplicationStatusTest { /** The rules written out a second time, independently of the enum, as the expected result. */ private static final Map<ApplicationStatus, Set<ApplicationStatus>> EXPECTED = Map.of( WISHLIST, EnumSet.of(APPLIED, WITHDRAWN), APPLIED, EnumSet.of(INTERVIEWING, REJECTED, WITHDRAWN), INTERVIEWING, EnumSet.of(OFFER, REJECTED, WITHDRAWN), OFFER, EnumSet.of(REJECTED, WITHDRAWN), REJECTED, EnumSet.noneOf(ApplicationStatus.class), WITHDRAWN, EnumSet.noneOf(ApplicationStatus.class)); static Stream<Arguments> everyPair() { return Stream.of(ApplicationStatus.values()) .flatMap(from -> Stream.of(ApplicationStatus.values()).map(to -> Arguments.of(from, to))); } @ParameterizedTest(name = "{0} to {1}") @MethodSource("everyPair") void everyPossibleMoveIsAllowedOrRefusedAsSpecified(ApplicationStatus from, ApplicationStatus to) { boolean expected = EXPECTED.get(from).contains(to); assertThat(from.canMoveTo(to)).isEqualTo(expected); } @ParameterizedTest @EnumSource(value = ApplicationStatus.class, names = {"REJECTED", "WITHDRAWN"}) void theFinalStagesAllowNothing(ApplicationStatus finalStage) { assertThat(finalStage.allowedNext()).isEmpty(); } @Test void noStageCanMoveToItself() { assertThat(ApplicationStatus.values()).noneMatch(status -> status.canMoveTo(status)); } @Test void theExpectedTableCoversEveryStage() { assertThat(EXPECTED.keySet()).containsExactlyInAnyOrder(ApplicationStatus.values()); }}JSON tests: pin the contract
#The JSON is the API's contract. If someone renames a field, clients break, and no other test notices. @JsonTest loads only the JSON mapper (so it is fast) and JacksonTester offers JSON-aware assertions:
// File: src/test/java/com/example/jobtracker/ApiJsonShapeTest.javapackage com.example.jobtracker;import static org.assertj.core.api.Assertions.assertThat;import java.time.Instant;import java.time.LocalDate;import java.util.List;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.autoconfigure.json.JsonTest;import org.springframework.boot.test.json.JacksonTester;/** * Pins down the JSON that clients see and send. If someone renames a field or changes a date format, * these tests fail before any client notices. @JsonTest loads only the JSON mapper, so it is very fast. */@JsonTestclass ApiJsonShapeTest { @Autowired JacksonTester<JobApplicationResponse> responseJson; @Autowired JacksonTester<JobApplicationRequest> requestJson; private static JobApplicationResponse response() { return new JobApplicationResponse(7L, 2, "Acme", "Java Developer", ApplicationStatus.INTERVIEWING, LocalDate.of(2026, 3, 1), null, null, Instant.parse("2026-03-15T10:00:00Z"), Instant.parse("2026-03-16T11:30:00Z"), List.of(new InterviewResponse(3L, Instant.parse("2026-04-01T09:00:00Z"), InterviewType.PHONE, "intro call"))); } @Test void theResponseUsesTheDocumentedFieldNamesAndFormats() throws Exception { var json = responseJson.write(response()); assertThat(json).extractingJsonPathNumberValue("$.id").isEqualTo(7); assertThat(json).extractingJsonPathNumberValue("$.version").isEqualTo(2); assertThat(json).extractingJsonPathStringValue("$.status").isEqualTo("INTERVIEWING"); assertThat(json).extractingJsonPathStringValue("$.appliedOn").isEqualTo("2026-03-01"); // a date, ISO 8601 assertThat(json).extractingJsonPathStringValue("$.createdAt").isEqualTo("2026-03-15T10:00:00Z"); // an instant, ISO 8601 assertThat(json).extractingJsonPathStringValue("$.interviews[0].type").isEqualTo("PHONE"); } @Test void emptyOptionalFieldsAreWrittenAsNullSoClientsCanRelyOnTheKeysBeingThere() throws Exception { var json = responseJson.write(response()); assertThat(json).hasEmptyJsonPathValue("$.jobUrl"); assertThat(json).hasJsonPathValue("$.interviews"); } @Test void theResponseNeverContainsAnythingInternal() throws Exception { String text = responseJson.write(response()).getJson(); assertThat(text).doesNotContain("ownerId").doesNotContain("password"); } @Test void aRequestIsReadFromJson() throws Exception { JobApplicationRequest request = requestJson.parseObject(""" {"company": "Acme", "position": "Java Developer", "appliedOn": "2026-03-01", "version": 4} """); assertThat(request.company()).isEqualTo("Acme"); assertThat(request.appliedOn()).isEqualTo(LocalDate.of(2026, 3, 1)); assertThat(request.version()).isEqualTo(4L); assertThat(request.notes()).isNull(); // a missing field arrives as null: validate it! } @Test void fieldsThatTheRequestDoesNotDeclareAreIgnored() throws Exception { JobApplicationRequest request = requestJson.parseObject(""" {"company": "Acme", "position": "Dev", "status": "OFFER", "ownerId": 99, "id": 5} """); assertThat(request.company()).isEqualTo("Acme"); // the extra status, ownerId and id changed nothing }}The last two tests document two facts from earlier articles in executable form: a missing field arrives as null (so validate), and fields the request does not declare (status, ownerId, id) are ignored (so mass assignment is blocked).
Web tests with MockMvc
#@WebMvcTest plus MockMvc tests the HTTP layer without a network: the service is a @MockitoBean, the real security rules run, and jwt() supplies a logged-in user. The full classes are in article 33 and article 36. They check what only the web layer owns: status codes, headers, the problem-details bodies, and that invalid input never reaches the service. One more test, added while writing this article, checks that an unknown interview becomes a 404. This is its code:
void deletingAnUnknownInterviewReturns404() throws Exception { willThrow(new InterviewNotFoundException(7L, 99L)).given(service).deleteInterview(1L, 7L, 99L); mvc.perform(delete("/api/applications/7/interviews/99").with(user())) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.title").value("Interview not found")) .andExpect(jsonPath("$.detail").value("Application 7 has no interview with id 99"));}Data and integration tests: use the real database
#The most valuable finding of the whole course came from a real PostgreSQL: the query that failed with function lower(bytea) does not exist (article 34). An in-memory substitute would probably have hidden it. The data tests share one real server for the whole run through a small helper, so the start-up cost is paid once:
// File: src/test/java/com/example/jobtracker/TestPostgres.javapackage com.example.jobtracker;import io.zonky.test.db.postgres.embedded.EmbeddedPostgres;import java.io.IOException;import java.io.UncheckedIOException;import org.springframework.test.context.DynamicPropertyRegistry;/** One real PostgreSQL server for the whole test run, shared by every test class that needs a database. */final class TestPostgres { private static EmbeddedPostgres instance; private TestPostgres() { } private static synchronized EmbeddedPostgres server() { if (instance == null) { try { instance = EmbeddedPostgres.start(); } catch (IOException e) { throw new UncheckedIOException("cannot start the test database", e); } EmbeddedPostgres started = instance; Runtime.getRuntime().addShutdownHook(new Thread(() -> { try { started.close(); } catch (IOException ignored) { // the JVM is exiting } })); } return instance; } /** Points the application's DataSource at the shared server. Call it from a @DynamicPropertySource method. */ static void register(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", () -> server().getJdbcUrl("postgres", "postgres")); registry.add("spring.datasource.username", () -> "postgres"); registry.add("spring.datasource.password", () -> "postgres"); }}Rules that keep database tests reliable: use the real migrations (Flyway runs in the tests), make each test independent (@DataJpaTest rolls back after each test, and tests that must commit clean up after themselves), and keep the number of them modest. Testcontainers, which runs PostgreSQL in Docker, is the common alternative to an embedded server. It needs Docker, which was not available where this course was verified, so it was not run here.
Coverage: what it shows and what it hides
#The project builds with the JaCoCo plugin, which records which lines and branches the tests run. The first measurement of the then 120 tests showed line coverage of 92%, with two classes standing out: the controller at 65% and the exception handler at 69%. Those were real gaps: the interview endpoints, deleting an application, and the catch-all handler that must not leak internals had no web-layer tests. Adding five tests (one of them checking that a secret in an exception message never appears in the response) raised them to 90% and 89%, and the total to 96%. This is the final report:
Class Lines BranchesApiExceptionHandler 89% n/aAppUser 100% n/aApplicationNotFoundException 100% n/aApplicationStatus 100% 100%AuthController 100% n/aAuthService 100% 100%ClockConfig 100% n/aEmailAlreadyUsedException 100% n/aInterview 100% n/aInterviewNotFoundException 100% n/aInterviewRequest 100% n/aInterviewResponse 100% n/aInterviewType 100% n/aInvalidCredentialsException 100% n/aInvalidStatusChangeException 100% 100%JobApplication 100% n/aJobApplicationController 90% n/aJobApplicationRequest 100% n/aJobApplicationResponse 100% n/aJobApplicationService 96% 100%JobTrackerApplication 33% n/aJwtConfig 100% 100%LoginRequest 100% n/aPageResponse 100% n/aRegisterRequest 100% n/aSecurityConfig 92% n/aSecuritySettings 100% n/aStaleVersionException 100% n/aStatusChangeRequest 100% n/aTokenResponse 100% n/aTokenService 100% n/aTrackerSettings 100% n/aUserResponse 100% n/aTOTAL 96% 100%What to take from it:
- Coverage is excellent for finding untested code, as it did here.
- It says nothing about assertion quality. A test with no assertions gives coverage and no protection, and records or exceptions with no logic inflate the number.
- A target such as "80%" encourages meaningless tests. Use the number as a question ("why is this class at 65%?"), not a goal.
- The entry point class
JobTrackerApplicationstays at 33% (itsmainmethod is not run by tests), and that is fine.
A mutation experiment
#The better question is: would the tests notice a bug? A simple way to find out is to introduce one on purpose. The most dangerous bug in this application is a missing ownership check, so the experiment changed the service's lookup from findByIdAndOwnerId(id, ownerId) to findById(id), ignoring the owner. This is the real result of running the suite, and the code was restored afterwards:
Tests run: 124, Failures: 5, Errors: 5, Skipped: 0JobApplicationServiceIntegrationTest.anotherUserCannotReadChangeOrDeleteTheApplicationJobApplicationServiceTest.aForbiddenMoveIsRejectedAndNothingIsSavedJobApplicationServiceTest.addingAnInterviewAttachesItToTheApplicationAndWritesItImmediatelyJobApplicationServiceTest.anApplicationOfAnotherUserLooksLikeOneThatDoesNotExistJobApplicationServiceTest.deleteRemovesTheApplicationAndAMissingOneThrowsJobApplicationServiceTest.deletingAnInterviewThatDoesNotBelongToTheApplicationFailsJobApplicationServiceTest.replaceChangesTheFieldsButNotTheStatusJobApplicationServiceTest.replaceWithAStaleVersionIsRejectedAndNothingIsSavedJobApplicationServiceTest.statusMovesOnlyAlongAllowedPathsSecurityIntegrationTest.aUserCannotSeeAnotherUsersApplicationNotEvenByGuessingItsIdTen tests failed, so the bug could not slip through. Look at how they failed:
SecurityIntegrationTest.aUserCannotSeeAnotherUsersApplicationNotEvenByGuessingItsIdfailed because Grace received200instead of404, andJobApplicationServiceIntegrationTest.anotherUserCannotReadChangeOrDeleteTheApplicationfailed because no exception was thrown. These are behavioural tests, and they would catch the bug however the code is written.- Most of the Mockito-based service tests also failed, but for a different reason: they stub
findByIdAndOwnerId, so they broke because the code stopped calling that method, not because behaviour was wrong. That is the coupling to implementation that article 25 warned about. Such tests protect little, and break during harmless refactoring.
Tools such as PIT automate this idea (they mutate the code many times and report which mutants survive). It was not run here.
Habits that keep a suite healthy
#- Name tests for behaviour:
aUserCannotSeeAnotherUsersApplication..., nottestGet2. - Test the negative cases: forged and expired tokens, another user's data, invalid input, the failure paths of transactions.
- Keep time and randomness out: inject a
Clock, as the service does. - Share the expensive parts (one database, a cached context) and keep most tests plain.
- Run
mvn verifyon every push in continuous integration, and treat a flaky test as a bug to fix or delete. - Do not assert on log text or on the wording of operating-system errors.
- Write the failing test first when you find a bug, as with the version-lag bug in article 35.
Common mistakes
#- Using
@SpringBootTestfor everything, so the suite is slow and nobody runs it. - Mocking so much that the test only checks the mocks (see the mutation result).
- Testing against a different database from the one you deploy to.
- Tests that depend on each other's data or execution order.
- Chasing a coverage percentage with assertion-free tests.
- Using
@MockBeanfrom old tutorials in Spring Boot 4. - Never testing the error paths and the security rules.
Exercises
#Exercise 1
Choose the test type
#For each, name the best test type: (a) a method that computes whether a status move is allowed, (b) that POST with a blank company returns 400, (c) that a query ignores letter case, (d) that the JSON field is called appliedOn, (e) that a registered user can log in and then call the API.
Exercise 2: Slow suite
#A suite has 40 test classes, each with @SpringBootTest and a different @MockitoBean, and takes ten minutes. Give three ways to speed it up.
Exercise 3: Coverage
#A class has 100% line coverage and a bug is found in it. How is that possible?
Exercise 4: Write the test
#Write a @WebMvcTest test, in the style of this article, that deleting an unknown interview returns 404 with the title Interview not found.
Exercise 5: Mutation
#Name one change to the code that would make the existing tests fail, and one that they might miss.
Show solutionHide solution
(a) A plain unit test. (b) A @WebMvcTest with MockMvc. (c) A @DataJpaTest on a real database. (d) A @JsonTest. (e) A @SpringBootTest with MockMvc (an integration test through the whole stack).
Exercise 2
Show solutionHide solution
(1) Replace most @SpringBootTest classes with slices (@WebMvcTest, @DataJpaTest) and plain unit tests. (2) Make the remaining integration tests share one configuration, so Spring reuses a single cached context, and put the mocks they need in a shared base or configuration instead of a different @MockitoBean per class. (3) Share one database server across the run, and avoid @DirtiesContext. Running test classes in parallel can also help, if the tests are independent.
Exercise 3
Show solutionHide solution
Coverage means a line was executed, not that its result was checked. The test may run the line without asserting anything, may check only one input, or may never exercise the case that fails (a boundary, a null, a concurrent call). The bug can also be in what is missing: a rule that was never written has no line to cover.
Exercise 4
Show solutionHide solution
void deletingAnUnknownInterviewReturns404() throws Exception { willThrow(new InterviewNotFoundException(7L, 99L)).given(service).deleteInterview(1L, 7L, 99L); mvc.perform(delete("/api/applications/7/interviews/99").with(user())) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.title").value("Interview not found")) .andExpect(jsonPath("$.detail").value("Application 7 has no interview with id 99"));}The mock is told to throw the domain exception, and the test checks that the exception handler turns it into the right status and problem-details content. This test is part of the project and passes.
Exercise 5
Show solutionHide solution
One change that is caught: removing the a.ownerId = :ownerId condition from the search query (the repository and integration tests check that a user never sees another's rows). One that might be missed: changing @Size(max = 100) on company to 120, because no test sends a 101-character company. Boundary values of validation rules are a classic gap, and a parameterised boundary test would close it.
Where to go next
#- Next: Production Practices: Configuration, Logging, Docker and Health Checks prepares the application to run outside your laptop.
- Then: Project 3: The Job Application Tracker API assembles and reviews everything.
Sources & further reading
- Technology
- Software Engineering
- #JaCoCo
- #Java
- #Java backend
- #Java interview
- #JPA
- #JUnit
- #learn Java
- #Mockito
- #MockMvc
- #Spring Boot
- #SQL
- #testing
Connected lessons
References
Linked from 5 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
Heaps and Priority Queues
Guide · Technology
Dynamic Programming Practice: From 1D to 2D
Course · Technology
DSA From Zero to Job-Ready: A Practical Guide with Java

