1. The lifecycle around your @Test
When you start writing tests, it is very easy to think: “JUnit just runs a method with @Test.” That is almost true, but there is important machinery around it: setup, cleanup, object creation, and repeatable scenarios. If you do not understand it, you quickly end up with tests that are sometimes green, sometimes red, and sometimes just get offended and stay silent.
In JUnit Jupiter 6, every test method has a standard cycle around it: JUnit creates an instance of the test class, then runs the setup methods, then the test itself, and after that — the cleanup methods. These “lifecycle hooks” are exactly what you need so you can keep shared setup in one place and the scenarios themselves in another, without mixing everything into a mess.
Let us look at this as a diagram. What matters here is the order, because it directly affects what can and cannot be moved into a fixture:
sequenceDiagram
participant J as JUnit
participant T as Test class instance
J->>T: "create a new test class object"
J->>T: "run @BeforeEach"
J->>T: "run @Test"
J->>T: "run @AfterEach"
The key — and often surprising for beginners — point is this: by default, JUnit creates a new instance of the test class for each test method. This means that ordinary fields (not static) do not “remember” state between tests — and that is good. There is also bad news: static fields do remember state, and that is where you can set up a small homegrown soap opera called “Why does the second test fail and the first one does not?”.
Now let us add @Nested, which we already know from the previous lecture. If you have nested classes, JUnit applies the same principle: each test gets a shared outer context and a more specific inner one. In practice, this means that @BeforeEach can exist both in the outer test class and in the inner one, and both will run — just in a specific order:
flowchart TD
A["Outer @BeforeEach"] --> B["Inner @BeforeEach"] --> C["@Test"]
C --> D["Inner @AfterEach"] --> E["Outer @AfterEach"]
This gives you a convenient tool: in the outer @BeforeEach, you create a common baseline, for example a service or a “default article draft,” and in the nested one you refine the context, for example “article is complete” or “article has no title.” But this is also where a risk appears: if the baseline grows into the size of a small novel, the tests become “magical” — the names still look clear, but where the data actually came from is no longer obvious.
2. Test fixture: meaning and boundaries
The word fixture sounds like something from football — like a match schedule — but in tests it means something much more down to earth: the initial state needed to run a scenario. This can be objects, prepared strings, a “draft article,” a list of events, even a temporary directory — anything you create so the test can perform Act and make Assert.
In a real project, ContentHub fixtures will appear all the time, because domain scenarios almost always require initial data. To verify the rule “a draft can be sent for review only if required fields are filled in,” you need a draft. To verify slug normalization, you need a title. To verify the visibility of a published article, you need an article with the status PUBLISHED. So a fixture is not a “special test style,” but simply preparation of the stage before the performance.
It is important to distinguish two types of fixture from the reader’s point of view. A local fixture is when everything important is visible right in the test, and the scenario reads top to bottom without jumping around the file. A shared fixture is when part of the setup is moved into @BeforeEach, and the test becomes shorter. The local one is usually clearer, the shared one usually saves lines. Our goal is to learn how to keep balance so tests are both short and transparent.
Another practical definition that will be useful to us: a good fixture makes a test deterministic. That means the same test, with the same code and environment, produces the same result. If your fixture creates random values, depends on the current time, or on “whatever was left in the static list after the previous test,” you are testing not the code but luck. And luck, as you know, does not usually live long in CI.
3. @BeforeEach: a common start for every scenario
@BeforeEach is a way to tell JUnit: “Before each test, do this setup.” And it is very easy to go to two extremes here. In one, @BeforeEach is not used at all and the same new SomeService() line is copied into twenty tests. In the other, people try to cram the whole universe into @BeforeEach, including the meaning of the scenario, and the test turns into a riddle: “what was actually on the input?”.
The right meaning of @BeforeEach for us in this course is: minimal common initialization needed by almost all scenarios in this test class. Usually that means creating the object under test, or a few basic dependencies if they are simple and do not distract from the core idea.
Below are short production-like types with very obvious behaviour. For the lifecycle topic, it matters more where the common baseline lives and when the state between tests is actually kept clean than the internal details of SlugService and ArticleSubmitRule.
Let us start with the simplest example, which also prepares us for ContentHub: there is a service that turns a title into a slug. SlugService here is a regular stateless service with string logic; in a real project the implementation will be more careful, but as a teaching illustration this is enough:
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SlugServiceTest {
// SUT (System Under Test): the object whose behaviour we verify
private SlugService slugService;
@BeforeEach
void setUp() {
// Important: a new instance before each test = clean state
slugService = new SlugService();
}
@Test
void trimsAndReplacesSpaces() {
// Arrange/Act: pass a string with extra spaces
String slug = slugService.toSlug(" JUnit Basics ");
// Assert: expect a normalized slug
assertEquals("junit-basics", slug);
}
}
Here, @BeforeEach removed repetition and did not hide anything: the reader can clearly see that each test gets a new SlugService. Even if SlugService is stateless and does not hold state, keeping its creation in one place is still convenient — especially when additional fields or settings appear later.
Now let us make the example a little more scenario-like. Suppose we have a rule: “an article is considered ready to be sent for review if title and body are filled in.” This is not about Spring or a database, just a small business rule that we can test with ordinary JUnit. We use a fixture here precisely so we do not have to write the same baseline in every test:
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ArticleSubmitRuleTest {
// SUT: the rule we verify in all tests of this class
private ArticleSubmitRule rule;
@BeforeEach
void setUp() {
// Common minimal fixture: the same rule for all scenarios
rule = new ArticleSubmitRule();
}
@Test
void allowsSubmitWhenTitleAndBodyPresent() {
// Keep scenario data directly in the test so the meaning is visible
assertTrue(rule.canSubmit("JUnit", "Readable tests"));
}
@Test
void rejectsSubmitWhenBodyBlank() {
// Separate scenario: body is empty/whitespace-only
assertFalse(rule.canSubmit("JUnit", " "));
}
}
The fixture here is rule. Notice that we do not hide the scenario data ("JUnit", "Readable tests", " ") in @BeforeEach, because those exact values are what make the test meaningful. If you move them into setup, it becomes hard for the reader to tell one scenario from another: everything starts to look like assertTrue(rule.canSubmit(title, body)), but where title and body came from is not visible.
It is also important to remember: @BeforeEach is not only about “creating an object,” but also about “resetting mutable state.” If the object under test keeps something inside itself — for example, a buffer or a cache — @BeforeEach helps ensure that each test starts from a clean slate. And yes, often the best “state reset” is simply creating a new object rather than trying to clean up the old one carefully.
Now let us look at the combination of @Nested and @BeforeEach — this is exactly the case where fixtures start to resemble a neatly organised stage. The outer class sets the common baseline, the inner one refines the context:
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ArticleSubmitRuleNestedTest {
// Common fixture for all nested contexts
private ArticleSubmitRule rule;
@BeforeEach
void setUp() {
// Initialize the common baseline for every test (including nested ones)
rule = new ArticleSubmitRule();
}
@Nested
class CompleteArticle {
@Test
void canBeSubmitted() {
// In this context we check "complete article"
assertTrue(rule.canSubmit("JUnit", "Text"));
}
}
@Nested
class IncompleteArticle {
@Test
void cannotBeSubmittedWithoutTitle() {
// In this context we check "no title"
assertFalse(rule.canSubmit(" ", "Text"));
}
}
}
The scenarios here read almost like “book chapters.” And notice: we do not create shared state unnecessarily — rule is shared, while the scenario data stays local. This is a very convenient style when there are more scenarios, but they naturally group by meaning.
4. Fixture boundaries and magic
At some point, you will almost certainly feel the temptation to make @BeforeEach “smart”: prepare the title, the body, the category, the author, and another ten fields there — because that way there is “less code in the tests.” And that is where the classic problem begins: the test gets shorter, but stops being readable. It is like writing a poem made of one word: formally nice, but the meaning got lost somewhere along the road.
To keep boundaries, it helps to ask yourself a simple question: “If I open this test in six months, will I be able to understand its input data without jumping into @BeforeEach and assembling a puzzle across the whole file?” If the answer is “no,” then the fixture is already hiding meaning. And that is especially annoying, because we read tests no less often than production code.
Let us look at an example of how easy it is to hide something important. Suppose we make a “default article” fixture and then tweak it slightly in tests. If the article is mutable, you may unexpectedly start sharing state between tests — or at least forget inside a test that the object was already changed earlier.
Below, Draft is just a mutable article draft with getters/setters, and DraftValidator is the rule that checks it. It is on a mutable object that it becomes especially easy to see how a fixture starts hiding the meaning of a scenario.
A bad style looks like this: @BeforeEach creates a “complete article,” and then the test slightly “tweaks” one detail, and it is no longer very clear what else was there:
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
class DraftValidatorTest {
// Common fixture
private DraftValidator validator;
// Potentially dangerous fixture: mutable object we are going to "tweak"
private Draft draft;
@BeforeEach
void setUp() {
// The validator baseline is shared — fine
validator = new DraftValidator();
// But a "complete draft" in setup can start hiding important scenario details
draft = new Draft("Title", "Summary", "Body", "java");
}
@Test
void rejectsBlankTitle() {
// The scenario difference is visible, but the rest of the draft state is hidden in setup
draft.setTitle(" ");
assertFalse(validator.isValid(draft));
}
}
Why does this smell like a problem? Because the reader sees only “title is blank,” but not what was in the other fields. Today that is still tolerable: the fields in @BeforeEach are short. Tomorrow there will be eight, then twelve, then a nested object appears — and the test will start reading like “first figure out what this draft is, and only then understand what exactly was changed in it.”
A more readable variant is to keep the data that matters to the scenario directly in the test. Sometimes that means you have to create the object inside the test, and that is fine. Yes, there are three or four more lines. But the meaning stays where it belongs.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
class DraftValidatorBetterTest {
// Common fixture: validator is the same for all scenarios
private DraftValidator validator;
@BeforeEach
void setUp() {
validator = new DraftValidator();
}
@Test
void rejectsBlankTitle() {
// Scenario data is directly in the test: it is clear what exactly is "bad"
Draft draft = new Draft(" ", "Summary", "Body", "java");
assertFalse(validator.isValid(draft));
}
}
Here the common fixture remains only where it really is common: validator. The scenario object (Draft) is created locally because it is part of the Arrange for this exact test.
Now for a compromise that very often turns out to be the sweet spot: you move not the data, but simple factory methods into the fixture, and they create the object in an obvious state. This is not “separate infrastructure” and not “builder pattern on steroids,” just a short helper inside the test:
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
class DraftValidatorHelperTest {
// Common fixture
private DraftValidator validator;
@BeforeEach
void setUp() {
validator = new DraftValidator();
}
@Test
void rejectsBlankTitle() {
// The test still keeps the meaning: we explicitly create a "draft with this title"
Draft draft = draftWithTitle(" ");
assertFalse(validator.isValid(draft));
}
private Draft draftWithTitle(String title) {
// Helper hides only the "noise" (valid baseline), but not the scenario difference
return new Draft(title, "Summary", "Body", "java");
}
}
The plus of this approach is that the scenario remains transparent — “draft with title …” is still readable by eye — but you do not copy the same “Summary/Body/java” ten times. The downside is also honest: if the helper starts growing and hiding half the meaning, you are back to the magic problem. So this is a tool, not a religion.
To reinforce the boundary, let us fix a small decision table. Think of it as a navigator, not a law:
| Where to create the fixture | What it usually is | When it is good | When it starts to hurt |
|---|---|---|---|
| Directly in the test (locally) | scenario input data | when the data is the meaning of the test | almost never harmful, except for copy-paste |
| Field + @BeforeEach | object under test (SUT) and simple dependencies | when almost every test uses the same object | when “scenario” data gets into @BeforeEach |
| Helper method in the test | short factory for a “valid draft” | when you need to reduce copy-paste without losing meaning | when the helper turns into a mini framework |
| static global state | “so it is available to everyone” | almost never | almost always: breaks isolation and determinism |
Notice: I am deliberately not suggesting a “shared mega-fixture for the whole project” at this stage. Our goal right now is to learn not to hide meaning. A large fixture infrastructure can be useful, but you should only move to it when you already clearly feel the boundary between convenience and magic.
5. @AfterEach: cleanup after the test
After @BeforeEach, it is very tempting to write @AfterEach right away, because “it is symmetrical.” In programming, symmetry is sometimes beautiful, and sometimes it only pretends you cleaned up the apartment while in fact you just placed the trash neatly on shelves. @AfterEach is needed not “because that is the convention,” but because the test really leaves behind a trace that may interfere with the next scenario.
The most common reason for @AfterEach in ordinary Java unit tests is cleaning up shared mutable state, most often static state. In an ideal world such state would not exist at all, but in both educational and real-world projects it sometimes appears: some global event registry, a singleton buffer, an in-memory log. If you do not clean it, tests start depending on execution order.
Below, AuditLog is a hypothetical static in-memory log. The example is intentionally rough, but it immediately shows why cleanup is needed after a test at all.
Here is an example of a “test trace” that must be removed. Imagine a simple audit log where events are recorded. It is static — and that alone is a reason to be careful — which means it survives tests:
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class AuditLogTest {
@AfterEach
void tearDown() {
// Cleanup is mandatory because EVENTS is static and survives tests
AuditLog.EVENTS.clear();
}
@Test
void recordsOneEvent() {
// Act: add an event to the shared audit log
AuditLog.record("draft-created");
// Assert: verify that exactly one event was added
assertEquals(1, AuditLog.EVENTS.size());
}
}
What matters methodologically here: we use @AfterEach as a “mop,” not as “the place where test logic lives.” In other words, we do not put any checks into tearDown() and we do not try to “smartly analyse the result” there. Cleanup should be boring. The more boring, the better. Boring cleanup is like a boring seatbelt: it may not be exciting, but it is reliable.
Another common case is when a test creates a temporary file or directory. In the real course we will later work with files — ContentHub has attachments — but for now it is enough to understand the principle: if a test created a resource that remains in the system, it must be deleted. Otherwise tests start depending on the machine’s state. You can do this manually, or through standard JUnit mechanisms, but the general idea is the same: resource created — resource removed.
A mini-example with a “file trail” can look like this — here we omit exception handling details for brevity:
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TempFileTest {
private Path tempFile;
@BeforeEach
void setUp() throws Exception {
// Arrange: create a temporary file before each test
tempFile = Files.createTempFile("contenthub-", ".tmp");
}
@AfterEach
void tearDown() throws Exception {
// Cleanup: delete the temporary file, even if the test fails on an assert
Files.deleteIfExists(tempFile);
}
@Test
void tempFileExists() {
// Assert: verify that the file really appeared
assertTrue(Files.exists(tempFile));
}
}
This is not “the perfect style for all cases” — in real life it is often more convenient to use special JUnit tools — but as a teaching illustration the example works: @AfterEach is the place where we return the world to its initial state.
And one more important nuance: @AfterEach is well suited for cleanup that must run even if the test fails on an assertion. When a test suddenly turns red, the last thing you want is for it to also leave garbage behind. That is why cleanup code should be as reliable, minimal, and free of complex logic as possible.
6. Test isolation and state
Purely technically, JUnit gives you a fairly safe baseline: a new test class instance for each test is already an excellent start against state “leaking.” But the human factor and static can work around any safety. So the fixture topic always has a second half: not only “how to prepare,” but also “how not to accidentally break isolation.”
The simplest way to break isolation is to keep shared mutable data in static and forget to clear it. The second way is to keep it not in static, but pass the same mutable object into different tests through a shared setup. The third, trickiest way is to prepare a fixture so that it depends on test order: for example, the first test writes something, and the second expects it to already be there. Such a “series” usually ends with a genre classic: individually the tests are green, but together they are somehow red.
Let us look at an example that seems harmless but is dangerous in practice. Suppose we made a “shared article” and modify it in different tests. If the article is mutable and you accidentally moved it into static somewhere, tests can easily start affecting each other:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SharedStateBadTest {
private static final Draft DRAFT = new Draft("Title", "Summary", "Body", "java");
@Test
void changesTitle() {
DRAFT.setTitle("New title");
assertEquals("New title", DRAFT.getTitle());
}
@Test
void expectsOriginalTitle() {
assertEquals("Title", DRAFT.getTitle());
}
}
And this is where the fun begins: if changesTitle() runs first, the second test will fail. If the order is different, the result can be different again. And if the tests run in parallel — even if you did not explicitly ask for that — the behaviour becomes completely unpredictable.
The right reaction to this is not “add another @AfterEach,” although that sometimes helps. The right reaction is not to share mutable state between tests. The simplest educational fix is to create a new object in each test or in @BeforeEach:
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SharedStateFixedTest {
private Draft draft;
@BeforeEach
void setUp() {
draft = new Draft("Title", "Summary", "Body", "java");
}
@Test
void changesTitle() {
draft.setTitle("New title");
assertEquals("New title", draft.getTitle());
}
@Test
void keepsOriginalTitleForNewTest() {
assertEquals("Title", draft.getTitle());
}
}
The important idea: @BeforeEach is not just convenience. It is also a way to explicitly guarantee a clean initial state. Even if the test class is recreated anyway, @BeforeEach makes the intention clear: “each test starts with a fresh draft.”
There is also another thing that breaks isolation no less than static: when you make a fixture “too general” and then start changing it in different nested contexts. A simple discipline helps here: if the object is mutable and important to the scenario, it is better to create it inside the nested @BeforeEach, so it belongs to that specific context and not to the whole class.
For example, like this:
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class NestedFixtureTest {
@Nested
class WhenDraft {
private Draft draft;
@BeforeEach
void setUp() {
draft = new Draft("Title", "Summary", "Body", "java");
}
@Test
void hasDraftTitle() {
assertEquals("Title", draft.getTitle());
}
}
}
This approach keeps boundaries very clear: the fixture belongs to the context. And almost always, that is easier to maintain than “one giant fixture for everything.”
Fixture-friendly test diagram
When you write tests every day, you want a simple internal algorithm: where to put setup and how not to get confused. In this lecture, we are not building a “universal methodology for all time,” but we can assemble a compact scheme that works almost always at the junior level: first determine what is the meaning of the scenario, then decide what can be moved into a shared baseline, and add cleanup only if there really is a trace left behind after the test.
Here is a convenient “mental flowchart” to keep in mind for every new test:
flowchart TD
A[New test] --> B{What is the meaning of the scenario?}
B -->|Input data| C[Keep in the body of the test]
B -->|Shared object under test| D["Create in @BeforeEach or as a field"]
D --> E{Is there a shared mutable tail?}
E -->|No| F[Do not write @AfterEach]
E -->|Yes| G["Clean up in @AfterEach"]
Two small rules that usually save you from big problems can be stated very simply. First: if you cannot understand the test without reading @BeforeEach, then @BeforeEach is already hiding too much meaning and should be reduced. Second: if you think @AfterEach is needed “for symmetry,” it probably is not; cleanup should appear only when you really see garbage the test leaves behind.
And one more practical thing: sometimes an object can be created not through @BeforeEach, but directly as a field, if it is very simple and has no state. In JUnit, this is often almost the same, because the test class is recreated for each test. The difference is mostly in style. @BeforeEach more loudly says “here is the common setup,” while private final says “this is a constant of the test class.” The only important thing is not to mislead the reader: if the object will change, it should not look like a “constant.”
7. Typical mistakes when working with @BeforeEach, @AfterEach, and fixture
Before we close the topic, it is useful to go through a few mistakes that literally everyone makes — including people who have been “writing tests for 10 years,” except some of them are lying. The danger here is that tests can remain green while proving something completely different from what you think: for example, they may verify not the behaviour, but random state left over from the previous scenario.
Mistake #1: turning @BeforeEach into a “storage room for everything.”
When setUp() contains both scenario data and a bunch of string constants, plus object setup, and even part of the Act logic, the test becomes short but muddy. After a couple of weeks it is already hard to tell what exactly differs between tests. If @BeforeEach grows, that is a signal: leave only the common baseline there and return the scenario meaning to the test body.
Mistake #2: using @AfterEach as a mandatory ritual.
Symmetry “since there is setup, let there be teardown” looks nice, but often only adds noise. The worst part is that you start thinking cleanup “is always needed” and stop noticing the real problem, such as static state. @AfterEach should appear only when the test really leaves a trace: a file, an entry in a static list, a changed global flag.
Mistake #3: sharing mutable objects between tests through static.
A static draft, a static event list, a static “error buffer” — all of these turn a set of independent scenarios into a series where episode #2 depends on episode #1. Locally this sometimes “works,” but when the order changes or parallel execution is enabled, it starts failing in completely unpredictable ways. The best treatment is not to share mutable state. If static is unavoidable — a rare case — then at least clear it in @AfterEach.
Mistake #4: changing the fixture in one test and expecting it to “reset itself.”
Sometimes it seems: “Well, I use draft again in the next test, so it will be fine.” That only works if draft is created anew — in the test or in @BeforeEach — and is definitely not static. If draft is shared and mutable, changes will remain. So as soon as you see setSomething() on a fixture object, immediately ask yourself: “Is this object definitely fresh for every test?”.
Mistake #5: hiding the reason for a test failure in setup.
When @BeforeEach does too much work, the test can fail before the scenario even starts. As a result, you see a red test, open the method — and there is one line, and it is unclear what actually broke. The more complex logic there is in setup, the more often you will get “a failure in a place that is not about the scenario.” A good setup either does not fail, or fails very rarely and for truly general reasons.
GO TO FULL VERSION