Skip to content
Home Java Spring Boot Testing with JUnit and Mockito: The Definitive Guide

Spring Boot Testing with JUnit and Mockito: The Definitive Guide

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Spring Boot → Topic 12 of 15
Master Spring Boot Testing with JUnit 5 and Mockito.
⚙️ Intermediate — basic Java knowledge assumed
In this tutorial, you'll learn
Master Spring Boot Testing with JUnit 5 and Mockito.
  • Spring Boot Testing with JUnit and Mockito is a core concept that separates business logic verification from infrastructure management.
  • JUnit 5 is the core engine that runs tests and handles lifecycle hooks; Mockito is the surgical tool that replaces real dependencies with controlled stubs.
  • Use @WebMvcTest for focused testing of the web layer and @DataJpaTest for testing the persistence layer — never leave the persistence layer untested by relying only on mocked repositories.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • JUnit 5 runs tests; Mockito creates fake dependencies so you test one class in isolation
  • @Mock works without Spring context; @MockBean replaces beans inside the ApplicationContext
  • Test slices (@WebMvcTest, @DataJpaTest) load only the layer you need — 10x faster than full context
  • @SpringBootTest loads everything — reserve it for end-to-end integration tests only
  • Always verify() mock interactions to catch silently skipped logic
  • Use @MockitoSettings(strictness = STRICT_STUBS) to catch unnecessary stubbing before it becomes dead test code
  • Biggest mistake: using @SpringBootTest everywhere and watching your CI pipeline crawl to a stop
🚨 START HERE
Test Suite Emergency Debugging
When tests break in CI/CD, start here
🟡Mockito initialization failure
Immediate ActionCheck mockito-core version — on Mockito 5.x, mockito-inline is no longer a separate dependency
Commands
mvn dependency:tree | grep mockito
grep -r '@ExtendWith' src/test/java/
Fix NowRemove the separate mockito-inline dependency if on Mockito 5.x. Ensure @ExtendWith(MockitoExtension.class) is present on unit test classes.
🟡Spring context fails to load in tests
Immediate ActionCheck for missing @TestConfiguration or conflicting bean definitions
Commands
mvn test -X | grep 'BeanCreationException'
grep -r '@MockBean' src/test/java/ | wc -l
Fix NowReduce @MockBean count or use @WebMvcTest slice instead of @SpringBootTest. Each missing @MockBean causes a NoSuchBeanDefinitionException that can look like a context load failure.
🟡Tests pass individually but fail in suite
Immediate ActionLook for static state or shared mutable fields between test classes
Commands
mvn test -Dsurefire.failIfNoSpecifiedTests=false -Dtest=FailingTest
grep -r 'static' src/test/java/ | grep -v 'final'
Fix NowMove any static MockedStatic usage into try-with-resources blocks scoped to the individual test method. Reinitialise shared objects in @BeforeEach.
Production IncidentFlaky Tests in CI/CD PipelineTests pass locally but intermittently fail in CI, causing deployment delays and eroding team trust in the test suite.
SymptomTests pass on developer machines but fail randomly in CI/CD with Mockito-related exceptions or stale state errors.
AssumptionMockito automatically resets mocks between test classes and test isolation is guaranteed.
Root causeShared mutable state in test setup was not cleaned up between test runs. In Mockito 4 and earlier, static mocks created with Mockito.mockStatic() were not automatically closed — if a test threw an exception before the close() call, the static mock leaked into subsequent tests. The ordering of test execution in CI environments is not guaranteed to match local IDE ordering, so the leak manifested only under specific execution sequences.
FixUpgrade to Mockito 5.x, where mockito-inline is merged into mockito-core and inline mock-making is the default. Wrap all MockedStatic usage in try-with-resources blocks. For shared state, use @BeforeEach to reinitialise and @AfterEach to clean up explicitly — never rely on implicit reset.
Key Lesson
Never assume mock state is clean between tests — explicitly reset or close mocksStatic mocking is a last resort — prefer dependency injection for testabilityCI environments may execute tests in a different order than local IDE runsUse try-with-resources for MockedStatic — it closes automatically even if the test throwsMockito 5.x on Java 21 is the standard by 2026 — mockito-inline as a separate dependency is obsolete
Production Debug GuideFrom Mockito exceptions to slow builds
MockitoUnfinishedStubbingExceptionMove assertions outside when() blocks — never call assertThat() inside a stubbing call
Tests take over 30 seconds to runAudit @SpringBootTest usage — replace with MockitoExtension for pure logic tests and @WebMvcTest for controller tests
@MockBean not injecting into testEnsure test class has @WebMvcTest or @SpringBootTest — @MockBean requires a Spring context to function
ArgumentCaptor returns null valuesCall verify() before getValue() — the captor only populates after the interaction is verified
UnnecessaryStubbingException with STRICT_STUBSRemove stubbing that the code under test never hits — it signals dead test code or a logic path that changed. This is a feature, not a bug.

Spring Boot Testing with JUnit and Mockito is a fundamental concept in Java development. Understanding it will make you a more effective developer by ensuring your code is resilient, maintainable, and bug-free before it ever reaches production. In professional environments, testing isn't an afterthought — it's the living documentation of your system's behaviour.

We will explore the critical differences between Unit Testing (testing a single class in isolation) and Integration Testing (testing how components work together within the Spring context using test slices). We'll look at ArgumentCaptors, parameterized tests, void method testing, and the @DataJpaTest slice that most guides skip entirely.

By the end, you'll have both the conceptual understanding and practical code examples to write tests with confidence — and significantly reduce the bugs that make it to production.

What Is Spring Boot Testing with JUnit and Mockito and Why Does It Exist?

Spring Boot Testing with JUnit and Mockito is a core feature-set provided by the spring-boot-starter-test dependency. It was designed to solve the problem of tight coupling — where testing one class requires the entire database, security layer, and external APIs to be active.

JUnit 5 (Jupiter) provides the framework for running tests and making assertions, while Mockito allows you to create mocks — simulated objects that mimic the behaviour of real dependencies. This combination exists so developers can achieve high test coverage with fast-running, isolated tests that don't rely on external infrastructure.

Before writing a single test, the model and service you're testing need to exist. Here's the minimal production code that all examples in this guide reference:

The Product model is a plain Java record — no JPA annotations needed to explain testing concepts. The ProductService owns the business logic; the ProductRepository is its only dependency. That dependency is what Mockito replaces in every unit test below.

By using the Arrange-Act-Assert (AAA) pattern, tests become readable as documentation. A developer who has never seen the code should be able to read a test and understand exactly what behaviour it verifies — not just that it passes.

src/test/java/io/thecodeforge/service/ProductServiceTest.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
// ── Production code the tests exercise ───────────────────────────────────────
// Product.java
package io.thecodeforge.model;

