Skip to content
SparkTime, home
Java From Zero to Job-Ready
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
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 37 of 41

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.

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):

Code
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                                            125

The 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

@JsonTest

Only the JSON mapper

The exact JSON that clients see and send

@WebMvcTest

Controllers, security, JSON, error handling. No services, no database

HTTP behaviour: status codes, headers, validation, errors

@DataJpaTest

JPA, repositories, the database, Flyway

Queries, mappings, transactions

@SpringBootTest

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 @MockBean and @SpyBean to replace beans in a test. In Spring Boot 4.1.1 those classes are gone. Use @MockitoBean and @MockitoSpyBean from Spring Framework's org.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):

Java
// 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:

Java
// 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:

Java
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:

Java
// 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:

Code
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 JobTrackerApplication stays at 33% (its main method 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:

Code
Tests run: 124, Failures: 5, Errors: 5, Skipped: 0JobApplicationServiceIntegrationTest.anotherUserCannotReadChangeOrDeleteTheApplicationJobApplicationServiceTest.aForbiddenMoveIsRejectedAndNothingIsSavedJobApplicationServiceTest.addingAnInterviewAttachesItToTheApplicationAndWritesItImmediatelyJobApplicationServiceTest.anApplicationOfAnotherUserLooksLikeOneThatDoesNotExistJobApplicationServiceTest.deleteRemovesTheApplicationAndAMissingOneThrowsJobApplicationServiceTest.deletingAnInterviewThatDoesNotBelongToTheApplicationFailsJobApplicationServiceTest.replaceChangesTheFieldsButNotTheStatusJobApplicationServiceTest.replaceWithAStaleVersionIsRejectedAndNothingIsSavedJobApplicationServiceTest.statusMovesOnlyAlongAllowedPathsSecurityIntegrationTest.aUserCannotSeeAnotherUsersApplicationNotEvenByGuessingItsId

Ten tests failed, so the bug could not slip through. Look at how they failed:

  • SecurityIntegrationTest.aUserCannotSeeAnotherUsersApplicationNotEvenByGuessingItsId failed because Grace received 200 instead of 404, and JobApplicationServiceIntegrationTest.anotherUserCannotReadChangeOrDeleteTheApplication failed 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..., not testGet2.
  • 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 verify on 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 @SpringBootTest for 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 @MockBean from 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
Java
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

#

Sources & further reading

  1. docs.spring.io
  2. docs.spring.io
  3. jacoco.org
  • 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