Skip to main content

Command Palette

Search for a command to run...

The Anatomy of a Unit Test

Updated
8 min read
The Anatomy of a Unit Test

Behind every robust software system lies a suite of well-structured unit tests. But what defines a great unit test? In this article, we’ll examine its anatomy and best practices to ensure your tests are both reliable and effective.

I. A Bigger Picture

Over the past five years, CI/CD has become a fundamental practice for modern software engineers.

According to Martin Fowler:

Continuous Integration is a software development practice where each member of a team merges their changes into a codebase together with their colleagues changes at least daily. Each of these integrations is verified by an automated build (including test) to detect integration errors as quickly as possible.

Yes, as quickly as possible. You could rely on manual testing, but it’s slow, prone to human error. To fully support Continuous Integration, automated testing is essential - something that runs and verifies your code every time you make a change.

Automated tests act as an early warning system, catching bugs before they reach production. With a solid suite of tests in place, you gain confidence that your changes won’t break existing functionality.

II. Testing Methodology

In automated testing, we commonly categorize tests into three main types:

  • Unit Tests – Focus on testing individual components in isolation.

  • Integration Tests – Verify interactions between multiple components or services.

  • End-to-End (E2E) Tests – Simulate real user flows to ensure the entire system works as expected.

The test pyramid provides a clear guideline on how to distribute tests across different layers. At the base, you should write a lot of small, fast unit tests. In the middle, you have integration tests, which are fewer but validate interactions between components. At the top, you write only a few high-level end-to-end (E2E) tests, which ensure the system works as a whole.

Lower levels focus on isolated code behaviors, while higher levels validate component integration and overall system functionality.

In this article, I’ll focus on the foundation of the pyramid - unit tests and explore what makes them effective.

III. What is a unit test?

Definition

A unit test is an automated test that:

  • Verifies a small piece of code (also known as a unit, often a method)

  • Runs quickly

  • Executes in isolation from dependencies or external systems as databases, APIs,…

Structure

A unit test follows a simple but effective pattern, often called the AAA pattern:

  • Arrange – prepare the dependencies and data of a scenario

  • Act – Call the testing method and capture the return value (if any)

  • Assert – Verify the outcome

And remember: never write a test without an assertion! Unless, of course, you just want to beautify your test report. 😆

Naming

Proper naming helps you understand what the test verifies. A good name encourages you to focus on the behavior of the code instead of the implementation details.

You can also use a very common convention is: MethodUnderTest_Scenario_ExpectedBehavior

Test Doubles

A test double is a term that describes fake dependencies in tests. There are five variations of test doubles: dummy, stub, spy, mock, and fake.

  • Mock: Emulates and verifies outgoing interactions - checking whether a method was called, how many times, and with what parameters. These interactions are calls the SUT (System under test) makes to its dependencies to change their state (e.g., sending an email, calling external services).

  • Stub: Emulates incoming interactions by providing predefined responses/exceptions. These interactions involve the SUT calling its dependencies to retrieve input data (e.g., fetching data from a database or external system).

  • Spy: Partially mocks an object while keeping some real behavior intact.

  • Dummy: A simple, hardcoded value (e.g., null or a placeholder string) used only to satisfy method parameters but never actually utilized.

  • Fake: A lightweight implementation of a dependency, such as an in-memory database or HashMap, used as a replacement for a real system (e.g., replacing an actual database)

IV. What makes a good test?

1. Protection against bugs

A test that shows good protection against regression depends on:

a. Amount of code executed during the test

Generally, the larger the amount of code that gets executed, the higher the chance that the test will reveal a bug.

b. Complexity and domain significance

Code that represents complex business logic is more important than boilerplate code. Therefore, our tests should focus on core domain components.

2. Fast feedback

A good unit test provides fast feedback, allowing developers to catch issues early in the development cycle. When a test fails immediately after a bug is introduced, fixing it is quick and inexpensive.

The cost increases exponentially when the bug is not discovered early. A fast and reliable test suite helps maintain development speed while preventing costly bugs.

3. Maintainability

  • Keep your unit tests small and readable.

  • A single test should not take a lot to arrange and too many assertions.

  • Independent of out-of-process dependencies: databases, APIs, message queues,…

4. Resistance to refactoring

If a test breaks due to refactoring of underlying code without any change in behavior, it indicates poor test design.

Example: Scooter Configuration

Imagine you want to buy a scooter with:
A blue handle
Illuminating wheels
A red bell

The constructor receives custom settings to configure the scooter, then configuration() method publishes the configuration for user to see.

Now, consider the following unit test:

  • It initializes the Scooter class with custom settings.

  • It asserts that the Scooter contains:

    • The first instance of the Handle class.

    • The second instance of the Wheel class.

    • The third instance of the Bell class.

The Problem

However, the test is looking at the implementation details of the class. It will fail if

  • We alter the order of part initialization to configure Wheel first, instead of Handle

  • We refactor the code to say that Bell is a sub-part of Handle

The Solution

Instead of asserting the exact instance positions, a refactoring-resistant test should verify:
✅ The Scooter and Bell has a handle with correct color.
✅ The Wheels have the expected illumination feature.

A better test focuses on observable behavior. Refactoring the underlying code like moving around the objects, or changing the composition won’t impact the test.

Where to find observable behavior?

If you are working with Clean Architecture, the observable behavior should reside in Domain and Use case layers, where the core business logic is implemented.

If you are working with Controller-Service-Repository projects, the observable behavior should reside in the Service layers.

V. Common Mistakes in Unit Testing

1. No assertions

A unit test without assertions is not really a test - it’s more like “just writing code for coverage”. A unit test should always assert that the code behaves as expected. If your test doesn’t verify the result, you’re not testing anything at all.

2. Test private methods

One of the most commonly asked questions is how to test a private method. The short answer: you shouldn’t.

Testing private methods violates an important principle mentioned earlier: test only observable behavior. Exposing private methods couples tests to implementation details, making them fragile and reducing their resistance to refactoring.

However, talk is cheap. Sometimes the codebase is just too bad 😆 to strictly follow this principle. That’s why PowerMock exists - it allows developers to mock private methods and more.

3. Too large Arrangement

A large arrangement section in a unit test can be a sign of problems. If your setup code is lengthy and repetitive, it makes tests harder to read and maintain.

When the arrangement code is large and repeated across multiple tests, consider refactoring:

  • Use helper methods to set up commonly used objects or scenarios with parameters.

  • Use factories to create reusable setups.

  • Consider mocking or using a framework like Mockito to simplify the setup.

4. UT contains implementation details

If you need to invoke more than one method in your act statements:

Please encapsulate implementation details in your code, don't couple them with unit tests.

5. Multiple act statements in a UT

We should write one unit test per scenario, meaning each unit test should have only a single act statement.

Avoid writing tests with multiple Act statements, as shown below, as it makes the test unclear and harder to understand the scenario being tested.

6. Trivial test

Attribute test

Instance test

Such tests are insignificant to the system and lower resistance to refactoring.

7. Conditional test

Separate conditions into different unit tests instead of using conditional statements within a single test.

8. Stub test

There is no value if we try asserting the stub data that we emulated in the Arrange phase.

9. Leaking domain knowledge

Don’t let your tests hold domain knowledge. Tests should focus on verifying behavior.

VI. Conclusion

Phewwww! We've just covered some basic concepts and practices. I hope they help you get started with unit testing and make your software more reliable.

This is an interesting topic and might spark some controversial discussions. If you have any comments, feel free to share!

Reference