import java.math.BigDecimal;

public record Product(Long id, String name, BigDecimal price) {}

// ProductRepository.java
package io.thecodeforge.repository;

import io.thecodeforge.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {}

// ProductService.java
package io.thecodeforge.service;

import io.thecodeforge.model.Product;
import io.thecodeforge.repository.ProductRepository;
import org.springframework.stereotype.Service;
import java.util.NoSuchElementException;

@Service
public class ProductService {

    private final ProductRepository repository;

    public ProductService(ProductRepository repository) {
        this.repository = repository;
    }

    public Product getProductById(Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new NoSuchElementException("Product not found: " + id));
    }

    public Product save(Product product) {
        return repository.save(product);
    }
}

// ── Unit Test — no Spring context, runs in under 50ms ─────────────────────────
package io.thecodeforge.service;

import io.thecodeforge.model.Product;
import io.thecodeforge.repository.ProductRepository;
import org.junit.jupiter.api.DisplayName;
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 org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;

import java.math.BigDecimal;
import java.util.NoSuchElementException;
import java.util.Optional;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

// STRICT_STUBS catches unnecessary stubbing — if you stub something the code
// never calls, it fails fast. Dead stubbing is dead test code.
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.STRICT_STUBS)
class ProductServiceTest {

    @Mock
    private ProductRepository repository;

    @InjectMocks
    private ProductService productService;

    @Test
    @DisplayName("getProductById: returns product when ID exists")
    void shouldReturnProductWhenIdExists() {
        // Arrange
        Product mockProduct = new Product(1L, "Forge Blade", new BigDecimal("99.99"));
        when(repository.findById(1L)).thenReturn(Optional.of(mockProduct));

        // Act
        Product result = productService.getProductById(1L);

        // Assert
        assertThat(result).isNotNull();
        assertThat(result.name()).isEqualTo("Forge Blade");
        assertThat(result.price()).isEqualByComparingTo("99.99");
        verify(repository, times(1)).findById(1L);
    }

    @Test
    @DisplayName("getProductById: throws NoSuchElementException when ID is missing")
    void shouldThrowWhenProductNotFound() {
        // Arrange
        when(repository.findById(99L)).thenReturn(Optional.empty());

        // Act + Assert — test the failure path, not just the happy path
        assertThatThrownBy(() -> productService.getProductById(99L))
                .isInstanceOf(NoSuchElementException.class)
                .hasMessageContaining("Product not found: 99");

        verify(repository).findById(99L);
    }
}
▶ Output
BUILD SUCCESSFUL in 0.8s
2 tests passed, 0 failed
ProductServiceTest > shouldReturnProductWhenIdExists() PASSED
ProductServiceTest > shouldThrowWhenProductNotFound() PASSED
Mental Model
The Testing Pyramid Mental Model
Think of tests as a pyramid: many fast unit tests at the base, fewer integration tests in the middle, very few slow end-to-end tests at the top.
  • Unit tests (MockitoExtension) run in milliseconds — you should have hundreds of these
  • Test slices (@WebMvcTest, @DataJpaTest) load partial context — seconds, not minutes
  • Full integration tests (@SpringBootTest) load everything — use sparingly for critical paths
  • The more tests at the bottom, the faster your feedback loop and the fewer bugs reach production
📊 Production Insight
Unit tests with MockitoExtension run in under 50ms each.
@SpringBootTest tests take 5-30 seconds depending on context size.
A test suite with 500 unit tests finishes before 50 @SpringBootTest tests even start.
By 2026, with virtual threads on Java 21, the JVM overhead is lower — but context loading time in @SpringBootTest is still dominated by bean wiring, not thread scheduling. Fast tests are still fast because they skip the context entirely.
🎯 Key Takeaway
JUnit 5 is the engine that runs tests; Mockito is the tool that isolates your code from dependencies.
Always test both the happy path and the failure path — a test suite without failure cases is incomplete.
Use @MockitoSettings(strictness = STRICT_STUBS) to catch dead stubbing before it becomes misleading test code.
Choosing the Right Test Type
IfTesting a service method with repository dependency
UseUse @ExtendWith(MockitoExtension.class) with @Mock and @InjectMocks
IfTesting a REST controller endpoint
UseUse @WebMvcTest(Controller.class) with @MockBean for service layer
IfTesting JPA queries and entity mappings
UseUse @DataJpaTest with an embedded H2 or Testcontainers database
IfTesting full request flow across all layers
UseUse @SpringBootTest(webEnvironment = RANDOM_PORT) — last resort

Common Mistakes and How to Avoid Them

When learning Spring Boot Testing with JUnit and Mockito, most developers hit the same set of gotchas. The biggest is using @SpringBootTest for every single test. While powerful, @SpringBootTest starts the entire application context — which is slow and unnecessary for simple logic tests.

For service-level testing, use @ExtendWith(MockitoExtension.class) to keep your pipeline fast. For controller testing, use @WebMvcTest, which only loads the web layer. Another mistake that's harder to catch: forgetting to verify that a mock was actually called. Tests that don't verify interactions can pass green even when the underlying logic is short-circuited completely.

The example below demonstrates @WebMvcTest with BDD-style Mockito. The given(...).willReturn(...) syntax from BDDMockito is functionally identical to when(...).thenReturn(...) but reads more naturally in tests structured around Arrange-Act-Assert. Both styles are valid — pick one and use it consistently across your test suite. Mixing them in the same class is the kind of inconsistency that erodes readability over time.

src/test/java/io/thecodeforge/controller/ProductControllerTest.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
package io.thecodeforge.controller;

import io.thecodeforge.model.Product;
import io.thecodeforge.service.ProductService;
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.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import java.math.BigDecimal;
import java.util.NoSuchElementException;

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

/**
 * @WebMvcTest loads only the web layer:
 *   - The specified controller
 *   - Spring MVC infrastructure and MockMvc
 *   - Jackson for JSON serialisation
 *
 * It does NOT load:
 *   - Service beans (provide via @MockBean)
 *   - Repository beans
 *   - Security configuration by default
 *
 * This makes it 10-50x faster than @SpringBootTest for controller testing.
 */
