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.
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.
@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.
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.
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");
}
}
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.
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.
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.
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
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:
- Write a Mockito unit test class for
TaskServiceusing@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). - Write a
@WebMvcTestforTaskControllerthat mocksTaskServicewith@MockBeanand asserts on HTTP status and JSON shape for at least two endpoints, using@WithMockUseror a mocked JWT to satisfy security. - Write a
@DataJpaTestfor 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. - Write a Testcontainers-backed
@SpringBootTestthat starts a realPostgreSQLContainerwith@Containerand@ServiceConnection, then usesMockMvcwith a mocked JWT to create a task through a real authenticated HTTP request, asserting the response and that the row exists afterward. - Run the whole suite with
./mvnw testand confirm all four test classes pass together, with the Testcontainers test visibly pulling and starting a Postgres container in the console output.
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?
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?
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?
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?
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.