On This Page
The test run that stopped in its tracks
I went into the Boot 4 migration assuming the test module would be the boring part. Rename @MockBean to @MockitoBean, fix a couple of imports, done. I was wrong about that, and it cost me more than a day to find out exactly how wrong, in two completely unrelated ways at once.
Here's the first one. A test that had run green for two years, untouched, on Boot 3.5:
org.springframework.boot.test.context.SpringBootTest$WebEnvironment RANDOM_PORT
java.lang.IllegalStateException: Failed to load ApplicationContext for
[WebMergedContextConfiguration@4f8a3c1 testClass = com.example.repro.GreetingControllerRestTemplateTests,
locations = [], classes = [com.example.repro.ReproApplication], ...]
at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContextInternal(...)
at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(...)
...
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with
name 'com.example.repro.GreetingControllerRestTemplateTests': Unsatisfied dependency expressed through
field 'restTemplate': No qualifying bean of type 'org.springframework.boot.resttestclient.TestRestTemplate'
available: expected at least 1 bean which qualifies as autowire candidate
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type
'org.springframework.boot.resttestclient.TestRestTemplate' available: expected at least 1 bean which
qualifies as autowire candidateNo APPLICATION FAILED TO START banner. No FailureAnalyzer. Just JUnit reporting a context load failure, because this isn't SpringApplication.run() blowing up. It's TestContextManager failing to build the ApplicationContext a test needs before the test method even gets to run. That distinction matters more than it looks like it should, and I'll come back to it.
Versions this run was pinned to: Spring Boot 4.1.1, Spring Framework 7.0.9, Java 21, JUnit 5.12, Maven. Everything here also applies to 4.0.0 through 4.0.8; the mechanism hasn't moved since 4.0, only patch numbers have.
Before you blame your service layer
The bean it can't find isn't yours. org.springframework.boot.resttestclient.TestRestTemplate is a Boot class, in a Boot package, and the reason it's missing has nothing to do with anything you wrote. So before you go anywhere near your own configuration, check two things.
First, is the class even on the classpath. Run ./mvnw dependency:tree -Dincludes=org.springframework.boot:spring-boot-resttestclient (Maven Central is unreachable for me while writing this, so I can't paste live tree output, but the command is correct against the BOM either way). If it prints nothing, you don't have the jar, and no annotation will save you. That's a different, earlier failure, and it shows up as a compile error, not this one.
Second, if the class does resolve, ask what actually got registered:
curl -s localhost:PORT/actuator/beans | grep -i "resttestclient\|testResttemplate\|MockMvc"You won't get anywhere with this one, because the context never finishes loading. Actuator has nothing to answer with. That absence is itself the diagnostic: a bean that's missing because a condition didn't match still lets the context start; a bean that's missing because nothing on your test class ever asked Boot to register it stops the context cold, at exactly the field that wanted it.
Break it the way the migration breaks it
What you need: Java 21, Maven 3.9+, Spring Boot 4.1.1. No database, no Docker, no network.
start.spring.io wasn't reachable for me today either, so here's the Initializr call and the exact dependency block side by side. Use whichever actually loads for you.
curl https://start.spring.io/starter.zip \
-d type=maven-project -d bootVersion=4.1.1 -d javaVersion=21 \
-d groupId=com.example -d artifactId=repro -d name=repro \
-d packageName=com.example.repro -d dependencies=webmvc \
-o repro.zip && unzip repro.zip -d repro && cd reproIf that 404s, add this to pom.xml yourself. This is the exact set the reproduction needs, nothing more:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>Deliberately not added yet: spring-boot-starter-webmvc-test. That's the fix, and skipping it is what makes the failure happen.
package com.example.repro;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ReproApplication {
public static void main(String[] args) {
SpringApplication.run(ReproApplication.class, args);
}
}package com.example.repro;
public interface GreetingService {
String greet(String name);
}package com.example.repro;
import org.springframework.stereotype.Service;
@Service
public class RealGreetingService implements GreetingService {
private volatile int callCount;
@Override
public String greet(String name) {
this.callCount++;
return "Hello, " + name;
}
public int getCallCount() {
return this.callCount;
}
}package com.example.repro;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class GreetingController {
private final GreetingService greetingService;
public GreetingController(GreetingService greetingService) {
this.greetingService = greetingService;
}
@GetMapping("/greet")
public String greet(@RequestParam String name) {
return this.greetingService.greet(name);
}
}Now the first test, which is the one that reproduces Section 1 exactly:
package com.example.repro;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class GreetingControllerRestTemplateTests {
@Autowired
private TestRestTemplate restTemplate;
@Test
void greetsByName() {
String body = this.restTemplate.getForObject("/greet?name=Gopi", String.class);
assertThat(body).isEqualTo("Hello, Gopi");
}
}Step: mvn -Dtest=GreetingControllerRestTemplateTests test
Expected: BUILD FAILURE. The failure isn't in the test method, it's in
"initializationError" / context load, and the exception chain is the
one at the top of this article, word for word down to the field name.Confirmation you hit the right one: the failure is a context-loading IllegalStateException, not an assertion failure, and it never gets as far as printing anything from greetsByName() itself. If you instead see an assertion failure with an actual HTTP response body, your TestRestTemplate already wired up fine and you're chasing a different bug.
The fix, first pass: add the missing starter and scope it to test.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>Then add the annotation Boot now asks for explicitly:
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate
class GreetingControllerRestTemplateTests {
// unchanged
}Run it again. It passes. Now the second scenario, which won't fail at all, and that's the point.
package com.example.repro;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@TestConfiguration
@MockitoBean(types = GreetingService.class)
public class SharedGreetingMockConfig {
}package com.example.repro;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
@SpringBootTest
@Import(SharedGreetingMockConfig.class)
class GreetingServiceSharedMockTests {
@Autowired
private RealGreetingService realGreetingService;
@Autowired
private GreetingService greetingService;
@Test
void mockNeverGetsInTheWay() {
given(greetingService.greet("Gopi")).willReturn("mocked");
// This passes on Boot 3.5. On Boot 4.1.1 it also "passes",
// but for the wrong reason, and that's what to look for.
assertThat(Mockito.mockingDetails(this.greetingService).isMock())
.as("greetingService should be a Mockito mock, injected via the shared @TestConfiguration")
.isFalse(); // <- confirms the mock never took effect
assertThat(this.realGreetingService.getCallCount()).isZero();
}
}Step: mvn -Dtest=GreetingServiceSharedMockTests test
Expected: green, silently. isMock() on the injected greetingService
returns false. The real RealGreetingService is what got wired, and
willReturn("mocked") was stubbing a bean nobody ever autowired.Nothing here throws. That's the whole reason it took me a day longer than the first one. A failing test is a lead; a passing test that's lying to you isn't.
The short version
Two unrelated things changed about how @SpringBootTest builds a context in Boot 4, and they both hide behind a clean compile. Boot no longer registers TestRestTemplate, MockMvc, or a reactive WebClient for you just because your test looks like it wants one. You now say so, with @AutoConfigureTestRestTemplate or @AutoConfigureMockMvc, and you bring the jar that contains them. Separately, @MockitoBean, the direct replacement for the removed @MockBean, only looks at the test class itself and its enclosing classes. It does not look inside @Configuration classes you @Import to share mock setup across several test classes, which @MockBean did. The rename compiles. The sharing pattern doesn't carry over, and nothing tells you that.
How @MockitoBean actually finds what to override
@MockBean and @MockitoBean sound like the same feature with a new name. Structurally they're closer to opposites, and the difference is exactly why one supports @Configuration classes and the other doesn't.
MockitoPostProcessor (the class behind @MockBean, deprecated at 3.4.0 and gone by 4.0) is a BeanFactoryPostProcessor. Its postProcessBeanFactory doesn't just look at your test class. It calls getConfigurationClasses(beanFactory), which walks every bean definition already registered in the factory and keeps the ones carrying the internal attribute ConfigurationClassPostProcessor stamps onto anything it processed as @Configuration. Then it re-parses each of those classes for @MockBean/@SpyBean. By the time this runs, Spring has already registered bean definitions from your @SpringBootApplication class, every @Import, every auto-configuration. Any @Configuration class among them, no matter how it got there, gets scanned. That's the whole trick behind putting @MockBean on a shared @TestConfiguration and @Import-ing it into a dozen test classes: it doesn't matter how the class entered the context, only that it did.
@MockitoBean works the other way around. BeanOverrideContextCustomizerFactory.createContextCustomizer calls BeanOverrideHandler.findAllHandlers(testClass), and that method's own Javadoc says exactly what it does: build the handler list "from @BeanOverride fields in the test class and in its type hierarchy," plus the enclosing-class hierarchy for @Nested tests. It never touches the bean factory. It never sees what got registered. It walks the Java class hierarchy of the one class JUnit handed it. Full stop. A @Configuration class sitting off to the side, reachable only through @Import or @ContextConfiguration(classes = ...), is invisible to it, because nothing about being @Import-ed makes it part of the test class's type hierarchy.
Old mechanism, late: after the context is assembled, look at everything that got in. New mechanism, early: before the context exists, look only at the one class you were handed. (@MockitoBean also runs before real singletons are created, through its own BeanFactoryPostProcessor, BeanOverrideBeanFactoryPostProcessor; it's the same "early" habit, just one that only knows about handlers discovered from the test class, never from what Spring itself assembles.) That's the whole article's mechanism in one sentence, and it's also, as far as I can tell, not written down anywhere except the Boot 4.0 migration guide's one line: "the new Mockito annotations cannot be used within @Configuration classes." The guide states it. It doesn't say why. Source does.
TestRestTemplate's absence has a plainer story, but it's still not obvious from the outside. spring-boot-starter-test in 4.1.1 depends on exactly two things it always did (spring-boot-test and spring-boot-test-autoconfigure), and nothing else changed there. What changed is that TestRestTemplateContextCustomizerFactory, the class that used to auto-register a TestRestTemplate whenever webEnvironment was RANDOM_PORT or DEFINED_PORT, doesn't exist in 4.1.1 at all. Not deprecated. Gone, the same way @MockBean is gone. In its place: TestRestTemplateTestAutoConfiguration, a normal @AutoConfiguration class, @ConditionalOnMissingBean, that only runs when @AutoConfigureTestRestTemplate (@since 4.0.0, and it says so right in the Javadoc) imports it. @AutoConfigureMockMvc got the identical treatment, moved wholesale into its own module, spring-boot-webmvc-test, alongside MockMvcAutoConfiguration. Auto-registration became explicit configuration, on purpose, for both of them, in the same release.
What actually fixes it, and what it costs
For TestRestTemplate and MockMvc together, one dependency covers both, because spring-boot-starter-webmvc-test pulls in spring-boot-webmvc-test and spring-boot-resttestclient:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>Then annotate whichever slice you're actually exercising. Use @AutoConfigureTestRestTemplate for the HTTP-round-trip style test, @AutoConfigureMockMvc for the mock-dispatcher style, both if a class does both. Full-fat @SpringBootTest classes that never inject either of these beans need no change at all; this only bites the ones that do.
If you're on WebFlux instead of MVC, the equivalent is spring-boot-starter-restclient-test for RestTestClient, via @AutoConfigureRestTestClient. Same shape, different starter, and it does not also pull in TestRestTemplate, because that class lives specifically in spring-boot-resttestclient, not spring-boot-restclient-test. Mixing those two up is an easy way to fix half the problem and not notice.
For the sharing pattern, there's no annotation that restores the old behavior, and I don't think there should be. A mechanism that scans every @Configuration class in your context for test doubles is a strange thing to want once you say it out loud. The honest fixes:
- Move the
@MockitoBeanfields onto the actual test classes. More typing, no magic. - Or write one composed annotation (
@MockitoBeanis meta-annotation-friendly) and put that on each test class:@Retention(RUNTIME) @MockitoBean(types = GreetingService.class) @interface MockGreetingService {}. Slightly more ceremony than the shared-@Configurationtrick, considerably more honest about what each test is doing.
What each one costs: field-per-test is more lines in more files. The composed annotation adds one extra file to your test sources, forever, for every group of beans you mock together. Neither costs you a silently-real dependency call in production-shaped tests, which is what the thing you're replacing was quietly risking.
Not a fix: leaving @MockitoBean on the shared @Configuration class and adding @Import in more places to "make sure it's picked up." It won't be. @Import changes what's in the context; it doesn't put anything into the test class's type hierarchy, which is the only place findAllHandlers looks.
Structuring test mocks so this can't happen quietly
If a group of tests genuinely need the same three or four mocks, the composed-annotation approach above is the design to standardize on going in, not a patch you reach for after getting bitten. Name it for what it replaces, @MockDownstreamPaymentClients rather than @SharedTestMocks, so a reviewer sees the annotation and knows exactly what's fake in that test without opening a second file.
For the TestRestTemplate/MockMvc half, the better default is deciding up front which slice a test belongs to. A test that only needs MockMvc wants @WebMvcTest, not full @SpringBootTest plus @AutoConfigureMockMvc bolted on. @WebMvcTest already imports MockMvcAutoConfiguration for you, because WebMvcTestContextBootstrapper sets that up as part of the slice, not as an afterthought. Reach for @AutoConfigureMockMvc on a full @SpringBootTest specifically when you need the whole context and a mock dispatcher, which is a real combination, just a less common one than people write by default.
Which versions bite, and the test that catches the silent one
Everything above is Boot 4.0.0 and later: 4.0.0, every 4.0.x patch, and 4.1.0/4.1.1. Boot 3.5.16 and earlier still auto-register TestRestTemplate and MockMvc, and @MockBean still works there too, deprecated but functional (deprecated since 3.4.0, for what its own Javadoc calls "removal in 3.6.0", a version number that never shipped because the next major got renamed to 4.0 along the way). If you're mid-migration with modules on both sides of that line, expect this exact pair of surprises the moment each module crosses it, not before.
The TestRestTemplate gap is the easy one to guard against, because it fails loudly: any CI run that includes the affected tests catches it immediately, every time, with the exact stack trace above.
The @MockitoBean-on-@Configuration gap won't fail on its own, which is what makes it worth one extra assertion rather than trusting a green build. Drop this into a shared test base class or a JUnit extension, once, for any test that relies on a shared mock configuration:
@BeforeEach
void verifyMocksAreActuallyMocks(@Autowired GreetingService greetingService) {
assertThat(Mockito.mockingDetails(greetingService).isMock())
.as("greetingService must be a Mockito mock; check it isn't declared on a @Configuration class")
.isTrue();
}Cheap, specific, and it fails exactly where the real risk is, instead of downstream in a network call your test environment happened to survive.
The five-second summary
@MockBean is gone in Boot 4, not deprecated-and-lingering. The package doesn't exist. @MockitoBean is the replacement, but it only sees mocks declared on the test class itself (and its enclosing classes), never on a @Configuration class you @Import to share them, because the discovery mechanism changed from "scan everything Spring assembled" to "walk this one class's hierarchy." Separately, @SpringBootTest stopped auto-registering TestRestTemplate and MockMvc. Add spring-boot-starter-webmvc-test plus @AutoConfigureTestRestTemplate/@AutoConfigureMockMvc explicitly. The TestRestTemplate failure is loud and CI catches it for free. The shared-mock failure is quiet, and a one-line isMock() assertion in a base test class is the difference between finding it in review and finding it in a postmortem.