Writing unit tests is easy. Writing good unit tests is difficult. But what actually makes a good unit test?

You may ask yourself this question when you start writing tests for your code. Or maybe you already have a bunch of tests, but they’re not as clean and maintainable as you’d like them to be. You surely found yourself in a situation where you had to change your code, but your tests were so messy that you had to rewrite them from scratch.

This is not a good sign for your tests. You should not be afraid of writing tests or changing them. Tests should act as a living documentation for your code — and you should not be afraid of your own documentation!

Let’s start with the basics. Good unit tests should follow the FIRST principle — a concept introduced by Robert C. Martin (aka “Uncle Bob”) in his book Clean Code.

It’s one of the most important foundations for writing reliable, maintainable, and meaningful tests.

Unit tests should be…

  • Fast:
    Unit tests should run quickly. Ideally, within a couple of milliseconds. You want them to run often, so they better not slow you down. If one of your unit tests runs longer than half a second (which should already be you upper bound), you are doing something wrong.
  • Isolated:
    A unit test should test a single piece of functionality and not rely on any external systems like databases, file systems, or network calls. It should also not depend on or affect other tests.
  • Repeatable:
    Tests must be repeatable and deterministic. They should always yield the same result. Regardless where you run them. No matter how often you run them.
  • Self-validating:
    A test should tell you, in a clear and binary way: pass or fail. You shouldn’t need to check log output or debug a print statement to know if it worked.
  • Thorough:
    A good unit test doesn’t just test the happy path. It also considers edge cases, invalid inputs, and potential failure scenarios. Be intentional. Be curious. Try to break your own code.

Following FIRST already helps in building more robust tests. But that’s not all. There’s more to writing good unit tests than just following rules.

Let’s dig deeper.

Intent matters

A good unit test should always make its intent clear. You shouldn’t have to read through every line of the test to figure out what it’s trying to prove. A common mistake: naming your test method something generic or vague.

Take this example:

1@Test
2void myTest() {
3    var book = new Book();
4    book.setName("DummyBook");
5    var page = new Page(book);
6    page.setContent("...");
7    assertEquals(1, book.getPages().size());
8}

Looks simple enough, right? But what’s actually being tested here? What’s the scenario? What do we expect to happen?

Now let’s add a more meaningful name to the test:

1@DisplayName("A new page should be added to the book when it is created")
2@Test
3void myTest() {
4    var book = new Book();
5    book.setName("DummyBook");
6    var page = new Page(book);
7    page.setContent("...");
8    assertEquals(1, book.getPages().size());
9}

That’s much clearer. Even before reading the implementation, we understand what the test is about. We’re testing that a newly created page gets automatically added to the book it belongs to.

The actual test code hasn’t changed. But its readability and usefulness just went up a notch.

But shouldn’t code be self-documenting?

Yes — that’s a core principle of clean code. Production code should be easy to understand without comments or explanations.

But unit tests are a bit different. They serve as living documentation for your system’s behavior, and they’re often read more than they’re written. A good test doesn’t just verify something. It should tell a story. It explains why a piece of logic exists and what the expected outcome is in a certain scenario.

So don’t hesitate to give your tests meaningful names. Use @DisplayName, describe the behavior, and be explicit about what’s being tested.

Why not just name the test method properly and be done with it?

Fair question. Something like this, for example:

1@Test
2void shouldAddPageToBookWhenCreated() {
3    // ...
4}

Technically, that works. The method name says what the test is doing. But is it really easy to read? It surely isn’t.

Method names are still bound to Java’s naming rules. No spaces, no punctuation, no special characters. Over time, those long camel-case names get harder to read, especially when you’re scanning through dozens of them.

That’s exactly why I recommend using JUnit’s @DisplayName annotation. It lets you keep your method names clean and consistent while still writing readable, natural-language descriptions for your tests.

Best of both worlds.

Excurse

Kotlin solves this problem quite well. Here, you can name you functions exactly as you would in Java and JUnit with @DisplayName. You can just put your function name in backticks and write more readable function names, which is especially useful for tests.

1@Test
2fun `should add page to book when created`() {
3    // ...
4}

Further reading in the official Kotlin documentation: https://kotlinlang.org/docs/coding-conventions.html#names-for-test-methods

Tests That Describe Behavior

A good unit test should always be written from a behavior-driven perspective. This means it doesn’t check implementation details, but rather describes the expected behavior of the system. This approach is often referred to as “Behavior-Driven Development” (BDD). At its core, it’s about asking yourself: “What should the system do?” instead of “How does it do it?”. For example, instead of checking if a method returns a specific value, you should write a test that describes the expected outcome of that method in a concrete scenario.