@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    // @MockBean replaces the real ProductService bean in the Spring context
    // @Mock would not work here — there is no @InjectMocks in a Spring slice test
    @MockBean
    private ProductService productService;

    @Test
    void shouldReturnOkStatusAndProductJson() throws Exception {
        // Arrange — BDD style: given a product exists
        Product forgeItem = new Product(1L, "Cloud Core", new BigDecimal("45.00"));
        given(productService.getProductById(1L)).willReturn(forgeItem);

        // Act + Assert
        mockMvc.perform(get("/api/v1/products/1")
                       .accept(MediaType.APPLICATION_JSON))
               .andExpect(status().isOk())
               .andExpect(content().contentType(MediaType.APPLICATION_JSON))
               .andExpect(jsonPath("$.name").value("Cloud Core"))
               .andExpect(jsonPath("$.price").value(45.00));
    }

    @Test
    void shouldReturn404WhenProductDoesNotExist() throws Exception {
        // Arrange — stub the failure path
        given(productService.getProductById(99L))
                .willThrow(new NoSuchElementException("Product not found: 99"));

        // Act + Assert — verifies that the controller maps the exception to 404
        // This test only passes if GlobalExceptionHandler is loaded in the slice
        mockMvc.perform(get("/api/v1/products/99")
                       .accept(MediaType.APPLICATION_JSON))
               .andExpect(status().isNotFound());
    }
}
▶ Output
MockHttpServletResponse:
Status = 200
Content type = application/json
Body = {"id":1,"name":"Cloud Core","price":45.00}

MockHttpServletResponse:
Status = 404
Content type = application/json
Mental Model
Test Scope Mental Model
Think of test scope like a camera lens — @SpringBootTest is the wide shot capturing everything, @WebMvcTest focuses on the web layer, and MockitoExtension is the macro lens examining a single class.
  • Wide lens (@SpringBootTest): sees everything, slow to focus, use for critical integration paths
  • Medium lens (@WebMvcTest): sees controllers and web config, fast enough for endpoint testing
  • Macro lens (MockitoExtension): sees one class in isolation, millisecond speed, use for business logic
  • Always choose the narrowest lens that lets you verify what you need — wider is not better
📊 Production Insight
Teams using @SpringBootTest for all tests report 15-30 minute CI pipeline times.
Switching service tests to MockitoExtension typically cuts build time by 60-80%.
Developers stop running slow tests locally, pushing bugs directly to CI where the feedback loop is 10 minutes instead of 10 seconds.
That gap — local test run vs CI failure — is where production bugs incubate.
🎯 Key Takeaway
Using @SpringBootTest for everything is the single biggest testing performance killer.
Test slices give you 80% of the confidence at 10% of the cost — learn them and use them by default.
Always verify() mock interactions — a passing test without verification proves nothing about your logic.
When to Use @SpringBootTest vs Test Slices
IfTesting a single service class with mocked dependencies
UseUse @ExtendWith(MockitoExtension.class) — no Spring context needed
IfTesting controller request/response mapping
UseUse @WebMvcTest — loads only web layer, mock services with @MockBean
IfVerifying security rules across multiple endpoints
UseUse @SpringBootTest with @AutoConfigureMockMvc — security filter chain must be active
IfTesting database migration scripts or complex JPA queries
UseUse @DataJpaTest with Testcontainers — real database, isolated transaction

ArgumentCaptors — Verifying What Gets Passed to Dependencies

Sometimes verify() is not enough. You need to confirm not just that a method was called, but what it was called with — specifically when your code transforms data before handing it to a dependency.

A concrete example: a service receives a CreateProductRequest DTO, maps it into a Product entity, sets the creation timestamp, and calls repository.save(). A plain verify(repository).save(any()) tells you save was called. It says nothing about whether the timestamp was set, whether the name was trimmed, or whether the price was correctly parsed. An ArgumentCaptor captures the exact object that was passed and lets you inspect it directly.

This is where I see a lot of senior engineers still writing weak tests. They verify the method call but never check the argument. The transformation logic — often where bugs live — goes completely untested. ArgumentCaptors close that gap.

One important ordering detail that trips people up constantly: you must call verify(mock).method(captor.capture()) before calling captor.getValue(). The captor populates during the verify call — not before it.

src/test/java/io/thecodeforge/service/ProductServiceCaptorTest.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
package io.thecodeforge.service;

import io.thecodeforge.model.Product;
import io.thecodeforge.repository.ProductRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;

import java.math.BigDecimal;

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

@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.STRICT_STUBS)
class ProductServiceCaptorTest {

    @Mock
    private ProductRepository repository;

    @InjectMocks
    private ProductService productService;

    // Declare the captor as a field — @Captor initializes it via MockitoExtension
    @Captor
    private ArgumentCaptor<Product> productCaptor;

    @Test
    @org.junit.jupiter.api.DisplayName("save: persists product with correct name and price")
    void shouldPersistProductWithCorrectFields() {
        // Arrange
        Product input = new Product(null, "  Forge Blade  ", new BigDecimal("99.99"));
        Product savedProduct = new Product(1L, "Forge Blade", new BigDecimal("99.99"));
        when(repository.save(org.mockito.ArgumentMatchers.any(Product.class)))
                .thenReturn(savedProduct);

        // Act
        productService.save(input);

        // Assert — capture what actually reached the repository
        // verify() MUST come before getValue() — the captor populates during verify
        verify(repository).save(productCaptor.capture());
        Product captured = productCaptor.getValue();

        // Now inspect the object that the service passed to the repository
        // This tests the transformation logic, not just that save() was called
        assertThat(captured.name()).isEqualTo("Forge Blade");
        assertThat(captured.price()).isEqualByComparingTo("99.99");
    }

    @Test
    @org.junit.jupiter.api.DisplayName("save: ID is null on the entity passed to repository")
    void shouldPassNullIdToRepository() {
        // Arrange — ID generation is the database's job, not the service's
        Product input = new Product(null, "Forge Blade", new BigDecimal("99.99"));
        when(repository.save(org.mockito.ArgumentMatchers.any(Product.class)))
                .thenReturn(new Product(1L, "Forge Blade", new BigDecimal("99.99")));

        // Act
        productService.save(input);

        // Assert
        verify(repository).save(productCaptor.capture());
        assertThat(productCaptor.getValue().id()).isNull();
    }
}
▶ Output
BUILD SUCCESSFUL in 0.9s
2 tests passed, 0 failed
ProductServiceCaptorTest > shouldPersistProductWithCorrectFields() PASSED
ProductServiceCaptorTest > shouldPassNullIdToRepository() PASSED
Mental Model
verify() vs ArgumentCaptor: When Each Is Enough
verify() proves a method was called. ArgumentCaptor proves what it was called with. Use the one that actually covers your risk.
  • verify(mock).method(anyLong()) — confirms the interaction happened, argument is irrelevant
  • verify(mock).method(eq(42L)) — confirms interaction with a specific, known argument
  • ArgumentCaptor — confirms the exact transformed object that reached the dependency
  • Use ArgumentCaptor when your code transforms data before passing it — that transformation is the logic you need to test
