Week 8: Testing Spring Boot Applications

Everything built across Weeks 1 through 7 — a secured, persisted, validated Task API — has been "tested" so far by hand: run the app, fire off a few curl commands, eyeball the response. That doesn't scale, and it catches nothing automatically the next time you touch the code. This week builds a real layered test suite instead — fast unit tests that need no Spring context at all, focused slice tests that load exactly one layer, full-context tests that exercise the real request pipeline including security, and integration tests that run against an actual PostgreSQL database via Testcontainers — so every future change can be trusted before it ships.

Module 6 of 12 Week 8 of 15 ~5–6 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write fast unit tests with JUnit 5 and Mockito, with zero Spring context involved
  • Use Spring's test slices — @WebMvcTest and @DataJpaTest — to test one layer in isolation
  • Write realistic integration tests against a real database with Testcontainers

1. Unit Testing with JUnit 5 & Mockito

A unit test tests one class, in isolation, with no Spring context started at all — no web server, no database, no dependency injection container. For a service class like the TaskService you've been building since Week 3, that means faking its collaborators (the repository) and asserting on the service's own logic: does it throw when a task isn't found, does it correctly compute a derived field, does it call the repository with the arguments you expect? These tests run in milliseconds because there's nothing to boot.

Mockito basics: @Mock and @InjectMocks

Mockito is the mocking library JUnit tests reach for to create fake implementations of a class's dependencies. @ExtendWith(MockitoExtension.class) tells JUnit 5 to process Mockito's annotations for this test class; @Mock creates a fake TaskRepository whose methods do nothing until you tell them to with when(...).thenReturn(...); @InjectMocks creates a real TaskService and injects the mocks into it automatically.

src/test/java/com/codeverse/week08/service/TaskServiceTest.java
package com.codeverse.week08.service;

import com.codeverse.week08.exception.TaskNotFoundException;
import com.codeverse.week08.model.Task;
import com.codeverse.week08.model.TaskStatus;
import com.codeverse.week08.repository.TaskRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class TaskServiceTest {

    @Mock
    private TaskRepository taskRepository;

    @InjectMocks
    private TaskService taskService;

    @Test
    void completeTask_marksExistingTaskAsDone() {
        // Given -- an existing, open task the repository knows about
        Task existing = new Task(1L, "Write tests", TaskStatus.OPEN, "alice");
        when(taskRepository.findById(1L)).thenReturn(Optional.of(existing));
        when(taskRepository.save(any(Task.class))).thenAnswer(inv -> inv.getArgument(0));

        // When -- the service is asked to complete it
        Task result = taskService.completeTask(1L, "alice");

        // Then -- the returned task is marked DONE, and save() was called with it
        assertThat(result.status()).isEqualTo(TaskStatus.DONE);
        verify(taskRepository).save(result);
    }

    @Test
    void completeTask_throwsWhenTaskDoesNotExist() {
        when(taskRepository.findById(99L)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> taskService.completeTask(99L, "alice"))
            .isInstanceOf(TaskNotFoundException.class)
            .hasMessageContaining("99");
    }
}

Given / When / Then

