What a Spring Boot test slice actually is

A test slice is an annotation, such as @WebMvcTest or @DataJpaTest, that starts a real Spring application context but restricts what goes into it. It limits auto-configuration to the infrastructure one layer needs, and it filters component scanning so only that layer's beans are registered. Everything outside the slice is absent unless you add it explicitly, usually as a mock.

A slice is not a different test runner. It uses the same Spring TestContext integration as @SpringBootTest. It is also not a unit test. A unit test instantiates a class with new and never touches Spring. A slice still exercises Spring's own machinery: request mapping, message conversion, validation, JPA mapping and query derivation. Those are the parts a unit test cannot verify and a full context verifies only slowly and indirectly.

Slices exist so you can check one layer's integration with the framework quickly, with an explicit boundary. A missing collaborator fails loudly at startup instead of being silently satisfied by the full application.

                  Unit test      @WebMvcTest       @DataJpaTest       @SpringBootTest
                  (no context)
Web layer         -              controllers,      -                  all beans
                                 advice, filters
Service layer     class under    mocks you         -                  all beans
                  test + mocks   register
Persistence       mocks          -                 entities, repos,   all beans
                                                   EntityManager

The same model applies to @JsonTest, @RestClientTest, @DataJdbcTest and @WebFluxTest.

          Unit test       @WebMvcTest     @DataJpaTest    @SpringBootTest
          (no context)    (web slice)     (JPA slice)     (full context)

Web                       +-------------+                 +-------------+
layer                     | controllers |                 | controllers,|
                n/a       | advice,     |   not loaded    | advice,     |
                          | filters     |                 | filters     |
                          +-------------+                 +-------------+
                                 |                               |
                                 v                               v
Service   +-------------+ +-------------+                 +-------------+
layer     | class under | | mocks you   |                 | services    |
          | test (new)  | | register    |   not loaded    | (real)      |
          +-------------+ +-------------+                 +-------------+
                 |                                               |
                 v                                               v
Persist-  +-------------+                 +-------------+ +-------------+
ence      | mocked      |                 | entities,   | | entities,   |
          | repository  |   not loaded    | repositories| | repositories|
          | (no Spring) |                 |EntityManager| |EntityManager|
          +-------------+                 | DataSource  | | DataSource  |
                                          +-------------+ +-------------+

Box = object present in the test. Arrow = call path the test exercises.
n/a = layer not involved; not loaded = excluded from the slice context.

Objects present in each layer for a plain unit test, the @WebMvcTest and @DataJpaTest slices, and a full @SpringBootTest context.

Who should use test slices

Slices fit teams building Spring Boot applications with a recognizable layered structure: controllers that delegate to services, and services that use Spring Data repositories. In that shape each layer has a clear framework boundary, and a slice can test exactly that boundary.

They are most useful in these situations:

  • API owners whose clients depend on exact status codes, JSON field names, date formats and error bodies. @WebMvcTest pins that contract without starting the rest of the application.
  • Teams with non-trivial persistence code: derived queries, custom JPQL, entity mappings, unique and not-null constraints. @DataJpaTest checks these against a real EntityManager instead of a mock that accepts anything.
  • Codebases where @SpringBootTest has become the default for every test and the suite is slow or failures are hard to localize. Moving layer-specific tests to slices narrows what each test can break on.

Slices are less useful for small applications where a handful of full-context tests already run quickly, for code with little framework interaction (pure domain logic is better served by plain unit tests), and for applications whose behavior lives mostly in cross-layer flows, where a slice would mock away the part that matters.

How test slices work under the hood

Every slice annotation is a composed annotation. Its meta-annotations do three jobs.

Bootstrapping the context. A slice is meta-annotated with @BootstrapWith, using a Spring Boot test context bootstrapper, and with @ExtendWith(SpringExtension.class) for JUnit Jupiter. The bootstrapper searches upward from the test's package for a class annotated with @SpringBootConfiguration (normally your @SpringBootApplication class) and uses it as the configuration source. This is why annotations placed on the main class affect every slice, as the pitfalls section describes.