The 3-Phase Pattern: Arrange, Act, Assert (or Given, When, Then)

To keep your tests clear and readable, an established pattern has proven effective: Arrange, Act, Assert (AAA). It divides your test into three logical phases:

  • Arrange: Here, you prepare the test environment, initialize objects, and set up all necessary preconditions.
  • Act: In this phase, you execute the action you want to test – the “behavior” of your system.
  • Assert: Finally, you verify whether the system delivered the expected result.

An alternative way of thinking is the Given-When-Then pattern, which particularly aligns with the behavior-driven idea:

  • Given: Define the initial state or preconditions (analogous to Arrange).
  • When: Describe the action or event that takes place (analogous to Act).
  • Then: Formulate the expected outcome or consequences (analogous to Assert).

Both patterns help you write precise and understandable tests. The choice between them is mostly a matter of personal preference, as both patterns serve the same purpose of structuring your tests and implementing the BDD approach.

Example of a Good BDD test

Let’s look at an example that tests the expected behavior of a ShoppingCart class when an item is added:

 1@DisplayName("An item should be added to the cart when it is valid")
 2@Test
 3void shouldAddItemToCartWhenValid() {
 4    // Arrange: Given an empty shopping cart and a valid item
 5    var shoppingCart = new ShoppingCart();
 6    var item = new Item("Laptop", 1200.00, 1);
 7
 8    // Act: When the item is added to the cart
 9    shoppingCart.addItem(item);
10
11    // Assert: Then the shopping cart should contain one item
12    assertTrue(shoppingCart.containsItem(item));
13}

This test is clear and focused. It describes the expected behavior: if a valid item is added to the shopping cart, then the shopping cart should contain that item.

Maintenance

In general, test methods should be short. No. They should be shorter than that.

If your test method is longer than, say, 10–15 lines, chances are it’s doing too much. Unit tests are not integration tests. They don’t need to set up the whole world just to check a tiny behavior.

Long tests are harder to read, harder to understand, and much, much harder to maintain (trust me!). A good unit test should be focused on one single behavior. Nothing more. Everything else should be abstracted away, ideally into helper methods or test utilities.

If your test needs a comment to explain what it’s testing, it’s probably too complex already.

Have a look at another example:

 1@DisplayName("It should not be possible to add pages to a published book")
 2@Test
 3void addPagesToPublishedBook() {
 4    // arrange
 5    var book = new Book();
 6    book.setName("DummyBook");
 7
 8    var page = new Page(book);
 9    page.setContent("First page");
10
11    var anotherPage = new Page(book); // irrelevant for this test
12    anotherPage.setContent("Second page");
13
14    var publisher = new Publisher();
15    var author = new Author();
16    publisher.publishBook(publisher, author);
17
18    // act
19    var anotherPage = new Page(book);
20    anotherPage.setContent("Second page");
21
22    // assert
23    assertEquals("DummyBook", book.getName()); // irrelevant for this test
24    assertEquals(1, book.getPages().size()); // actual behavior check
25    assertEquals("First page", book.getPages().get(0).getContent()); // too detailed
26    assertTrue(book.isPublished()); // could be its own test
27}

Of course I am overdoing it here. But you can clearly tell that there is too much stuff going on in this test. It might be tempting to “get it all done” in one test. But here’s the problem: The more assertions you cram into a test, the harder it becomes to understand its actual purpose. Also, when the arrange section starts growing too large, it adds noise and makes the test harder to maintain. You lose focus.

The cleaner version

 1@DisplayName("It should not be possible to add pages to a published book")
 2@Test
 3void shouldNotAllowAddingPagesToPublishedBook() {
 4    var book = TestFactory.createPublishedBookWithOnePage();
 5
 6    var newPage = new Page(book);
 7    newPage.setContent("Second page");
 8
 9    assertEquals(1, book.getPages().size());
10}

Now the test is focused. Clear. Easy to read. Easy to maintain. And most importantly: If it fails, you’ll immediately know why.

You also can tell which parts belong to arrange, act and assert. I like to separate them by one single blank line, which is all it takes. No need for comments like // arrange, // act, or // assert. If your test is clean and focused, the structure will speak for itself.

When your code evolves (and it will), you want your test suite to evolve with it, without breaking apart.

Conclusion

Writing good unit tests is an art that requires practice and discipline. By following the FIRST principle, focusing on intent, and structuring your tests with the AAA or BDD patterns, you can create tests that are not only reliable but also easy to read and maintain. Remember, tests are not just a safety net for your code; they are also a form of documentation that helps you and others understand the expected behavior of your system.