Integration Testing: @SpringBootTest and Slice Tests
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.
Five words, one line each (used throughout):
- 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 —
@WebMvcTesttakes the web layer,@DataJpaTesttakes 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
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.

After this article you should be able to answer three questions:
- Should my case use
@SpringBootTest,@WebMvcTestor@DataJpaTest— and what is the decision criterion? - Why did adding one
@MockitoBeantriple the runtime of the whole suite? @Transactionaltests roll back automatically, so how can that hide a real unique-constraint bug?
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:
- Wiring errors: your test does
new UserService(mockRepo)and is perfectly green; at real startup the container holds twoUserRepositoryimplementations, neither with a@Qualifier, and it throwsNoSuchUniqueBeanDefinitionException. You tested an object; runtime uses the container. - SQL errors: the mocked repository always returns data, while the real
@Queryuses a field namedusernamealthough the entity property is actuallyuserName. A unit test never parses that JPQL, until the endpoint returns a 500 on first call. - Serialization errors: a DTO carries a
LocalDateTimewith noJavaTimeModuleregistered. A unit test skips JSON serialization, until a real HTTP response fails to write withInvalidDefinitionException.
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.
@SpringBootTest does one thing: start a real Spring application context and wire up everything it should. Its two most-used attributes are:
// 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}webEnvironment has four modes that differ a lot; choosing wrong costs you either "it will not start" or "twice as slow":
| Mode | Behaviour | Provides | Best for |
|---|---|---|---|
MOCK (default) | No real server, only a web environment | MockMvc / MockEnvironment | Most web-layer tests; fast |
RANDOM_PORT | Starts an embedded server on a random port | Real HTTP with @LocalServerPort | Verifying the real network stack and filters |
DEFINED_PORT | Uses the configured port (8080 by default) | Real HTTP on a fixed port | Integrating with something that needs a fixed port |
NONE | No web environment at all | A plain ApplicationContext | Pure service-layer / scheduled-job integration tests |

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.

Beyond the main config, @TestConfiguration lets you add beans that exist only in tests — say a fake SMS client:
@TestConfigurationclass TestBeans { @Bean @Primary // override the real one without polluting main config SmsClient fakeSmsClient() { return (phone, msg) -> System.out.println("stub sms -> " + phone); }}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.
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:
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: truethe 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.
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.
@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 }}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:
- 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.
@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.
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:

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:
@SpringBootTest(webEnvironment = RANDOM_PORT) // this line decides the fate of beat 6@Transactional // on the test class, not on the serviceLong id = userService.create("alice"); // the service has its own @TransactionalassertThat(userService.getById(id)).isPresent(); // it reads back: same connection// afterTestMethod: the framework calls connection.rollback() unconditionallyrest.getForEntity("/users/1", User.class); // this one runs on a Tomcat worker thread| thread | main |
| context cache key | DemoApplication + test + {} |
| transaction | not opened yet |
SpringExtension.beforeAllbuildMergedContextConfigurationThat line about "the service's commit is only a vote" can be watched directly in the kernel — same @Transactional, different propagation, different ending:
MOCK mode starts no server, but you can feed a request into the DispatcherServlet with MockMvc and walk the whole MVC flow:
@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")); }}perform(...)builds and executes the request, supportingget/post/put/deletepluscontentType/content(a JSON body)andExpect(...)is a chain of assertions: status, then headers, then JSON fields, step by stepjsonPath("$.id")locates fields with JSONPath, far more robust than parsing strings by handandDo(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.
MockMvc does not traverse the real network stack. To verify real HTTP behaviour (filters, serialization, status handling), use RANDOM_PORT with a real client:
| Client | Stack | Traits |
|---|---|---|
MockMvc | Servlet (MOCK mode) | Fastest, no network, fits most assertions |
TestRestTemplate | Servlet (RANDOM_PORT) | Blocking, simple, good for synchronous endpoints |
WebTestClient | WebFlux (also tests Servlet) | Reactive, streaming assertions, more powerful |
@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"); }}- With
@SpringBootTestandRANDOM_PORT,TestRestTemplateis 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
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:

