JUnit 6 with Spring Boot: Unit, Slice, and Integration Testing

Spring Boot and JUnit 6 are the most widely used combination in the Java testing ecosystem. Together they give you a layered testing strategy β€” fast pure unit tests, focused slice tests that load only part of the Spring context, and full integration tests with a running application. This guide covers all three layers with complete, production-ready examples.

The Three Testing Layers

LayerAnnotationSpeedSpring ContextUse for
Unit@Test onlyVery fast (<10ms)NoneService logic, utilities, domain objects
Slice@WebMvcTest, @DataJpaTest, etc.Fast (1–5s)Partial β€” one layer onlyControllers, repositories, JSON serialization
Integration@SpringBootTestSlow (5–30s)Full application contextEnd-to-end flows, real DB, real HTTP

Setup: Spring Boot Test Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <!-- spring-boot-starter-test bundles:
         junit-jupiter, mockito-core, assertj-core,
         spring-test, hamcrest, jsonpath -->
</dependency>

Layer 1: Pure Unit Tests (No Spring Context)

For service and domain logic, avoid loading a Spring context entirely. Instantiate the class directly and mock dependencies with Mockito:

import org.junit.jupiter.api.*;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

// @ExtendWith(MockitoExtension.class): activates Mockito annotation processing
// No Spring context loaded β€” this test runs in milliseconds
@ExtendWith(MockitoExtension.class)
@DisplayName("OrderService β€” unit tests")
class OrderServiceTest {

    // @Mock creates a Mockito mock and injects it into @InjectMocks
    @Mock
    private OrderRepository orderRepository;

    @Mock
    private EmailService emailService;

    // @InjectMocks creates an instance with mocked dependencies injected
    @InjectMocks
    private OrderService orderService;

    @Test
    @DisplayName("Creating an order saves it and sends a confirmation email")
    void creatingOrderSavesItAndSendsConfirmationEmail() {
        // Arrange: define mock behaviour
        Order savedOrder = new Order(1L, "[email protected]", 99.99, OrderStatus.PENDING);
        when(orderRepository.save(any(Order.class))).thenReturn(savedOrder);

        // Act
        Order result = orderService.createOrder("[email protected]", 99.99);

        // Assert: verify result and interactions
        assertNotNull(result);
        assertEquals(OrderStatus.PENDING, result.getStatus());
        verify(orderRepository, times(1)).save(any(Order.class));
        verify(emailService,    times(1)).sendConfirmation("[email protected]");
    }

    @Test
    @DisplayName("Creating an order with negative total throws IllegalArgumentException")
    void negativeOrderTotalThrowsException() {
        assertThrows(IllegalArgumentException.class,
            () -> orderService.createOrder("[email protected]", -1.00));
        // Verify no repository or email interaction occurred
        verifyNoInteractions(orderRepository, emailService);
    }
}

Layer 2a: @WebMvcTest β€” Controller Slice Test

@WebMvcTest loads only the web layer (controllers, filters, converters) without the full application context. It is the fastest way to test HTTP request mapping, response status codes, and JSON serialization:

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.beans.factory.annotation.Autowired;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.mockito.Mockito.*;

// Only loads OrderController and related web infrastructure
// OrderService is NOT loaded β€” must be mocked with @MockBean
@WebMvcTest(OrderController.class)
@DisplayName("OrderController β€” web layer slice tests")
class OrderControllerTest {

    // Injected by Spring β€” used to perform HTTP requests in tests
    @Autowired
    private MockMvc mockMvc;

    // @MockBean: creates a Mockito mock AND registers it as a Spring bean
    // Replaces the real OrderService bean in the partial context
    @MockBean
    private OrderService orderService;

