1. Decision tree versus the @SpringBootTest reflex
We have already broken Boot tests down by boundaries: JSON, MVC, JPA, outbound client, and full context. But the map itself still does not answer the practical question: which path should you choose for a specific defect so you do not reach for @SpringBootTest out of habit?
When someone first sees Spring Boot testing, a very natural thought appears: “If @SpringBootTest starts the whole application, then it is probably the most reliable approach. I will use it everywhere — and life will be good.” That is exactly the moment when a testing strategy becomes religious: “I believe in full context,” instead of “I choose a tool based on risk.”
The problem is not that @SpringBootTest is bad. The problem is that it is expensive and wide-angle. It checks many links at once, but you pay for that with startup time, difficulty isolating the cause of a failure, and a tendency toward duplication. Tests start to “touch everything,” and in the end it becomes hard to understand what exactly you proved: a business rule, a JSON contract, wiring, repository behavior, configuration, filters, serialization, or just that the Earth is still orbiting the Sun.
A decision tree is a way to stop that reflex. It forces you to begin not with an annotation, but with a problem statement. Then, step by step, you ask yourself short questions like “Do we need Spring at all?” and “Which system boundary is under suspicion right now?” As a result, you can almost physically feel where you truly need full context, and where you are accidentally paying for it like an “all inclusive” subscription when all you needed was still water.
2. Step 0: define the risk and the layer
Before choosing an annotation, you need to learn how to describe a defect or a risk in layer terms. That sounds abstract, but in practice it is a very concrete discipline: you stop saying “something broke” and start saying “boundary X broke, so I will check it with these means.” In testing, that is almost a superpower.
A good risk statement answers three questions: what the client observes (or another layer observes), what can go wrong, and where it lives. For example, “endpoint GET /api/public/articles/{slug} returns the publishedAt field in the wrong format” is not a controller problem and not a database problem; it is a JSON response contract problem. And “the repository stopped filtering articles by status PUBLISHED” is not an HTTP problem, but a query/persistence boundary problem. And finally, “when submitting for review, the article moves to the wrong status” is either pure business logic (unit) or an end-to-end wiring issue across several layers (full context), and that is where you need to clarify things.
Using ContentHub as an example, it is useful to keep in mind that we have different kinds of “external observers.” In one case, the observer is a unit test looking at a Java class method. In another, it is the API client, which cares about JSON and HTTP semantics. In a third, it is the database as a system that must really enforce constraints and provide correct queries. In a fourth, it is an external moderation HTTP service that we talk to through RestClient. The decision tree starts right here: first you name the observable behavior, and only then do you choose which “microscope” you need to see it.
3. Step 1: when a plain unit test is enough
This question feels a bit insulting, because you came to a course about Spring Boot testing, and I am suggesting that we start with “maybe we do not need Spring.” But that is exactly the mature engineering habit: if a tool is expensive, you should not turn it on by default. A unit test is like a bicycle: not the coolest transport in the world, but on short distances and in the city it beats almost everything.
If the logic under test lives in an ordinary class and does not depend on Spring magic, the container, auto-configuration, serialization, transactions, or other “paid features,” then the right answer is most often a unit test. In ContentHub there are many such places: status transition rules in PublicationPolicy, slug generation in SlugService, local attachment validation in AttachmentValidationService. We even tested ArticleWorkflowService at the unit level, because we stabilized time through Clock and the current user through a provider instead of pulling in the whole application.
It is important to note: “Do we need Spring?” is not the same as “Is Spring used somewhere in the project?” It is the question “Do we need Spring to prove a specific claim?” If you want to prove that Spring Boot Testing becomes spring-boot-testing, Spring does not help you. It will just sit nearby and consume resources like a cat that came into the kitchen “just to look,” but then suddenly ate half the sour cream.
A small example that shows that a unit test remains the norm even in a Spring Boot project:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SlugServiceTest {
@Test
void convertsTitleToSlug() {
// Arrange: create the service under test without a Spring context.
SlugService service = new SlugService();
// Act + Assert: call the method and verify the result.
assertThat(service.toSlug("Spring Boot Testing"))
.isEqualTo("spring-boot-testing");
}
}
There is not a single Spring annotation here — and that is great. The test is cheap, fast, predictable, and fails exactly where the logic broke.
4. Step 2: if Spring is needed, choose the verification boundary
Once “we cannot do without Spring” has been established, there is no need to open the entire list of annotations again. What matters here is quickly matching the suspected boundary with what the test is supposed to prove.
The DTO JSON contract is broken → @JsonTest. It locks down serialization and deserialization without pulling in MVC, security, or the database.
The controller HTTP boundary is under suspicion → @WebMvcTest. It checks mapping, status codes, headers, binding, and validation at the MVC level.
The risk lives in the persistence boundary → @DataJpaTest. It checks JPA mapping, constraints, queries, ordering, and repository behavior.
The problem is in an external HTTP client → @RestClientTest. It locks down the behavior of the adapter and the outward communication contract.
The defect appears only at the intersection of several layers → @SpringBootTest. It checks wiring and several key end-to-end links, not the local mechanics of one layer.
This short list is enough to keep moving through the decision tree. What matters here is not a second atlas of annotations, but a quick transition from risk to the minimally sufficient context.
5. Step 3: slice or full context — about proof, not convenience
Once you know that Spring is needed and you remember the cost of context, it becomes very easy to mix up motivations. The most dangerous motivation sounds like this: “It is easier for me to write the test in full context because everything is already there.” Yes, it is easier. But that “easier” is usually paid later: in the form of slow execution, unstable diagnostics, and the temptation to write one large test instead of several strong ones.
A slice test is a narrow context that starts only part of the application. It makes the test faster and more precise in meaning, because you are testing exactly the boundary. Full context is a “system build” test for the application, useful as a wiring safety net and as a few key end-to-end scenarios, but harmful as a universal hammer.
Inside the decision tree, this question can be stated honestly: “Could the breakage have happened inside one layer, or only at the boundary between layers?” If it is inside one layer, full context does not increase proof strength; it only adds noise. If the defect appears only when layers interact, a slice test may be too narrow and simply fail to reproduce the problem.
And that is where a practical trick appears: if you are unsure, try starting with the narrow test that directly matches the suspected layer. If it does not catch the problem or you cannot express the check correctly within the layer, that is already a signal that the defect is systemic, and then you can expand to full context. In other words, full context is the “second attempt,” not the first reaction.
6. The decision tree diagram and how to use it
People like diagrams because they turn “feelings” into an algorithm. But it is important to remember: a decision tree is not a law of nature. It is a tool to keep you from doing stupid things by inertia. Use it like a navigator: it will not forbid you from leaving the route, but at least it will ask, “Are you sure?”
Below is a simple diagram for choosing a test level for ContentHub. It starts with the risk, not the annotation. And that is the key point: until you can formulate the risk, you are not choosing a test — you are choosing a “mood.”
flowchart TD
A["There is a defect/risk. What do we want to prove?"] --> B{"Can we prove it without Spring?"}
B -->|Yes| U["Plain unit test (JUnit 6 + AssertJ + Mockito if needed)"]
B -->|No| C{"Which boundary is the subject of verification?"}
C --> J["DTO JSON contract"] --> JT["@JsonTest"]
C --> W["Controller HTTP boundary (MVC)"] --> WT["@WebMvcTest"]
C --> D["Persistence boundary: mapping/constraints/queries"] --> DT["@DataJpaTest"]
C --> R["Outbound HTTP client (RestClient adapter)"] --> RT["@RestClientTest"]
C --> F["Layer intersections / wiring of the whole application"] --> FT["@SpringBootTest"]
How do you use this diagram in practice? Very simply: you take a specific “breakage,” formulate it as a statement you want to prove, and walk through the tree. If you cannot honestly answer the first fork — whether Spring is needed — that usually means you still do not understand what exactly you are testing. And it is better to spend 2 minutes clarifying the statement than 20 minutes starting an unnecessary context.
7. Applying the decision tree to ContentHub: real situations
Theory is useful only until it collides with a real bug. So let us take typical ContentHub situations and see how the decision tree leads us to the minimally sufficient test. What matters here is not only “which annotation,” but also “why exactly this annotation gives the needed proof.”
The article status transition rules broke
Imagine that someone accidentally allowed the transition PUBLISHED → IN_REVIEW. In the UI, this might look like “the article is already published, but it can be sent for review again.” No controllers or database are needed here, because this is a pure business rule. So the decision tree says: Spring is not needed, take a unit test.
The mini skeleton of the test here will be maximally boring — and that is a compliment:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicationPolicyTest {
@Test
void doesNotAllowPublishedToReview() {
// Arrange: test the business rule as ordinary Java code.
PublicationPolicy policy = new PublicationPolicy();
// Assert: fix the prohibition of a specific status transition.
assertThat(policy.canMove(ArticleStatus.PUBLISHED, ArticleStatus.IN_REVIEW))
.isFalse();
}
}
Such a test catches the defect quickly and cheaply. And, pleasantly, it will not break if you change Spring configuration or controllers, because that is not its job at all.
The client stopped understanding JSON: the field name in the DTO changed
Suppose that in ArticleDetailsResponse the field authorUsername was renamed to author because “it looks prettier.” From the Java code perspective everything may be perfect: the object is built, the controller returns it, status 200, service works. But the external client suddenly receives different JSON and fails. That is not a web mapping problem. It is exactly a JSON contract problem. Therefore, @JsonTest.
Here it is important: we do not have to start the controller to verify DTO serialization. A JSON slice is enough.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
@JsonTest
class ArticleDetailsResponseJsonTest {
@Test
void serializesAuthorUsernameField() {
// There will be a check for the presence/name of the field in JSON (we are not writing the implementation now).
// Idea: the test fails if the DTO contract changes “by accident.”
}
}
Yes, the test is empty, but the choice is correct: we have “targeted” the test at the contract. And if the contract changes by accident, this is the place that will light up first.
The endpoint returns the wrong status or does not accept a parameter
Now a different kind of problem. Suppose GET /api/public/articles/{slug} suddenly started returning 404 even for an existing published article. Or the controller stopped accepting the sort/page parameters. This is a web boundary: mapping, path variables, query params, status semantics. Here a unit test of the service may still be green, because the service does not know how the URL becomes a controller method. A data test will not help either, because the database may be perfect.
So, @WebMvcTest.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
@Test
void returnsOkForExistingSlug() {
// Here there will be a check of the controller's HTTP behavior (status, JSON, parameters).
// Important: the subject of the test is the controller as a “translator,” not the service business logic.
}
}
The point of this choice is that we are testing the “translator” between HTTP and the service. And that is exactly where such defects live.
The repository stopped filtering published articles
Suppose the public list suddenly started showing DRAFT articles. This is almost certainly a persistence/query issue: either a wrong query, or incorrect filtering in the repository, or an invalid status mapping. Testing this through @WebMvcTest is pointless: repositories are not present in a web slice. Testing this through @SpringBootTest is possible, but it is expensive and may blur the cause.
So, @DataJpaTest.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest
class PublishedArticleQueryDataJpaTest {
@Test
void findsOnlyPublishedArticles() {
// Here there will be a check of query behavior (which articles ended up in the result set).
// Idea: prepare data in the database, execute the repository query, verify the result composition.
}
}
This is the case where a data slice gives maximum proof strength for a specific risk: we are not testing HTTP, we are not testing serialization, we are testing that the “database boundary tells the truth.”
The moderation client handles the external service response incorrectly
Another typical story: the moderation service starts returning a new response format or a different status, and our RestClientModerationClient does not know how to read it. As a result, submission for review fails, even though the business logic and repositories are fine. This is an outbound integration boundary: the client adapter.
The correct choice is @RestClientTest.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
@RestClientTest(RestClientModerationClient.class)
class RestClientModerationClientTest {
@Test
void mapsBlockDecisionFromExternalService() {
// Here there will be a check of request/response/moderation payload mapping.
// Important: we lock down the interaction contract with the external HTTP service.
}
}
Why is this minimally sufficient? Because we are not testing the whole workflow; we are testing a specific risk: “the adapter communicates correctly over HTTP.” And that can be proved without starting the whole application.
A bug at the layer boundary → @SpringBootTest
And finally, the most unpleasant class of defects: individually, everything looks correct. Service unit tests are green, the DTO JSON contract seems correct, the repository works in its own way. But as soon as you run the application, the real request fails: maybe because of wiring, maybe because of configuration, maybe because one layer expects one thing and another returns something else.
That is where the justification for @SpringBootTest appears. Because you need to prove the link itself: controller → service → repository, plus settings and infrastructure. Such a test is expensive, but it catches defects “between layers” that cannot be caught locally.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class ArticlePublicationFlowIntegrationTest {
@Test
void fullContextWiringWorks() {
// Here there will be an end-to-end check: the context assembles, layers are connected.
// Usually this is the place for several key happy-path/critical-path scenarios.
}
}
And that is the key point of the decision tree: full context is an answer to a specific type of risk, not a “universal habit.”
8. Common mistakes when choosing a Boot test type
Mistake #1: starting with the annotation instead of the risk.
The most common trap sounds like this: “I need a test... probably @SpringBootTest.” It is similar to the situation “I need to eat... probably order the whole menu.” As a result, you write an expensive test that does not clearly prove anything, and it is even less clear why it failed. The fix is simple: first formulate the statement you want to prove, and only then choose the minimal context.
Mistake #2: starting Spring where a unit test is enough.
This is especially painful because it looks “professional”: a test with a Spring annotation, context, auto-configuration — beautiful. But if you are testing SlugService or PublicationPolicy, Spring adds only cost, not value. Such a test will be slower, harder to debug, and ultimately more likely to be “not something I want to run.” And a test you do not want to run usually does not get run at the critical moment.
Mistake #3: choosing a slice that is too narrow for a systemic defect.
The opposite extreme also happens: you suspect the problem is at a layer boundary, but try to prove it with a slice test where half of the needed components are simply missing. In the end the test “does not reproduce the bug,” and you draw the false conclusion that everything is fine. The right discipline here is: if the defect appears only in a combination, you test the combination. A slice is good when the risk truly lives inside one layer.
Mistake #4: duplicating the same check on three levels without new proof strength.
Sometimes, “for reliability,” you want to verify the same thing with a unit test, @WebMvcTest, and @SpringBootTest. In practice this often turns into triple the cost and triple the fragility, not triple the confidence. It is much more useful to distribute checks across layers so that each test proves something of its own: unit — business rule, slice — boundary contract, full context — wiring.
Mistake #5: trying to “combine” multiple slice annotations into one super test.
Intuition suggests: “What if I put @WebMvcTest and @DataJpaTest together, then I get a web+data test.” But that is not how it works: slices are specifically designed as mutually exclusive narrow contexts, not as a Lego set. If you need the full set of layers, that is already a full context story. If you need only one layer, choose one slice.
GO TO FULL VERSION