Notice the comment structure in the test above: Given sets up the scenario (stub the mock's return values), When calls the one method under test, and Then asserts on the outcome. This isn't a JUnit or Mockito feature — it's a naming discipline — but keeping every test in this shape makes a failing test easy to read at 2am without re-deriving what it was trying to prove.

This is why Week 2's constructor injection mattered

@InjectMocks works this cleanly because TaskService takes its TaskRepository through the constructor, as covered back in Week 2. Mockito just calls that constructor with the fake repository — no Spring context, no reflection into private fields, no container required. If TaskService used field injection instead, you'd need @Autowired and a running ApplicationContext just to get a dependency into the object, turning a millisecond unit test into a slow, Spring-dependent one.

2. Slice Tests: @WebMvcTest & @DataJpaTest

A pure unit test can't verify things that only exist once Spring is involved — a controller's request mapping, JSON serialization, a repository's derived query actually working against real SQL. A slice test is the middle ground: it boots a real, but deliberately narrow, Spring context containing only the beans relevant to one layer, auto-configuring just enough infrastructure to test that layer honestly, without paying for the entire application.

@WebMvcTest: the web layer, without a database

@WebMvcTest(TaskController.class) loads only MVC infrastructure — the controller, its @ControllerAdvice exception handlers, filters, and a MockMvc client — and nothing from the persistence layer. Because TaskService isn't part of that slice, it's replaced with @MockBean so the controller has something to call. This tests routing, status codes, and JSON shape without ever touching a database.

src/test/java/com/codeverse/week08/web/TaskControllerTest.java
package com.codeverse.week08.web;

import com.codeverse.week08.model.Task;
import com.codeverse.week08.model.TaskStatus;
import com.codeverse.week08.service.TaskService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(TaskController.class)
class TaskControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private TaskService taskService;

    @Test
    @WithMockUser(username = "alice")
    void getTask_returns200WithTaskJson() throws Exception {
        when(taskService.findById(1L))
            .thenReturn(new Task(1L, "Write tests", TaskStatus.OPEN, "alice"));

        mockMvc.perform(get("/api/tasks/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.status").value("OPEN"));
    }
}

@DataJpaTest: the persistence layer, without the web tier

@DataJpaTest loads the opposite slice: repositories, the entity manager, and a database connection — but no controllers, no security filter chain. By default it swaps in an embedded in-memory database and wraps every test in a transaction that rolls back afterward, so tests can insert data freely without cleaning up. This is exactly where the derived query methods and JPQL you wrote for the Task repository back in Week 4 get proven correct against real SQL, not just compiled successfully.

src/test/java/com/codeverse/week08/repository/TaskRepositoryTest.java
package com.codeverse.week08.repository;

import com.codeverse.week08.model.Task;
import com.codeverse.week08.model.TaskStatus;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.jdbc.Sql;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
class TaskRepositoryTest {

    @Autowired
    private TaskRepository taskRepository;

    @Test
    void findByAssigneeAndStatus_returnsOnlyMatchingTasks() {
        taskRepository.save(new Task(null, "Write tests", TaskStatus.OPEN, "alice"));
        taskRepository.save(new Task(null, "Deploy service", TaskStatus.DONE, "alice"));
        taskRepository.save(new Task(null, "Review PR", TaskStatus.OPEN, "bob"));

        List<Task> result = taskRepository.findByAssigneeAndStatus("alice", TaskStatus.OPEN);

        assertThat(result).hasSize(1);
        assertThat(result.get(0).title()).isEqualTo("Write tests");
    }
}
What a @DataJpaTest is actually proving

It isn't testing your entity's Java code — that's a unit test's job. It's testing that the derived query method name in TaskRepository (or the JPQL from Week 4) gets translated into SQL that returns the rows you think it does, against real JPA/Hibernate mapping. That's a category of bug a plain unit test can never catch, because there's no SQL involved in a mocked repository at all.

3. Full-Context Tests with @SpringBootTest

Slices are fast because they deliberately leave things out — and that's exactly the problem when what you need to test is one of the things left out. Neither @WebMvcTest nor @DataJpaTest loads the Spring Security filter chain you built in Weeks 6 and 7, so neither can tell you whether a request without a valid JWT actually gets rejected, or whether your SecurityFilterChain bean is wired correctly end to end. For that, you need the real thing: @SpringBootTest boots the entire application context, exactly as it would run in production.

webEnvironment = RANDOM_PORT

By default @SpringBootTest doesn't start a web server at all. Setting webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT starts a real embedded Tomcat on a free port, so requests travel through actual HTTP — the full stack, filters included — rather than being dispatched in-process the way MockMvc does under @WebMvcTest.

MockMvc vs TestRestTemplate

