Integration Testing: @SpringBootTest and Slice Tests

bee2026-10-0850 min read0 views
When do you need a real container? The four webEnvironment modes, @WebMvcTest/@DataJpaTest slices, MockMvc end-to-end assertions and Testcontainers for a real database.
1 / 142
Section
0. The 30-second version
2 / 142

Integration testing exists to kill one very specific embarrassment: all unit tests green, production explodes. In a unit test you new the objects yourself and hand them fake collaborators; in production the objects are whatever the container assembled. Nobody ever verified that middle stretch — "did the parts actually get bolted together". Integration tests let the container really assemble once; slice tests assemble only the small region you want to check.

3 / 142

Five words, one line each (used throughout):

4 / 142
  • ApplicationContext: Spring's housekeeper that creates and manages every object; starting it means assembling the whole application's parts
  • Slice test: an annotation that loads only one layer — @WebMvcTest takes the web layer, @DataJpaTest takes JPA
  • MockMvc: sends no real network traffic; it feeds a pretend request into Spring MVC's front door and walks routing → argument binding → serialization
  • TestContext: the scheduler inside Spring Test that prepares the context, injects fields, runs callbacks and handles rollback — everything automated happens around it
  • Context cache: tests with identical configuration reuse one context; change one thing in the key and it is rebuilt from scratch
5 / 142
类比|Analogy

a slice test is like switching on just the one desk lamp while you revise a drawing in the study, instead of throwing the main breaker to read a single book. @SpringBootTest is "power the whole building": every light, every air conditioner, every elevator — best at finding problems, most expensive and slowest too. The smart question to ask first is: if this thing breaks, would the desk lamp reveal it? If yes, do not touch the main breaker.

6 / 142
Diagram
Figure · Full context versus slice: whole building or one lamp
Figure · Full context versus slice: whole building or one lamp
7 / 142

After this article you should be able to answer three questions:

8 / 142
  • Should my case use @SpringBootTest, @WebMvcTest or @DataJpaTest — and what is the decision criterion?
  • Why did adding one @MockitoBean triple the runtime of the whole suite?
  • @Transactional tests roll back automatically, so how can that hide a real unique-constraint bug?
9 / 142
Section
1. The blind spot of unit tests: a mock always obeys
10 / 142

The previous article showed the power of unit tests — millisecond verification of business branches. But they have a built-in blind spot: a mock only answers per your script and never misbehaves. So three classes of problems pass all unit tests and blow up in production:

11 / 142
  • Wiring errors: your test does new UserService(mockRepo) and is perfectly green; at real startup the container holds two UserRepository implementations, neither with a @Qualifier, and it throws NoSuchUniqueBeanDefinitionException. You tested an object; runtime uses the container.
  • SQL errors: the mocked repository always returns data, while the real @Query uses a field named username although the entity property is actually userName. A unit test never parses that JPQL, until the endpoint returns a 500 on first call.
  • Serialization errors: a DTO carries a LocalDateTime with no JavaTimeModule registered. A unit test skips JSON serialization, until a real HTTP response fails to write with InvalidDefinitionException.
12 / 142

What these share is the point: the problem is not in the business logic but in how components are assembled. That is exactly the domain of integration and slice tests.

13 / 142
Section
2. @SpringBootTest in full: its four modes and configuration
14 / 142

@SpringBootTest does one thing: start a real Spring application context and wire up everything it should. Its two most-used attributes are:

15 / 142
java
// classes: which configuration class to use (defaults to @SpringBootConfiguration)// webEnvironment: whether to start a real server, and which web environment to use@SpringBootTest(        classes = DemoApplication.class,        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)class UserApiIntegrationTest {    @LocalServerPort    private int port;          // the actual random port, injected here}
16 / 142

webEnvironment has four modes that differ a lot; choosing wrong costs you either "it will not start" or "twice as slow":

17 / 142
Table
ModeBehaviourProvidesBest for
MOCK (default)No real server, only a web environmentMockMvc / MockEnvironmentMost web-layer tests; fast
RANDOM_PORTStarts an embedded server on a random portReal HTTP with @LocalServerPortVerifying the real network stack and filters
DEFINED_PORTUses the configured port (8080 by default)Real HTTP on a fixed portIntegrating with something that needs a fixed port
NONENo web environment at allA plain ApplicationContextPure service-layer / scheduled-job integration tests
18 / 142
Animation
Animation · Lifecycle of an integration test
Animation · Lifecycle of an integration test
19 / 142
类比|Analogy

starting a full context is like turning on the central heating, the elevators and the pool pumps of an entire hotel just to check whether one shower head runs. You do learn "the shower works" — at the cost of the whole building's electricity. A slice opens only that floor's valve: fast, but if the fault is in the boiler room (wiring, connection pools, transaction managers) it cannot see it. The timeline below gets reused; Section 11 uses it to diagnose slow tests.