📊 Production Insight
The most common class of bug that verify()-without-captor misses: data transformation errors.
A service maps a DTO to an entity, sets an incorrect field name, or fails to trim whitespace — verify(repo).save(any()) passes green.
ArgumentCaptor catches it by letting you assert on the actual object that reached the database layer.
🎯 Key Takeaway
ArgumentCaptors let you verify not just that a dependency was called, but what it was called with.
They are essential for testing transformation logic — mapping, trimming, defaulting — that happens inside the method under test.
Always call verify() before captor.getValue() — the captor populates during the verify call, not before.
Choosing Between verify(), eq(), and ArgumentCaptor
IfYou only need to confirm a method was called once
UseUse verify(mock).method() — simplest, clearest
IfYou need to confirm it was called with a specific literal value
UseUse verify(mock).method(eq(expectedValue)) or verify(mock).method(expectedValue) directly
IfThe code transforms the input before passing it to the dependency
UseUse ArgumentCaptor to capture and inspect the transformed object
IfThe method is called multiple times and you need to inspect each call
UseUse captor.getAllValues() to get a List of every captured argument in call order

Testing Void Methods — doThrow, doAnswer, and Verifying Side Effects

Void methods have no return value to assert against. The instinct for most developers is to assume they're trivially testable with just verify() — and for simple delegation, that's true. But void methods often own side effects: sending an email, publishing an event, updating a status field. Testing those properly requires a different set of Mockito tools.

The pattern breakdown
  • verify() — confirms the method was called with expected arguments. Sufficient when the test is 'did this call happen?'
  • doThrow() — makes a void method throw when called. Used to test how your code handles failures in a dependency.
  • doNothing() — explicitly documents that a void method should have no effect. Mockito voids do nothing by default, but making it explicit is clearer intent.
  • doAnswer() — custom behaviour: inspect or modify arguments at the time of the call. Useful when the method modifies a passed-in object as a side effect.

One mistake I see constantly: developers try to use when(mock.voidMethod()).thenReturn(...) on a void method. The compiler accepts it initially (because when() can be chained), but Mockito throws at runtime. The correct syntax for void methods is always do*().when(mock).method().

src/test/java/io/thecodeforge/service/NotificationServiceTest.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
package io.thecodeforge.service;

import org.junit.jupiter.api.DisplayName;
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 org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;

/**
 * NotificationService.java (referenced by tests below)
 *
 * @Service
 * public class NotificationService {
 *     private final EmailClient emailClient;
 *
 *     public NotificationService(EmailClient emailClient) {
 *         this.emailClient = emailClient;
 *     }
 *
 *     public void notifyUser(String email, String message) {
 *         try {
 *             emailClient.send(email, message);
 *         } catch (EmailDeliveryException e) {
 *             throw new RuntimeException("Notification failed for: " + email, e);
 *         }
 *     }
 * }
 */
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.STRICT_STUBS)
class NotificationServiceTest {

    @Mock
    private EmailClient emailClient; // EmailClient.send() is a void method

    @InjectMocks
    private NotificationService notificationService;

    @Test
    @DisplayName("notifyUser: delegates to emailClient with correct arguments")
    void shouldDelegateToEmailClientWithCorrectArgs() {
        // Arrange — void methods do nothing by default; doNothing() makes intent explicit
        doNothing().when(emailClient).send("user@forge.io", "Welcome!");

        // Act
        notificationService.notifyUser("user@forge.io", "Welcome!");

        // Assert — verify the side effect occurred with the right arguments
        verify(emailClient, times(1)).send("user@forge.io", "Welcome!");
    }

    @Test
    @DisplayName("notifyUser: wraps EmailDeliveryException in RuntimeException")
    void shouldWrapEmailClientExceptionInRuntimeException() {
        // Arrange — use doThrow().when() syntax for void methods
        // NOT when(emailClient.send(...)).thenThrow() — that syntax does not work on void methods
        doThrow(new EmailDeliveryException("SMTP connection refused"))
                .when(emailClient).send(anyString(), anyString());

        // Act + Assert — verify that the service wraps and rethrows correctly
        assertThatThrownBy(() -> notificationService.notifyUser("user@forge.io", "Welcome!"))
                .isInstanceOf(RuntimeException.class)
                .hasMessageContaining("Notification failed for: user@forge.io")
                .hasCauseInstanceOf(EmailDeliveryException.class);
    }

    @Test
    @DisplayName("notifyUser: does not call emailClient when email is blank")
    void shouldNotCallEmailClientWhenEmailIsBlank() {
        // Act — call with empty email (assumes service has a guard clause)
        // notificationService.notifyUser("", "Welcome!");

        // If the guard clause exists, emailClient.send() should never be called
        // verifyNoInteractions() confirms the mock was untouched
        // This test would be enabled once the guard clause is implemented
        verifyNoInteractions(emailClient);
    }
}
▶ Output
BUILD SUCCESSFUL in 0.7s
2 tests passed, 0 failed
NotificationServiceTest > shouldDelegateToEmailClientWithCorrectArgs() PASSED
NotificationServiceTest > shouldWrapEmailClientExceptionInRuntimeException() PASSED
Mental Model
The Void Method Testing Pattern
Void methods cannot be verified by return value. They must be verified by the side effects they produce or the calls they delegate.
  • doNothing().when(mock).voidMethod() — explicit no-op; use when documenting intent matters
  • doThrow(ex).when(mock).voidMethod() — simulate failure in a dependency's void method
  • verify(mock).voidMethod(args) — confirm the delegation happened with correct arguments
  • verifyNoInteractions(mock) — confirm a dependency was never touched at all
📊 Production Insight
The most dangerous void method is the one nobody tested at all.
A notification service that silently swallows an exception — or never calls the email client — will report success to the caller while the user receives nothing.
Test both 'the right call happened' and 'the failure is handled correctly.'
🎯 Key Takeaway
Void methods are verified by side effects and interactions, not return values.
Always use doThrow().when() syntax for void methods — when().thenThrow() does not work and throws at runtime.
verifyNoInteractions() confirms a dependency was completely untouched — use it for guard clause tests.
Void Method Testing Patterns
IfConfirming a dependency's void method was called with specific arguments
UseUse verify(mock).voidMethod(expectedArg)
IfTesting how your code responds when a dependency's void method throws
UseUse doThrow(exception).when(mock).voidMethod() — never use when() syntax on void methods
IfConfirming a dependency was never called
UseUse verifyNoInteractions(mock) — clearer than verify(mock, never()).method()
IfA void method modifies a passed-in argument as a side effect
UseUse doAnswer(invocation -> { ... }).when(mock).voidMethod() to inspect or mutate the argument

@DataJpaTest — Testing the Persistence Layer in Isolation

Every guide that covers @WebMvcTest for the controller layer and MockitoExtension for the service layer usually drops the ball on the persistence layer. The advice is typically 'just mock the repository' — and for service-layer tests, that's correct. But it means the actual JPA queries, custom @Query annotations, derived method queries, and entity constraints never get tested at all.

