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
| Layer | Annotation | Speed | Spring Context | Use for |
|---|---|---|---|---|
| Unit | @Test only | Very fast (<10ms) | None | Service logic, utilities, domain objects |
| Slice | @WebMvcTest, @DataJpaTest, etc. | Fast (1β5s) | Partial β one layer only | Controllers, repositories, JSON serialization |
| Integration | @SpringBootTest | Slow (5β30s) | Full application context | End-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
@SpringBootTestconfiguration across integration test classes - Avoid
@MockBeanin@SpringBootTestβ each unique set of@MockBeancreates a new context - Use
@DirtiesContextonly 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
- JUnit 6 Tutorial: Complete Series Index
- Testing REST APIs with JUnit 6: MockMvc vs WebTestClient
- JUnit 6 with Mockito: Mocking, Spying, and Best Practices
- JUnit 6 with Testcontainers: Real Database Integration Testing
- JUnit 6 Extensions Model: Build Custom Extensions
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.