1. Running tests as part of quality
When a project is small, it seems that running tests is just “click the green button in the IDE, get a green checkmark, go drink tea.” But when the test suite becomes multi-level, and tests start generating snippets for REST Docs, it suddenly turns out that execution is already an engineering part of the system. Tests acquire a “by-product,” and it must be stable, reproducible, and not depend on the mood of your IDE.
In ContentHub, by this point tests live in different modes: unit tests without Spring, slices (@WebMvcTest, @JsonTest, @DataJpaTest), several full integration tests, and a late container subset. Each type has its own execution cost, its own timeouts, and its own sources of instability. And if we do not agree on the basic rules of execution, we get the classic tragedy: “it failed in CI,” “it does not fail on my machine,” “well, let’s add Thread.sleep(5000)” (spoiler: that is how flakiness is born, not quality).
Execution discipline is not about “process for the sake of process,” but about three practical things: unified JUnit default rules, proper handling of output (snippets), and respect for the fact that the Spring context is cached, which saves us time.
The junit-platform.properties file
junit-platform.properties is a way to give JUnit Platform common execution parameters for tests. In essence, it is similar to “global settings for the test engine”: not for one class, not for one method, but for the entire set of tests that run on the platform.
It is important not to mix up the two levels. JUnit Platform is the “foundation” that launches test engines (for example, Jupiter). And JUnit Jupiter is the specific engine that understands @Test, @Nested, @Timeout, and other annotations. In junit-platform.properties, we usually write Jupiter parameters, because in ContentHub we use JUnit Jupiter 6.x as the main engine, but technically the file is read by the platform.
Where should you put it? The most practical option for a regular Gradle project is to place the file in:
src/test/resources/junit-platform.properties
Why there? Because src/test/resources ends up on the test classpath, and the JUnit Platform looks for junit-platform.properties exactly at the root of the classpath. In other words, you put the file where Gradle is guaranteed to deliver it to the test runtime.
If you ever forget where this file is, you can use the ancient developer ritual: “search the project.” It works even without Spring.
3. Settings in junit-platform.properties
At this step, it is easy to go to two extremes. The first is to not extract anything and then copy the same annotations/settings into dozens of tests. The second is to turn junit-platform.properties into a “magic book of spells” with 30 parameters set, and nobody knows why the suite behaves so strangely. We will stay in the middle: extract only what really provides predictability, while not hiding the meaning of the tests.
For ContentHub, three kinds of settings usually work well: a default timeout (so tests do not hang forever), timeout behavior in debug mode (so you can calmly set a breakpoint), and the default lifecycle of test instances (so you do not get accidental shared state).
Example of a minimal file:
# src/test/resources/junit-platform.properties
# Global timeout for tests where @Timeout is not set explicitly
junit.jupiter.execution.timeout.default = 10 s
# When running in debug, timeouts are disabled so a breakpoint is not treated as "hanging"
junit.jupiter.execution.timeout.mode = disabled_on_debug
# A new test class instance for each @Test method — fewer chances of state leakage
junit.jupiter.testinstance.lifecycle.default = per_method
Let’s break down these lines in plain language, without “here are 15 links to the documentation.”
Default timeout: protection from the “never-ending test”
junit.jupiter.execution.timeout.default = 10 s says: if the test itself did not specify @Timeout, then JUnit applies a common timeout. The idea is simple: a test must either pass or fail. And “hang forever and wait” is not the third good option; it is technical debt in “compounding interest” mode.
Why 10 seconds instead of 2? Because our suite is not made only of microscopic unit tests. We have Spring-based tests and, in some places, waits (for example, Awaitility in earlier days). The default timeout should be large enough not to kill honest tests, and small enough to catch hangs. 10 seconds is a good educational starting point. In a real team, this number is chosen empirically, not by the lunar calendar.
Timeouts while debugging: so a breakpoint is not considered a “hang”
junit.jupiter.execution.timeout.mode = disabled_on_debug is one of those settings that once bites you and you never forget it. Without it, you set a breakpoint, step away to think, come back — and the test has already failed because of the timeout, since JUnit honestly thinks: “friend, you promised to fit within the limit.”
The disabled_on_debug mode allows timeouts to be disabled when you run tests in debug mode. That makes debugging normal, not a race to “catch the variables in 2 seconds.”
Test class lifecycle: fewer surprises with state sharing
junit.jupiter.testinstance.lifecycle.default = per_method means that a new instance of the test class is created for each test method. For a beginner, this is almost always the safest option, because test state does not “leak” between methods through fields.
Yes, per_class is sometimes more convenient, especially when you need @BeforeAll without static. But if you are not sure that your test class does not hold state, it is better to keep the default baseline as per_method. Let the tests be a little “more boring,” but more predictable. Boring tests are a compliment.
4. Local rule overrides
Global settings are the “base gravity” of the suite. But sometimes a specific test objectively requires different conditions. At this point, it is important not to start a war between “annotations vs. properties.” The normal engineering model is this: junit-platform.properties sets a reasonable default, and the test, when needed, explicitly declares an exception.
The most typical case is a local timeout. For example, you have a test that checks that an operation must not be too slow (and this is part of the contract, not just “because I want it”). Then @Timeout on that specific test is exactly what is needed.
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import java.util.concurrent.TimeUnit;
class SlugServiceTest {
@Test
@Timeout(value = 200, unit = TimeUnit.MILLISECONDS) // local, stricter limit specifically for this test
void shouldGenerateSlugFastEnough() {
// This would be a call to slugService.generate(...)
// The idea: the test should fail if the method suddenly becomes a "slower."
//
// Important: this timeout is part of the speed contract for a specific scenario,
// not an attempt to "speed up" the entire suite globally.
}
}
Another common case is the lifecycle of the test instance. If you intentionally want per_class (for example, because setup is expensive and you are sure that you do not store mutable state between tests), this is done locally via @TestInstance.
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
@TestInstance(TestInstance.Lifecycle.PER_CLASS) // one test class instance for all methods
class ContentHubApplicationSmokeTest {
@Test
void contextShouldStart() {
// Smoke test: the context starts, beans are wired, configuration is read.
//
// Important: with PER_CLASS it is especially easy to accidentally start sharing state through fields.
// If you did that — make sure the fields do not become "hidden storage" between tests.
}
}
The point is this: globally we play the safe game, locally we take responsibility and make an exception. Not the other way around.
5. generated-snippets as a build artifact
Before REST Docs, tests behaved like a strict judge: “guilty / not guilty,” “green / red.” With REST Docs, tests also become a “mini-factory” that produces files — snippets. And that automatically makes the execution question more serious: files must appear where they are expected and must be current.
If you run a REST Docs test (for example, PublicArticleRestDocsTest) and it contains:
.apply(document("public-article-list"));
then a folder like this will appear in Gradle’s build directory:
build/generated-snippets/public-article-list/
And inside it there will be snippet files (the exact set depends on what you documented). Even if you create Markdown snippets, this is still file output. And that is where very practical questions begin:
If you renamed a snippet, will the old directory remain? Yes, it very well might.
If you did not clean build/, could you accidentally assemble documentation where part of the snippets are old? Also yes.
If you ran tests partially (for example, only one class), will you get a “full set of snippets” for the documentation? No, you will only get what you ran.
Hence an important thought: generated-snippets is not “garbage after tests,” but a result of execution that must be manageable. We usually do not commit it to git, but we are obliged to treat it as a real build artifact.
To see the whole chain, it is convenient to imagine it like this:
flowchart TD
A["./gradlew test"] --> B["JUnit Platform"]
B --> C["Spring TestContext"]
C --> D["MockMvcTester + document(...)"]
D --> E["build/generated-snippets"]
There is no magic in this graph: if the test did not run, the snippets did not appear. If the test failed, the snippets most likely did not appear either (or they appeared partially, which is even more fun). Therefore, execution discipline is literally the condition for REST Docs to work as intended at all.
6. Gradle and generated-snippets
When Gradle does not know that some directory is a task output, it treats it as a side effect. Side effects are tricky: they are poorly cached, poorly tracked, and poorly explained in build logic.
We need Gradle to understand: “after test, a snippets directory is expected.” Then both local scenarios, CI, and simply understanding the project structure become easier.
Minimal configuration looks like this:
import org.gradle.api.tasks.testing.Test
tasks.named<Test>("test") {
useJUnitPlatform() // explicitly fix that tests run on JUnit Platform (Jupiter 6 in the context of the course)
// Tell Gradle: after test finishes, expect this directory as output (important for predictability and caching)
outputs.dir(layout.buildDirectory.dir("generated-snippets"))
}
There are three points here. First, useJUnitPlatform() makes the intent explicit: we run tests through the JUnit Platform (in our course — JUnit Jupiter 6). Second, outputs.dir(...) says: “this folder is the result of executing tests.” Third, the path is taken through layout.buildDirectory, so we do not hardcode build/ by hand and do not argue with Gradle about where its buildDir is.
There is one more practical nuance: sometimes it is useful to clean snippets before running tests, so you do not live with old files. This is especially nice in documentation, because an “old snippet” looks current until you notice that the API already has a different field.
Mini version of such cleanup:
import org.gradle.api.tasks.testing.Test
tasks.named<Test>("test") {
doFirst {
// Honest cleanup: before running tests, delete old snippets so we do not pick up outdated files
delete(layout.buildDirectory.dir("generated-snippets"))
}
}
This is not “mandatory magic,” just honest cleanup before work. Like in the kitchen: if last time you were making borscht, and now you want to bake a cake, it is better to wash the pot first. And yes, I understand that developers do not like washing pots. But tests do.
7. Spring context cache and @DirtiesContext
The Spring TestContext framework does not recreate the ApplicationContext for every test, because that would be too slow. Instead, it caches contexts and reuses them across test classes if the configuration matches. This is one of the reasons why slice tests and even part of full-context tests can perform reasonably well.
But we have a tool that can “burn” that cache: @DirtiesContext. This annotation tells Spring: “after the test, the context is considered dirty; do not reuse it.” And Spring obediently removes the context from the cache. The next test that needs the same context will build it again.
It looks harmless:
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
@SpringBootTest // full context: expensive, but sometimes necessary for integration
@DirtiesContext // after the test the context is considered "dirty" and will be recreated in subsequent tests
class ArticlePublicationFlowIntegrationTest {
// Important: @DirtiesContext is not a "universal fix," but a conscious time cost for the suite.
}
But the price of this decision is such that it is worth stating it plainly. If the context builds in, say, 1–3 seconds (and sometimes more), then one extra @DirtiesContext can turn a “suite in a minute” into a “suite in ten minutes.” And worse, this is often done as a “quick fix” for flakiness, not as a conscious choice.
To cement the mental model, you can imagine the cache like this:
flowchart TD
A["Test class #1"] --> K["Context key"]
B["Test class #2"] --> K
K --> Cache[("Spring context cache")]
Cache --> Ctx["ApplicationContext reused"]
D["@DirtiesContext"] --> Cache
D --> X["Cache entry removed"]
@DirtiesContext is indeed a useful mechanism. But it should be an exception, not a habit. It feels like the “restart laptop” button when the browser is slow. Sometimes you need it. But if you do that every 5 minutes, the problem is probably not the browser.
Most often, instead of @DirtiesContext, it is better to understand what exactly the test is “dirtying” in the state. Sometimes it is static caches. Sometimes it is writing files into a shared directory. Sometimes it is global System.setProperty(...). Sometimes it is a shared mutable fixture. These things are better fixed locally: reset the cache after the test, use temporary directories, do not touch global process settings without rollback. Yes, that requires a bit more discipline. But the suite stays fast.
8. A scenario for stable docs test execution
In ContentHub, REST Docs is usually attached to public endpoints, because that is exactly the surface external clients use. So we need to achieve three practical properties: tests always run the same way, snippets always appear in the expected place, and the context is not recreated without reason.
And for such a scenario, we follow the same direct flow as in the other REST Docs examples: request → assertions → document(...).
Below is a “skeleton” of a REST Docs test that fits this philosophy well. It is short, tests the HTTP contract, and documents on top of assertions:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@WebMvcTest(PublicArticleController.class) // MVC slice: faster and more deterministic than starting a server
@AutoConfigureRestDocs // enables integration of Spring REST Docs with Spring MVC tests
class PublicArticleRestDocsTest {
@Autowired
MockMvcTester mvc; // test HTTP client on top of MockMvc
@Test
void shouldDocumentPublicArticleList() {
// Explicitly fix the request contract: path and query parameters are part of the documentation
assertThat(mvc.get().uri("/api/public/articles?page=0&size=20"))
.hasStatusOk() // minimal check: the endpoint returns 200 OK
.apply(document("public-article-list")); // generate snippets in build/generated-snippets/...
}
}
What is important here in the context of execution discipline? The test uses an MVC slice, not a RANDOM_PORT server, so it is fast and deterministic. It does not require @DirtiesContext, because we do not change the context. It generates snippets in the standard place, and Gradle knows that this is test output. And junit-platform.properties ensures that the test will not hang forever, and at the same time it will not fail because of a timeout when you are debugging.
That is how small settings turn into systemic quality: you do not “fix each test,” you make the whole ecosystem predictable.
9. Typical mistakes: running tests and snippets
Mistake #1: turning junit-platform.properties into a “secret place of power” with dozens of parameters.
Usually this ends with the suite behaving strangely, and nobody remembers who set parallel.enabled=true and why half the tests now fail depending on the phase of the moon. A good settings file is small: timeout, debug mode, lifecycle. Everything else is added only when you can explain what problem you are solving.
Mistake #2: setting an overly aggressive global timeout and then treating failures by increasing it “just in case.”
If you set, for example, 1 second “for all tests,” then some integration or async checks will start failing not because the code is bad, but because you yourself created impossible conditions. And then it starts: “let’s make it 60 seconds,” and the timeout stops playing the role of a safety net. Choose a default that is realistic for the whole suite, and fix point speed requirements with @Timeout where it matters.
Mistake #3: not treating build/generated-snippets as a test execution result.
When snippets are seen as garbage, nobody watches their freshness. As a result, documentation may be assembled from old files, and you will be sure that since it came “from tests,” it is always correct. Snippets are an artifact. It must live in a clear place, be cleaned when necessary, and appear predictably when tests are run.
Mistake #4: abusing @DirtiesContext as a universal pill for instability.
@DirtiesContext is sometimes truly needed, but in most cases it simply hides a test-design or production-code problem. And almost always it sharply increases suite time. If a test depends on dirty global state, it is better to find the source of that dirt and remove it: reset the cache, do not use a shared directory for files, do not leave static singletons in an unknown state.
Mistake #5: mixing “what is convenient in the IDE” with “how the build really works.”
Running one test from the IDE is fine. But when you rely on artifacts like snippets, more and more often you need to run the suite the way it will be run in the real build: through Gradle. Otherwise you get a situation where “docs did not appear for me,” although you simply did not run the necessary tests or ran them in a different context.
GO TO FULL VERSION