@SpringBootTest is useful, but it is an expensive default. It asks Spring Boot to discover the application configuration and build the complete application context even when a test only needs MVC routing or JPA mappings.
Spring Boot test slices load a narrower context. In this lab, we will use @WebMvcTest for an HTTP boundary and @DataJpaTest with a real PostgreSQL container for persistence. The goal is production-like infrastructure without an application-sized test context.
If you are building the API from scratch, start with REST API with Boot 3 + Records as DTOs. For persistence design traps beyond testing, see Spring Data JPA Footguns.
Pick the smallest context that proves the behavior
Start each test by asking which Spring-managed boundary it must prove.
| Test style | Loads | Best for | Does not prove |
|---|---|---|---|
@WebMvcTest | MVC infrastructure and selected controllers | Routing, JSON, validation, status codes, exception handling | Database mappings or the complete application wiring |
@DataJpaTest | Entities, repositories, Hibernate, and a test database | Queries, mappings, constraints, persistence behavior | Controllers, services, or HTTP serialization |
@SpringBootTest | The complete application context | Cross-layer wiring and application-level flows | A fast, narrowly diagnosed feedback loop |
Use a slice when the assertion belongs to one framework boundary. Keep a smaller number of @SpringBootTest tests for flows whose value comes from proving that multiple layers work together.
Use @WebMvcTest for the HTTP contract
@WebMvcTest(PostController.class) limits component scanning to MVC-related beans and the selected controller. Collaborators such as services are not discovered, so provide them as test doubles.
Assume this controller returns a record:
package com.geekmonks.posts;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/posts")
class PostController {
private final PostService posts;
PostController(PostService posts) {
this.posts = posts;
}
@GetMapping
List<PostResponse> findAll() {
return posts.findAll();
}
record PostResponse(Long id, String title) {}
}
The slice test can prove the route, status, content type, and JSON shape without starting a server:
package com.geekmonks.posts;
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.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.List;
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.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(PostController.class)
class PostControllerTest {
@Autowired
MockMvc mvc;
@MockitoBean
PostService posts;
@Test
void returnsPostsAsJson() throws Exception {
given(posts.findAll()).willReturn(List.of(
new PostController.PostResponse(7L, "Test slices")));
mvc.perform(get("/api/posts"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(
MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$[0].id").value(7))
.andExpect(jsonPath("$[0].title").value("Test slices"));
}
}
This example uses @MockitoBean, the current Spring Framework replacement for the deprecated Spring Boot @MockBean. On an older Boot release, use the annotation supported by that release.
Note: Do not mock MVC itself. MockMvc is the real Spring MVC test client inside the slice; mock only collaborators outside the boundary you are testing.
Use @DataJpaTest for persistence behavior
@DataJpaTest finds @Entity classes and Spring Data repositories, configures Hibernate, and wraps each test in a transaction that rolls back by default. That makes it a good fit for:
- derived and custom repository queries,
- entity mappings and converters,
- database constraints,
- lazy-loading behavior inside a transaction,
- and persistence callbacks.
The default test database is often embedded. H2 is quick, but it does not reproduce every PostgreSQL type, operator, collation, constraint, or SQL dialect behavior. A green H2 test does not prove that PostgreSQL accepts the same schema and query.
Testcontainers closes that gap by running a disposable PostgreSQL instance while the JPA slice keeps the Spring context focused.
Lab: test a repository with PostgreSQL
This lab requires Java 17+, Docker or another Testcontainers-compatible container runtime, Spring Boot 3.1+, and Maven. We will test a case-insensitive title lookup against PostgreSQL.
Add the test dependencies
Keep spring-boot-starter-data-jpa and the PostgreSQL driver in the application dependencies. Add Spring Boot’s Testcontainers integration plus JUnit and PostgreSQL modules in test scope:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
When the project inherits from the Spring Boot parent POM or imports its dependency BOM, Boot manages compatible Testcontainers versions.
Create the entity and repository
The entity is deliberately small so the test focuses on the database boundary:
package com.geekmonks.posts;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "posts")
class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
protected Post() {}
Post(String title) {
this.title = title;
}
Long getId() {
return id;
}
String getTitle() {
return title;
}
}
Use an explicit PostgreSQL query for the behavior we want to verify:
package com.geekmonks.posts;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
interface PostRepository extends JpaRepository<Post, Long> {
@Query(value = """
select * from posts
where lower(title) = lower(:title)
""", nativeQuery = true)
Optional<Post> findByTitleIgnoringCase(@Param("title") String title);
}
A native query makes the reason for PostgreSQL in this lab visible. In application code, prefer JPQL or derived queries when database-specific SQL adds no value.
Connect the slice to the container
@ServiceConnection tells Spring Boot to derive JDBC connection details from the PostgreSQL container. There are no random ports, usernames, or URLs to copy into test properties.
package com.geekmonks.posts;
import static org.assertj.core.api.Assertions.assertThat;
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.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@DataJpaTest
@Testcontainers
class PostRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:17-alpine");
@Autowired
PostRepository posts;
@Test
void findsTitleIgnoringCase() {
posts.saveAndFlush(new Post("Testing Spring Boot"));
var result = posts.findByTitleIgnoringCase(
"testing spring boot");
assertThat(result)
.isPresent()
.get()
.extracting(Post::getTitle)
.isEqualTo("Testing Spring Boot");
}
}
The static field starts one container for this test class instead of one container per method. saveAndFlush sends the insert to PostgreSQL before the query runs, and @DataJpaTest rolls the transaction back after the test.
Run only this lab:
./mvnw -Dtest=PostRepositoryTest test
The first run pulls the image and is slower. Later runs reuse the local image layers, but the database container is still clean and disposable.
Note: This example lets Hibernate create the test schema. If production uses Flyway or Liquibase, let the migration tool build the container schema in an integration test so the test also proves your migration scripts.
When @SpringBootTest earns its cost
Use @SpringBootTest when the test fails to provide value unless the complete application is wired. Typical examples include:
- an HTTP request that must cross controller, security, service, repository, and database layers,
- configuration properties shared across several subsystems,
- application events whose publishers and listeners live in different layers,
- or a startup smoke test that proves the production configuration can build.
Choose its web environment intentionally:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
MOCK builds a web application context without opening a real port and can be paired with @AutoConfigureMockMvc. Use RANDOM_PORT only when a real embedded server and network client are part of what the test must prove.
Do not convert every slice into @SpringBootTest merely because a dependency is missing. A missing service in @WebMvcTest is usually a signal to mock or import that boundary dependency. A missing repository in the same test is usually a signal that the test is crossing boundaries.
A practical test portfolio
Use all three styles, but give each a different job:
Many tests:
plain JUnit tests for business rules
@WebMvcTest for HTTP contracts
@DataJpaTest for mappings and queries
Fewer tests:
@SpringBootTest for critical cross-layer flows
Real infrastructure where semantics matter:
Testcontainers for PostgreSQL, Kafka, Redis, and similar services
The fastest test is a plain JUnit 5 unit test with no Spring context. A slice is the next step when Spring configuration is part of the behavior. The full context belongs at the top, where one test can cover a high-value application flow.
Practice drills
- Add a unique constraint to
Post.title, save the same title twice, and assert the exception afterflush(). - Add
POST /api/postsand use@WebMvcTestto prove that a blank title returns400. - Add a Flyway migration for the
poststable and configure a container-backed test to run it — see Flyway Without theddl-autoRoulette. - Write one
@SpringBootTesttest for the complete create-and-fetch flow, then compare its context startup time with each slice.
Wrap-up
@WebMvcTest proves the HTTP boundary, @DataJpaTest proves the persistence boundary, and @SpringBootTest proves complete application wiring. Selecting the smallest context makes failures easier to diagnose and keeps the suite responsive.
Testcontainers does not require a full application context. Pairing PostgreSQL with @DataJpaTest gives repository tests the database semantics production depends on while preserving the speed and focus of a slice.