20 / 142
Animation
Animation · One round trip through TestContext: cache lookup to reuse
Animation · One round trip through TestContext: cache lookup to reuse
21 / 142

Beyond the main config, @TestConfiguration lets you add beans that exist only in tests — say a fake SMS client:

22 / 142
Code
Codejava
@TestConfigurationclass TestBeans {    @Bean    @Primary                                     // override the real one without polluting main config    SmsClient fakeSmsClient() {        return (phone, msg) -> System.out.println("stub sms -> " + phone);    }}
Notes

Note: @TestConfiguration is a test-only configuration and is not picked up by component scanning; you must @Import it or declare it as a static nested class. The benefit: doubles that tests need will never leak into the production context.

23 / 142

What a test environment needs is really just a variant of the production config: embedded database instead of the real one, schema handed to Hibernate, a profile of its own. Instead of copy-pasting somebody's src/test/resources/application.yml, tick the boxes and see which lines come out — and what breaks when you untick each one:

24 / 142
Generator
GeneratorThe test application.yml: tick it and see which lines must existapplication.yml3 / 5
Tick only datasource and JPA to get the smallest runnable pair (embedded DB + create-drop); then add Profile and Logging and map them back to the H2-versus-MySQL dialect gap in Section 7 and the 'one extra properties line, one new cache key' rule in Section 11; finally untick each line and predict which one stops the context from starting
Output
server:
  port: 8080

spring:
  application:
    name: demo-service
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
    username: ${DB_USER:root}          # ${} 占位符:环境变量优先,冒号后是默认值
    password: ${DB_PASS:}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      max-lifetime: 1740000            # 必须小于 MySQL 的 wait_timeout
      pool-name: beeHikari
  jpa:
    open-in-view: never
    hibernate:
      ddl-auto: validate                 # 生产用 validate,别用 update/create
    show-sql: false
    properties:
      hibernate.format_sql: true
      hibernate.jdbc.batch_size: 50

---
spring:
  config:
    activate:
      on-profile: prod
logging:
  level: { root: WARN }
---
spring:
  config:
    activate:
      on-profile: dev
spring:
  jpa:
    show-sql: true
Why each choice matters
datasourcePool settings only apply here; constructing HikariDataSource in code ignores every one of them.
jpaopen-in-view: never closes the implicit transaction extension that lets controllers grab connections during lazy loads.
profiles + 分档配置Multi-document blocks are split by --- and activated with spring.config.activate.on-profile.
25 / 142
Trap

the moment you point the generated spring.datasource.url at a real MySQL but forget to disable @AutoConfigureTestDatabase, the test still connects to the embedded database — your yml was overridden by the slice, not honoured. That is exactly why Section 7's replace = Replace.NONE line exists.

26 / 142
Section
3. Test transactions: how @Transactional rollback works, and its traps
27 / 142

The most annoying part of integration tests is "data pollution" — data inserted by case A affects case B. Spring Test offers a neat fix: annotate the test class with @Transactional and every test method rolls back when it finishes.

28 / 142
java
@SpringBootTest@Transactional                       // each test method rolls back when done — no residueclass UserServiceIntegrationTest {    @Autowired private UserService userService;    @Test    void should_create_and_query_user() {        Long id = userService.create("alice", "alice@example.com");        assertThat(userService.getById(id).getUsername()).isEqualTo("alice");        // the method ends, the transaction rolls back, the database is as before    }}
29 / 142

Why can it roll back? Spring Test opens a transaction around the test method and binds it to the current thread's ThreadLocal, so every operation in the test reuses that one connection, and the framework rolls it back afterwards. This also hides two traps:

30 / 142
  • It does not work for real HTTP under RANDOM_PORT: the HTTP request runs on a server thread, which cannot see the connection bound to the test thread, so the request's writes are not in the test transaction and will not roll back.
  • It masks constraint problems: because the data is finally rolled back, foreign key and unique-constraint issues may surface only at flush time, or never.
31 / 142
Trap

@Transactional rollback only works for direct bean calls on the same thread. Once you go through real HTTP, a new thread or an async method, the transaction breaks — then use @Sql cleanup or @DirtiesContext instead of counting on rollback.

32 / 142

That "same thread works, other thread leaks" boundary is not something prose makes stick. This animation runs the whole life of the framework's transaction — watch frame 6, where the rows it writes are never rolled back by anybody:

33 / 142
Animation
Animation · Opening and rolling back a test transaction
Animation · Opening and rolling back a test transaction
34 / 142

Now spread it out as a single-step run. The six lines on the left are what this test really executes; the right panel refreshes the variables and the call stack as you go. Step through and watch two moments: beat 3, where the service thinks it committed, and beat 6, where the thread name changes:

35 / 142
Stepper
StepperStep by step: did that row actually reach the table?1 / 6
Six beats. Beat 3 explains why the assertion can read the row; beat 6 explains why the rollback cannot save you
Code under debug
1@SpringBootTest(webEnvironment = RANDOM_PORT) // this line decides the fate of beat 6
2@Transactional // on the test class, not on the service
3Long id = userService.create("alice"); // the service has its own @Transactional
4assertThat(userService.getById(id)).isPresent(); // it reads back: same connection
5// afterTestMethod: the framework calls connection.rollback() unconditionally
6rest.getForEntity("/users/1", User.class); // this one runs on a Tomcat worker thread
Variables now
threadmain
context cache keyDemoApplication + test + {}
transactionnot opened yet
Call stack
1SpringExtension.beforeAll
2buildMergedContextConfiguration
1Separate two things first: @SpringBootTest only prepares the context, no transaction exists yet. RANDOM_PORT means the embedded Tomcat really is listening — that is the setup for beat 6.
36 / 142

That line about "the service's commit is only a vote" can be watched directly in the kernel — same @Transactional, different propagation, different ending:

37 / 142
Kernel lab
TeaVMIs the service's transaction the test's transaction?idle
Pick 'Outer rollback' to see how an inner commit is swallowed by one outer rollback under REQUIRED; then switch to REQUIRES_NEW, which really does commit and cannot be undone
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
38 / 142
Section
4. MockMvc in practice: one complete assertion chain
39 / 142

MOCK mode starts no server, but you can feed a request into the DispatcherServlet with MockMvc and walk the whole MVC flow:

40 / 142
Code
Codejava
@SpringBootTest@AutoConfigureMockMvcclass UserControllerTest {    @Autowired private MockMvc mockMvc;    @Test    void should_return_user_json_when_found() throws Exception {        mockMvc.perform(get("/users/42")                       // build the request                        .accept(MediaType.APPLICATION_JSON))                .andExpect(status().isOk())                    // 1. status code                .andExpect(header().string("Content-Type",                        containsString("application/json")))   // 2. response header                .andExpect(jsonPath("$.id").value(42))         // 3. JSON fields                .andExpect(jsonPath("$.username").value("alice"))                .andDo(print());                               // print request/response — a debugging gem    }    @Test    void should_return_404_when_missing() throws Exception {        mockMvc.perform(get("/users/999"))                .andExpect(status().isNotFound())                .andExpect(jsonPath("$.code").value("USER_NOT_FOUND"));    }}
Notes
  • perform(...) builds and executes the request, supporting get/post/put/delete plus contentType / content (a JSON body)
  • andExpect(...) is a chain of assertions: status, then headers, then JSON fields, step by step
  • jsonPath("$.id") locates fields with JSONPath, far more robust than parsing strings by hand
  • andDo(print()) dumps the full request and response to the console — add it first when an assertion fails

Tip: with Chinese text, watch MockMvc's character encoding. If the response shows garbled characters, first confirm the Content-Type carries charset=UTF-8, then check the encoding on the controllers / global HttpMessageConverter. JSON defaults to UTF-8, but a plain-string return value can be affected by StringHttpMessageConverter's default encoding.

41 / 142
Section
5. Real HTTP calls: TestRestTemplate and WebTestClient
42 / 142

MockMvc does not traverse the real network stack. To verify real HTTP behaviour (filters, serialization, status handling), use RANDOM_PORT with a real client:

43 / 142
Table
ClientStackTraits
MockMvcServlet (MOCK mode)Fastest, no network, fits most assertions
TestRestTemplateServlet (RANDOM_PORT)Blocking, simple, good for synchronous endpoints
WebTestClientWebFlux (also tests Servlet)Reactive, streaming assertions, more powerful
44 / 142
Code
Codejava
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)class UserApiRealHttpTest {    @Autowired    private TestRestTemplate rest;          // auto-targeted at the random port    @Test    void should_fetch_user_over_real_http() {        ResponseEntity<User> resp = rest.getForEntity("/users/42", User.class);        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);        assertThat(resp.getBody().getUsername()).isEqualTo("alice");    }}
Notes
  • With @SpringBootTest and RANDOM_PORT, TestRestTemplate is auto-wired to the real port; no URL to write by hand
  • It passes through the whole Servlet stack: filters, interceptors and serializers all really execute — precisely what MockMvc cannot cover
  • The cost is slowness: a real server starts each time, so keep these tests few and valuable
45 / 142
Section
6. Slice tests: load only the layer you care about
46 / 142

A full context is heavy. Most of the time you only want the "web layer" or the "JPA layer"; slice test annotations wire only the relevant components and load nothing else:

47 / 142
Diagram
Figure 1 · From slices to the whole context
Figure 1 · From slices to the whole context
48 / 142
Table
AnnotationLoadsTypical use
@WebMvcTestMVC layer only (controllers / filters / converters)Routing, arguments, validation, exception handling
@DataJpaTestJPA only (entities / repositories), embedded DB by defaultDerived queries, @Query, mapping
@JsonTestSerialization only (ObjectMapper / Jackson)JSON serialization and deserialization
@RestClientTestHTTP client onlyCalling and parsing an external API
49 / 142

@WebMvcTest wires only the web layer, so the Service a controller depends on is not created — fill the gap with @MockBean:

50 / 142
java
@WebMvcTest(UserController.class)class UserControllerSliceTest {    @Autowired private MockMvc mockMvc;    @MockBean    private UserService userService;        // the service is absent in a slice; @MockBean stands in    @Test    void should_return_user() throws Exception {        when(userService.getById(42L)).thenReturn(new User(42L, "alice"));        mockMvc.perform(get("/users/42"))                .andExpect(status().isOk())                .andExpect(jsonPath("$.username").value("alice"));    }}
51 / 142
Code
Codejava
@DataJpaTestclass UserRepositorySliceTest {    @Autowired private UserRepository userRepository;    @Autowired private TestEntityManager entityManager;   // a handy tool the slice provides    @Test    void should_find_by_username() {        entityManager.persist(new User(null, "alice"));        entityManager.flush();        assertThat(userRepository.findByUsername("alice")).isPresent();    }}
Notes

Key point: @DataJpaTest by default runs each test method in a transaction that rolls back, and replaces the real datasource with an embedded database. It loads very few components, so it is fast — but precisely because it uses an embedded database, dialect differences become the focus of the "real database" section below.

52 / 142

The table above says what each slice loads, but on a real ticket you ask the opposite question: which lamp does this particular bug need? Play a round — pick a failure class on the left, then the cheapest tool that catches it on the right; a wrong pick explains itself immediately.

53 / 142
Match
MatchWhich layer should catch this bugMatched 0/6 · Missed 0
Six real ticket descriptions on the left, the cheapest capture tool on the right — both columns are shuffled, so positions mean nothing
Pick a card on the left first
54 / 142
Section
7. Test database strategy: from H2 to Testcontainers
55 / 142

H2 is fast, but it has dialect differences from MySQL — native functions, ON DUPLICATE KEY, pagination syntax and column types can all mismatch. So a harsh reality appears: all green on H2, a syntax error the moment you switch to MySQL.

56 / 142
Table
StrategySpeedFidelityRisk
H2 in-memoryVery fastLowDialect differences cause "test passes, prod fails"
A local real MySQLMediumHighDepends on the local machine, flaky in CI
Testcontainers (Docker runs the real DB)Slower (first image pull)HighestNeeds Docker, and CI configuration
57 / 142

Testcontainers is blunt and effective — spin up a real MySQL in Docker for the tests:

58 / 142
Code
Codejava
@DataJpaTest@Testcontainers@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)  // stop using the embedded DBclass UserRepositoryContainerTest {    @Container    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")            .withDatabaseName("demo")            .withUsername("test")            .withPassword("test");    @DynamicPropertySource    static void props(DynamicPropertyRegistry registry) {        registry.add("spring.datasource.url", mysql::getJdbcUrl);        registry.add("spring.datasource.username", mysql::getUsername);        registry.add("spring.datasource.password", mysql::getPassword);    }}
Notes
  • @Testcontainers plus @Container manage the container's lifecycle (a static container starts once per class)
  • @AutoConfigureTestDatabase(replace = NONE) is the key: by default it swaps the datasource for an embedded one, and you must disable that to reach the container
  • @DynamicPropertySource injects the container's dynamic port into Spring config
  • Reuse switch: enabling testcontainers.reuse.enable=true lets repeated local runs reuse an already-started container, saving the image pull each time (recommended off in CI to keep isolation)

Note: Testcontainers is the modern answer to "real database tests". It solves the long-standing "H2 differs from MySQL" pain at the root — you test the very MySQL version running in production. The cost is a working Docker environment that CI must be prepared with.

59 / 142
Section
8. Preparing test data: never let cases pollute each other
60 / 142

How you create data decides how stable the tests are. Three approaches, ordered from least to most recommended:

61 / 142
  • data.sql: executed automatically at startup, good for baseline data every class needs (such as dictionary tables). The downside is it is global and easily interferes.
  • @Sql: names a script on a single method or class, precisely scoping the data, and can set executionPhase = BEFORE_TEST_METHOD.
62 / 142
Code
Codejava
@Test@Sql(scripts = "/sql/users.sql",     executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)void should_list_active_users() {    assertThat(userService.findActive()).hasSize(3);}
Notes
  • Builder / factory methods: the most flexible and clearest — test data lives inside the test code, with preconditions visible at a glance:
63 / 142
Code
Codejava
static User aUser() {    return User.builder()            .username("alice")            .email("alice@example.com")            .status(Status.ACTIVE)            .build();}
Notes

Trap: test data should be self-contained, not shared. Depending on data left by a previous case is the most common source of unstable integration tests — passing alone but failing in a full run is usually pollution. Prefer builders inside the case, or clear the database in @BeforeEach.

64 / 142
Section
9. Running tests in CI: mvn test versus mvn verify
65 / 142

In a pipeline, first tell the two commands apart:

66 / 142
Table
CommandPhase reachedWhat runs
mvn testtestUnit and slice tests only
mvn verifyverifyAll tests plus integration tests, the failsafe plugin, coverage checks
67 / 142

The reason is Maven's two test plugins: **surefire runs Test (unit / slice) and failsafe runs IT (integration) bound to the verify phase**. So integration classes usually end in IT:

68 / 142
Code
Codetext
UserServiceTest.java     <- surefire, runs on mvn testUserApiIT.java           <- failsafe, runs only on mvn verify
Notes

Tip: in CI, split "fast feedback" from "full verification": run mvn test on push for quick feedback, and mvn verify (with Testcontainers integration tests) before merge for full gating. On retries, one sentence — first re-run only the failed cases to check for flakiness; if they still fail, investigate the root cause, never let a re-run hide a flaky test.

69 / 142

Integration tests should cover every link of the request chain; the demo below makes clear just how many components one request passes through:

70 / 142
Kernel lab
TeaVMWhat is really under test is this chainidle
Walk /users/42 once and list every link an integration test should cover
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
71 / 142
Section
10. Two traps, a decision card and a summary
72 / 142
Trap

@MockBean pollutes the context cache and slows tests down. Spring caches and reuses contexts with identical configuration, and every distinct combination of @MockBean builds a fresh context. With dozens of mock combinations across a project, that is dozens of Spring startups — exactly how integration-test time balloons. Keep @MockBean usage narrow and centralised.

73 / 142
Trap

@Transactional tests mask constraint problems. Because data finally rolls back, foreign key and unique-constraint conflicts may be pushed to flush or never trigger. For cases involving constraints, use a real commit (drop @Transactional) plus @Sql cleanup so real problems surface.

74 / 142
Decision
DecisionA team maintains a Spring Boot backend. Local dev machines have no Docker; CI supports Docker but wants to stay fast. Which testing strategy should dominate?
75 / 142
Section
11. What TestContext actually does: taking one integration test apart
76 / 142

Every piece of magic so far — auto-injection, auto-rollback, context reuse — comes from one engine: TestContext, Spring Test's scheduler, the object that runs the whole routine before and after the first line of test code you wrote. Its complete round trip is the animation from Section 0:

77 / 142
Animation
Animation · One round trip through TestContext: cache lookup to reuse
Animation · One round trip through TestContext: cache lookup to reuse
78 / 142

Map each step onto the annotations you know and the machinery stops being mysterious:

79 / 142
Table
RoundWhat TestContext doesWhat you can hook in
1. keyCombines config classes, @ActiveProfiles, properties and @MockitoBeans into one cache keyAnything different → a different key
2. cacheOn a hit, reuses the existing contextTo stay fast, maximise other people's hits
3. buildOnly on a miss does Spring actually start; a slice filters out everything else here@WebMvcTest / @DataJpaTest
4. injectFills @Autowired and @MockitoBean fields on the test instanceDo not use them inside a constructor
5. beforeRuns @BeforeEach methods in orderSeed data and stubbing belong here
6. runExecutes the method and assertions; with @Transactional it reuses one connectionReal HTTP escapes that connection
7. teardownRolls back, then parks the context in the cache for the next test@DirtiesContext forces non-reuse
80 / 142

Seven rows is a lot to hold in your head, and "which round is slow" is not something reading teaches. This diagram turns the same round trip into something you can click, one box at a time — stop on boxes 2 and 3:

81 / 142
Diagram
FlowOne test case's life: how it finds a context1 / 6
Click from ① to ⑥; box ② decides whether your case costs 5ms or six seconds
→
→
→
→
→
① Compute the fingerprint
Config classes, @ActiveProfiles, properties and every @MockitoBean are folded into one key. This step is pure in-memory arithmetic and never slow — but it decides everything slow or fast that follows.
All clearHow long your suite takes is barely about how many assertions you wrote; it is about how often box ② hit.
82 / 142

Walk those seven steps yourself — switch the arguments to see each difference:

83 / 142
Kernel lab
TeaVMHow much @SpringBootTest really loadsidle
Pick '@SpringBootTest boot' to see every component a full context wires, then '@WebMvcTest slice' to compare how much less it loads
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
84 / 142

The second argument of that same lab is the single most important lesson of this article:

85 / 142
Kernel lab
TeaVMContext caching: why your tests got sloweridle
Pick 'Context caching' to measure what a key hit saves, then watch how mock combinations split the key apart
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
86 / 142
  • A cache hit costs almost nothing: test number 100 may take 5ms because it reuses what test number 1 built
  • A split key means powering the building up again: one extra properties line or one different mock combination creates a new key
  • So the engineering discipline is: write tests of the same kind with byte-identical configuration, and group the odd ones into one package instead of scattering them project-wide
87 / 142

First pin down what actually enters the fingerprint — everything in the left column changes the key, everything in the right column never does:

88 / 142
Diagram
Figure · The context cache fingerprint: what goes into the key
Figure · The context cache fingerprint: what goes into the key
89 / 142

What turns this into intuition, though, is the cache capacity number. Boot keeps only eight contexts, and nobody on a CI box has ever mentioned it:

90 / 142
Tuner
TunerThe cache holds 8 contexts — how many keys does your suite have?
spring.test.context.cache.max-size
8contextsNow 1 – 32
Boot default: usually fine, rarely checked
  • Eight suits small projects: a few slice keys plus one or two full contexts
  • The test is simple — count the mock combinations, profiles and properties sources you have
  • When distinct keys approach or exceed eight, Refreshing ApplicationContext starts appearing before nearly every case
  • This cache is not released until the JVM exits, so eight full contexts are hundreds of megabytes
Cache hit rate85%
Total suite time45%
Test JVM memory60%
Count the fingerprints before touching the cache size — raising it is painkillers for scattered configuration, not a cure.
91 / 142

Two more things only TestContext explains. First, the transaction inside a test is not your service-layer @Transactional — the framework opens it before round 6 and rolls it back unconditionally in round 7:

92 / 142
Kernel lab
TeaVMTest transactions: automatic rollbackidle
Pick 'Rollback after test' to see exactly what the framework does after your assertions, and why not one row survives
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
93 / 142

Second, why a slice insists you supply your own mocks — the filtered-out components simply never entered the container:

94 / 142
Kernel lab
TeaVMHow @MockitoBean swaps the real beanidle
Pick '@MockitoBean swap' to watch the real implementation being replaced, and note the effect on the cache key
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
95 / 142
Section
12. From unit test to integration test: who walks the middle stretch
96 / 142

In the previous article you new-ed the test class yourself; in an integration test, the object that builds things becomes TestContext. Refresh the pure-Mockito side first, then contrast it with the container side:

97 / 142
Kernel lab
TeaVMHow a test class runs without any containeridle
Start with 'Execution order', then 'Replace with @Mock' and 'Verify interactions' — with a container these three steps are taken over by TestContext
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
98 / 142

The closing piece: what an integration test really starts is the very same SpringApplication.run() you met in the startup article. The only differences are that it runs inside the test JVM and its state is rolled back rather than handed to the outside world:

99 / 142
Kernel lab
TeaVMAn integration test really boots the app onceidle
Pick 'The eight steps' and line it up against the 'build context' row of the table above; then use 'Reading a failed start' to practise reading errors
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
100 / 142

Enough buttons — type the commands yourself. This console talks to the same in-browser Java kernel and every reply is computed there: start with beans to count what a full context wires, then work down the lab lines:

101 / 142
Console
102 / 142
Note

run beans first and lab tctx slice after it — comparing the two component counts is what turns Section 6's "a slice loads only its own layer" from a slogan into something you saw. And lab tctx tx should be followed immediately by lab tx rollback: the first is TestContext's outer transaction, the second is your service layer's own commit. Confusing those two is what makes Section 11's sandbox unreadable.

103 / 142
Section
13. Sandbox: how big a lamp does this case need?
104 / 142

Choosing a test scope is not mysticism; it is one explicit trade — how much fidelity you want against how many seconds you will pay. The sandbox below puts the three choices side by side; switch one position and you see per-case cost, memory footprint, and whether a certain class of bug can be caught at all:

105 / 142
Sandbox
SandboxTest scope picker: how big a lamp
Result
Per case: 180ms - 1.2s
# loads only that layer: @WebMvcTest wires MVC, @DataJpaTest wires JPA plus an embedded DB
Covers: routing, argument binding, validation, serialization, derived queries and @Query
Cannot catch: wiring conflicts between Service and Repository, cross-layer transactions
Stable cache hits when sibling slices share identical configuration
The daily workhorse. Most "this layer is written wrong" bugs surface here, fast enough that nobody skips them.
106 / 142
Note

the numbers are illustrative but the proportions are real — a healthy backend looks like "Mockito dominates, slices in the middle, full contexts sprinkled". If your distribution is inverted (full contexts dominate), you can conclude one thing: most of those full-context cases re-test what a slice already covered.

107 / 142
Section
14. Check yourself
108 / 142

A warm-up question, straight from the context-cache trap in Section 9:

109 / 142
Quiz
Check yourselfThirty integration test classes originally shared one context and took 40 seconds. After someone added differently-configured @MockitoBeans to six of them, the total jumped to 4 minutes. Most likely cause?
Pick one — you get feedback right away
110 / 142

Now a combined question threading the rollback trap of Section 3 with the dialect gap of Section 7:

111 / 142
Quiz
Check yourselfAn entity has a unique index on email. The test class is annotated @Transactional and calls userService.create("a@x.com") twice; the second call still throws DataIntegrityViolationException, so someone concludes "the constraint works, test passed". Which statement is correct?
Pick one — you get feedback right away
112 / 142
Section
15. Common errors, quick reference
113 / 142

Beginners spend most of their stuck time just decoding messages. This table is ordered by the literal fragment, so you can search it verbatim:

114 / 142
Table
Error fragmentReal cause30-second self-rescueDig deeper in
Refreshing ApplicationContext printed before nearly every case, alongside Loaded default TestExecutionListenerContexts are not being reused: differing @MockitoBean sets, properties or @ActiveProfiles produce a new cache key per classEnable DEBUG on org.springframework.test.context, diff the keys of two adjacent cases, consolidate classes with identical configThis article, Section 11
LazyInitializationException: failed to lazily initialize a collection of role: [...]A lazy collection queries the database only when touched, but by then the session is closed. In tests this shows up outside @DataJpaTest or on async threadsentityManager.flush() then clear() before asserting; or use JOIN FETCH in the query; if you truly need a long session, add @Transactional explicitlyPersistence article (laziness & N+1)
DataIntegrityViolationException / Unique index or primary key violation only in production, all green in tests@Transactional test rollback masked the unique constraint; or tests run on an embedded DB while production runs MySQL, so DDL and dialect differDrop @Transactional for that case and commit, clean with @Sql; then move it into a Testcontainers caseThis article, Sections 3 and 7
java.lang.IllegalStateException: Failed to load ApplicationContext ... WebApplicationContext is requiredYou injected MockMvc / @LocalServerPort but the context is not a web one: webEnvironment = NONE, or @SpringBootTest found no @SpringBootConfigurationConfirm webEnvironment is MOCK or RANDOM_PORT; name the class explicitly with classes = XxxApplication.classThis article, Section 2
No qualifying bean of type 'com.example.UserService' available inside a @WebMvcTestA slice wires only the web layer; the Service was never in the containerAdd @MockitoBean UserService userService;, or promote the test to @SpringBootTestThis article, Section 6
NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager'TestEntityManager is contributed only by @DataJpaTest, and you asked for it inside @SpringBootTestTurn that class into a JPA slice test, or inject EntityManagerFactory and pull it yourselfThis article, Section 6
org.junit.ComparisonFailure: expected:<[alice]> but was:<[null]>The assertion is fine; the field under test was simply not populated yet (constructor vs field injection timing)Add andDo(print()) to inspect the body; check serialized field names; verify an async write had actually completedUnit testing article
java.lang.IllegalStateException: Unable to read SQL script location '/sql/users.sql'You wrote a filesystem path, but @Sql resolves against the classpath and the test resource was never copiedPut it in src/test/resources/sql/, reference /sql/users.sql; run mvn test-compile to confirm the resource landedThis article, Section 8
115 / 142
Tip

the phrase dominating this table is Failed to load ApplicationContext. It almost never means the text after it is wrong — it means the context never came up at all. Follow the caused-by chain down to the first class name that is not from Spring; that is your root cause.

116 / 142

That tip deserves a live run. Below is the most common @WebMvcTest failure scene — do not read the analysis, just click the frame you think is guilty:

117 / 142
Triage
Error triageIllegalStateException: Failed to load ApplicationContext
A slice that cannot start: a full screen with not one line of your business code in it

A junior colleague writes UserControllerSliceTest with @WebMvcTest to test only the controller. It fails red on the first run. He insists: 'UserService clearly has @Service on it — how can the container not have it?'

java.lang.IllegalStateException: Failed to load ApplicationContext for [WebMergedContextConfiguration@4b8d7ca1 testClass = com.example.user.UserControllerSliceTest, locations = [], classes = [com.example.demo.DemoApplication], activeProfiles = [], propertySourceProperties = [], contextCustomizers = [WebMvcTestContextCustomizer@2f1a3b], contextLoader = SpringBootContextLoader, parent = null]
at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:124)
at org.springframework.test.context.junit.jupiter.SpringExtension.getApplicationContext(SpringExtension.java:111)
at org.springframework.test.context.junit.jupiter.SpringExtension.postProcessTestInstance(SpringExtension.java:98)
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'userController' defined in file [UserController.class]: Unsatisfied dependency expressed through constructor parameter 0
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.user.UserService' available
Action:
Consider defining a bean of type 'com.example.user.UserService' in your configuration.
Click the frame you blame — guessing is allowed
No pressure: guess the exception first, then which line actually made the call.
118 / 142
Section
16. Hands-on practice
119 / 142
Section
Tier 1 · Follow along
120 / 142

Goal: run one complete "testing a controller" slice and see with your own eyes that the Service is absent from the container and a mock stands in.

121 / 142

Step one — pom.xml needs exactly one starter (everything else comes from the parent):

122 / 142
xml
<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-test</artifactId>    <scope>test</scope></dependency>
123 / 142

Step two — the controller and service under test:

124 / 142
java
package com.example.user;@RestController@RequestMapping("/users")public class UserController {    private final UserService userService;                 // constructor injection    public UserController(UserService userService) {        this.userService = userService;    }    @GetMapping("/{id}")    public User get(@PathVariable Long id) {        return userService.getById(id);    }}
125 / 142

Step three — the test class:

126 / 142
java
package com.example.user;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.test.context.bean.override.mockito.MockitoBean;import org.springframework.test.web.servlet.MockMvc;import static org.hamcrest.Matchers.containsString;import static org.mockito.Mockito.when;import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;@WebMvcTest(UserController.class)class UserControllerSliceTest {    @Autowired private MockMvc mockMvc;    @MockitoBean private UserService userService;   // no Service in a slice; fake one here    @Test    void should_return_user_json() throws Exception {        when(userService.getById(42L)).thenReturn(new User(42L, "alice"));        mockMvc.perform(get("/users/42"))                .andExpect(status().isOk())                .andExpect(content().string(containsString("alice")))                .andExpect(jsonPath("$.id").value(42))                .andExpect(jsonPath("$.username").value("alice"))                .andDo(print());                                  // dump request/response    }}
127 / 142

Step four — the command and the expected output:

128 / 142
bash
$ mvn -q test -Dtest=UserControllerSliceTest[INFO] Running com.example.user.UserControllerSliceTest2026-10-07T10:12:33.482  INFO  c.e.u.UserControllerSliceTest : Started in 1.94 secondsMockHttpServletRequest:      HTTP Method = GET      Request URI = /users/42MockHttpServletResponse:           Status = 200    Content type = application/json             Body = {"id":42,"username":"alice"}[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0[INFO] BUILD SUCCESS
129 / 142

Started in 1.94 seconds plus Tests run: 1 means you passed. Notice the startup banner contains no Tomcat started on port — a slice never starts a server, which is exactly why it is fast.

130 / 142
Section
Tier 2 · Variants
131 / 142
  1. Add @SpringBootTest on top of that class. You will observe: Tomcat appears in the banner, wall-clock time rises, and the console gains the full-context assembly logs — and ask yourself what changed in the cache key.
  2. Delete @MockitoBean UserService userService; and run. You will observe: Failed to load ApplicationContext whose caused-by reads NoSuchBeanDefinitionException: UserService — row five of the Section 15 table, live.
  3. Copy the class as UserControllerSliceTest2, changing only the stubbed return value from alice to bob, then run the whole package. You will observe: almost no time increase (same key, context reused). Now add @TestPropertySource(properties = "server.port=0") to one of them and rerun — this time a second context is built. That is Section 10's two traps, quantified.
132 / 142
Section
Tier 3 · Build one
133 / 142

Create a "three-layer test skeleton": the same order endpoint tested once each with @WebMvcTest, @DataJpaTest, and @SpringBootTest + Testcontainers, plus a short README stating what each covers. Acceptance checklist:

134 / 142
  • [ ] All three classes pass together under mvn verify, and the slice tests still pass alone under mvn test
  • [ ] The web case asserts status code + JSON fields + at least one response header
  • [ ] The JPA case verifies a real @Query (not a derived one) and calls flush() explicitly
  • [ ] The full case starts a real MySQL via Testcontainers and sets @AutoConfigureTestDatabase(replace = NONE)
  • [ ] One case proves the unique constraint: no @Transactional, cleanup via @Sql, and the second insert must fail
  • [ ] You measured and recorded the per-case cost of all three tiers, roughly matching the shape of the Section 13 sandbox
  • [ ] The README answers one sentence: "if only one full-context case could survive, which would you keep, and why?"
135 / 142
Section
17. Key points, self-checked
136 / 142
Self-check

can I say at a glance which tier catches which bug — wiring goes full, SQL goes @DataJpaTest, routing and serialization go @WebMvcTest / @JsonTest?

137 / 142
Self-check

do I own a test class with a properties entry nobody else uses? If yes, that is a cache killer — either merge the configuration or accept rebuilding a context for it.

138 / 142
Self-check

have I ever used "the data did not persist" as proof that "the logic is right" inside a @Transactional test? Real HTTP and new threads are not protected by that rollback.

139 / 142
Self-check

when an assertion fails, do I add andDo(print()) first? Debugging without seeing the request and response is guessing.

140 / 142
Self-check

can I name the top two real causes of Failed to load ApplicationContext (cache key changed / missing bean in a slice)?

141 / 142
Mnemonic

if one lamp suffices, do not throw the main breaker; if you did throw it, make it catch something real.

142 / 142
Summary

Compress this article into a few lines — the unit test's blind spots are "wiring, SQL, serialization", caught only by a real container or a slice; @SpringBootTest picks one of four webEnvironment modes: MOCK is fastest, RANDOM_PORT the most real, NONE best for services; @Transactional tests roll back automatically but not for real HTTP or new threads; MockMvc walks the whole MVC flow with chained assertions, and slice tests (@WebMvcTest / @DataJpaTest) load only the layer you care about; the database strategy moves from H2 to Testcontainers, killing dialect differences with a real database; mvn test runs units and mvn verify runs integration tests. Hold these and you will know "when to start a real container", instead of turning every test into a slow giant.