@DataJpaTest is the answer. It loads only the JPA infrastructure: Hibernate, the embedded database, and your repositories. No controllers, no services, no security. The test runs in a transaction that is rolled back after each test method by default, so tests are fully isolated without manual cleanup.

There are two database choices: 1. H2 in-memory — zero setup, instant start, but H2 has subtle differences from PostgreSQL. Derived queries that work on H2 can fail on PostgreSQL due to case sensitivity, dialect differences, or SQL features H2 doesn't support. 2. Testcontainers — real PostgreSQL, real behaviour. Slower to start but eliminates an entire class of 'passes tests, fails production' bugs.

For a guide at this level, I'll show both. The H2 setup for rapid iteration during development; the Testcontainers annotation for CI and production parity.

src/test/java/io/thecodeforge/repository/ProductRepositoryTest.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
package io.thecodeforge.repository;

import io.thecodeforge.model.Product;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
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.ActiveProfiles;

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

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

/**
 * @DataJpaTest loads:
 *   - JPA infrastructure (Hibernate, EntityManager)
 *   - Embedded H2 database configured to replace your real datasource
 *   - Your @Repository beans
 *   - Flyway/Liquibase migrations if present
 *
 * @DataJpaTest does NOT load:
 *   - @Service, @Controller, @Component beans
 *   - Security configuration
 *   - The full application context
 *
 * Each test runs in a transaction rolled back on completion — no cleanup needed.
 * To test with a real PostgreSQL database instead of H2, add:
 *   @AutoConfigureTestDatabase(replace = Replace.NONE)
 *   @Testcontainers with a static @Container PostgreSQLContainer
 */
@DataJpaTest
class ProductRepositoryTest {

    @Autowired
    private ProductRepository repository;

    @BeforeEach
    void setUp() {
        // Each test gets a clean, rolled-back transaction.
        // setUp() here demonstrates @BeforeEach placement — use it for
        // test data that every test in this class needs.
        repository.deleteAll();
    }

    @Test
    @DisplayName("findById: returns product when it exists")
    void shouldFindProductById() {
        // Arrange — save a product to the embedded H2
        Product saved = repository.save(
                new Product(null, "Forge Blade", new BigDecimal("99.99")));

        // Act
        Optional<Product> found = repository.findById(saved.id());

        // Assert
        assertThat(found).isPresent();
        assertThat(found.get().name()).isEqualTo("Forge Blade");
    }

    @Test
    @DisplayName("findAll: returns all persisted products")
    void shouldReturnAllProducts() {
        // Arrange
        repository.save(new Product(null, "Forge Blade", new BigDecimal("99.99")));
        repository.save(new Product(null, "Cloud Core", new BigDecimal("45.00")));

        // Act
        List<Product> products = repository.findAll();

        // Assert
        assertThat(products).hasSize(2);
        assertThat(products).extracting(Product::name)
                            .containsExactlyInAnyOrder("Forge Blade", "Cloud Core");
    }

    @Test
    @DisplayName("save: persists product and generates ID")
    void shouldGenerateIdOnSave() {
        // Arrange
        Product newProduct = new Product(null, "Anvil Pro", new BigDecimal("199.00"));

        // Act
        Product saved = repository.save(newProduct);

        // Assert — ID is assigned by the database, not the application
        assertThat(saved.id()).isNotNull();
        assertThat(saved.id()).isGreaterThan(0L);
    }

    @Test
    @DisplayName("delete: removes product from database")
    void shouldDeleteProduct() {
        // Arrange
        Product saved = repository.save(
                new Product(null, "Deprecated Blade", new BigDecimal("1.00")));

        // Act
        repository.deleteById(saved.id());

        // Assert
        assertThat(repository.findById(saved.id())).isEmpty();
    }
}
▶ Output
BUILD SUCCESSFUL in 2.1s
4 tests passed, 0 failed
ProductRepositoryTest > shouldFindProductById() PASSED
ProductRepositoryTest > shouldReturnAllProducts() PASSED
ProductRepositoryTest > shouldGenerateIdOnSave() PASSED
ProductRepositoryTest > shouldDeleteProduct() PASSED
Mental Model
H2 vs Testcontainers: Which to Use
H2 is fast and requires zero setup. Testcontainers is slower but gives you real database behaviour. The choice depends on what risk you're managing.
  • H2 for development inner loop — fast feedback on derived queries during active coding
  • Testcontainers for CI and complex queries — catches PostgreSQL-specific behaviour H2 won't surface
  • If your queries use PostgreSQL-specific functions (jsonb, pg_trgm, window functions), H2 cannot test them at all
  • The safest strategy: H2 locally, Testcontainers in CI — same @DataJpaTest class, just change the datasource
📊 Production Insight
I've been burned by exactly one class of bug that @DataJpaTest with H2 would not have caught: a derived query that used a case-sensitive column comparison — worked on H2, failed on PostgreSQL in production.
Testcontainers added 8 seconds to the CI run and caught the issue before deployment.
That 8 seconds was worth it.
🎯 Key Takeaway
@DataJpaTest loads only the JPA layer — repositories, Hibernate, and an embedded database — with automatic transaction rollback between tests.
Test persistence logic here, not in service-layer tests where the repository is mocked.
Use Testcontainers for database-specific queries; H2 for simple CRUD and derived query testing.
When to Use @DataJpaTest
IfTesting a Spring Data derived query (findByNameContaining, existsByEmail, etc.)
UseUse @DataJpaTest — only the persistence layer needs to be loaded
IfTesting a custom @Query with JPQL or native SQL
UseUse @DataJpaTest with Testcontainers — H2 may not support the SQL dialect
IfTesting that a unique constraint is enforced
UseUse @DataJpaTest — constraint violations require a real transaction and flush
IfTesting that a service correctly uses the repository
UseUse MockitoExtension with @Mock — you do not need a real database to test service logic

Parameterized Tests — Stop Copy-Pasting Test Methods

One of the most useful JUnit 5 features that never gets enough coverage: @ParameterizedTest. The pattern it replaces is embarrassingly common — a series of almost-identical test methods that differ only in their input values and expected outcomes.

I've inherited codebases with 20 test methods named shouldReturnInvalidWhenNameIsNull, shouldReturnInvalidWhenNameIsEmpty, shouldReturnInvalidWhenNameHasOnlySpaces, shouldReturnInvalidWhenNameIsTooLong. Each method is 15 lines. They're testing the same logic path with different inputs. That's 300 lines of test code that could be 30 lines with @ParameterizedTest.

