1. The smell of bad setup in unit tests
When you write the first 3-5 unit tests, everything seems great: a couple of Arrange lines, one Act line, a few Assert lines — and life is good. But as soon as there are more tests, an unpleasant imbalance appears: data preparation starts to take up 80% of the code. As a result, the test checks the business rule almost as if “on the side,” while the main part of the text is occupied by assembling the object so it simply does not fall apart.
Imagine that over time new fields inevitably appear in Article (this is exactly what happens in a real project): summary, body, timestamps, category, version, rejection reason. At some point you run into a very familiar life situation: you want to test “an article in DRAFT moves to IN_REVIEW”, but in every test you are forced to fill in five more fields that have nothing to do with the scenario at all. And if tomorrow another required field is added to Article, your test suite will start failing... even though the business rules have not changed.
Here is a very recognizable test fragment that is still almost good, but already starting to get tiring:
import java.time.Instant;
// An explicit constructor with a bunch of parameters: it reads fine in one place,
// but as the number of fields grows, every test starts to swell.
Article article = new Article(
42L, // id
"Spring Testing", // title
"spring-testing", // slug
ArticleStatus.IN_REVIEW, // status
"alice", // author
Instant.parse("2026-03-18T10:00:00Z") // createdAt
);
So far this is 6 lines — tolerable. But then it usually starts: “and also summary... and also category... and also submittedAt... and also publishedAt...”. And now you are already paging through the test and thinking: “Where is the point here at all?”
We want the test to read like a scenario. Roughly the way we ourselves want to think about it:
// The test shows only the differences that matter for the scenario.
Article article = ArticleFixtures.inReview().withAuthor("alice").build();
The main thing is immediately visible here: the article is in review, the author is alice. Everything else is noise that should not interfere with reading.
To get to this level, we introduce two tools:
1) Test Data Builder — a “builder” for a domain object with sensible default values and explicit configuration of the fields you need.
2) Fixture Factory — a set of named presets for typical states (draft, in review, published) so you do not have to remember every time which fields are needed.
And one more small but important conclusion: sometimes the problem is not in the tests. Sometimes production code is arranged in such a way that it itself wants to say: “I cannot be tested. And do not ask.” Then you need a small refactoring for testability.
2. Test Data Builder for Article
Test Data Builder is, in essence, a constructor for test data that allows you to create domain objects step by step and readably. It has stable default values, and you change only what matters for the current scenario. It is like building a burger: the bun and patty are already there by default, while “add cheese” is a conscious decision that is visible in the order.
The key idea of a builder in unit tests is to reduce noise in the Arrange part, but not hide the meaning. That is why a builder is especially good when a domain object becomes “fat,” and its constructor turns into a train of 12 cars where you keep mixing up where submittedAt is and where publishedAt is.
Let us create a test-only builder for Article. Its logical place is in src/test/java, so as not to “pollute” production code with educational conveniences.
An example project structure (this is just a reference, not a law of the universe):
src/test/java/com/example/contenthub
└─ testdata
├─ ArticleBuilder.java
├─ ArticleFixtures.java
└─ TestInstants.java
Let us start with one important practice: deterministic time constants. If the builder internally uses Instant.now(), that instantly turns tests into a lottery. So it is better to define test “time points” once.
import java.time.Instant;
public final class TestInstants {
// Fixed values: tests do not depend on real time.
public static final Instant T0 = Instant.parse("2026-03-18T10:00:00Z");
public static final Instant T1 = Instant.parse("2026-03-18T11:00:00Z");
// Utility class: no instances.
private TestInstants() {}
}
Now the ArticleBuilder itself. Let us make it as simple as possible: defaults, fluent methods, and build().
import java.time.Instant;
public class ArticleBuilder {
// Defaults should produce a valid object: tests change only what matters.
private String title = "Spring Testing";
private String slug = "spring-testing";
private String author = "alice";
private ArticleStatus status = ArticleStatus.DRAFT;
private Instant createdAt = TestInstants.T0;
// Static factory for readability in a test: anArticle().withX(...).build()
public static ArticleBuilder anArticle() { return new ArticleBuilder(); }
}
The with... methods and build() are concise, but without magic:
// Fluent API: each with-method returns the builder so you can chain calls.
public ArticleBuilder withStatus(ArticleStatus status) { this.status = status; return this; }
public ArticleBuilder withAuthor(String author) { this.author = author; return this; }
public ArticleBuilder withTitle(String title) { this.title = title; return this; }
public ArticleBuilder withCreatedAt(Instant createdAt) { this.createdAt = createdAt; return this; }
// build() should assemble a correct domain object.
// Important: here we consciously do NOT add any "smart" business logic — only data assembly.
public Article build() { return new Article(title, slug, status, author, createdAt); }
Following the same pattern without surprises, withSubmittedAt(...), withPublishedAt(...), and other fields are added if your model really stores them. What matters here is the pattern itself: defaults + explicit overrides without randomness and hidden logic.
Here I intentionally made build() very straightforward. In the real ContentHub, Article will likely contain more fields. Then build() can:
1) either call a factory method Article.draft(...)/Article.inReview(...),
2) or use a “test” constructor / package-private setters,
3) or assemble the object through the production API, if it is convenient and does not make tests brittle.
The main criterion: the builder should produce a valid object by default, so you do not run into strange failures because “a required field was forgotten in the fixture.”
Now using the builder in a test looks exactly as we want: short and to the point.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ArticleBuilderTest {
@Test
void buildsArticleWithOverriddenFields() {
Article article = ArticleBuilder.anArticle()
// The test clearly shows what is overridden (that is the builder's purpose).
.withStatus(ArticleStatus.IN_REVIEW)
.withAuthor("bob")
.build();
// Verify the observable result, without tying the test to the builder's internals.
assertThat(article.getStatus()).isEqualTo(ArticleStatus.IN_REVIEW);
}
}
Notice the psychological effect: you read the test and immediately see the important differences — IN_REVIEW, author bob. You do not need to “open” a constructor with 10 parameters and search by eye for where the status is.
In the same way, you can make a builder for attachment metadata, because that is also a typical candidate for copy-paste: contentType, size, file name. In unit tests, attachment metadata is almost always enough.
public class AttachmentMetaBuilder {
// Defaults: "realistic", but stable (no randomness).
private String filename = "guide.pdf";
private String contentType = "application/pdf";
private long size = 1_000_000L;
public static AttachmentMetaBuilder anAttachment() { return new AttachmentMetaBuilder(); }
// Direct object assembly: minimum "magic" in test data.
public AttachmentMeta build() { return new AttachmentMeta(filename, contentType, size); }
}
Yes, this builder is simple. And that is normal: in test data, “simple and transparent” is better than “universal and unclear.”
3. Fixture Factory: typical states
Fixture Factory is the next level of convenience, when you realize that in 70% of cases you still have to configure the builder the same way. If your domain has typical states — draft, article in review, published article — it is useful to name them and move them into one place. Then tests start to resemble normal text: “take a published article,” not “create an article with status PUBLISHED and publishedAt not null.”
Important: a fixture factory should not turn into a “secret language” that only its author understands. This is a common problem: people write ArticleFixtures.magicArticle(), and a week later no one remembers what is “magic” about it. A good fixture usually speaks in human words: draft(), inReview(), published().
There are two common styles of fixture factory:
— The first style: methods return ready-made objects. This is as simple as possible, but sometimes lacks flexibility: you want “a draft, but with a different author.”
— The second style: methods return a builder preset, already configured for a typical state. This is often more convenient: the state name is preserved, and the necessary fields can still be overridden without dancing around.
We will choose the second style because it supports both readability and flexibility at the same time.
public final class ArticleFixtures {
// Only static factories: no fixture instances are needed.
private ArticleFixtures() {}
public static ArticleBuilder draft() {
// "Named state": draft.
return ArticleBuilder.anArticle().withStatus(ArticleStatus.DRAFT);
}
}
The IN_REVIEW status is a good example of how a fixture encodes a named state. In the simplified example below, we fix the status itself. If your model also needs a required submittedAt, the builder is extended with the same pattern via withSubmittedAt(...).
public static ArticleBuilder inReview() {
return ArticleBuilder.anArticle()
.withStatus(ArticleStatus.IN_REVIEW);
}
Using this in tests turns out to be very natural:
// Reads like a scenario: "an article in review, author alice".
Article article = ArticleFixtures.inReview()
.withAuthor("alice")
.build();
A couple of important nuances that greatly improve fixture quality:
If a field is important for the scenario, it is better to set it explicitly in the test rather than rely on the default. For example, if you are testing slug generation, the article title is part of the rule, so in the test it is better to write .withTitle(" Spring Boot ") rather than leave the default Spring Testing.
If a field is not important for the scenario, but the object must still be valid, keep it inside the builder/fixture. For example, if category is required by domain invariants, and the test is about something else entirely, let the default live in the fixture. A test should not turn into filling out a form for the sake of filling it out.
To avoid confusion, it is useful to keep a simple comparison in mind — without any holy war:
| Tool | What it gives | When it is convenient | Typical smell that you have overdone it |
|---|---|---|---|
| Inline setup | Maximum transparency | Very simple object or a single test | Copy-paste of fields and dates 20 times |
| Test Data Builder | Little noise, important fields are visible | “Fat” domain objects | Builder hides business logic or becomes “smarter” than the domain itself |
| Fixture Factory | Names of typical states | Many tests with the same states | Cryptic methods superValidArticle() and articleForCase42() appeared |
4. Builders/fixtures in ContentHub tests
Now let us do the most useful thing: look at how builders and fixtures really “heal” tests. You can understand the idea in a minute, but you only feel the benefit when you rewrite a real piece of code and see that the test finally starts to breathe.
Let us take a typical scenario from ArticleWorkflowServiceTest: we want to verify that when a draft is created, the service records the author, slug, and time. In the past we might have captured the argument using ArgumentCaptor. That is a workable approach, but part of the boilerplate can be replaced with a fake repository — in unit tests this is sometimes even simpler and more honest than mocks.
A tiny fake repository (this is the same fake from the previous topic about test doubles):
public class InMemoryArticleRepository implements ArticleRepository {
// Store what was saved: this allows the test to assert on the fact.
private Article lastSaved;
public Article save(Article article) {
// A fake should not be "smart": it just remembers and returns.
this.lastSaved = article;
return article;
}
// Convenient "gateway" for checks in the test.
public Article lastSaved() { return lastSaved; }
}
Now the test reads more smoothly: we talk less about Mockito and more about the result.
import java.time.Clock;
import java.time.ZoneOffset;
import static org.assertj.core.api.Assertions.assertThat;
// Fix the time: no Instant.now() in the test.
Clock clock = Clock.fixed(TestInstants.T0, ZoneOffset.UTC);
// Fix the user: the test does not depend on system properties.
CurrentUserProvider user = () -> "alice";
// Fix the slug: the test is not about the slugify algorithm, but about orchestration.
SlugService slugService = title -> "spring-testing";
// Replace the storage with a fake: we will verify exactly what was saved.
InMemoryArticleRepository repo = new InMemoryArticleRepository();
ArticleWorkflowService service = new ArticleWorkflowService(repo, new PublicationPolicy(), slugService, user, clock);
service.createDraft("Spring Testing", "short", "body", "java");
assertThat(repo.lastSaved().getAuthorUsername()).isEqualTo("alice");
Several important points are visible here.
First, the dependencies are deterministic: clock is fixed, user is fixed, slugService is fixed. The test does not depend on the current time and does not guess the user from system settings.
Second, we are checking the observable behavior: what was actually saved. That is the direct meaning of an orchestration unit test.
And where are builders/fixtures here? They are needed less in createDraft(): the object is created inside the service. But as soon as we test methods that accept a ready-made article, they start saving a lot of code.
For example, submitForReview(article) requires a “draft.” And that is where a fixture is ideal:
// Starting point of the scenario: "draft".
Article draft = ArticleFixtures.draft()
.withAuthor("alice")
.build();
service.submitForReview(draft);
// Verify the key rule: the status changed.
assertThat(draft.getStatus()).isEqualTo(ArticleStatus.IN_REVIEW);
The test is very short and yet does not turn into a mystery. You can immediately see the starting point — draft — and what exactly we expect — IN_REVIEW.
And if timestamps matter to us, the builder makes that readable:
Article draft = ArticleFixtures.draft()
// Explicitly set the time in the test when it matters for the scenario.
.withCreatedAt(TestInstants.T0)
.build();
service.submitForReview(draft);
// Compare against the fixed "reference" time point.
assertThat(draft.getSubmittedAt()).isEqualTo(TestInstants.T1);
Here it is assumed that the service uses a fixed Clock and sets submittedAt through it.
The main idea of this section: builders and fixtures help the test look like a story, not like “finger exercises on a keyboard.” But they should not turn Arrange into a “black box.” If you read a test and have to jump through files in testdata to understand what is happening, you have already overdone it.
5. Refactoring for testability in production code
At this point there is usually an epiphany: sometimes a test is ugly not because you “write tests badly.” Sometimes the test is ugly because the production code has hidden dependencies and became non-deterministic. And you can build builders all you want, but if Instant.now() and new SomeService() live inside the method, the test will still suffer.
Refactoring for testability is not rewriting the application and not “let us add architecture with 40 diagrams.” In the ContentHub educational project, it most often means a few predictable steps: make dependencies explicit, remove hidden global calls, separate rules from orchestration, and make it possible to substitute time and user sources.
The most recognizable example of non-testable code is this:
public Article createDraft(String title) {
// Problem: real time => tests are non-deterministic.
Instant now = Instant.now();
// Problem: machine global state => tests depend on the environment.
String author = System.getProperty("user.name");
// Problem: new inside method => impossible to control dependency behavior in the test.
String slug = new DefaultSlugService().toSlug(title);
Article article = Article.draft(title, slug, author, now);
return repository.save(article);
}
It still works. But unit testing it is unpleasant: time is always different, the author depends on the machine, and the slug service is created inside the method, so its behavior cannot be controlled.
A small refactoring makes the code friendly to the unit layer:
public Article createDraft(String title) {
// Time comes from outside (Clock can be fixed in a test).
Instant now = Instant.now(clock);
// Current user is a port that is easy to replace with a stub/lambda in a test.
String author = currentUserProvider.currentUsername();
// SlugService is a dependency that can be replaced with a test implementation.
String slug = slugService.toSlug(title);
Article article = Article.draft(title, slug, author, now);
return repository.save(article);
}
Notice that the business meaning has not changed. We just made the dependencies explicit: clock, currentUserProvider, slugService.
And for this to work, the dependencies must come through the constructor (constructor injection — a project rule, and it also works perfectly in the unit layer):
public ArticleWorkflowService(ArticleRepository repository,
PublicationPolicy policy,
SlugService slugService,
CurrentUserProvider currentUserProvider,
Clock clock) {
// Explicit dependencies: in a unit test, the service can be assembled manually without a container.
this.repository = repository;
this.policy = policy;
this.slugService = slugService;
this.currentUserProvider = currentUserProvider;
this.clock = clock;
}
From the unit test’s point of view, this is magic without magic: you create the service by hand and fully control the environment. No more “why did the test fail today, but not yesterday?”
Sometimes it is useful to literally draw a dependency diagram for yourself. Even a simple diagram helps you see what is a rule (policy) and what is infrastructure (time, user, repository).
flowchart TD Test["Unit test"] --> Svc["ArticleWorkflowService"] Svc --> Policy["PublicationPolicy"] Svc --> Repo["ArticleRepository (port)"] Svc --> Clock["Clock"] Svc --> User["CurrentUserProvider (port)"] Svc --> Slug["SlugService"]
If you see an arrow in the service to the “outside world” — time, user, storage, generators — and that arrow is implemented with new or a static call, that is almost always a candidate for refactoring. In the unit layer, these things hurt in two places at once: determinism and test clarity.
And yes, a bit of self-irony: if you ever caught yourself thinking “let us mock Instant.now(),” do not blame yourself. It is just the moment when the code whispers: “pass me a Clock, and we will both be happy.”
6. Ports and adapters for the unit layer
The words “ports and adapters” sometimes sound like the beginning of a lecture on architectural magic, after which you want to quietly go into the forest with a blanket. But in the context of unit tests, this is a very down-to-earth idea: business logic should depend on interfaces, not on specific ways of “going out into the world.” Then in a unit test you replace the outside world with a simple stub or fake — and the test remains fast and local.
We have already effectively used this approach when we introduced CurrentUserProvider. This is a typical port: “give me the current user name,” but do not tell me where you got it from.
@FunctionalInterface
public interface CurrentUserProvider {
// Port: business logic does not know where the username comes from (SecurityContext, HTTP, etc.).
String currentUsername();
}
In a test, this turns into one line — and that is wonderful:
// Stub: fixed value, the test does not depend on the environment.
CurrentUserProvider user = () -> "alice";
You can do the same with slug generation if you want to fully control the result in a test:
// Stub: the test controls the result and does not test the slugify algorithm.
SlugService slugService = title -> "spring-testing";
And ArticleRepository is also a port: “save the article.” In a unit test, you do not need a database. You need to prove that the service correctly assembled the object and attempted to save it. For that, a mock or a fake is enough, and the choice depends on what is easier to read.
If you catch yourself thinking that verify(repo).save(captor.capture()) looks heavy, a fake really can be cleaner. We already showed InMemoryArticleRepository, and it is a good example that test doubles are not only Mockito.
The only important thing is not to overdo it and turn fakes into a mini-database with lots of logic. A fake should remain dumb: “remembered the last saved object and returned it.” As soon as it starts “smartly validating” or “generating ids,” you accidentally start testing the fake instead of the service logic.
That is why the combination of “ports + explicit dependencies through the constructor” is the foundation of testability. Builders and fixtures simplify data preparation. Ports and adapters simplify control over the outside world. Together they give you a unit layer that does not live “by luck.”
7. Typical mistakes with builders and fixtures
At this point you usually want to happily run off and write 15 builders, 25 fixtures, and a small DSL so the tests look like poetry. Let us pause for a minute and talk about the land mines. They are almost always the same, and it is better to step on them in a teaching text than in production on Friday evening.
Mistake No. 1: the builder creates random data (randomness, now(), UUID) “for realism.”
Realism in unit tests is often overrated. If defaults change from run to run, you get non-determinism: the test may fail “sometimes.” A builder should be boring and stable: fixed dates, predictable strings, no surprises.
Mistake No. 2: by default the builder constructs an invalid object.
If ArticleBuilder.anArticle().build() produces an article without a required field, some tests will start failing for reasons unrelated to the scenario. In the end you will be fixing “fixtures,” not testing business rules. A good builder by default should build a minimally correct state so that you change only what matters.
Mistake No. 3: the fixture factory turns into a set of mysterious incantations.
Methods like ArticleFixtures.articleForCase42() or superValidArticle() often work only as long as the author remembers what is inside them. Then the team starts being afraid to change fixtures because “we might break something unknown.” Fixtures should be named by domain meaning: draft(), inReview(), published(), not by internal implementation details.
Mistake No. 4: helpers hide the business meaning of the scenario instead of hiding noise.
Reducing “noise” means removing extra fields and repeated values. But if a helper hides the key condition of the scenario — for example, the article author or status — the test stops being documentation of behavior. A good test should answer the question “what are we checking?” without jumping through files.
Mistake No. 5: production code continues to create dependencies inside methods.
You can write the perfect builder, but if the service still does new DefaultSlugService() or pulls the user from global state, the test will be painful. In the unit layer, we want to control the environment. Therefore, refactoring for testability almost always starts with something simple: dependencies into the constructor, global calls into adapters.
GO TO FULL VERSION