Spring Boot Testing with JUnit and Mockito: The Definitive Guide
- 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.
- 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
Mockito initialization failure
mvn dependency:tree | grep mockitogrep -r '@ExtendWith' src/test/java/Spring context fails to load in tests
mvn test -X | grep 'BeanCreationException'grep -r '@MockBean' src/test/java/ | wc -lTests pass individually but fail in suite
mvn test -Dsurefire.failIfNoSpecifiedTests=false -Dtest=FailingTestgrep -r 'static' src/test/java/ | grep -v 'final'Production Incident
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.Production Debug GuideFrom Mockito exceptions to slow builds
when() blocks — never call assertThat() inside a stubbing callverify() before getValue() — the captor only populates after the interaction is verifiedSpring 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.
// ── 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); } }
2 tests passed, 0 failed
ProductServiceTest > shouldReturnProductWhenIdExists() PASSED
ProductServiceTest > shouldThrowWhenProductNotFound() PASSED
- 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
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.
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()); } }
Status = 200
Content type = application/json
Body = {"id":1,"name":"Cloud Core","price":45.00}
MockHttpServletResponse:
Status = 404
Content type = application/json
- 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
verify() mock interactions — a passing test without verification proves nothing about your logic.ArgumentCaptors — Verifying What Gets Passed to Dependencies
Sometimes 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.verify()
A concrete example: a service receives a CreateProductRequest DTO, maps it into a Product entity, sets the creation timestamp, and calls . A plain repository.save()verify(repository).save( tells you any())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( before calling captor.capture())captor.getValue(). The captor populates during the verify call — not before it.
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(); } }
2 tests passed, 0 failed
ProductServiceCaptorTest > shouldPersistProductWithCorrectFields() PASSED
ProductServiceCaptorTest > shouldPassNullIdToRepository() PASSED
- 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
verify()-without-captor misses: data transformation errors.verify(repo).save(any()) passes green.verify() before captor.getValue() — the captor populates during the verify call, not before.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 — 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.verify()
— confirms the method was called with expected arguments. Sufficient when the test is 'did this call happen?'verify()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 can be chained), but Mockito throws at runtime. The correct syntax for void methods is always when()do*().when(mock).method().
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); } }
2 tests passed, 0 failed
NotificationServiceTest > shouldDelegateToEmailClientWithCorrectArgs() PASSED
NotificationServiceTest > shouldWrapEmailClientExceptionInRuntimeException() PASSED
- 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
when().thenThrow() does not work and throws at runtime.when() syntax on void methodsnever()).method()@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.
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(); } }
4 tests passed, 0 failed
ProductRepositoryTest > shouldFindProductById() PASSED
ProductRepositoryTest > shouldReturnAllProducts() PASSED
ProductRepositoryTest > shouldGenerateIdOnSave() PASSED
ProductRepositoryTest > shouldDeleteProduct() PASSED
- 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
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.
@ValueSource— a single set of values of one type: strings, ints, longs, booleans.@MethodSource— a static method that returns aStream<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@ValueSourcewithout 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.'
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); } }
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
- 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
@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.
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); } } }
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
- 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
| Feature | Plain JUnit 5 (Unit) | Spring Boot Test Slice (@WebMvcTest) | Full Integration (@SpringBootTest) |
|---|---|---|---|
| Speed | Extremely Fast (ms) | Fast (slices layer only) | Slower (full context load) |
| Spring Context | None (isolated) | Partial (web/controller only) | Full application context |
| Annotation | @ExtendWith(MockitoExtension.class) | @WebMvcTest(Target.class) | @SpringBootTest |
| Mocking Tool | Mockito @Mock | Spring @MockBean | Real beans or @MockBean |
| Database | Simulated via mocks | Simulated via mocks | Real (H2 or Testcontainers) |
| Typical Duration | 5-50ms per test | 200ms-2s per test | 5-30s per test |
| Best For | Service and domain logic | Controller mapping and validation | Full request-to-database flows |
| CI/CD Impact | Negligible | Moderate | Significant — 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
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
- QExplain how @WebMvcTest works. Which beans are automatically included in the context, and which must be manually mocked?Mid-levelReveal
- QCan you explain the AAA (Arrange, Act, Assert) pattern and why it is superior to unstructured test scripting?JuniorReveal
- QHow do you test a void method using Mockito? Explain the use of
verify()versus doThrow().Mid-levelReveal - QWhat are ArgumentCaptors in Mockito, and in what scenario would you use them to validate data passed to a dependency?SeniorReveal
- QWhat does @MockitoSettings(strictness = Strictness.STRICT_STUBS) do, and why should it be the default in new test classes?SeniorReveal
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.
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.