Restricting auto-configuration. A slice disables the normal auto-configuration mechanism with @OverrideAutoConfiguration(enabled = false). It then imports a fixed list of auto-configuration classes through one or more @AutoConfigure... annotations, such as @AutoConfigureWebMvc and @AutoConfigureMockMvc for @WebMvcTest, or @AutoConfigureDataJpa and @AutoConfigureTestDatabase for @DataJpaTest. Each of these uses @ImportAutoConfiguration, and the list of auto-configurations it brings in is declared in an imports file inside the Spring Boot test autoconfigure module. Only those auto-configurations run, so a web slice never creates a DataSource and a JPA slice never creates a DispatcherServlet.

Filtering component scanning. Your application's component scan still runs, but a slice registers a type-exclude filter through @TypeExcludeFilters. For @WebMvcTest the filter keeps types such as @Controller, @ControllerAdvice, @JsonComponent, Converter, Filter and WebMvcConfigurer and drops the rest. For @DataJpaTest the filter excludes regular components entirely, because repositories are created by Spring Data's own auto-configuration and entities are found by entity scanning, not component scanning. The default @SpringBootApplication scan includes TypeExcludeFilter, which is the hook slices rely on; replacing that scan with a custom @ComponentScan that omits it lets unwanted beans leak in.

Some slices add test-specific behavior on top. @DataJpaTest is meta-annotated with @Transactional, which produces the per-test rollback, and with @AutoConfigureTestDatabase, which swaps in an embedded DataSource. @WebMvcTest adds the auto-configured MockMvc. Because the resulting context goes through the normal TestContext framework, it is cached and reused like any other test context.

How @WebMvcTest works and how to test the HTTP contract

@WebMvcTest starts only the Spring MVC infrastructure: DispatcherServlet configuration, every @Controller (or just those listed in the controllers attribute), @ControllerAdvice classes, the Jackson ObjectMapper and any @JsonComponent, converters, Filter and WebMvcConfigurer beans, plus an auto-configured MockMvc. Regular @Service, @Component and @Repository beans are not scanned. Any collaborator a controller injects must be registered as a mock bean with @MockitoBean, or @MockBean in older codebases. If you skip this, the context fails to start. That failure is useful because it shows exactly which dependencies the web layer has.

Limiting controllers to the class under test keeps the context small. It also stops an unrelated controller's missing dependency from breaking the test.

What a useful test asserts

MockMvc sends requests through the real dispatcher without opening a socket. A good test covers the HTTP contract a client depends on:

  • Mapping: the path, method and content type resolve to the expected handler. A wrong verb returns 405.
  • Validation: a @Valid request body with a missing field returns 400 before the service mock is called.
  • Serialization: the response JSON has the field names, date formats and null handling your Jackson configuration produces.
  • Error handling: when the stubbed service throws, the @ControllerAdvice handler returns the expected status and error body.

Stub the mock to produce a scenario, then assert on status, headers and body. Avoid checks like verify(service).create(dto) that repeat the controller's own code line by line. Those checks fail on harmless refactors and miss real contract changes. A verification is worth writing only when the interaction is itself the behavior under test, for example confirming that an invalid request never reaches the service.

// Imports omitted. @MockitoBean needs Spring Framework 6.2+; older codebases use @MockBean.
@WebMvcTest(controllers = OrderController.class) // one controller plus advice, Jackson and MVC infrastructure
class OrderControllerTest {

    @Autowired MockMvc mvc;
    @MockitoBean OrderService orderService; // the slice does not scan services, so this must be mocked

    @Test
    void serializesOrderAsClientsExpect() throws Exception {
        when(orderService.find(42L)).thenReturn(new Order(42L, "PAID", LocalDate.of(2026, 3, 1)));
        mvc.perform(get("/orders/42").accept(MediaType.APPLICATION_JSON))
           .andExpect(status().isOk())
           .andExpect(jsonPath("$.id").value(42))
           .andExpect(jsonPath("$.status").value("PAID"))
           .andExpect(jsonPath("$.createdOn").value("2026-03-01")); // pins the date format on the wire
    }

    @Test
    void rejectsInvalidBodyBeforeReachingService() throws Exception {
        mvc.perform(post("/orders").contentType(MediaType.APPLICATION_JSON).content("{\"quantity\": 0}"))
           .andExpect(status().isBadRequest());
        verifyNoInteractions(orderService); // here the absence of a call is the behavior under test
    }