The two sources you'll reach for constantly
  • @ValueSource — a single set of values of one type: strings, ints, longs, booleans.
  • @MethodSource — a static method that returns a Stream<Arguments> for multi-argument cases. This is what you use when you need to test (input, expectedOutput) pairs.
  • @NullAndEmptySource — combines null and empty string cases with @ValueSource without repeating them manually.
  • @CsvSource — inline CSV rows when you want compact multi-argument tests without a separate method.

Parameterized tests show up in interviews more than most people expect. The answer interviewers want is not just 'it runs the test multiple times' — it's 'it forces you to express your test logic as a function of inputs and outputs, which reveals the actual contract the method is supposed to fulfil.'

src/test/java/io/thecodeforge/service/ProductValidatorTest.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
package io.thecodeforge.service;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.math.BigDecimal;
import java.util.stream.Stream;

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

/**
 * ProductValidator.java (inline for reference)
 *
 * public class ProductValidator {
 *     public boolean isValidName(String name) {
 *         return name != null && !name.isBlank() && name.length() <= 100;
 *     }
 *
 *     public void validatePrice(BigDecimal price) {
 *         if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
 *             throw new IllegalArgumentException("Price must be positive");
 *         }
 *     }
 * }
 */
@ExtendWith(MockitoExtension.class)
class ProductValidatorTest {

    private final ProductValidator validator = new ProductValidator();

    // @ValueSource: single argument, multiple values
    // Each value runs the test independently
    @ParameterizedTest(name = "isValidName returns false for: [{0}]")
    @NullAndEmptySource                          // covers null and ""
    @ValueSource(strings = {" ", "   ", "\t"})  // covers blank strings
    @DisplayName("isValidName: returns false for null, empty, and blank names")
    void shouldReturnFalseForInvalidNames(String invalidName) {
        assertThat(validator.isValidName(invalidName)).isFalse();
    }

    // @ValueSource for valid inputs — the happy path also deserves parameterization
    @ParameterizedTest(name = "isValidName returns true for: [{0}]")
    @ValueSource(strings = {"Forge Blade", "A", "Cloud Core Pro Max"})
    @DisplayName("isValidName: returns true for valid names")
    void shouldReturnTrueForValidNames(String validName) {
        assertThat(validator.isValidName(validName)).isTrue();
    }

    // @MethodSource: multiple arguments per test case
    // The static method returns Stream<Arguments> — each Arguments is one test run
    @ParameterizedTest(name = "validatePrice throws for price: {0}")
    @MethodSource("invalidPrices")
    @DisplayName("validatePrice: throws IllegalArgumentException for non-positive prices")
    void shouldThrowForInvalidPrice(BigDecimal price, String expectedMessage) {
        assertThatThrownBy(() -> validator.validatePrice(price))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining(expectedMessage);
    }

    // Static factory method for @MethodSource — must be static
    static Stream<Arguments> invalidPrices() {
        return Stream.of(
            Arguments.of(null,                     "Price must be positive"),
            Arguments.of(BigDecimal.ZERO,           "Price must be positive"),
            Arguments.of(new BigDecimal("-1.00"),   "Price must be positive")
        );
    }

    // @CsvSource: inline table for compact input/output pairs
    // Useful when the data is simple enough to live in the annotation
    @ParameterizedTest(name = "name=[{0}] expected={1}")
    @CsvSource({
        "Forge Blade,   true",
        "'',           false",
        "A,             true",
        "'   ',        false"
    })
    @DisplayName("isValidName: CsvSource variant for inline documentation")
    void shouldValidateNamesWithCsvSource(String name, boolean expected) {
        assertThat(validator.isValidName(name)).isEqualTo(expected);
    }
}
▶ Output
BUILD SUCCESSFUL in 0.9s
12 tests passed, 0 failed (5 parameterized variants from @NullAndEmptySource+@ValueSource,
3 from @ValueSource valid names,
3 from @MethodSource invalid prices,
4 from @CsvSource)
All parameterized tests PASSED
Mental Model
When Parameterized Tests Reveal Design Problems
If writing a @MethodSource for your parameterized test is harder than writing five separate test methods, the method under test might be doing too much.
  • Easy to parameterize: pure input-output functions with clear domain rules
  • Hard to parameterize: methods with complex setup, mocking per case, or multiple responsibilities
  • If every test case needs different mocking — that's a sign the method has too many code paths
  • Parameterized tests force you to express your method as a function — if it isn't one, you'll feel it
📊 Production Insight
In a validation-heavy domain — financial services, healthcare, e-commerce — boundary condition bugs dominate the bug tracker.
Zero, null, empty string, max length, negative number: these are the inputs that reach production because unit tests only covered the happy path.
@ParameterizedTest with @NullAndEmptySource and @ValueSource edge cases covers this systematically rather than hoping someone remembers to test each boundary.
🎯 Key Takeaway
@ParameterizedTest eliminates copy-pasted test methods for different input values — use it for boundary testing, validation rules, and input/output mapping.
@NullAndEmptySource + @ValueSource covers the full blank/null/whitespace boundary in one declaration.
@MethodSource is the most flexible source — use it whenever the test data involves multiple arguments or complex types.
Choosing a @ParameterizedTest Source
IfTesting with null and empty string inputs
UseUse @NullAndEmptySource — covers both in one annotation, combine with @ValueSource for blanks
IfTesting with multiple values of the same type
UseUse @ValueSource(strings = {...}) or @ValueSource(ints = {...}) — concise and readable
IfTesting with pairs or triples of (input, expectedOutput)
UseUse @MethodSource with a static Stream<Arguments> method — most flexible
IfTesting with simple inline tabular data
UseUse @CsvSource for compact annotation-level tables — good for documentation, bad for complex types

@Nested Test Classes — Organising Tests by Behaviour, Not by Method Name

As test classes grow, they become harder to navigate. By the time you have 20+ test methods in a single class, the relationship between them is only visible from the method naming convention — and conventions drift. JUnit 5's @Nested annotation solves this by allowing inner classes to group tests that share a common precondition or scenario.

The pattern I use consistently: one @Nested class per public method of the class under test, with a descriptive display name. Inside each nested class, the setup specific to that method's scenarios lives in a @BeforeEach, and each @Test covers one code path. The outer class holds the shared setup — the @Mock and @InjectMocks declarations.

This produces test output that reads like a specification: ProductService > getProductById > should return product when ID exists. When a test fails in CI, you see exactly which method and which scenario failed — no parsing of underscore-separated method names required.

It also enforces a natural constraint: if a nested class is getting too large, it's a signal that the method under test has too many responsibilities. The test structure reveals the code structure.

src/test/java/io/thecodeforge/service/ProductServiceNestedTest.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
package io.thecodeforge.service;

import io.thecodeforge.model.Product;
import io.thecodeforge.repository.ProductRepository;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;