    @Test
    @DisplayName("GET /api/orders/{id} returns 200 with order JSON when order exists")
    void getOrderByIdReturns200WhenFound() throws Exception {
        // Arrange: stub the service mock
        Order order = new Order(1L, "[email protected]", 99.99, OrderStatus.PENDING);
        when(orderService.findById(1L)).thenReturn(Optional.of(order));

        // Act + Assert: perform HTTP request and verify response
        mockMvc.perform(get("/api/orders/1")
                .accept("application/json"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.email").value("[email protected]"))
            .andExpect(jsonPath("$.status").value("PENDING"));
    }

    @Test
    @DisplayName("GET /api/orders/{id} returns 404 when order not found")
    void getOrderByIdReturns404WhenNotFound() throws Exception {
        when(orderService.findById(99L)).thenReturn(Optional.empty());

        mockMvc.perform(get("/api/orders/99"))
            .andExpect(status().isNotFound());
    }

    @Test
    @DisplayName("POST /api/orders returns 201 CREATED with valid request body")
    void postOrderReturns201WithValidBody() throws Exception {
        Order created = new Order(2L, "[email protected]", 49.99, OrderStatus.PENDING);
        when(orderService.createOrder(anyString(), anyDouble())).thenReturn(created);

        mockMvc.perform(post("/api/orders")
                .contentType("application/json")
                .content("{"email":"[email protected]","total":49.99}"))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value(2));
    }
}

Layer 2b: @DataJpaTest β€” Repository Slice Test

import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.beans.factory.annotation.Autowired;

// @DataJpaTest: loads only JPA layer (entities, repositories, H2 in-memory DB)
// No web layer, no service beans β€” fast and focused
@DataJpaTest
@DisplayName("OrderRepository β€” JPA slice tests")
class OrderRepositoryTest {

    // TestEntityManager: JPA helper for test setup without using the repository itself
    @Autowired
    private TestEntityManager entityManager;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    @DisplayName("findByEmail returns orders for the given customer email")
    void findByEmailReturnsOrdersForEmail() {
        // Arrange: persist test data directly via EntityManager
        Order order1 = entityManager.persistAndFlush(
            new Order(null, "[email protected]", 49.99, OrderStatus.PENDING));
        Order order2 = entityManager.persistAndFlush(
            new Order(null, "[email protected]", 99.99, OrderStatus.COMPLETED));
        entityManager.persistAndFlush(
            new Order(null, "[email protected]",   29.99, OrderStatus.PENDING));

        // Act
        List<Order> aliceOrders = orderRepository.findByEmail("[email protected]");

        // Assert
        assertEquals(2, aliceOrders.size());
        assertTrue(aliceOrders.stream().allMatch(o -> o.getEmail().equals("[email protected]")));
    }

    @Test
    @DisplayName("findByStatus returns only orders with the given status")
    void findByStatusReturnsPendingOrders() {
        entityManager.persistAndFlush(new Order(null, "[email protected]", 10.0, OrderStatus.PENDING));
        entityManager.persistAndFlush(new Order(null, "[email protected]", 20.0, OrderStatus.COMPLETED));

        List<Order> pendingOrders = orderRepository.findByStatus(OrderStatus.PENDING);

        assertEquals(1, pendingOrders.size());
        assertEquals(OrderStatus.PENDING, pendingOrders.get(0).getStatus());
    }
}

Layer 3: @SpringBootTest β€” Full Integration Test

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.*;

// Starts full Spring Boot application on a random port
// Use sparingly β€” slowest test type, but most realistic
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DisplayName("Order API β€” full integration tests")
class OrderApiIntegrationTest {

    // Injects the random port chosen by Spring Boot
    @LocalServerPort
    private int serverPort;

