1. Purpose of spring-boot-starter-test
If you have ever tried to assemble a test stack “the honest way” by hand, you already know that feeling: you seem to just want to write a test, but in reality you suddenly find yourself studying version archaeology. “Which JUnit?”, “Which Mockito?”, “Does AssertJ play nicely with it?”, “Which Spring Test do we need for our Spring?” — and now you are no longer testing, but performing a compatibility ritual. spring-boot-starter-test appeared precisely as a remedy for such dependency chaos: it provides a coordinated set of libraries and compatible versions.
It is important to understand the word starter correctly. It is not a “magical library that makes tests high quality.” A starter is, in essence, a convenient list of dependencies that Spring Boot can maintain as a single coherent set. It solves the engineering problem of reproducibility: you do not have to choose JUnit/Mockito/AssertJ versions again and again and worry that one module pulled in one version while another pulled in something else. In backend development, this is a severely underrated value: while tests are green, everything seems simple; when CI fails because of classpath conflicts, you want to go back in time and tell yourself, “don’t touch the versions by hand.”
Another important point: spring-boot-starter-test does not turn your project into a “project where everything is tested through Spring.” It does not replace unit tests without Spring, which we built in previous days. On the contrary, the starter merely guarantees that when you need Spring-specific tools, they will be there and they will be compatible. But the choice of test level (unit vs slice vs full context) remains an engineering decision, not a side effect of adding a dependency.
2. Adding it in Gradle
In a real project, errors most often start not in test code, but in build.gradle.kts: someone added JUnit separately “just in case,” someone hardcoded a Mockito version “like in a 2019 tutorial,” and then the project mysteriously compiles for one person and just as mysteriously breaks for another. The power of Spring Boot as a platform is that it provides a curated dependency set: a fixed set of versions that has been checked for mutual compatibility. To use that, spring-boot-starter-test is added in the most boring possible way — and that is exactly the good part.
A typical minimum for a Gradle project looks like this (the format is intentionally short, with no extra noise):
plugins {
// Add the Java plugin for standard compilation/testing tasks.
java
// The Spring Boot plugin will bring in coordinated version management for the starter.
id("org.springframework.boot") version "4.0.3"
}
dependencies {
// The starter is only needed for tests: it should not end up on the production classpath.
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.test {
// Run tests on the JUnit platform (JUnit Jupiter 6 in this course).
useJUnitPlatform()
}
Here, the important word is testImplementation. It means: “this dependency is needed only for tests.” It does not get into production code (src/main/java), and it does not get into your runtime jar either. It is almost like a separate storage room where you keep a hammer, a screwdriver, and duct tape, but do not carry them around in your office suit all the time (though... some developers still do).
Now about versions. In this course we have a fixed baseline (for example, Spring Boot 4.0.3, Spring Framework 7.0.5, JUnit Jupiter 6.0.3, AssertJ 3.27.7, Mockito 5.20.0). And the idea of the Boot platform is that you usually do not write these versions by hand in dependencies { ... } for libraries that are already part of the managed stack. Boot itself will pull in coordinated versions through its dependency management. In doing so, you buy stability: if you updated Boot, the whole set updates in a coordinated way; if you did not update Boot, you do not get a “random upgrade” of one out of five libraries.
This does not mean versions can never be overridden. But for an educational project (and for most real projects, to be honest) the rule is simple: do not break the platform without a reason. If you add something like “I will put in a newer JUnit” to build.gradle.kts, you can easily end up with a test suite that compiles fine but behaves strangely. Usually, that kind of strangeness is enough to spend half a day arguing with Gradle and another half a day arguing with yourself.
3. Contents of spring-boot-starter-test
When we say “the starter includes the test stack,” it is tempting to think of it as a black box: “well, there is some magic in there, tests work.” But for good testing, something else matters more: understanding the roles. Imagine a test as a small play. One actor starts the action, another checks the result, a third substitutes the scene partners, and there is also a person who opens the theater, turns on the lights, and starts the orchestra. If you mix up the roles, you get either chaos or a very strange performance.
Below is a compact map of what is usually hidden behind spring-boot-starter-test in the Boot-centered world:
| Stack component | What it is in plain words | What it does in tests | An example of how you “see” it |
|---|---|---|---|
| JUnit Jupiter | test engine | runs test methods, manages the lifecycle | @Test, @BeforeEach, @Nested |
| AssertJ | assertion language | makes assertions readable and failures understandable | assertThat(result).isEqualTo(...) |
| Mockito (+ junit-jupiter integration) | mocking/stubbing | helps isolate dependencies where that is justified | @ExtendWith(MockitoExtension.class) |
| Hamcrest | matchers (often legacy) | sometimes used in the Spring testing ecosystem | hasSize(3) and similar |
| JSONassert | JSON comparison | allows structural JSON comparison instead of character-by-character comparison | “the JSON has a status field” |
| JsonPath | data selection from JSON | allows you to check specific pieces of JSON precisely | $.data[0].title |
| Spring Test | bridge between a test and Spring | provides the TestContext framework, MVC tests, utilities | @ExtendWith(SpringExtension.class) (often implicitly) |
| Spring Boot Test | Boot layer on top of Spring Test | helps start the Boot context and use Boot testing annotations | @SpringBootTest, @JsonTest, @WebMvcTest, etc. |
If we make it very concrete: JUnit is responsible for “when and what to run,” AssertJ for “how to check it clearly and precisely,” Mockito for “how to replace a dependency,” and Spring Test + Spring Boot Test for “how to connect Spring/Boot cleanly where it is needed.” JSONassert and JsonPath are tools that let you verify JSON as a structure, not as “a string that happened to match this Tuesday.”
It is also useful to see this as a diagram, without too much detail so that we do not rush into the next lectures:
flowchart LR %% Simplified map: who does what in a typical Boot project test. T[Test class] --> J[JUnit Jupiter] T --> A[AssertJ] T --> M[Mockito] T --> ST[Spring Test] ST --> BT[Spring Boot Test] BT -->|support| Anno[Boot test annotations]
Notice that spring-boot-starter-test is exactly a bundle of tools. It does not say: “this is what your test must look like.” It only guarantees that all of these pieces, once you start using them, will not fight over the classpath like two cats in a kitchen.
4. Unit tests alongside Spring tests
There is a very common psychological trap: as soon as Spring Boot appears in a project, it feels like “everything must be with Spring.” As soon as spring-boot-starter-test appears, the temptation arrives: “if Spring is in tests, let’s always start the context, that is safer.” And so slow and expensive tests quietly creep in, even though in fact we had already been catching half the risks perfectly well with unit tests without Spring. So let us make one thing explicit now: the starter is connected — but the Spring context is not required.
Let us look at a simple unit test from our domain. Here the business rule is being checked, and it should not depend on the container:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicationPolicyTest {
@Test
void allowsDraftToMoveToReview() {
// Create the domain object directly: no Spring and no context.
PublicationPolicy policy = new PublicationPolicy();
// Check the business rule itself (behavior), not the implementation details.
assertThat(policy.canMove(ArticleStatus.DRAFT, ArticleStatus.IN_REVIEW)).isTrue();
}
}
This is plain JUnit + AssertJ, with no Spring annotations. And yet the test lives perfectly well in a Boot project with spring-boot-starter-test connected. This is where the main practical meaning of the starter shows up: it does not force you to live “only in Spring,” it simply makes one common baseline available. You choose the test level consciously, not because “that is what the tutorial does.”
Now a small contrast: here is a test that already relies on the Boot test ecosystem. By itself, it proves nothing except that the context starts, but it demonstrates that Spring-specific testing infrastructure comes “out of the box” with the starter:
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class ContentHubApplicationSmokeTest {
@Test
void contextLoads() {
// Smoke test: if the context fails to start, the test will fail before the method body runs.
}
}
Two tests — two completely different meanings, and that is normal. The first is cheap and focused, the second is infrastructural and expensive. The starter supports both, but it does not choose for you.
And one more small demonstration that the battery is already included: Mockito is there too, without any separate dependency. Even if the code below looks familiar to you (we have worked with Mockito before), what matters here is specifically that you did not have to add a separate dependency for MockitoExtension:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class MockitoBaselineTest {
@Test
void mockitoIsAvailable() {
// Create a mock: we do not need real logic here, what matters is that Mockito is available.
Object dep = mock(Object.class);
// Simple check that the mock was created successfully and the library is connected.
assertThat(dep).isNotNull();
}
}
Yes, this is a test “for the existence of Mockito,” and in a real project people do not do that. But as a learning “click of understanding,” it is useful: the starter has already brought in what you need and did not ask for a separate ceremony in return.
5. What the starter does not do
There is a dangerous myth: “I will connect spring-boot-starter-test, and testing will be ‘on the rails’.” That is roughly like buying a tool set and expecting the kitchen to assemble itself. The starter solves the problem of compatibility and the basic set, but it does not replace the engineering thinking that this course is built around in the first place.
The starter does not answer the question “which test should I write now?” It does not turn any test into an integration test, and it does not make any test “reliable.” More than that, if used incorrectly, it can indirectly encourage a bad habit: “if Spring is here, I’ll just start Spring.” And we have already established that the minimally sufficient test usually wins if it really catches the risk we care about. So the starter should live in your head as “we have a common foundation,” not as “now every test must have a big annotation on top.”
The starter also does not solve the problem of the quality of the assertions themselves. If you write a test that checks implementation details instead of behavior, it will not become better just because you are using AssertJ version 3.27.7 instead of 3.24.2. Yes, a good tool helps, but it does not choose for you what exactly to assert. Likewise, it does not cure over-mocking: Mockito may be perfectly compatible on the classpath, but if you have mocked half the world, you still built a fragile illusion, not a real check.
And finally: the starter is not “everything included.” It covers the basic needs, but in a real application there are usually test dependencies that are added separately because they belong to specific risk areas (for example, security or starting a real database in a container). The important thing is that such things are added consciously and do not break the integrity of the platform. But the main idea should stay calm: the starter is the base, not a dump for the entire test world.
6. Common mistakes when working with spring-boot-starter-test
Mistake #1: add the starter and then add the same libraries by hand “for reliability.”
That is usually how “version hell” begins. For example, the project already has spring-boot-starter-test, but someone adds junit-jupiter as a separate line, and with a version “found in an article.” In the end, you have two sources of truth about versions: the Boot platform and your manual override. Sometimes it even compiles, but behaves unpredictably, and the failure looks like “something strange inside the test engine.” The cure is boring: trust the platform and do not duplicate dependencies.
Mistake #2: put the starter in implementation instead of testImplementation.
At first glance it seems, “well, what difference does it make, let it be available everywhere.” The difference is that you increase the production classpath, may accidentally drag test libraries into runtime, and make dependency maintenance harder. In production, you do not need Mockito, no matter how much you may want to mock reality sometimes. The starter should live in the test scope.
Mistake #3: assume that the starter means “all tests are now Spring tests.”
This is a quiet project degradation. You will start bringing up Spring where it is not needed, tests will slow down, and feedback will get worse. The most frustrating part is that the slowdown happens gradually, and you get used to it. In the end you will be afraid to run tests locally, and that is already close to a diagnosis for engineering culture. The correct idea is: the starter is enabled, but unit tests remain the norm for local logic.
Mistake #4: confuse the roles of tools and write “checks for the sake of checks.”
For example, JUnit does not “check,” it runs. AssertJ does not “mock,” it asserts. Mockito does not “prove a business rule,” it helps isolate a dependency. When roles are confused, a test turns into a set of random lines of code: somewhere assertEquals, somewhere verify, somewhere a Spring annotation “so it works.” The cure is to return to a simple model: who runs, who checks, who isolates, who starts the infrastructure.
Mistake #5: try to “fix” a bad testing strategy with even more dependencies.
Sometimes it seems: “everything is bad, let’s add another library.” But the starter is not about quantity. It is about the foundation. If tests are fragile, the cause is most often in responsibility boundaries, nondeterminism, incorrect assertions, or the fact that you are testing the wrong layer. First we treat the causes, and only then do we add tools if they are really needed.
GO TO FULL VERSION