import java.math.BigDecimal;
import java.util.NoSuchElementException;
import java.util.Optional;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.STRICT_STUBS)
@DisplayName("ProductService")
class ProductServiceNestedTest {

    // Shared across all nested classes — MockitoExtension manages injection
    @Mock  private ProductRepository repository;
    @InjectMocks private ProductService productService;

    @Nested
    @DisplayName("getProductById")
    class GetProductById {

        @Test
        @DisplayName("should return product when ID exists")
        void shouldReturnProductWhenIdExists() {
            // Arrange
            Product mockProduct = new Product(1L, "Forge Blade", new BigDecimal("99.99"));
            when(repository.findById(1L)).thenReturn(Optional.of(mockProduct));

            // Act
            Product result = productService.getProductById(1L);

            // Assert
            assertThat(result.name()).isEqualTo("Forge Blade");
            verify(repository).findById(1L);
        }

        @Test
        @DisplayName("should throw NoSuchElementException when ID does not exist")
        void shouldThrowWhenProductNotFound() {
            // Arrange
            when(repository.findById(99L)).thenReturn(Optional.empty());

            // Act + Assert
            assertThatThrownBy(() -> productService.getProductById(99L))
                    .isInstanceOf(NoSuchElementException.class)
                    .hasMessageContaining("99");
        }
    }

    @Nested
    @DisplayName("save")
    class Save {

        @Test
        @DisplayName("should delegate to repository and return saved entity")
        void shouldDelegateToRepositoryAndReturnSaved() {
            // Arrange
            Product input  = new Product(null,  "Anvil Pro", new BigDecimal("199.00"));
            Product output = new Product(42L,   "Anvil Pro", new BigDecimal("199.00"));
            when(repository.save(input)).thenReturn(output);

            // Act
            Product result = productService.save(input);

            // Assert
            assertThat(result.id()).isEqualTo(42L);
            verify(repository).save(input);
        }
    }
}
▶ Output
BUILD SUCCESSFUL in 0.8s
3 tests passed, 0 failed

ProductService
getProductById
✓ should return product when ID exists
✓ should throw NoSuchElementException when ID does not exist
save
✓ should delegate to repository and return saved entity
Mental Model
@Nested as a Living Specification
Test output from @Nested reads like a specification document. ProductService > getProductById > should throw when ID missing tells you the class, the method, and the scenario — in one line.
  • One @Nested class per public method — groups related scenarios under the method they test
  • @BeforeEach in a nested class applies only to that class — shared setup at the outer class level, scenario-specific setup inside
  • Nested class can have its own @BeforeEach without affecting sibling nested classes
  • If a @Nested class has more than 6-8 tests, the method under test probably does too much
📊 Production Insight
The practical value of @Nested shows up most clearly in CI failure output.
A flat class with 40 methods named shouldThrowWhenXAndYAndZ_butNotIfQ gives you a method name to decode.
@Nested gives you a breadcrumb: ProductService > getProductById > should throw when ID missing.
That difference matters at 2am when something breaks in production and you're reading CI logs on a phone.
🎯 Key Takeaway
@Nested groups tests by method and scenario, producing specification-style test output that identifies failures clearly.
Use one @Nested class per public method of the class under test — each inner class covers one method's scenarios.
Nested classes can have their own @BeforeEach without affecting sibling classes — use this for scenario-specific setup.
When @Nested Adds Value vs When It's Overhead
IfA service class has 3+ public methods, each with 2+ test scenarios
UseUse @Nested — one inner class per method. The output becomes a readable specification.
IfA simple utility class with 2 public methods and 4 total tests
UseSkip @Nested — the overhead isn't worth it for small classes
IfScenarios share a common precondition that differs from other scenarios
UseUse @Nested with a @BeforeEach that sets up the shared precondition for that group only
IfTests are already well-organised with clear @DisplayName annotations
UseOptional — @Nested is most valuable for larger classes where flat structure becomes noisy
🗂 Test Type Comparison
Speed vs. confidence trade-offs
FeaturePlain JUnit 5 (Unit)Spring Boot Test Slice (@WebMvcTest)Full Integration (@SpringBootTest)
SpeedExtremely Fast (ms)Fast (slices layer only)Slower (full context load)
Spring ContextNone (isolated)Partial (web/controller only)Full application context
Annotation@ExtendWith(MockitoExtension.class)@WebMvcTest(Target.class)@SpringBootTest
Mocking ToolMockito @MockSpring @MockBeanReal beans or @MockBean
DatabaseSimulated via mocksSimulated via mocksReal (H2 or Testcontainers)
Typical Duration5-50ms per test200ms-2s per test5-30s per test
Best ForService and domain logicController mapping and validationFull request-to-database flows
CI/CD ImpactNegligibleModerateSignificant — use sparingly

🎯 Key Takeaways

  • Spring Boot Testing with JUnit and Mockito is a core concept that separates business logic verification from infrastructure management.
  • JUnit 5 is the core engine that runs tests and handles lifecycle hooks; Mockito is the surgical tool that replaces real dependencies with controlled stubs.
  • Use @WebMvcTest for focused testing of the web layer and @DataJpaTest for testing the persistence layer — never leave the persistence layer untested by relying only on mocked repositories.
  • Always verify interactions with mocks to ensure that side effects — database saves, external API calls, event publishing — actually occur as intended.
  • Adopting a test slice strategy (partial contexts) offers the best balance between test speed and confidence — learn all three slices: @WebMvcTest, @DataJpaTest, and @SpringBootTest.
  • @Mock and @MockBean are not interchangeable — @Mock is standalone Mockito, @MockBean requires and integrates with the Spring ApplicationContext.
  • Use @MockitoSettings(strictness = Strictness.STRICT_STUBS) by default — it catches unnecessary stubbing that signals dead test code or logic that changed without the test catching up.
  • @ParameterizedTest eliminates copy-pasted test methods — use @NullAndEmptySource and @MethodSource to cover boundary conditions systematically.
  • Mockito 5.x on Java 21 is the standard by 2026 — mockito-inline is no longer a separate dependency; inline mock-making is built in.

