Readable tests
Do you like reading tests? I like reading tests. Tests tell you what the developers cared about. Good tests also tell you what the software is supposed to do. 😁 And very good tests even tell you why. In this post, I will show how you can get more out of your tests with a few simple tricks.
We will begin with a rather tangled unit test class and apply each trick in turn to arrive at a set of well-structured, readable tests that clearly communicate intent. While this is a toy example, it is modeled after real tests I encountered in customer projects. The complete code (including the implementation it tests) can be found in the GitHub repository bbvch/readable-tests-example.
The example
Our toy test is written in Java 25 and uses JUnit 6 and AssertJ for fluent assertions.
class BlogPostRankerTest0 {
private static final Clock MORNING = Clock.fixed(LocalDateTime.parse("2026-01-01T09:00:00")
.atZone(ZoneId.systemDefault())
.toInstant(),
ZoneId.systemDefault());
private static final Clock EVENING = Clock.fixed(LocalDateTime.parse("2026-01-01T19:00:00")
.atZone(ZoneId.systemDefault())
.toInstant(),
ZoneId.systemDefault());
private static final List<Label> MUTED_LABELS = List.of(new Label("muted1", NEUTRAL),
new Label("muted2", NEUTRAL),
new Label("neutral", NEUTRAL));
private User user;
private Preferences preferences;
private BlogPostRanker ranker;
@BeforeEach
void setUp() {
preferences = new Preferences();
preferences.setChronologicalTimeline(false);
user = new User();
user.setName("Lazy Defaulter");
user.setPreferences(preferences);
ranker = new BlogPostRanker(MORNING);
}
@Test
void rankPosts() {
var posts = List.of(POST1, POST2, POST3, POST4, POST5);
var interestingLabels = Set.of(new Label("this-is-what-i-want", NEUTRAL),
new Label("interesting-leisure", LEISURE),
new Label("interesting-work", WORK));
assertThat(posts).hasSize(5);
var rankedPosts = ranker.rankPosts(posts, user);
assertThat(rankedPosts).hasSize(5);
assertThat(rankedPosts
.getFirst()
.metaData()
.labels())
.map(Label::name)
.contains("work");
assertThat(new BlogPostRanker(EVENING).rankPosts(posts, user)
.getFirst()
.metaData()
.labels())
.map(Label::name)
.contains("leisure");
preferences.setInterests(interestingLabels);
rankedPosts = ranker.rankPosts(List.of(POST1, POST2, POST6, POST7, POST8, POST9), user);
assertThat(rankedPosts).hasSize(6);
assertThat(rankedPosts.stream()
.map(post -> post.metaData().labels())
.flatMap(Set::stream)
.map(Label::name))
.containsExactly("interesting-work",
"this-is-what-i-want",
"interesting-leisure",
"boring-work",
"boring-leisure");
}
@Test
void rankPosts_muted() {
var posts = List.of(POST1, POST3, POST10, POST8, POST11);
assertThat(posts).hasSize(5);
preferences.setMuted(MUTED_LABELS);
preferences.setInterests(Set.of(new Label("neutral", NEUTRAL)));
assertThat(ranker.rankPosts(posts, user))
.hasSize(3)
.allSatisfy(post ->
assertThat(post.metaData().labels())
.map(Label::name)
.doesNotContain("muted1", "muted2"));
}
}
As you can see, this class appears to test a ranking algorithm for blog posts.
There are labels involved, and obviously something can be muted.
There also seems to be something time-based going on because we can see the test
uses two different clocks.
But I bet you can't really tell from the test code alone what exactly is being
tested. Why, for example, does the expected label list at the end of
rankPosts() look like the way it does?
The first test case in particular contains a mixture of setup code, ranking
invocations and assertions that makes it hard to tell what is going on and what
the intended behavior actually is.1
Another thing that makes it difficult to see what the tests do is that most of the input is not even part of the tests. All posts the tests rely on are defined in a separate class with test data. Here it is.
public class TestPosts {
public static final Author SOME_AUTHOR = new Author("Some Author",
"Born somewhere, Some got into writing after being bitten by a spider.");
public static final BlogPost POST1 = new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
SOME_AUTHOR,
Collections.emptySet()));
public static final BlogPost POST2 = new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
SOME_AUTHOR,
Set.of(new Label("this-is-what-i-want", NEUTRAL))));
public static final BlogPost POST3 = new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
SOME_AUTHOR,
Set.of(new Label("neutral", NEUTRAL))));
public static final BlogPost POST4 = new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
SOME_AUTHOR,
Set.of(new Label("leisure", LEISURE))));
public static final BlogPost POST5 = new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
SOME_AUTHOR,
Set.of(new Label("work", WORK))));
public static final BlogPost POST6 = new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
SOME_AUTHOR,
Set.of(new Label("boring-leisure", LEISURE))));
public static final BlogPost POST7 = new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
SOME_AUTHOR,
Set.of(new Label("boring-work", WORK))));
public static final BlogPost POST8 = new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
SOME_AUTHOR,
Set.of(new Label("interesting-leisure", LEISURE))));
public static final BlogPost POST9 = new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
SOME_AUTHOR,
Set.of(new Label("interesting-work", WORK))));
public static final BlogPost POST10 = new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
SOME_AUTHOR,
Set.of(new Label("muted1", NEUTRAL))));
public static final BlogPost POST11 = new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
SOME_AUTHOR,
Set.of(new Label("l5", NEUTRAL), new Label("muted2", NEUTRAL))));
}
That makes everything a lot clearer, doesn't it? No? Don't worry! We are going
to improve this test step by step until you can easily see what our
BlogPostRanker it is about, even without looking at its implementation.
The first step is to bring a bit of order into the chaos.
Trick 1: Arrange, Act, Assert
The pattern “Arrange, Act, Assert” (AAA)2 is a time-honored way of structuring unit tests by breaking them into three phases. The pattern is quite old and much has been written about it, so I won't go into details here. The phases are:
- Arrange – set up the preconditions for the test (input data and system state).
- Act – perform the action to be tested.
- Assert – check the result (output data and system state).
Our first trick is to look over the tests and identify which parts correspond to which phase. We will use comments to label the parts and apply minor refactoring if necessary to clearly separate the phases. “Minor“ refactoring means that we might split statements that do multiple things at once to separate them, but we will not completely rearrange the test code.
When we apply this trick to our example test, the result looks like this.
class BlogPostRankerTest1 {
private static final Clock MORNING = Clock.fixed(LocalDateTime.parse("2026-01-01T09:00:00")
.atZone(ZoneId.systemDefault())
.toInstant(),
ZoneId.systemDefault());
private static final Clock EVENING = Clock.fixed(LocalDateTime.parse("2026-01-01T19:00:00")
.atZone(ZoneId.systemDefault())
.toInstant(),
ZoneId.systemDefault());
private static final List<Label> MUTED_LABELS = List.of(new Label("muted1", NEUTRAL),
new Label("muted2", NEUTRAL),
new Label("neutral", NEUTRAL));
private User user;
private Preferences preferences;
private BlogPostRanker ranker;
@BeforeEach
void setUp() {
preferences = new Preferences();
preferences.setChronologicalTimeline(false);
user = new User();
user.setName("Lazy Defaulter");
user.setPreferences(preferences);
ranker = new BlogPostRanker(MORNING);
}
@Test
void rankPosts() {
// Arrange
var posts = List.of(POST1, POST2, POST3, POST4, POST5);
// Act
var rankedPosts = ranker.rankPosts(posts, user);
// Assert
assertThat(rankedPosts).hasSize(5);
assertThat(rankedPosts
.getFirst()
.metaData()
.labels())
.map(Label::name)
.contains("work");
// Arrange
var eveningRanker = new BlogPostRanker(EVENING);
// Act
rankedPosts = eveningRanker.rankPosts(posts, user);
// Assert
assertThat(rankedPosts
.getFirst()
.metaData()
.labels())
.map(Label::name)
.contains("leisure");
// Arrange
posts = List.of(POST1, POST2, POST6, POST7, POST8, POST9);
preferences.setInterests(Set.of(new Label("this-is-what-i-want", NEUTRAL),
new Label("interesting-leisure", LEISURE),
new Label("interesting-work", WORK)));
// Act
rankedPosts = ranker.rankPosts(posts, user);
// Assert
assertThat(rankedPosts).hasSize(6);
assertThat(rankedPosts.stream()
.map(post -> post.metaData().labels())
.flatMap(Set::stream)
.map(Label::name))
.containsExactly("interesting-work",
"this-is-what-i-want",
"interesting-leisure",
"boring-work",
"boring-leisure");
}
@Test
void rankPosts_muted() {
// Arrange
var posts = List.of(POST1, POST3, POST10, POST8, POST11);
preferences.setMuted(MUTED_LABELS);
preferences.setInterests(Set.of(new Label("neutral", NEUTRAL)));
// Act
var rankedPosts = ranker.rankPosts(posts, user);
// Assert
assertThat(rankedPosts)
.hasSize(3)
.allSatisfy(post ->
assertThat(post.metaData().labels())
.map(Label::name)
.doesNotContain("muted1", "muted2"));
}
}
Note that the first test method contains 3 repetitions of the 3 phases and is thus not a pure instance of the AAA pattern. If we had wanted to, we could have forced the test into this form by, e.g., giving each post list a different name and initializing all of them at the beginning of the test method, but this would have obscured the structure of the test instead of highlighting it. The point of this step is to get an overview of what the test does, not yet to restructure it.
We did change a few things in the test rankPosts(), however:
- Moved the initialization of the set of interesting labels from the beginning of the method down into the “arrange” phase directly preceding its actual use (and inlined it there because there was no longer a use for the temporary variable).
- Deleted the first assertion (of the post list size) because we noticed that it
did not assert anything about the class under test. We just created the list,
of course it has 5 elements. Classes and methods used in the “arrange” and
“assert” phases should have their own tests elsewhere. In a test of the
BlogPostRankerwe don't want to testList.of(). - Split the statement containing
new BlogPostRanker(EVENING)into initialization (arrange), method invocation (act) and assertion (assert). - Split the third invocation of
ranker.rankPosts()into list initialization (arrange) and the actual method call (act).
In rankPosts_muted(), we did similar things. These are minor refactorings that
enhance understanding of the tests.
With this step, the tests have already become a bit more readable, but they are not very good yet. For one thing, we already saw that the first test actually tests at least 3 different behaviors. This makes it hard to understand, and it reduces the amount of information we get in the case of failures. If the first assertion for the first behavior fails, how do we know whether the other behaviors are correct? We can't! The more we pack into a single test method, the less information we get out of our tests. So the next step will be to remedy this.
Trick 2: Test one behavior in each test case
To get better signal from failing tests, we are going to split our test methods into as many methods as needed to get them down to one behavior each.
The test rankPosts() consists of three distinct Arrange/Act/Assert sequences.
Splitting these is straightforward and yields three test methods. But are we
done yet? The second AAA sequence only has a single assertion, so we can
probably leave it as it is. Here is the split out test method.
I named it ranking_leisure() because of the label in the assertion. This is
not a particularly good name, but the main goal for now is to tease distinct
tests apart. Naming will come later.
@Test
void ranking_leisure() {
var posts = List.of(POST1, POST2, POST3, POST4, POST5);
ranker = new BlogPostRanker(EVENING);
var rankedPosts = ranker.rankPosts(posts, user);
assertThat(rankedPosts
.getFirst()
.metaData()
.labels())
.map(Label::name)
.contains("leisure");
}
The one thing I changed is, I removed the Arrange/Act/Assert comments. If all your tests are structured like this, it is usually enough to separate the three parts with blank lines. In my opinion, adding section marker comments just adds noise unless the test case is really long, and if it is, you should probably change that instead of adding comments.
The first AAA sequence looks similar, but has an additional hasSize assertion.
Given that ranking_leisure() does not need it, maybe this is a different
aspect we are testing here? Let's make two tests out of this one.
@Test
void ranking_work() {
var posts = List.of(POST1, POST2, POST3, POST4, POST5);
var rankedPosts = ranker.rankPosts(posts, user);
assertThat(rankedPosts
.getFirst()
.metaData()
.labels())
.map(Label::name)
.contains("work");
}
@Test
void ranking_count_posts() {
var posts = List.of(POST1, POST2, POST3, POST4, POST5);
var rankedPosts = ranker.rankPosts(posts, user);
assertThat(rankedPosts).hasSize(5);
}
Again, the names are trivially inferred from the assertions.
So how about the third AAA sequence?
This one also has two assertions.
The hasSize() assertion basically does the same as the one in
ranking_count_posts(), so we'll skip it.
Having redundant tests for the same thing hurts maintainability.
If you are unsure whether deleting an assertion is really OK, you might wait
until the next step before deciding, but in this case, the resulting test method
would look so similar to the other one that I'm confident they would really be
testing the same behavior.
The remaining assertion looks quite complex. This “work” and “leisure” stuff is similar to what we have above, but now it's combined with user interests. Probably it's this combination that is supposed to be tested. But one thing sticks out – the label “this-is-what-i-want”, which seems to be related neither to work nor to leisure. From the assertion, it's obvious that labels that appear in the interests of the user are supposed to be ordered before labels that do not. This seems to be worth its own test case. Let's try pulling it out.
@Test
void ranking_with_interest() {
var posts = List.of(POST1, POST2, POST6, POST7, POST8, POST9);
preferences.setInterests(Set.of(new Label("this-is-what-i-want", NEUTRAL)));
var rankedPosts = ranker.rankPosts(posts, user);
assertThat(rankedPosts.getFirst()
.metaData()
.labels())
.map(Label::name)
.contains("this-is-what-i-want");
}
@Test
void ranking_interest_work_leisure() {
var posts = List.of(POST1, POST6, POST7, POST8, POST9);
preferences.setInterests(Set.of(new Label("interesting-leisure", LEISURE),
new Label("interesting-work", WORK)));
var rankedPosts = ranker.rankPosts(posts, user);
assertThat(rankedPosts.stream()
.map(post -> post.metaData().labels())
.flatMap(Set::stream)
.map(Label::name))
.containsExactly("interesting-work",
"interesting-leisure",
"boring-work",
"boring-leisure");
}
This worked, but we had to remove POST2 from the input of the work/leisure
test because its label (“this-is-what-i-want”) interfered with our assertion.
Applying the same kind of reasoning to the test ranking_muted() allows us to
split it into two as well.
@Test
void ranking_muted() {
var posts = List.of(POST1, POST3, POST10, POST8, POST11);
preferences.setMuted(MUTED_LABELS);
var rankedPosts = ranker.rankPosts(posts, user);
assertThat(rankedPosts).hasSize(3)
.allSatisfy(post ->
assertThat(post.metaData().labels())
.map(Label::name)
.doesNotContain("muted1", "muted2"));
}
@Test
void ranking_conflicted() {
var conflicted = new Label("neutral", NEUTRAL);
var posts = List.of(POST3);
preferences.setInterests(Set.of(conflicted));
preferences.setMuted(List.of(conflicted));
var rankedPosts = ranker.rankPosts(posts, user);
assertThat(rankedPosts).hasSize(1);
}
Here, we separated the behavior when dealing with muted labels in the absence and in the presence of user interest in the same label.
The end result of all this work is a set of focused single-purpose test cases. If one of them fails, you can pinpoint much more accurately which behavior caused it. And if we want to change the ranking behavior later, it will be much easier to adapt these small test cases than it would be to adapt the larger ones containing a mixture of to-be-changed and to-be-preserved behaviors.
But which behaviors are these, exactly? If a test fails, how can you tell in which way users are impacted? You could say something like, “Ranking conflicted is broken,” but what does that actually mean?
Trick 3: Naming in “X should Y if Z” form
This is probably the hardest step because it requires you to understand what
the class under test is actually supposed to do, not just what the code
currently does. Take the test ranking_conflicted(), for example. Here it is
again:
@Test
void ranking_conflicted() {
var conflicted = new Label("neutral", NEUTRAL);
var posts = List.of(POST3);
preferences.setInterests(Set.of(conflicted));
preferences.setMuted(List.of(conflicted));
var rankedPosts = ranker.rankPosts(posts, user);
assertThat(rankedPosts).hasSize(0);
}
Or is it?
Did you notice I changed the number in the assertion from 1 to 0?
If you caught it, good for you! But riddle me this: if the test fails now, where
is the bug? In the implementation of BlogPostRanker or in the test? I've come
across a lot of bugs in my career, and not all of them were in production
code. Sometimes, it was the test that was wrong.
And there's no way to tell unless you know what the intended behavior actually
is.
This is why good test names are so important.
The name isn't just a unique identifier for the test.
Ideally, it communicates the intent.
There are multiple ways to do this.
The naming scheme I currently like best is “X should Y if Z”,
where X is the name of the acting entity or the functionality that is being
tested3, Y is the expected behavior, and Z is the condition that determines
the behavior.
In the case of ranking_conflicted(), a better name would be
ranking_should_keep_posts_with_muted_labels_if_the_same_label_is_interesting().
Yes, it's long4, but now you can tell which number in the assertion is correct,
right?5
The other test methods could be renamed as follows (underscores replaced with whitespace so your browser can wrap the names for better readability).
| Old name | New name |
|---|---|
| ranking with interest | ranking should prefer posts with interesting labels if user has interests |
| ranking work | ranking should prefer posts with work labels in the morning |
| ranking leisure | ranking should prefer posts with leisure labels in the evening |
| ranking interest work leisure | ranking should assign higher priority to user interests than to timebased classification if user has interests |
| ranking count posts | ranking should return all posts if user has no muted labels |
| ranking muted | ranking should remove posts with muted labels if they are not interesting |
A good starting point for coming up with these names is to look at the three
parts of the AAA sequence. Basically, the Act part goes into X, the Assert
part goes into Y, and the Arrange part goes into Z, and then you edit it to
get a readable sentence out of it.
For example:
@Test
void ranking_should_prefer_posts_with_leisure_labels_in_the_evening() {
// Arrange
var posts = List.of(POST3, POST4, POST5);
ranker = new BlogPostRanker(EVENING); // => in the evening
// Act
var rankedPosts = ranker.rankPosts(posts, user); // => ranking
// Assert
assertThat(rankedPosts
.getFirst() // => prefer posts
.metaData()
.labels()) // with labels
.map(Label::name)
.contains("leisure"); // leisure
}
While this is not bad as a starting point, sometimes you have to abstract a bit or even add something that is not obvious from the code to arrive at the real intent. And sometimes, the test even changes as a result. For example:
@Test
void ranking_should_return_all_posts_if_user_has_no_muted_labels() {
// Old: var posts = List.of(POST1, POST2, POST3, POST4, POST5);
// POST5 didn't add anything to the test, so we can just as well remove it
var posts = List.of(POST1, POST2, POST3, POST4);
// Nothing here says “no muted labels”, but this is a critical condition for
// the test to make sense, so it should be mentioned in the name.
var rankedPosts = ranker.rankPosts(posts, user);
// Old: assertThat(rankedPosts).hasSize(5);
// What we really want is the same number of posts as before, so this becomes:
assertThat(rankedPosts).hasSameSizeAs(posts);
}
If you look at the full source code of this step, you can see I adjusted the list of input posts in some other test cases, too, to match them to the business intent.
As I wrote at the beginning, this is the hardest step because there is really no shortcut to understanding the business intent behind the code you are testing. If you are writing new code with new tests, you are lucky. You probably already know why you are doing it. So please document the intent in the test names! You will make someone's life down the line much easier. And that someone might even be you in 2 years.
If you aren't the one who wrote the code, understanding the intent of a poorly named test is difficult, and you may need to hunt down the original author (who might have forgotten) or someone from the business who can explain it to you. But once you have pushed through the pain and given each test a good name, you have
- self-documenting tests that actually mean something,
- much better diagnosis if something breaks,
- and a means to check the correctness of the test itself (compare what is being asserted to what the name says).
The tests are much better now, but there is still room for improvement.
For example, when I wrote above that POST5 didn't add anything to the test,
did you ask yourself, “How did he know?”
Actually, in this case it's simple because the test does not really depend on
anything in the BlogPost objects.
But other tests do, and you cannot determine the correct behavior by looking at
the test code alone.
You also have to look at the actual test data, and most of this currently lives
outside the tests themselves.
You may also have noticed that the test data is shared among tests. While this is not a problem in every case, it does mean that I cannot simply change a single test, e.g., by adding a label to a blog post, without considering the impact on all other tests that also use this blog post. Depending on the complexity of your test data and the number of tests, this can make maintenance a real pain in the ass.
Trick 4: All relevant test data in the test case
To overcome these problems, we are going to pull (almost) all test data into the
test methods by moving their initialization into the Arrange phase of each
test case. Doing this manually is not much fun. Luckily IntelliJ IDEA supports
automated refactoring using the “Inline field” operation, which works
beautifully for our blog post constants. It does not work for the setup we do in
the @BeforeEach method, but that's just a matter of copying and pasting the same
chunk of code into each test method.
Of course, the tests will become much longer, but don't worry! We'll deal with this in the next step. For now, the result looks like this:
class BlogPostRankerTest4 {
private static final Clock MORNING = Clock.fixed(LocalDateTime.parse("2026-01-01T09:00:00")
.atZone(ZoneId.systemDefault())
.toInstant(),
ZoneId.systemDefault());
private static final Clock EVENING = Clock.fixed(LocalDateTime.parse("2026-01-01T19:00:00")
.atZone(ZoneId.systemDefault())
.toInstant(),
ZoneId.systemDefault());
public static final Author SOME_AUTHOR = new Author("Some Author",
"Born somewhere, Some got into writing after being bitten by a spider.");
@Test
void ranking_should_prefer_posts_with_interesting_labels_if_user_has_interests() {
var posts = List.of(
new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH, SOME_AUTHOR, Collections.emptySet())),
new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH, SOME_AUTHOR, Collections.emptySet())),
new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
SOME_AUTHOR,
Set.of(new Label("this-is-what-i-want", NEUTRAL))))
);
var preferences = new Preferences();
preferences.setChronologicalTimeline(false);
preferences.setInterests(Set.of(new Label("this-is-what-i-want", NEUTRAL)));
var user = new User();
user.setName("Lazy Defaulter");
user.setPreferences(preferences);
BlogPostRanker ranker = new BlogPostRanker(MORNING);
var rankedPosts = ranker.rankPosts(posts, user);
assertThat(rankedPosts.getFirst()
.metaData()
.labels())
.map(Label::name)
.contains("this-is-what-i-want");
}
@Test
void ranking_should_prefer_posts_with_work_labels_in_the_morning() {
var posts = List.of(
new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
SOME_AUTHOR,
Set.of(new Label("neutral", NEUTRAL)))),
new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
SOME_AUTHOR,
Set.of(new Label("leisure", LEISURE)))),
new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
SOME_AUTHOR,
Set.of(new Label("work", WORK))))
);
var preferences = new Preferences();
preferences.setChronologicalTimeline(false);
var user = new User();
user.setName("Lazy Defaulter");
user.setPreferences(preferences);
BlogPostRanker ranker = new BlogPostRanker(MORNING);
var rankedPosts = ranker.rankPosts(posts, user);
assertThat(rankedPosts
.getFirst()
.metaData()
.labels())
.map(Label::name)
.contains("work");
}
// … and a few similarly verbose test methods
}
The TestPosts class is no more.
You'll notice we didn't inline MORNING, EVENING, and SOME_AUTHOR, but we
moved the latter into the test class. This is because SOME_AUTHOR is kind of
scenery for this test. A blog post needs an author, but it has no bearing on the
functionality under test and is not changed by the tests in any way.
Thus, sharing it is not a problem and reduces boilerplate.
The clocks do have bearing on the tests, but since there are only two different
points in time we care about we can share those and include the important
part – the choice which one to use – in the test method where the
BlogPostRanker is instantiated.
So what have we accomplished? We have improved locality, which gets us two benefits:
- Better insight into what, exactly, a test is doing. Looking at
ranking_should_prefer_posts_with_leisure_labels_in_the_evening(), we can tell immediately that the test checks labels classified asLEISUREnot just against labels classified asWORK, but also against labels classified asNEUTRAL. We couldn't read that from the linewe had before without navigating to each of the referencedvar posts = List.of(POST3, POST4, POST5);BlogPostobjects. And we can also immediately see that the leisure post is not the first one in the input list. (Otherwise a no-op ranking would pass the test.) - Simplified maintenance. Suppose we want to add another label to the leisure
post in the same test. We can just do it without fear of breaking any other
test. With shared test data, we would have had to check which other tests used
POST4and how the change would affect them. If in doubt, we would replace it with a newly addedPOST12. And then our Arrange phase would look like this:Can you imagine the face of the next developer looking at this and asking themselves, “Why the … isvar posts = List.of(POST3, POST12, POST5);POST12in the middle?”
I hope I have convinced you that locality is king in tests, but you might be thinking, “But it's so much code!” And right you are. That's why we have another trick up our sleeves.
Trick 5: Factories for test data
In this step, we will introduce factory methods for test data to reduce boilerplate code. The factory methods encapsulate the knowledge of how to create objects required for the test and thus remove the bloat we introduced in the previous step – not unlike using shared data definitions. However, there are two important differences between those and the factory methods.
- The factory methods let us control those parts of the data that are relevant to the test while hiding everything else.
- The factory methods return new objects, which does not matter much in our toy example, but is very important for test isolation if the objects are mutable. Creating new objects for each test ensures each test starts with a known state.
Let's first see what a test using the factory methods looks like:
@Test
void ranking_should_prefer_posts_with_leisure_labels_in_the_evening() {
var posts = List.of(
somePost(label("neutral", NEUTRAL)),
somePost(label("leisure", LEISURE)),
somePost(label("work", WORK))
);
var rankedPosts = ranker(EVENING).rankPosts(posts, userWithDefaultSettings());
assertThat(rankedPosts.getFirst()
.metaData()
.labels())
.map(Label::name)
.contains("leisure");
}
Much better! The only variance the test cares about is the label classification
and the time of day. Both are expressed clearly, while hiding irrelevant stuff
like the other attributes of blog posts and the user preferences.
As you can see, I relaxed the separation between the Arrange and Act phases
somewhat by calling the factories for the BlogPostRanker and the User in the
same line where the action takes place. I did this because the code is now
declarative6 and short.
I didn't inline the list of posts because it is too long for that.
But that's just my style. You could insist on strict phase separation and assign
the ranker and the user to variables first. In any case, the resulting test is
much more readable because it is focused on the essential parts.
Here is another test:
@Test
void ranking_should_prefer_posts_with_interesting_labels_if_user_has_interests() {
var interestingPost = somePost(label("this-is-what-i-want"));
var posts = List.of(
somePost(),
somePost(),
interestingPost
);
var rankedPosts = ranker().rankPosts(posts, userWithInterestIn(label("this-is-what-i-want")));
assertThat(rankedPosts.getFirst()
.metaData()
.labels())
.map(Label::name)
.contains("this-is-what-i-want");
}
In this one, we don't care about the categorization of the labels or the time of day, but we do care about the interests of the user. Therefore we use different variants of the factory methods to, again, expose only those characteristics of the test objects that matter for this test.
And here are our factory methods:
private BlogPost somePost(Label... labels) {
return new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
new Author("Some Author",
"Born somewhere, Some got into writing after being bitten by a spider."),
Arrays.stream(labels).collect(Collectors.toSet())));
}
private BlogPostRanker ranker() {
return ranker(MORNING);
}
private BlogPostRanker ranker(Clock clock) {
return new BlogPostRanker(clock);
}
private Label label(String name, Label.Classification classification) {
return new Label(name, classification);
}
private Label label(String name) {
return new Label(name, NEUTRAL);
}
private User userWithDefaultSettings() {
var preferences = new Preferences();
preferences.setChronologicalTimeline(false);
var user = new User();
user.setName("Lazy Defaulter");
user.setPreferences(preferences);
return user;
}
private User userWithInterestIn(Label... interests) {
var user = userWithDefaultSettings();
user.getPreferences().setInterests(Arrays.stream(interests).collect(Collectors.toSet()));
return user;
}
Note that you don't need factory methods for every situation, just ones that are awkward to express within a test case.7 In our example, I chose not to provide a separate factory method for users with muted labels. A test that needs one can do something like this:
@Test
void ranking_should_keep_posts_with_muted_labels_if_the_same_label_is_interesting() {
var conflicted = label("conflicted");
var posts = List.of(somePost(conflicted));
var user = userWithInterestIn(conflicted); // <-- base user config
user.getPreferences().setMuted(List.of(conflicted)); // <-- specifics for this test
var rankedPosts = ranker().rankPosts(posts, user);
assertThat(rankedPosts).hasSize(1);
}
One more line of code in the test method to express a requirement that is specific to this test and not needed in most other tests seems like a reasonable trade-off to me.
Which and how many factory methods to use depends on the complexity of the objects you need to construct and the variance you need in your tests. It is a matter of judgment.
One additional point I want to make is about maintainability.
Having object construction encapsulated in a factory saves you effort when
changing things that don't have direct bearing on the test. If, for example,
BlogPostMetaData gained a new attribute lastChangedAt to support updates
to blog posts, every single constructor invocation needs to be changed to add
this attribute (because BlogPostMetaData is a record), even though it is
completely irrelevant to our test. With the factory methods shown above, there
is exactly 1 place to change vs. 20 without them (or 11 in the very first
version of the tests).
I think our tests are pretty readable now. But there is still room for improvement. Up to now, we have focused our efforts on the Arrange phase. The Act phase is pretty lean already. It calls the method under test, no more. But what about the Assert phase?
Trick 6: Custom assertions
Look at this test again:
@Test
void ranking_should_prefer_posts_with_interesting_labels_if_user_has_interests() {
var interestingPost = somePost(label("this-is-what-i-want"));
var posts = List.of(
somePost(),
somePost(),
interestingPost
);
var rankedPosts = ranker().rankPosts(posts, userWithInterestIn(label("this-is-what-i-want")));
assertThat(rankedPosts.getFirst()
.metaData()
.labels())
.map(Label::name)
.contains("this-is-what-i-want");
}
Paraphrasing it in English, we get this: Let there be an interesting post, which is some post with the label “this-is-what-i-want”. Let there be a list of some posts, including the interesting one. Let the ranked posts be the result of ranking these posts for a user with interest in the label “this-is-what-i-want”. Then assert that the first of the ranked posts has meta data with labels which, when we map them to names, contain “this-is-what-i-want”.
That last part doesn't really roll off the tongue, does it? Can we make this better? Of course we can! With AssertJ custom assertions! While the first 5 tricks are actually not specific to Java or a particular testing framework or library, this one is. The tests in our toy example use AssertJ as a baseline. If you don't yet know AssertJ, you should look into it. It lets you write succinct assertions in the fluent style you have been seeing throughout this post. And with custom assertions, you can add your own to deal with situations like these where deeply nested data structures make assertions awkward to write and hard to understand.
You write custom assertions by extending the class AbstractAssert.
Your assertion should have a constructor that takes the object about which
assertions shall be made and implement methods with your custom checks.
Per convention, the assertion should have a static method assertThat that
accepts the object and returns an instance of your assertion. Each assertion
should return this so they can be chained. In the method body you can perform
any checks you like and call AbstractAssert's methods to make the assertion
fail if something is amiss.
In our case, we reach into the deeply nested data structure and look for a label with the name we expect. If we don't find it, we fail with a descriptive message.8
public class BlogPostAssert extends AbstractAssert<BlogPostAssert, BlogPost> {
public static BlogPostAssert assertThat(BlogPost blogPost) {
return new BlogPostAssert(blogPost);
}
private BlogPostAssert(BlogPost blogPost) {
super(blogPost, BlogPostAssert.class);
}
public BlogPostAssert hasLabel(String expectedLabel) {
isNotNull();
var labels = actual.metaData().labels();
if (labels.isEmpty()) {
failWithMessage("Expected blog post to have label '%s' but it had none", expectedLabel);
}
if (labels.stream().noneMatch(l -> Objects.equals(l.name(), expectedLabel))) {
failWithMessage("Expected blog post to have label '%s' but it only had %s",
expectedLabel,
labels.stream().map(Label::name).collect(Collectors.joining("', '", "'", "'"))
);
}
return this;
}
}
The assertion in the test above can now be written like this:
assertThat(rankedPosts.getFirst()).hasLabel("this-is-what-i-want");
This expresses the intention much more clearly. And in case something is wrong, the error message is much better, too. The old one (emitted by AssertJ's out-of-the-box assertions) looks like this:
java.lang.AssertionError:
Expecting ArrayList:
[]
to contain:
["this-is-what-i-want"]
but could not find the following element(s):
["this-is-what-i-want"]
The new one from our custom assertion looks like this:
java.lang.AssertionError: Expected blog post to have label 'this-is-what-i-want' but it had none
I know which one I would rather debug.
The result
After applying all 6 tricks, the complete test class looks like this:
class BlogPostRankerTest6 {
private static final Clock MORNING = Clock.fixed(LocalDateTime.parse("2026-01-01T09:00:00")
.atZone(ZoneId.systemDefault())
.toInstant(),
ZoneId.systemDefault());
private static final Clock EVENING = Clock.fixed(LocalDateTime.parse("2026-01-01T19:00:00")
.atZone(ZoneId.systemDefault())
.toInstant(),
ZoneId.systemDefault());
@Test
void ranking_should_prefer_posts_with_interesting_labels_if_user_has_interests() {
var interestingPost = somePost(label("this-is-what-i-want"));
var posts = List.of(
somePost(),
somePost(),
interestingPost
);
var rankedPosts = ranker().rankPosts(posts, userWithInterestIn(label("this-is-what-i-want")));
assertThat(rankedPosts.getFirst()).hasLabel("this-is-what-i-want");
}
@Test
void ranking_should_prefer_posts_with_work_labels_in_the_morning() {
var posts = List.of(
somePost(label("neutral", NEUTRAL)),
somePost(label("leisure", LEISURE)),
somePost(label("work", WORK))
);
var rankedPosts = ranker(MORNING).rankPosts(posts, userWithDefaultSettings());
assertThat(rankedPosts.getFirst()).hasLabel("work");
}
@Test
void ranking_should_prefer_posts_with_leisure_labels_in_the_evening() {
var posts = List.of(
somePost(label("neutral", NEUTRAL)),
somePost(label("leisure", LEISURE)),
somePost(label("work", WORK))
);
var rankedPosts = ranker(EVENING).rankPosts(posts, userWithDefaultSettings());
assertThat(rankedPosts.getFirst()).hasLabel("leisure");
}
@Test
void ranking_should_assign_higher_priority_to_user_interests_than_to_timebased_classification_if_user_has_interests() {
var interestingLeisureLabel = label("interesting-leisure", LEISURE);
var interestingWorkLabel = label("interesting-work", WORK);
var posts = List.of(
somePost(label("boring-leisure", LEISURE)),
somePost(label("boring-work", WORK)),
somePost(interestingLeisureLabel),
somePost(interestingWorkLabel)
);
var rankedPosts = ranker(MORNING).rankPosts(posts,
userWithInterestIn(interestingLeisureLabel,
interestingWorkLabel));
assertThat(rankedPosts.stream()
.map(post -> post.metaData().labels())
.flatMap(Set::stream)
.map(Label::name))
.containsExactly("interesting-work",
"interesting-leisure",
"boring-work",
"boring-leisure");
}
@Test
void ranking_should_return_all_posts_if_user_has_no_muted_labels() {
var posts = List.of(
somePost(),
somePost(label("l1")),
somePost(label("l3"), label("l4"))
);
var user = userWithDefaultSettings();
user.getPreferences().setMuted(List.of());
var rankedPosts = ranker().rankPosts(posts, user);
assertThat(rankedPosts).hasSameSizeAs(posts);
}
@Test
void ranking_should_remove_posts_with_muted_labels_if_they_are_not_interesting() {
var muted1 = label("muted1");
var muted2 = label("muted2");
var posts = List.of(
somePost(),
somePost(label("l1")),
somePost(muted1),
somePost(label("l3"), label("l4")),
somePost(label("l5"), muted2)
);
var user = userWithDefaultSettings();
user.getPreferences().setMuted(List.of(muted1, muted2));
var rankedPosts = ranker().rankPosts(posts, user);
assertThat(rankedPosts).hasSize(3)
.allSatisfy(post ->
assertThat(post).doesNotHaveLabel(muted1.name())
.doesNotHaveLabel(muted2.name()));
}
@Test
void ranking_should_keep_posts_with_muted_labels_if_the_same_label_is_interesting() {
var conflicted = label("conflicted");
var posts = List.of(somePost(conflicted));
var user = userWithInterestIn(conflicted);
user.getPreferences().setMuted(List.of(conflicted));
var rankedPosts = ranker().rankPosts(posts, user);
assertThat(rankedPosts).hasSize(1);
}
private BlogPost somePost(Label... labels) {
return new BlogPost("Some topic",
"This is a generic blog post.",
new BlogPostMetaData(Instant.EPOCH,
new Author("Some Author",
"Born somewhere, Some got into writing after being bitten by a spider."),
Arrays.stream(labels).collect(Collectors.toSet())));
}
private BlogPostRanker ranker() {
return ranker(MORNING);
}
private BlogPostRanker ranker(Clock clock) {
return new BlogPostRanker(clock);
}
private Label label(String name, Label.Classification classification) {
return new Label(name, classification);
}
private Label label(String name) {
return new Label(name, NEUTRAL);
}
private User userWithDefaultSettings() {
var preferences = new Preferences();
preferences.setChronologicalTimeline(false);
var user = new User();
user.setName("Lazy Defaulter");
user.setPreferences(preferences);
return user;
}
private User userWithInterestIn(Label... interests) {
var user = userWithDefaultSettings();
user.getPreferences().setInterests(Arrays.stream(interests).collect(Collectors.toSet()));
return user;
}
}
The total size of the final version of our tests is 227 lines of code versus 169 lines of code for the original version. A good portion of the additional bulk is due to the custom assertions. The main cause for the large difference is that our toy example does not have many tests. With a growing amount of test cases, the lines of code saved usually quickly begin to outweigh the (fixed) amount of code for the custom assertions.
However, the version of our tests without custom assertions (after trick 5) weighs in at 195 lines of code, which is still about 15% more than the original version. This is because the goal was not to write less code. It was to have more readable, better understandable and easier to maintain tests. And on that count I trust you will agree, the final version wins hands down.
Conclusion
Writing good tests is hard. Reading and understanding tests someone else wrote is even harder. If you are working with an old codebase and want to improve maintainability, these six tricks can help you transform messy tests into well-structured, readable ones:
- Separate the test code into the phases Arrange, Act, and Assert to understand what the tests do.
- Disentangle test cases until each each test case tests only one behavior to get better signal from failing tests.
- Name tests following the schema “X should Y if Z” to communicate intent clearly.
- Put all relevant test data in each test case for ease of understanding and better test isolation.
- Introduce test data factories to eliminate boilerplate in the Arrange phase and reduce overhead when making changes.
- Use custom assertions to eliminate boilerplate in the Assert phase and improve failure messages.
You don't need to apply all of the tricks at once to get value out of them. Even making just the first step will immediately make life easier when working with the tests. In my experience, once you have gotten past trick 3 (which is the most difficult one), you are already way ahead of the game.
When writing new tests, starting at “trick level 4” incurs almost no overhead, and the overhead for “trick level 5“ is very low, so I usually do that by default. As the number of test cases grows, I try to spot opportunities for trick 6 and apply it only where it is really worth it.
What's your experience writing and improving tests? Know any additional tricks? I'd love to hear from you, so please leave a comment below!