| Annotation | Loads | Typical use |
|---|---|---|
@WebMvcTest | MVC layer only (controllers / filters / converters) | Routing, arguments, validation, exception handling |
@DataJpaTest | JPA only (entities / repositories), embedded DB by default | Derived queries, @Query, mapping |
@JsonTest | Serialization only (ObjectMapper / Jackson) | JSON serialization and deserialization |
@RestClientTest | HTTP client only | Calling and parsing an external API |
@WebMvcTest wires only the web layer, so the Service a controller depends on is not created — fill the gap with @MockBean:
@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")); }}@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(); }}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.
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.
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.
| Strategy | Speed | Fidelity | Risk |
|---|---|---|---|
| H2 in-memory | Very fast | Low | Dialect differences cause "test passes, prod fails" |
| A local real MySQL | Medium | High | Depends on the local machine, flaky in CI |
| Testcontainers (Docker runs the real DB) | Slower (first image pull) | Highest | Needs Docker, and CI configuration |
Testcontainers is blunt and effective — spin up a real MySQL in Docker for the tests:
@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); }}@Testcontainersplus@Containermanage 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@DynamicPropertySourceinjects the container's dynamic port into Spring config- Reuse switch: enabling
testcontainers.reuse.enable=truelets 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.
How you create data decides how stable the tests are. Three approaches, ordered from least to most recommended:
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 setexecutionPhase = BEFORE_TEST_METHOD.
@Test@Sql(scripts = "/sql/users.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)void should_list_active_users() { assertThat(userService.findActive()).hasSize(3);}- Builder / factory methods: the most flexible and clearest — test data lives inside the test code, with preconditions visible at a glance:
static User aUser() { return User.builder() .username("alice") .email("alice@example.com") .status(Status.ACTIVE) .build();}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.
In a pipeline, first tell the two commands apart:
| Command | Phase reached | What runs |
|---|---|---|
mvn test | test | Unit and slice tests only |
mvn verify | verify | All tests plus integration tests, the failsafe plugin, coverage checks |
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:
UserServiceTest.java <- surefire, runs on mvn testUserApiIT.java <- failsafe, runs only on mvn verifyTip: 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.
Integration tests should cover every link of the request chain; the demo below makes clear just how many components one request passes through:
@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.
@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.
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:

Map each step onto the annotations you know and the machinery stops being mysterious:
| Round | What TestContext does | What you can hook in |
|---|---|---|
| 1. key | Combines config classes, @ActiveProfiles, properties and @MockitoBeans into one cache key | Anything different → a different key |
| 2. cache | On a hit, reuses the existing context | To stay fast, maximise other people's hits |
| 3. build | Only on a miss does Spring actually start; a slice filters out everything else here | @WebMvcTest / @DataJpaTest |
| 4. inject | Fills @Autowired and @MockitoBean fields on the test instance | Do not use them inside a constructor |
| 5. before | Runs @BeforeEach methods in order | Seed data and stubbing belong here |
| 6. run | Executes the method and assertions; with @Transactional it reuses one connection | Real HTTP escapes that connection |
| 7. teardown | Rolls back, then parks the context in the cache for the next test | @DirtiesContext forces non-reuse |
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:
Walk those seven steps yourself — switch the arguments to see each difference:
The second argument of that same lab is the single most important lesson of this article:
- 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
propertiesline 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
First pin down what actually enters the fingerprint — everything in the left column changes the key, everything in the right column never does:

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:
- 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
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:
Second, why a slice insists you supply your own mocks — the filtered-out components simply never entered the container:
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:
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:
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:
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.
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:
Per case: 180ms - 1.2s# loads only that layer: @WebMvcTest wires MVC, @DataJpaTest wires JPA plus an embedded DBCovers: routing, argument binding, validation, serialization, derived queries and @QueryCannot catch: wiring conflicts between Service and Repository, cross-layer transactionsStable cache hits when sibling slices share identical configuration
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.
A warm-up question, straight from the context-cache trap in Section 9:
Now a combined question threading the rollback trap of Section 3 with the dialect gap of Section 7:
Beginners spend most of their stuck time just decoding messages. This table is ordered by the literal fragment, so you can search it verbatim:
| Error fragment | Real cause | 30-second self-rescue | Dig deeper in |
|---|---|---|---|
Refreshing ApplicationContext printed before nearly every case, alongside Loaded default TestExecutionListener | Contexts are not being reused: differing @MockitoBean sets, properties or @ActiveProfiles produce a new cache key per class | Enable DEBUG on org.springframework.test.context, diff the keys of two adjacent cases, consolidate classes with identical config | This 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 threads | entityManager.flush() then clear() before asserting; or use JOIN FETCH in the query; if you truly need a long session, add @Transactional explicitly | Persistence 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 differ | Drop @Transactional for that case and commit, clean with @Sql; then move it into a Testcontainers case | This article, Sections 3 and 7 |
java.lang.IllegalStateException: Failed to load ApplicationContext ... WebApplicationContext is required | You injected MockMvc / @LocalServerPort but the context is not a web one: webEnvironment = NONE, or @SpringBootTest found no @SpringBootConfiguration | Confirm webEnvironment is MOCK or RANDOM_PORT; name the class explicitly with classes = XxxApplication.class | This article, Section 2 |
No qualifying bean of type 'com.example.UserService' available inside a @WebMvcTest | A slice wires only the web layer; the Service was never in the container | Add @MockitoBean UserService userService;, or promote the test to @SpringBootTest | This 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 @SpringBootTest | Turn that class into a JPA slice test, or inject EntityManagerFactory and pull it yourself | This 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 completed | Unit 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 copied | Put it in src/test/resources/sql/, reference /sql/users.sql; run mvn test-compile to confirm the resource landed | This article, Section 8 |
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.
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:
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?'
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.
Step one — pom.xml needs exactly one starter (everything else comes from the parent):
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope></dependency>Step two — the controller and service under test:
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); }}Step three — the test class:
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 }}Step four — the command and the expected output:
$ 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 SUCCESSStarted 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.
- Add
@SpringBootTeston 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. - Delete
@MockitoBean UserService userService;and run. You will observe:Failed to load ApplicationContextwhose caused-by readsNoSuchBeanDefinitionException: UserService— row five of the Section 15 table, live. - Copy the class as
UserControllerSliceTest2, changing only the stubbed return value fromalicetobob, 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.
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:
- [ ] All three classes pass together under
mvn verify, and the slice tests still pass alone undermvn 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 callsflush()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?"
can I say at a glance which tier catches which bug — wiring goes full, SQL goes @DataJpaTest, routing and serialization go @WebMvcTest / @JsonTest?
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.
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.
when an assertion fails, do I add andDo(print()) first? Debugging without seeing the request and response is guessing.
can I name the top two real causes of Failed to load ApplicationContext (cache key changed / missing bean in a slice)?
if one lamp suffices, do not throw the main breaker; if you did throw it, make it catch something real.
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.