    @Test
    void mapsMissingOrderThroughControllerAdvice() throws Exception {
        when(orderService.find(7L)).thenThrow(new OrderNotFoundException(7L));
        mvc.perform(get("/orders/7"))
           .andExpect(status().isNotFound())
           .andExpect(jsonPath("$.error").value("order_not_found")); // body produced by the advice
    }

    @Test
    void unsupportedVerbIsRejected() throws Exception {
        // No DELETE handler is mapped for /orders/{id}, so the dispatcher answers 405
        mvc.perform(delete("/orders/42")).andExpect(status().isMethodNotAllowed());
    }
}

A @WebMvcTest limited to one controller that mocks the service and asserts only HTTP-observable behavior: JSON serialization, a 400 from bean validation, the error body from @ControllerAdvice, and a 405 for an unmapped verb.

How @DataJpaTest works and why you must flush

@DataJpaTest builds a context with only the persistence layer: entity scanning, Spring Data JPA repositories, the EntityManager, a TestEntityManager for test setup, the DataSource and transaction management. Controllers, services and other components are not loaded. If an embedded database such as H2 is on the classpath, it replaces your configured DataSource by default. Every test method runs in a transaction that is rolled back at the end, so tests don't need cleanup code and don't leak state into one another.

A typical class covers three areas. The first is a derived query such as findByEmailIgnoreCase, which confirms that the method name parses and returns the expected rows. The second is a custom @Query, where JPQL typos and wrong parameter bindings show up. The third is a mapping or constraint test: persist an entity with a duplicate unique column or a null in a nullable = false field, then assert that an exception is thrown.

That third test is easy to get wrong. Hibernate queues inserts and updates in the persistence context and writes them at flush time. If nothing triggers a flush, the rollback discards the queued SQL before it reaches the database, and the test passes even though the constraint, the column mapping or the SQL itself is broken. Call flush() explicitly, or use persistAndFlush or saveAndFlush, so the database actually executes the statement. Calling clear() afterward forces later reads to load from the database instead of the first-level cache, which makes mapping errors visible on the read path as well.

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

import jakarta.persistence.PersistenceException;
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.test.autoconfigure.orm.jpa.TestEntityManager;

@DataJpaTest // persistence layer only; each test runs in a transaction that is rolled back
class UserRepositoryTest {
  @Autowired TestEntityManager em;
  @Autowired UserRepository users;

  @Test
  void derivedQueryIgnoresCase() {
    em.persistAndFlush(new User("ada@example.com"));
    em.clear(); // force the read to hit the database, not the first-level cache
    assertThat(users.findByEmailIgnoreCase("ADA@example.com")).isPresent();
  }

  @Test
  void customQueryFiltersByDomain() {
    // UserRepository: @Query("select u from User u where u.email like concat('%@', :domain)")
    em.persist(new User("ada@example.com"));
    em.persist(new User("bob@other.org"));
    em.flush();
    em.clear();
    assertThat(users.findByDomain("example.com"))
        .extracting(User::getEmail)
        .containsExactly("ada@example.com");
  }

  @Test
  void duplicateEmailViolatesUniqueConstraint() {
    em.persistAndFlush(new User("ada@example.com"));
    // Without the flush, this INSERT would stay queued and be discarded by the rollback,
    // so the test would pass even if the unique constraint were missing.
    assertThatThrownBy(() -> em.persistAndFlush(new User("ada@example.com")))
        .isInstanceOf(PersistenceException.class);
  }
}

A @DataJpaTest class (Spring Boot 3 package names) covering a derived query, a custom @Query and a unique-constraint check, using persistAndFlush, flush and clear so the SQL actually reaches the database before the transaction is rolled back.

Running @DataJpaTest against a real database engine

An embedded database such as H2 accepts much of the SQL your application sends, but it does not behave like PostgreSQL, MySQL or Oracle. Compatibility modes only approximate another engine's dialect. Native types like jsonb, arrays or database enums may be emulated or missing. Unquoted identifiers fold to uppercase in H2 but to lowercase in PostgreSQL, and string comparison depends on each engine's collation. Locking differs too: SELECT ... FOR UPDATE, SKIP LOCKED and isolation-level behavior are where concurrency bugs hide. A green test against H2 therefore shows that your mappings work on H2, and that is all it shows.

