# 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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1706454128081/53d9042a-7055-43cd-a0fa-fbe00a6de0f1.png align="center")

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

According to [Martin Fowler](https://martinfowler.com/articles/continuousIntegration.html):

*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.
    

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1706454482942/0687968f-9b6c-4e6a-9f0b-74697e055419.png align="center")

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
    

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1706455669342/da795d38-88d3-4846-be4a-2024c7d56a10.png align="center")

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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739980669990/5dcc8d7b-2348-4f0e-9ebe-922063d5e686.png align="center")

You can also use a very common convention is: **MethodUnderTest\_Scenario\_ExpectedBehavior**

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739980762096/92d97dbb-6cd8-4bb8-a12f-4b775e2bc685.png align="center")

## 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.**

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739981118907/be9e7f8b-fdba-4cc8-b50a-1dcd8fee82d2.png align="center")

* **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).
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1740023840504/a21bca26-6b02-4a79-9704-ec21c69178f5.png align="center")
    
* **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).
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1740035693526/b56f88e9-552a-4d54-845f-4ccf7e75e831.png align="center")
    
* **Spy:** Partially mocks an object while keeping some real behavior intact.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1740035800595/c4d1668e-c640-4556-ab75-6418a804d91a.png align="center")
    
* **Dummy:** A simple, hardcoded value (e.g., `null` or a placeholder string) used **only** to satisfy method parameters but never actually utilized.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1740035627676/5a39db8e-7f12-4447-9d1e-ecdaa48b1eee.png align="center")
    
* **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)
    
* ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1740034277066/d46451a5-203b-44f2-adb1-a49e22148d1b.png align="center")
    

# 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**

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739893091093/0cdbeda9-25bf-4098-83bf-53c0aff51222.png align="center")

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739893123196/93fbf2c7-a9c3-4fdb-bdb6-eb14b94e353e.png align="center")

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.
        

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739893369352/d3206be6-c15e-422a-9329-5dda6507f61a.png align="center")

**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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739893433238/3c2656db-b31e-41a3-92f8-13ab3514c63f.png align="center")

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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739893512491/c0f2d77c-0afe-416e-a163-809a381caec0.png align="center")

### **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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739894752200/57832b87-e3c6-435f-b9ba-2b8c4af19a50.png align="center")

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**](https://github.com/powermock/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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1740385393455/798d535b-aadb-42fb-b4f5-afd4fb548bcf.png align="center")

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.
    

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1740385629722/63a254e3-6869-428c-afd3-2f113bd5d201.png align="center")

## 4\. UT contains implementation details

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1706457062337/8cc11efb-8df7-4f4a-bae7-40541977e379.png align="center")

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1706457150578/69ec1aaa-95c5-459f-a774-fda555d9cd53.png align="center")

## 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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739895231495/8e06c397-461a-4823-9fa8-b43b45604733.png align="center")

## 6\. Trivial test

**Attribute test**

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739871326054/bcbd176d-bce0-4386-afd2-322e826f72be.png align="center")

**Instance test**

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739871346133/dade61c5-a146-4597-b234-dd3a41aced5f.png align="center")

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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739871472913/7ebf2dd5-af10-4ed5-bc96-c0091357e69b.png align="center")

## 8\. Stub test

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739871647566/20b317e7-9db8-4eb8-ada4-35b3ab7b8a60.png align="center")

## 9\. Leaking domain knowledge

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739871613087/9a95d321-7881-4955-9c5e-a78a2ac6a948.png align="center")

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739871627099/1c26f41f-4b0f-4ff9-a6a0-63f4c52b743a.png align="center")

# 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

* [Unit Testing: Principles, Practices, and Patterns](https://www.amazon.com/Unit-Testing-Principles-Practices-Patterns/dp/1617296279)
