1. Choosing @SpringBootTest
If you have just learned how to start an application end to end, your hand starts reaching for @SpringBootTest almost the same way it reaches for the “Ctrl+C / Ctrl+V” buttons: fast, familiar, and almost always with consequences. But the full context is not just another annotation — it is a costly way to gain confidence, and the price here is very real: startup time, diagnostic complexity, and the “noise” from extra components.
After the smoke path and one wiring scenario, a dangerous thought appears: since the full context can catch so much, maybe it should be the default test? This is where a filter is needed. Otherwise, @SpringBootTest quickly turns from a useful tool into an expensive habit.
In a unit test, you verify local logic and know exactly what failed: one class, one method, one error. In a slice test, you verify a layer boundary and also keep the situation under control. And in @SpringBootTest, you launch a small universe: auto-configurations, component scanning, datasource, migrations, security configurations, all kinds of @ConfigurationProperties, and other “pleasant surprises”.
Paradoxically, the larger the context, the more often a beginner gets the feeling “I tested everything.” In reality, what they often get is something else: “I have one green test that starts slowly, fails somewhere unclear, and proves nothing specific.” And precisely to avoid that, we need a practical criterion for choosing.
Let’s make a simple rule “for people, not for philosophy”: @SpringBootTest is good when you can honestly say to yourself (and your team lead, and your future self three months from now): “This defect is not visible at a cheaper level.” If that sentence does not exist, then this is not a test — sorry — but a ritual for summoning the color green.
2. The unique value of the full context
When people say “full context,” a beginner often imagines: “Well, I booted Spring Boot, which means I checked everything.” That is roughly like saying: “I started the car, which means I reached the sea.” Starting is important, but it is only the beginning, not the journey. The full context gives special confidence, but that confidence has a specific shape.
@SpringBootTest is especially useful when the risk lies in the assembly of the system: in how real beans are found, created, wired together, and how settings and auto-configurations are woven into that chain. In ContentHub, this can show up in a very ordinary way: you renamed a package, and suddenly @ComponentScan stopped seeing the service; you added @ConfigurationProperties but forgot to enable binding; you changed datasource settings in application-test.yml, and Flyway migrations stopped applying; you changed a service constructor signature, and Spring can no longer assemble the bean.
It is important to say this clearly: @SpringBootTest is not required to prove that all business rules are correct; it is not required to prove that a specific JSON contract is stable; it is not required to prove that a repository sorts results correctly; it is not required to prove that a controller validates a DTO correctly. For all of that, you already have cheaper levels.
To avoid confusion, it is convenient to keep a small “question → tool” table in mind. This is not dogma, but a quick compass when your brain is tired and you still need to write a test.
| Question the test answers | Minimally sufficient tool (usually) | Why it is cheaper/more precise |
|---|---|---|
| “Does the local rule/algorithm work without Spring?” | Unit test (plain JUnit 6) | Instant, no context, precise diagnostics |
| “Is the DTO JSON contract stable?” | @JsonTest | Catches serialization/deserialization without MVC and services |
| “Is the HTTP boundary of the controller correct (status/validation/error contract)?” | @WebMvcTest | Starts the web layer, but not data/services end to end |
| “Does JPA mapping/query/constraint work?” | @DataJpaTest | Cheaper than the full context, focused on persistence |
| “Does the application assemble as a system, and do the real beans fit together?” | @SpringBootTest | The only level that truly verifies full wiring |
If you look at the table and see that your question is about SlugService.toSlug(...), while you have already opened an @SpringBootTest file, that is a good moment for a short pause, a deep breath, and a careful step back.
3. Decision tree for choosing the test level
Now we will build a simple decision tree that helps you arrive at @SpringBootTest as a conclusion, not as a habit. This should not be a hundred-item checklist; on the contrary, it should be simple enough that you can run through it in your head in 15 seconds while Gradle has not yet fully hypnotized you with logs.
Below is a schematic flowchart that reflects the idea of the “minimally sufficient test.” Notice: @SpringBootTest is almost at the end — not because it is “bad,” but because it is expensive.
flowchart TD
A["What exactly do I want to prove?"] --> B{"Can it be verified without Spring?"}
B -->|Yes| U["Unit-test (plain JUnit 6)"]
B -->|No| C{"Is it about a DTO/JSON contract?"}
C -->|Yes| J["@JsonTest"]
C -->|No| D{"Is it about the HTTP boundary of one controller?"}
D -->|Yes| W["@WebMvcTest"]
D -->|No| E{"Is it about JPA/query/constraints?"}
E -->|Yes| P["@DataJpaTest"]
E -->|No| F{"Is the risk in wiring/configuration/system assembly?"}
F -->|Yes| S["@SpringBootTest (full context)"]
F -->|No| G["It looks like the question is phrased too vaguely — clarify the risk"]
Now for the important part: this tree works only if you honestly formulate the risk. Not “I want to test the approve() method,” but “I want to prove that in the real context, the chain service → repository → transaction → saving to the database is assembled correctly.” Not “I want to test the controller,” but “I want to prove that the controller correctly returns 400 and ApiProblem for an invalid DTO.” Formulating the risk makes the test level almost obvious.
In practice, many things in ContentHub are solved at cheaper levels. For example, article status transition rules were already tested with unit tests through PublicationPolicy. The JSON format of ArticleDetailsResponse is protected by @JsonTest. Pagination and query params were caught in the MVC slice. Repositories and migrations were covered in the data slice. The full context remains for what is hard to see otherwise: “does all of this together really live?”
4. Examples from ContentHub
When criteria sound abstract, they are easy to “understand” and just as easy not to apply. So let’s examine a few typical ContentHub situations in the style of “what do we want to prove → which level do we choose → why.” I will deliberately show both the wrong choice and the right one so you can see the difference not as theory, but as an engineering compromise.
Let’s start with a classic sin: testing local string logic through the full context. It looks solid, but in fact it is like trying to brush your teeth with a fire hydrant: there is water, of course... but why so much?
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest // Start the entire Spring context (and pay with time) — even though it is not needed here
class SlugServiceHeavyTest {
@Test
void generatesSlug() {
// Even without @Autowired, it is already obvious: we started the context, but gained no extra meaning
// We are testing pure string logic that does not depend on Spring
assertThat(new SlugService().toSlug("Hello Spring Boot"))
.isEqualTo("hello-spring-boot");
}
}
This test is almost guaranteed to be slower than necessary, and it does not provide any new confidence. If SlugService is an ordinary class without Spring magic, its verification should be cheap and fast.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SlugServiceTest {
// Create the object explicitly: this is a regular unit test without a Spring context
private final SlugService slugService = new SlugService();
@Test
void generatesSlug() {
// Fast and diagnosable: exactly what we check will fail
assertThat(slugService.toSlug("Hello Spring Boot"))
.isEqualTo("hello-spring-boot");
}
}
This is an ideal match: the question is local, and the answer is a unit test. It is fast, clear, and diagnosable.
Now another common mistake: testing a repository query through the full context. Repositories and JPA are a separate risk area, but they have their own specialized tool — @DataJpaTest. It gives you a truthful verification of the persistence layer without bringing up the web and services.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest // Start only the JPA infrastructure, not the entire Boot context
class ArticleRepositoryDataJpaTest {
@Autowired ArticleRepository articleRepository; // A real repository bean from the persistence slice
@Test
void findsBySlug() {
// In a real test, you would normally need to prepare data (persist/save) before searching
// What matters here is the match itself: the risk is in mapping/query, not in assembling the whole application
assertThat(articleRepository.findBySlug("spring-boot-testing")).isPresent();
}
}
This test answers the question “does the repository find by slug?” quickly, and it does not need controllers, services, or the full context. If you write the same thing with @SpringBootTest, you get something slower, more complicated, and, importantly, you will more often catch failures unrelated to the repository.
The third situation is controllers. If you want to check the HTTP contract of a single controller, @WebMvcTest is a better fit, because it starts the web layer but does not drag in JPA, migrations, repositories, and half the application.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
@WebMvcTest(PublicArticleController.class) // Only the MVC layer and the selected controller are started
class PublicArticleControllerWebMvcTest {
@Test
void returns200ForListEndpoint() {
// Here the main thing is the HTTP boundary (status/headers/body), not assembling the whole application
// In a real test, you would usually wire MockMvc/MockMvcTester and mock the controller's dependencies
}
}
In this test (in a real project) you would verify the status, headers, and JSON body through MockMvc/MockMvcTester. But even without details, it is clear: if the risk is in the HTTP boundary, you choose the web slice, not the full context.
Now for the cases where @SpringBootTest is honestly needed. The first is a smoke test. It can be minimal and still very useful: it catches application assembly failures that unit and slice tests do not see. For example, you accidentally removed a required bean from the configuration or broke property binding in application-test.yml.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) // Verify only context assembly, without a web server
@ActiveProfiles("test") // Explicitly fix the profile so the test is reproducible
class ContentHubApplicationSmokeTest {
@Test
void contextLoads() {
// The test is intentionally empty:
// if the context fails to start (missing bean/broken config/props do not bind), it will fail on its own
}
}
Notice that webEnvironment = SpringBootTest.WebEnvironment.NONE emphasizes that we are checking the context itself, not web transport. This is an honest and very “short” test.
The second justified case is a full wiring test. Its purpose is that unit and slices can each be green on their own, but together the real beans begin to diverge in unexpected places. For example, a service calls a repository, the repository requires the correct database schema, the schema requires migrations, and migrations require correct datasource settings.
Let me show the core of the scenario — only what really proves the wiring.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ArchiveFlowSnippet {
@Test
void archive_persistsArchivedStatus() {
// 1) Prepare data: save an article to the database to get a real id
Long id = articleRepository.save(ArticleBuilder.published().build()).getId();
// 2) Action: run the business operation through the real service (wiring check)
workflowService.archive(id);
// 3) Verify the result: read from the database and check the final state
ArticleStatus status = articleRepository.findById(id).orElseThrow().getStatus();
assertThat(status).isEqualTo(ArticleStatus.ARCHIVED);
}
// articleRepository and workflowService here are real @Autowired fields in the @SpringBootTest class
}
The point of this check is not that we “tested the whole workflow.” We verified one critical transition in the real service + repository + database chain. And this matters: a full wiring test should not become an “epic” of 200 lines, otherwise it loses readability and turns into an expensive debugging quest.
If you compare two approaches — “everything through @SpringBootTest” and “@SpringBootTest selectively for wiring + the rest through unit/slices” — the second almost always wins. It is faster, clearer, and better explains where the risk actually lives.
5. Suite speed and context cache
The full context is expensive not only because it is “big.” It is also expensive because Spring TestContext tries to cache contexts, but the cache works well only when you are not creating an endless number of “almost identical” variants. And this is where engineering begins: you choose @SpringBootTest, but in a way that does not spawn dozens of unique contexts.
A good strategy looks like this: base test values live in application-test.yml, the profile is enabled explicitly with @ActiveProfiles("test"), and local overrides are used rarely and intentionally. If you add a unique properties = ... to every test class, you almost certainly increase the number of contexts and, therefore, the execution time.
This becomes especially noticeable when the full context reaches the web boundary: both mocked MVC and a live server use the same expensive application startup. If the risk does not require a full context, you are simply multiplying slow tests in a different package.
Another discipline in ContentHub is repeatable data setup. If you need an initial state, it is better to prepare it predictably: through a builder/fixture factory or through @Sql. And it is important not to confuse that with “let’s clean the context.” A common beginner mistake is to grab @DirtiesContext because “it is simpler.” In practice, this is like fixing a socket with a sledgehammer: it works, but the price is excessive. In most scenarios, data should be rolled back with transactions, migrations, and clean setup, while the context should remain cacheable.
One more sober point: @SpringBootTest should not duplicate what is already reliably covered more cheaply. If you have already tested PublicationPolicy with unit tests, there is no need to re-check the status transition matrix by brute force in full-context tests. In full context, it is better to verify what unit tests cannot see: for example, that ArticleWorkflowService actually persists the result in the database, that the required beans are truly created, and that the configuration is not broken.
Justification in one sentence
The easiest way to discipline yourself is to leave an “explanatory note” right in the test. Not at the level of a corporate document, but literally as a comment or @DisplayName: why the full context is here. This is especially useful when, a month later, you open the test and try to remember why it exists and why it is so heavy.
An example with a short explanation in JavaDoc looks almost comically simple, but it works surprisingly well — like a reminder: “do not use the heavy tool without a reason.”
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
/**
* Full-context test: prove that in the test profile, service + JPA + migrations line up.
* In other words, the risk is in wiring/configuration, not in local business logic.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) // Do not start the web server: this test is specifically about the context
@ActiveProfiles("test") // Fix the profile so we do not depend on the environment
class ArticleWorkflowFullWiringTest {
// Usually there will be at least one scenario that actually touches the service/repository
}
And if you want to make it more “test-like,” use @DisplayName on the scenario. This helps not only you, but also reports and colleagues who read the run results.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@DisplayName("Full wiring: archive published article persists ARCHIVED status") // Briefly explain why integration is here
class ArchiveFlowDisplayNameSnippet {
@Test
void archive_persistsArchivedStatus() {
// Here the scenario is short, and the meaning is clear even without implementation details
// The main thing: verify the final state in real wiring (service + repository + DB)
}
}
The important thing here is not to aim for literature. One sentence is enough to explain: “why unit/slice was not enough.” If such a sentence does not exist, that is a reason to reconsider the test level.
6. Common mistakes when choosing @SpringBootTest
Mistake #1: starting with the annotation, not the risk.
When you open a test file and the first thing you write is @SpringBootTest, you are effectively deciding in advance that the full context is needed. But the correct order is different: first define what you want to prove, and only then choose the minimally sufficient level. Otherwise, you get a slow test with no clear area of responsibility.
Mistake #2: checking local logic through the full context “for confidence.”
Testing SlugService, PublicationPolicy, or any pure business logic through @SpringBootTest almost always means you are paying for something you do not need. The confidence here is false: the test did not become “more correct”; it simply became “more expensive.”
Mistake #3: duplicating the same behavior on three levels without any new benefit.
Sometimes there is a temptation: “let’s have unit, slice, and full-context — then it will definitely be reliable.” In practice, you get three tests that fail at the same time for one change, but do not add confidence. A good strategy is that different levels cover different risks, rather than repeating the same one.
Mistake #4: turning a full wiring test into an epic covering every scenario.
A full wiring test should be about one meaningful flow and one key final-state check. If you cram create → submit → approve → publish → archive → five more checks into it, you end up with a complex scenario that is hard to read and hard to fix. Better to have several short but clear tests.
Mistake #5: breaking the context cache with random overrides and “per-test tuning.”
When every test class lives in its own little world of settings, Spring cannot cache the context efficiently, and the suite starts to slow down. It is much more reliable to put the base setup in application-test.yml, enable @ActiveProfiles("test"), and apply local overrides only when they truly change the branch of behavior under test.
GO TO FULL VERSION