By default, @DataJpaTest replaces your configured DataSource with an embedded one. To stop that, annotate the test with @AutoConfigureTestDatabase(replace = Replace.NONE) and point the spring.datasource properties at a container running the same engine and major version you use in production. Your real migrations then run against it, so schema drift surfaces in the test suite.

The cost is real. Each container adds startup time, and CI agents need Docker or a compatible runtime. Share one container across test classes so you pay that startup cost once per run, not once per class.

A real engine still does not show commit-time behavior while the test transaction rolls back. Deferred constraints are never checked. Flush ordering at commit never happens. Lazy associations load without error because the session stays open, so a LazyInitializationException that would occur outside a transaction stays hidden. Tests that need to observe commit-time behavior should commit explicitly.

// Imports omitted. Assumes CI started the production engine first, e.g.:
//   docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=test-only-pw postgres:16
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // keep our DataSource, no embedded swap
@TestPropertySource(properties = {
    "spring.datasource.url=jdbc:postgresql://localhost:5432/postgres",
    "spring.datasource.username=postgres",
    "spring.datasource.password=test-only-pw",
    // let the real migrations build the schema; Hibernate only checks it
    "spring.jpa.hibernate.ddl-auto=validate"
})
class OrderRepositoryPostgresTest {

    @Autowired OrderRepository orders;

    @Test
    void duplicateOrderNumberIsRejectedByPostgres() {
        orders.save(new Order("ORD-1001"));
        // flush now; otherwise rollback discards the SQL before it ever runs
        assertThrows(DataIntegrityViolationException.class,
            () -> orders.saveAndFlush(new Order("ORD-1001")));
    }

    @Test
    void commitExplicitlyToSeeCommitTimeBehavior() {
        orders.save(new Order("ORD-2001"));
        // commit instead of rolling back: deferred constraints and
        // final flush ordering are only exercised here
        TestTransaction.flagForCommit();
        TestTransaction.end();

        // committed rows outlive the test, so clean up in a new transaction
        TestTransaction.start();
        orders.deleteAll();
        TestTransaction.flagForCommit();
        TestTransaction.end();
    }
}

A @DataJpaTest that disables embedded database replacement, runs against a PostgreSQL container with the real migrations, and uses TestTransaction to commit explicitly when a test needs commit-time behavior.

When to use a slice, a unit test or @SpringBootTest

Start from the question "what could break here?" and pick the cheapest test that can actually observe that failure.

Business logic such as pricing rules, state transitions and validation belongs in plain JUnit tests. With constructor injection you can instantiate the class directly, pass in fakes and run thousands of cases in milliseconds without Spring. Use @WebMvcTest when the risk is in the HTTP contract: status codes, request binding, JSON shape, validation errors and exception handlers. Use @DataJpaTest when the risk is in a query, a mapping or a constraint.

A slice is the wrong choice when it would mock away the behavior you care about. If a controller test stubs the service to return exactly what the assertion expects, the test proves only that Jackson works. A green @WebMvcTest says nothing about whether the service bean exists, whether it wires up at startup or whether its transactions commit and roll back correctly.

Use @SpringBootTest for those gaps. That includes cross-layer flows, security filter chains interacting with real controllers, transactions spanning several services and startup wiring. You can optionally add a real server and real infrastructure.

Test type Loads Verifies Cannot catch Relative cost
Plain JUnit Nothing Logic, edge cases Wiring, serialization, SQL Lowest
@WebMvcTest MVC infrastructure, controllers HTTP contract, web wiring Missing beans, transactions Low
@DataJpaTest JPA, repositories, datasource Queries, mappings Service logic, web layer Low–medium
@SpringBootTest Full context Cross-layer behavior, startup Little, but failures are harder to localize Highest

A healthy suite mixes all three. The right proportions depend on where your system's risk actually sits.

Alternatives to Spring Boot test slices

Slices are one option among several. Each alternative trades a different amount of realism for speed and isolation.

Plain unit tests with mocks or fakes. Instantiate the controller or repository-using class directly and pass in collaborators. This is the fastest option and needs no Spring at all, but it cannot verify request mapping, validation, JSON serialization, JPA mappings or generated SQL.

Standalone MockMvc. MockMvcBuilders.standaloneSetup(controller) builds a MockMvc around controller instances you create yourself, without an application context. You register advice, converters and validators manually on the builder. It is lighter than @WebMvcTest, but it tests your manual setup rather than the configuration your application actually uses, so a misconfigured ObjectMapper or a missing @ControllerAdvice registration in the real app goes unnoticed.