    // TestRestTemplate: real HTTP client for integration tests
    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    @DisplayName("Complete order lifecycle: create, retrieve, complete")
    void completeOrderLifecycle() {
        String baseUrl = "http://localhost:" + serverPort + "/api/orders";

        // Step 1: Create an order via HTTP POST
        CreateOrderRequest createRequest =
            new CreateOrderRequest("[email protected]", 149.99);
        ResponseEntity<Order> createResponse =
            restTemplate.postForEntity(baseUrl, createRequest, Order.class);

        assertEquals(HttpStatus.CREATED,   createResponse.getStatusCode());
        assertNotNull(createResponse.getBody().getId());
        Long orderId = createResponse.getBody().getId();

        // Step 2: Retrieve the created order via HTTP GET
        ResponseEntity<Order> getResponse =
            restTemplate.getForEntity(baseUrl + "/" + orderId, Order.class);

        assertEquals(HttpStatus.OK,         getResponse.getStatusCode());
        assertEquals(OrderStatus.PENDING,   getResponse.getBody().getStatus());

        // Step 3: Complete the order via HTTP PATCH
        restTemplate.patchForObject(baseUrl + "/" + orderId + "/complete", null, Void.class);
        ResponseEntity<Order> completedResponse =
            restTemplate.getForEntity(baseUrl + "/" + orderId, Order.class);

        assertEquals(OrderStatus.COMPLETED, completedResponse.getBody().getStatus());
    }
}

Expected Integration Test Output

  .   ____          _            __ _ _
 / / ___'_ __ _ _(_)_ __  __ _    
( ( )___ | '_ | '_| | '_ / _` |    
 / ___)| |_)| | | | | || (_| | ) ) ) )
  '  |____| .__|_| |_|_| |___, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::

[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 6.42 s
  βœ” Complete order lifecycle: create, retrieve, complete

Spring Context Caching

Spring Boot caches the ApplicationContext across tests with the same configuration. This means a 5-second startup cost is paid once, not per test class. To maximise caching:

  • Use the same @SpringBootTest configuration across integration test classes
  • Avoid @MockBean in @SpringBootTest β€” each unique set of @MockBean creates a new context
  • Use @DirtiesContext only when you must β€” it forces context recreation for the next test

Frequently Asked Questions (FAQs)

Q1: Should I use @SpringBootTest for every test in a Spring Boot project?

No. @SpringBootTest is the slowest test type because it loads the full application context. Use it only for end-to-end integration tests. For service logic, use plain @ExtendWith(MockitoExtension.class) unit tests. For controllers and repositories, use @WebMvcTest and @DataJpaTest slice tests. A well-structured Spring Boot project has the majority of tests as fast unit tests, with a smaller number of slice and integration tests.

Q2: What is the difference between @Mock and @MockBean?

@Mock (Mockito) creates a mock outside of any Spring context β€” it is injected via field injection by Mockito’s extension. @MockBean (Spring Boot Test) creates a Mockito mock AND registers it as a bean in the Spring ApplicationContext, replacing the real bean of that type. Use @Mock in pure unit tests; use @MockBean in slice and integration tests where a Spring context is loaded.

Q3: How do I test with a real database in Spring Boot tests?

For a real database in tests, use Testcontainers with @SpringBootTest. Testcontainers starts a Docker container running your actual database (PostgreSQL, MySQL, etc.) for the duration of the test suite. See JUnit 6 with Testcontainers for a complete setup guide. For lightweight alternatives, use H2 in @DataJpaTest β€” but be aware that H2 dialect differences can hide real database bugs.

Q4: Can I use @Nested classes inside @SpringBootTest?

Yes. @Nested classes work inside @SpringBootTest, @WebMvcTest, and @DataJpaTest classes. The Spring context is created at the outer class level and shared with all nested classes. This is an excellent pattern for grouping endpoint tests by HTTP method or business scenario. See JUnit 6 Nested Tests for patterns.

Q5: How do I configure test-specific properties in @SpringBootTest?

Use @SpringBootTest(properties = {"my.property=testValue"}) for inline overrides, or create src/test/resources/application-test.properties and activate it with @ActiveProfiles("test"). For per-class property overrides without breaking context caching, prefer @TestPropertySource(properties = {...}).

See Also

Conclusion

The key to effective Spring Boot testing is knowing which layer to test at. Pure unit tests for service logic β€” zero Spring, maximum speed. Slice tests for controllers and repositories β€” partial context, targeted assertions. Integration tests for end-to-end flows β€” full context, realistic behaviour. Apply the Test Pyramid: many unit tests, some slice tests, few integration tests.

Next: Testing REST APIs with JUnit 6: MockMvc vs WebTestClient β€” a deep dive into both approaches with real request/response examples.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.