You can still use MockMvc here — under @SpringBootTest it runs against the full context, security filters and all, without opening a real socket. TestRestTemplate (or WebTestClient) instead makes a genuine HTTP call to the running embedded server, which is closer to how a real client behaves but slightly slower. For most full-context tests, MockMvc is the simpler default; reach for TestRestTemplate when you specifically need to prove the app is reachable over a real socket.

src/test/java/com/codeverse/week08/TaskApiAuthTest.java
package com.codeverse.week08;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class TaskApiAuthTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void tasksEndpoint_rejectsRequestsWithNoToken() throws Exception {
        mockMvc.perform(get("/api/tasks"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void tasksEndpoint_acceptsRequestWithValidJwt() throws Exception {
        mockMvc.perform(get("/api/tasks")
                .with(SecurityMockMvcRequestPostProcessors.jwt()
                    .jwt(jwt -> jwt.claim("sub", "alice").claim("scope", "tasks:read"))))
            .andExpect(status().isOk());
    }
}

SecurityMockMvcRequestPostProcessors.jwt() lets you attach a fake, already-decoded JWT to the request instead of generating and signing a real token — enough to exercise your authorization rules from Week 7 without wiring an actual identity provider into the test. When you specifically need to verify token validation itself (expiry, signature, issuer checks), use a real signed token instead.

4. Real-Database Integration Tests with Testcontainers

@DataJpaTest's default embedded database is convenient, but it's a different database engine than the PostgreSQL you deploy to in production. Column types, function names, case-sensitivity of identifiers, and JSON/array support all differ enough that a repository method — or a Flyway migration from Week 5 — can pass perfectly against an embedded database and fail the moment it hits real Postgres. Testcontainers closes that gap by launching a real, throwaway PostgreSQL instance in Docker for the test run itself.

@Testcontainers, @Container, and @ServiceConnection

@Testcontainers on the test class tells JUnit 5 to manage container lifecycle; @Container marks the field holding the container definition, typically started once per test class. Since Spring Boot 3.1, @ServiceConnection does the wiring that used to require a manual @DynamicPropertySource block — it automatically points spring.datasource.* at the running container, so the application context connects to the real, ephemeral Postgres instance with no extra configuration.

src/test/java/com/codeverse/week08/TaskApiIntegrationTest.java
package com.codeverse.week08;

import com.codeverse.week08.model.TaskStatus;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@ActiveProfiles("test")
class TaskApiIntegrationTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private MockMvc mockMvc;

    @Test
    void createTask_persistsThroughRealHttpAndRealPostgres() throws Exception {
        mockMvc.perform(post("/api/tasks")
                .with(SecurityMockMvcRequestPostProcessors.jwt()
                    .jwt(jwt -> jwt.claim("sub", "alice").claim("scope", "tasks:write")))
                .contentType("application/json")
                .content("""
                    {"title": "Ship Week 8", "status": "%s"}
                    """.formatted(TaskStatus.OPEN)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").exists())
            .andExpect(jsonPath("$.title").value("Ship Week 8"));
    }
}

Because this test boots the full application context against a real container, Spring Boot's Flyway auto-configuration runs your actual migrations from Week 5 against actual PostgreSQL before the first test executes — silently proving the migration scripts themselves are valid, not just the Java code that assumes they ran.

Preview: this whole suite runs in CI

None of this is only a local convenience — Week 13 wires this exact suite (unit, slice, full-context, and Testcontainers tests together) into a CI pipeline that spins up Docker on every push, so a broken migration or a regressed endpoint fails the build before it ever reaches a deployment.

5. Hands-on Exercise

Hands-on

Build a full layered test suite for the Task API

Take the Task API you've built across Weeks 3–7 — secured, persisted, and validated — and back it with a real test suite spanning every layer covered this week.

Requirements:

  1. Write a Mockito unit test class for TaskService using @ExtendWith(MockitoExtension.class), @Mock, and @InjectMocks, covering at least one success path and one failure path (for example, completing a task that doesn't exist).
  2. Write a @WebMvcTest for TaskController that mocks TaskService with @MockBean and asserts on HTTP status and JSON shape for at least two endpoints, using @WithMockUser or a mocked JWT to satisfy security.
  3. Write a @DataJpaTest for one custom repository query (a derived query method or JPQL from Week 4), saving test rows and asserting the query returns exactly the rows it should — no more, no fewer.
  4. Write a Testcontainers-backed @SpringBootTest that starts a real PostgreSQLContainer with @Container and @ServiceConnection, then uses MockMvc with a mocked JWT to create a task through a real authenticated HTTP request, asserting the response and that the row exists afterward.
  5. Run the whole suite with ./mvnw test and confirm all four test classes pass together, with the Testcontainers test visibly pulling and starting a Postgres container in the console output.
Hint

Docker Desktop (or an equivalent Docker daemon) needs to be running locally before the Testcontainers test starts — it launches a real container, not a simulated one. If ./mvnw test hangs on that test, check Docker is actually up first.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does constructor injection make unit testing a service easier than field injection?

With constructor injection, a class's dependencies are ordinary constructor parameters, so Mockito's @InjectMocks can build the object under test by simply calling that constructor with fake mocks — no Spring container, no reflection into private fields, no application context required. Field injection relies on @Autowired setting private fields after construction, which normally only happens inside a running Spring context; testing a field-injected class without one means either standing up a real (slow) context or resorting to reflection tricks just to get a mock into place. Constructor injection, which this course adopted back in Week 2, keeps the service pure Java from a testing standpoint.

Q2

What does a "slice" test like @WebMvcTest or @DataJpaTest actually load, compared to a full @SpringBootTest?

A slice test auto-configures only the beans relevant to one architectural layer and disables the rest of Spring Boot's auto-configuration. @WebMvcTest loads the MVC infrastructure — the specified controller, its exception handlers, filters, and a MockMvc client — but no repositories or datasource, so any service the controller depends on must be supplied as a @MockBean. @DataJpaTest is the mirror image: it loads repositories, the entity manager, and a (by default embedded) database connection wrapped in a rolled-back transaction, but no controllers and no security filter chain. A full @SpringBootTest loads the entire application context exactly as it would run in production — every layer, every auto-configuration, optionally a real embedded web server — which is why it's the only option for testing things a slice deliberately leaves out, like the security filter chain.

Q3

Why can running integration tests against a real PostgreSQL container with Testcontainers catch bugs an embedded H2 database wouldn't?

H2 and PostgreSQL are different database engines with different SQL dialects, different built-in functions, different behavior around identifier case-sensitivity, and different support for types like JSON columns or arrays. Code that compiles against Hibernate and passes against H2 can still generate SQL that PostgreSQL rejects outright, or — worse — SQL that runs but returns subtly different results, such as a case-sensitive string comparison that H2 treats as case-insensitive by default. A Flyway migration written with Postgres-specific syntax may not even be valid against H2 in the first place. Testcontainers removes the discrepancy entirely by running the exact same database engine, version, and configuration the application deploys against, so a passing test means the code genuinely works against production infrastructure, not just an approximation of it.

Q4

When should you reach for @WebMvcTest instead of a full @SpringBootTest, and vice versa?

Reach for @WebMvcTest when what you're verifying lives entirely in the web layer — request mapping, path variables, validation error responses, JSON serialization shape — and every other collaborator can safely be a @MockBean stand-in. It boots in a fraction of the time a full context takes, which matters once a suite has hundreds of tests. Reach for a full @SpringBootTest when the behavior under test only exists because multiple layers interact for real: verifying the Spring Security filter chain actually rejects an unauthenticated request, confirming beans wire together correctly across the whole context, or running an end-to-end flow through a real embedded server with webEnvironment = RANDOM_PORT. As a rule of thumb, default to the narrowest slice that can prove the behavior, and escalate to @SpringBootTest — optionally combined with Testcontainers — only when a slice genuinely can't see the thing you're testing.