@SpringBootTest with MockMvc. Adding @AutoConfigureMockMvc to a @SpringBootTest gives you the same request-level assertions against the full context. Real services and repositories run behind the controller, so this catches wiring and transaction problems a web slice cannot, at the cost of a larger context.

@SpringBootTest with a real server. With webEnvironment = WebEnvironment.RANDOM_PORT, the application starts on a real port and you call it with an HTTP client such as TestRestTemplate or WebTestClient. This exercises the servlet container, real filters and real network serialization. It is the closest to production and the slowest to start and diagnose.

Narrowed @SpringBootTest contexts. @SpringBootTest(classes = ...) or @ContextConfiguration with an explicit list of configuration classes builds a context from only the classes you name. This gives you a custom boundary when no built-in slice matches, but you maintain the list yourself and lose the curated auto-configuration a slice provides.

Other slices or data-access tests. If your persistence code uses JdbcTemplate or Spring Data JDBC instead of JPA, @JdbcTest or @DataJdbcTest is the matching slice. For reactive web code, @WebFluxTest replaces @WebMvcTest.

Contract or end-to-end tests outside the JVM process. Tests that drive a deployed instance over HTTP verify the whole system, including infrastructure and configuration, but they are slow, need a running environment and do not localize failures to a layer.

Common pitfalls when using slices

Most slice failures trace back to one fact: a slice still boots from your @SpringBootApplication class and filters what it finds. Anything that bypasses that filter, or anything the filter removes, surprises you.

Pitfall Symptom Fix
Annotations on the main class @WebMvcTest fails looking for entityManagerFactory Move @EnableJpaRepositories, @EnableScheduling and similar to a dedicated @Configuration class
Custom @ComponentScan on the main class Services and repositories load in a web slice Remove it, or keep the default TypeExcludeFilter and AutoConfigurationExcludeFilter
Security auto-configuration 401 on every request; 403 on POST/PUT Use @WithMockUser and the csrf() request post-processor, or @Import your security configuration
Missing collaborators Context startup fails with NoSuchBeanDefinitionException Register a mock for the service, or @Import the specific bean
Unexpected filters A servlet Filter or WebMvcConfigurer changes responses Expect it, since web slices include these by design; import their dependencies or exclude the filter explicitly

The main-class leak is the most common of these. Every slice processes annotations placed on the application class. If you put @EnableJpaRepositories(basePackages = "com.example.repo") there, a controller test tries to build repositories with no JPA infrastructure. The fix is to declare that annotation on a separate JpaConfig class. Regular configuration classes are not picked up by slices, so web tests stay lean.

For security, decide what the slice should verify. If authorization rules are part of the HTTP contract, import the real security configuration and test both allowed and denied requests. Avoid disabling security silently.

Impact on cost and engineering time: context caching

Spring's test framework caches each application context it builds and reuses it for any later test class with the same configuration key. That key includes the configuration classes, active profiles, test property sources, and context customizers such as mocked beans. If two test classes differ in any of these, even by one @MockitoBean or one inline property, Spring starts a second context. @DirtiesContext goes further: it evicts the context, so the next test that needs it pays the full startup cost again. The cache also lives inside a single JVM, so forked test processes do not share it.

This is why the number of distinct configurations often matters more for suite speed than the choice between slices and full contexts. Several slightly different slice setups can cost more than a few shared @SpringBootTest contexts.

Illustrative estimate (assumed numbers, not a benchmark):

  • Total startup time ≈ (distinct contexts + forced reloads) × average startup time.
  • Assume 25 distinct slice configurations at 2 s each: 25 × 2 = 50 s.
  • Assume 3 shared full contexts at 8 s each: 3 × 8 = 24 s.

With these inputs, the full-context suite starts faster. Measure your own suite before assuming the opposite.

To keep the count low, give each layer a shared base test class or composed annotation, standardize which beans are mocked, and remove @DirtiesContext wherever resetting state in the test is enough.

Slices also cost learning time, because engineers must know what each slice includes and leaves out. In return, they localize failures. A failing @WebMvcTest points to the web layer. A failing full-context test can come from any bean, so it takes longer to diagnose.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to spring boot test slices, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.