⚠ Common Mistakes to Avoid

    Overusing @SpringBootTest for logic that doesn't need the Spring container
    Symptom

    Build takes 15+ minutes; developers stop running tests locally because they are too slow.

    Fix

    Use @ExtendWith(MockitoExtension.class) for service-layer tests and @WebMvcTest for controller tests. Reserve @SpringBootTest for critical integration paths only.

    Not resetting mocks between test cases
    Symptom

    Tests pass individually but fail when run as a suite; Test A's mock state leaks into Test B.

    Fix

    On Mockito 5.x, inline mock making is the default — no separate mockito-inline dependency needed. For static mocks, use try-with-resources with MockedStatic to guarantee cleanup even if the test throws.

    Ignoring failure cases — only testing the happy path
    Symptom

    Production throws unhandled exceptions for empty Optional, null inputs, or unauthorized access that tests never covered.

    Fix

    For every public method, write at least one test for: empty results, exception scenarios, and boundary conditions. Use @ParameterizedTest with @NullAndEmptySource to cover null/empty/blank systematically.

    Using @Autowired in unit tests with MockitoExtension
    Symptom

    Test fails with NoSuchBeanDefinitionException because there is no Spring context to autowire from.

    Fix

    In pure unit tests, use @InjectMocks to inject @Mock dependencies. @Autowired requires a running Spring context — use it only with @WebMvcTest, @DataJpaTest, or @SpringBootTest.

    Forgetting to verify mock interactions
    Symptom

    Tests pass green but the actual database save or API call never executes — the test proves nothing.

    Fix

    Always call verify(mock).methodName() after the Act phase to confirm the expected interaction actually occurred. A test without verification is a false positive.

    Using when().thenThrow() on void methods
    Symptom

    Test compiles but throws MissingMethodInvocationException at runtime — the when() syntax does not work on methods that return void.

    Fix

    Use doThrow(exception).when(mock).voidMethod() for all void method stubbing. The do*().when() syntax works for both void and non-void methods — when required and it's safer to default to for any spy or void scenario.

Interview Questions on This Topic

  • QWhat is the internal difference between @Mock and @MockBean in a Spring Boot environment? When is one required over the other?Mid-levelReveal
    @Mock is a Mockito annotation that creates a mock object without any Spring context involvement. It is used with @ExtendWith(MockitoExtension.class) and injected via @InjectMocks. @MockBean is a Spring Boot annotation that replaces or adds a bean in the Spring ApplicationContext. It is used with @SpringBootTest or test slices like @WebMvcTest. Use @Mock for pure unit tests that run in milliseconds; use @MockBean when you need the Spring context running but want to replace a specific dependency.
  • QExplain how @WebMvcTest works. Which beans are automatically included in the context, and which must be manually mocked?Mid-levelReveal
    @WebMvcTest loads only the web layer of your application — the specified controller, Spring MVC infrastructure, MockMvc, and JSON serialization beans. It does NOT load service beans, repository beans, or security configurations by default. Any dependency the controller needs (like a service class) must be provided via @MockBean. This keeps the test fast while still verifying HTTP request/response mapping and validation logic.
  • QCan you explain the AAA (Arrange, Act, Assert) pattern and why it is superior to unstructured test scripting?JuniorReveal
    AAA divides every test into three phases: Arrange (set up preconditions, create mocks, define stubbing), Act (call the method under test), and Assert (verify the result and mock interactions). This structure makes tests readable as documentation, allows quick identification of which phase failed, and enforces a single responsibility per test. Unstructured tests mix setup and verification, making failures hard to diagnose and maintenance painful.
  • QHow do you test a void method using Mockito? Explain the use of verify() versus doThrow().Mid-levelReveal
    For void methods, use verify() to confirm the method was called with expected arguments: verify(repository).save(product). To test exception handling, use doThrow() to make the mock throw when the void method is called: doThrow(new RuntimeException()).when(service).sendEmail(any()). Never use when().thenThrow() on void methods — it throws MissingMethodInvocationException at runtime. Use doNothing() to explicitly confirm no side effects, and verifyNoInteractions() to confirm a dependency was completely untouched.
  • QWhat are ArgumentCaptors in Mockito, and in what scenario would you use them to validate data passed to a dependency?SeniorReveal
    ArgumentCaptors capture arguments passed to mocked methods so you can inspect them after the interaction. Use ArgumentCaptor<Type> captor = ArgumentCaptor.forClass(Type.class), then verify(mock).method(captor.capture()), then captor.getValue() to retrieve the argument. This is essential when a method transforms data before passing it to a dependency — you need to verify the transformed value, not just that the method was called. Always call verify() before getValue() — the captor populates during the verify call.
  • QWhat does @MockitoSettings(strictness = Strictness.STRICT_STUBS) do, and why should it be the default in new test classes?SeniorReveal
    STRICT_STUBS causes Mockito to fail the test if a stubbing is declared but never used during the test run — an UnnecessaryStubbingException. This is a feature, not an annoyance: unnecessary stubbing is a sign that either the code path you expected to exercise was never reached, or the test was written against logic that has since changed. It also prevents stubbing of one test's setup from silently influencing another test in the same class. In Mockito 4+ and 5, STRICT_STUBS is the recommended default for new test classes — it catches dead test code before it misleads the next developer.

Frequently Asked Questions

Is Mockito better than EasyMock?

In modern Java development, Mockito is the industry standard due to its readable API and seamless integration with JUnit 5. EasyMock requires expect/replay/verify cycles that most teams find harder to read and maintain. Most searchable testing examples in the Java ecosystem are written with Mockito — that network effect matters for onboarding new team members.

Should I test private methods?

Generally, no. Private methods are implementation details. You should test the public API of your class; if a private method is complex enough to need its own test, it likely belongs in its own class with a public interface. Testing private methods via reflection is brittle — the test breaks whenever you rename the method, even if the behaviour is unchanged.

What is stubbing vs mocking?

Stubbing is providing a pre-defined answer to a method call — when(x).thenReturn(y). Mocking is verifying that an interaction occurred — verify(x).method(). A single mock object can be both stubbed (to return test data) and verified (to confirm calls happened). Most people use 'mocking' to mean both, but understanding the distinction matters when you're debugging why a test is passing when it shouldn't be.

How do I test code that uses java.time classes like LocalDateTime.now()?

Use Clock injection. Pass a Clock bean to your service constructor, then in tests provide Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC). This makes time-dependent logic fully deterministic without static mocking. It's the clean approach — static mocking of LocalDateTime.now() works but requires MockedStatic cleanup and is harder to maintain.

Do I need Testcontainers or is H2 good enough for repository tests?

H2 is good enough for simple CRUD and derived query testing during local development. It is not good enough if your queries use PostgreSQL-specific features: jsonb operations, window functions, pg_trgm similarity search, or case-sensitive collation differences. The rule I use: H2 for the development inner loop, Testcontainers in CI. Same @DataJpaTest class — you only change the datasource configuration. That way you get fast local feedback and production-parity in your pipeline.

What is the difference between spy() and mock()?

A mock() creates a complete fake — all methods return default values (null, 0, false) unless stubbed. A spy() wraps a real object — unstubbed methods call through to the real implementation, while stubbed methods return the defined value. Use mocks for dependencies you want full control over. Use spies sparingly — typically only when you need to test part of a class while letting the rest execute normally. Overuse of spies is usually a sign the class under test does too much.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousSpring Boot Actuator and MonitoringNext →Spring Boot with Docker
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged