Unit testing is a fundamental practice for serious software developers. However, figuring out how to write effective unit tests for specific code sections can be challenging. Developers often attribute their testing difficulties to a lack of knowledge or secret techniques.
This tutorial will demonstrate that unit testing is straightforward. The real obstacles are poorly designed, untestable code that leads to complexity and makes testing difficult. We will explore what makes code hard to test, identify anti-patterns and bad practices to avoid, and highlight the benefits of writing testable code. You’ll learn that writing unit tests and generating testable code not only simplifies testing but also makes the code more robust and maintainable.

What Does Unit Testing Mean?
At its core, a unit test is a method that creates an instance of a small part of our application and verifies its behavior without relying on other parts. It typically involves three phases: initializing a small portion of the application (the System Under Test or SUT), applying a stimulus to it (usually by calling a method), and observing the resulting behavior. The test passes if the observed behavior matches expectations; otherwise, it fails, signaling a problem within the SUT. These phases are known as Arrange, Act, and Assert (AAA).
A unit test can examine various behavioral aspects of the SUT, often falling into two categories: state-based and interaction-based. State-based unit testing confirms that the SUT generates expected results or ends in the correct state. Interaction-based unit testing ensures that specific methods are invoked correctly.
Imagine a scientist creating a fantastical creature (chimera, combining frog legs, octopus tentacles, bird wings, and a dog’s head (a metaphor for software development). To ensure each “unit” works, they might apply an electrical stimulus to a frog leg and observe muscle contraction. This mirrors the Arrange-Act-Assert steps in a unit test, with the difference being that the “unit” here is a physical component, unlike the abstract objects in software.

Although this article uses C# for examples, the principles discussed apply to all object-oriented programming languages.
Here’s a simple unit test example:
| |
Unit Testing vs. Integration Testing
Understanding the distinction between unit and integration testing is crucial.
Unit tests in software engineering focus on verifying the behavior of small, isolated code units. They have a narrow scope, enabling comprehensive coverage and ensuring each part functions correctly.
Integration tests, on the other hand, demonstrate the harmonious interaction of different system components in a real-world environment. They validate complex scenarios, often simulating a user’s high-level interaction with the system, and usually require external resources like databases or web servers.
Returning to our scientist, after assembling the creature, they might conduct an integration test to see if it can walk on different surfaces. This involves emulating environments, observing the creature’s movement, and cleaning up afterward.

Notice the significant difference: Unit tests verify small, isolated parts and are easy to implement, while integration tests cover interactions between components in near-real environments, demanding more effort and setup.
A well-balanced combination of unit and integration tests ensures that individual units function correctly in isolation and work together seamlessly, providing high confidence in the overall system’s functionality.
However, it’s crucial to correctly identify the type of test being implemented. If a supposed unit test for a business logic class requires external resources, it indicates a design flaw—essentially using a sledgehammer to crack a nut.
Best Practices for Writing Unit Tests
Before delving into the core of unit testing and coding, let’s briefly review the characteristics of a good unit test. Good unit tests are:
Easy to write. Developers often write numerous unit tests, so they must be easy to code without significant effort.
Readable. A unit test should clearly convey its purpose, like a story about the application’s behavior. It should be easy to understand the scenario under test and identify the problem if the test fails, ideally allowing bug fixes without debugging.
Reliable. Unit tests should only fail due to bugs in the SUT. However, tests might fail even without bugs, such as passing individually but failing as a suite or passing locally but failing on a CI server. These situations indicate design flaws. Good unit tests should be reproducible and independent of external factors.
Fast. Developers rely on frequent unit test execution to catch regressions. Slow tests discourage frequent runs. A single slow test might not seem significant, but a thousand can cause delays. Slow tests might also indicate interaction with external systems, making them environment-dependent.
Truly unit, not integration. As discussed, unit and integration tests have distinct purposes. Both the unit test and the SUT should avoid accessing network resources, databases, or the file system to eliminate external influences.
There are no secrets to writing unit tests. However, techniques for writing testable code exist.
Unit Testing and Coding: Differentiating Testable from Untestable Code
Some code is inherently difficult or impossible to unit test effectively. Let’s examine some anti-patterns, code smells, and bad practices to avoid when striving for testable code.
Avoiding Non-Deterministic Factors in Your Codebase
Consider a program for a smart home microcontroller that automatically turns on the backyard light if motion is detected during the evening or night. Imagine starting by implementing a method to return a string representation of the approximate time of day (“Night”, “Morning”, “Afternoon”, or “Evening”):
| |
This method reads the current system time and returns a result based on that value. What’s wrong with this code?
From a unit testing perspective, writing a proper state-based unit test for this method is impossible. DateTime.Now acts as a hidden input, likely changing during execution or between test runs. Consequently, subsequent calls yield different results.
This non-deterministic behavior makes it impossible to test the GetTimeOfDay() method’s internal logic without manipulating the system date and time. Such a test might look like this:
| |
This test violates many previously discussed rules. It’s complex to write due to setup and teardown logic, unreliable due to potential system permission issues, and not guaranteed to be fast. Furthermore, it blurs the line between unit and integration testing by pretending to test a simple edge case while requiring specific environment setup. The effort outweighs the benefits.
These testability issues stem from the poorly designed GetTimeOfDay() API. Here are some of its flaws:
Tight coupling to a concrete data source. The method cannot process date and time from other sources or arguments, limiting its reusability. Tight coupling often leads to testability problems.
Violation of the Single Responsibility Principle (SRP). The method handles both information consumption and processing. Another SRP violation indicator is multiple reasons to change.
GetTimeOfDay()could change due to internal logic adjustments or data source modifications.Lack of transparency about required information. Developers must analyze the source code to understand hidden inputs, as the method signature alone isn’t informative enough.
Difficulty in prediction and maintenance. Predicting the behavior of a method reliant on a mutable global state requires understanding its current value and the events that might have modified it, making real-world application analysis challenging.
Let’s fix the API! Thankfully, it’s simpler than outlining its flaws—we just need to decouple the tightly coupled concerns.
Fixing the API: Introducing a Method Argument
The most straightforward solution is introducing a method argument:
| |
The method now expects the caller to provide a DateTime argument instead of secretly obtaining it. This makes the method deterministic, enabling straightforward state-based testing by passing a DateTime value and checking the result:
| |
This simple refactor also addresses the API issues (tight coupling, SRP violation, unclear API) by creating a clear separation between what data to process and how to process it.
The method is now testable, but what about its clients? The caller is now responsible for providing the DateTime value, potentially making them untestable. Let’s see how to address this.
Fixing the Client API: Dependency Injection
Continuing with the smart home system, imagine implementing the client that uses the GetTimeOfDay(DateTime dateTime) method:
| |
The hidden DateTime.Now input problem persists, just at a higher abstraction level. We could introduce another argument, pushing the responsibility further up the call stack, but let’s utilize a technique that keeps both the ActuateLights(bool motionDetected) method and its clients testable: Inversion of Control, or IoC.
Inversion of Control is a powerful technique for decoupling code, especially beneficial for unit testing. Its core principle is separating decision-making code (when to do something) from action code (what to do). This enhances flexibility, modularity, and reduces coupling.
Let’s explore Dependency Injection using a constructor and how it helps build a testable SmartHomeController API.
First, create an IDateTimeProvider interface:
| |
Then, modify SmartHomeController to reference an IDateTimeProvider implementation, delegating the responsibility of obtaining the date and time:
| |
This demonstrates the “inversion” of control: the client of SmartHomeController now controls the mechanism for reading the date and time, not SmartHomeController itself. Executing the ActuateLights(bool motionDetected) method now depends on the motionDetected argument and the concrete IDateTimeProvider implementation passed to the constructor, both externally manageable.
For unit testing, this enables using different IDateTimeProvider implementations in production and test code. In production, a real implementation (e.g., reading system time) would be used. In unit tests, a “fake” implementation returning a constant or predefined DateTime value allows testing specific scenarios.
Here’s a fake IDateTimeProvider implementation:
| |
This class helps isolate SmartHomeController from non-deterministic factors, enabling state-based unit testing. Let’s verify that LastMotionTime is updated when motion is detected:
| |
This test wasn’t possible before refactoring. We’ve eliminated non-deterministic factors and verified a state-based scenario. But is SmartHomeController fully testable yet?
Avoiding Side Effects in Your Codebase
Despite addressing the non-deterministic input and enabling some testing, parts of the code remain untestable.
Consider the ActuateLights(bool motionDetected) method section responsible for light switching:
| |
SmartHomeController delegates light switching to a BackyardLightSwitcher object, which implements a Singleton pattern. What’s the issue with this design?
Thoroughly unit testing ActuateLights(bool motionDetected) requires interaction-based testing alongside state-based testing, ensuring that methods for turning the light on or off are called only under appropriate conditions. However, the current design doesn’t allow for this. The TurnOn() and TurnOff() methods of BackyardLightSwitcher trigger system state changes, producing side effects. Verifying their invocation requires checking for these side effects, which can be cumbersome.
Imagine a scenario where the motion sensor, light, and microcontroller communicate wirelessly. Unit testing might involve intercepting and analyzing network traffic. Alternatively, if the components are wired, the test might check voltage applied to circuits. It could even use a light sensor to confirm the light’s state.
Unit testing side-effecting methods can be as challenging as testing non-deterministic ones and sometimes impossible. Any attempt leads to familiar problems: complex implementation, unreliability, potential slowness, and a blurred line between unit and integration testing.
Again, the culprit is the bad API, not the developer’s ability to write unit tests. Regardless of the light control implementation, the SmartHomeController API suffers from:
Tight coupling to the concrete implementation. Hard-coding the
BackyardLightSwitcherinstance hinders reusability for other lights.Violation of the Single Responsibility Principle. The API has two reasons to change: internal logic modifications and light-switching mechanism replacement.
Hidden dependencies. Developers must dig into the source code to discover the dependency on
BackyardLightSwitcher.Difficulty in understanding and maintenance. Debugging issues becomes complex, as the problem could lie in
SmartHomeController,BackyardLightSwitcher, or even a physical problem like a burned-out bulb.
The solution to both testability and API quality issues lies in decoupling tightly coupled components. Similar to the previous example, Dependency Injection would work well: introduce an ILightSwitcher dependency to SmartHomeController, delegate light switching responsibility, and inject a fake, test-only ILightSwitcher implementation to record method calls. However, let’s explore an interesting alternative for decoupling responsibilities.
Fixing the API: Higher-Order Functions
This approach leverages first-class functions available in object-oriented languages. Let’s use C#’s functional features and modify the ActuateLights(bool motionDetected) method to accept two additional arguments: Action delegates representing methods for turning the light on and off. This transforms the method into a higher-order function:
| |
This solution aligns more with functional programming than Dependency Injection but achieves the same result with less code and increased expressiveness. It eliminates the need for interface-conforming classes, allowing direct function definition passing. Higher-order functions can be viewed as another IoC implementation.
Interaction-based unit testing is now possible by passing verifiable fake actions:
| |
The SmartHomeController API is now fully testable, allowing both state-based and interaction-based unit tests. Additionally, separating decision-making from action code enhances reusability and API clarity.
Achieving full unit test coverage is now straightforward—implement similar tests for all cases, made easier by the improved testability.
Impurity and Testability
Uncontrolled non-determinism and side effects negatively impact the codebase, leading to deceptive, tightly coupled, non-reusable, and untestable code.
Conversely, deterministic and side-effect-free methods, known as pure functions in functional programming, are easier to test, understand, and reuse. Unit testing pure functions involves simply passing arguments and verifying the results. The real obstacles are hard-coded, impure factors that are difficult to replace, override, or abstract.
Impurity is contagious: if method Foo() depends on a non-deterministic or side-effecting method Bar(), Foo() also becomes impure. This can poison the entire codebase, leading to a maintainability nightmare in complex applications.

However, some impurity is unavoidable in real-world applications that interact with external systems. Instead of aiming for complete elimination, focus on limiting these factors, preventing their spread, and breaking hard-coded dependencies to enable independent analysis and unit testing.
Recognizing Hard-to-Test Code
Let’s identify some common warning signs of potentially hard-to-test code.
Static Properties and Fields
Global state, represented by static properties and fields, can complicate code comprehension and testability by hiding dependencies, introducing non-determinism, or encouraging excessive side effects. Functions interacting with mutable global state are inherently impure.
Consider the following code:
| |
Debugging why HeatWater() isn’t called when expected is difficult because any part of the application might have modified CostSavingEnabled. Additionally, some static properties, like DateTime.Now or Environment.MachineName, are read-only but still non-deterministic, making testing challenging.
However, immutable and deterministic global state, or constants, are acceptable. Constant values like Math.PI don’t introduce non-determinism or allow side effects:
| |
Singletons
The Singleton pattern is essentially global state with additional drawbacks. Singletons encourage obscure APIs that hide dependencies and create unnecessary coupling. They also violate the Single Responsibility Principle by managing their initialization and lifecycle alongside their primary responsibilities.
Singletons can introduce test order dependency due to their application-lifetime state. Consider the following:
| |
If a cache-hit test runs before a cache-miss test, the cache won’t be empty as expected, potentially causing the second test to fail. This requires additional teardown code to clean the UserCache after each test.
Avoid using Singletons whenever possible. However, distinguish between the Singleton pattern and a single instance of an object managed by the application through a factory or Dependency Injection container. The latter is perfectly acceptable from both testability and API quality perspectives.
The new Operator
Directly creating object instances using new leads to similar issues as Singletons: unclear APIs, hidden dependencies, tight coupling, and poor testability.
Testing the following code requires setting up a test web server:
| |
However, new isn’t always problematic. It’s acceptable for creating simple entity objects:
| |
It’s also fine for small, temporary objects that only modify their internal state and return a result based on that state. In the following example, we only care about the final result:
| |
Static Methods
Static methods can introduce non-deterministic or side-effecting behavior, leading to tight coupling and hindering testability.
Verifying the following method requires manipulating environment variables and reading console output:
| |
However, pure static functions are acceptable, as any combination remains pure. For example:
| |
Advantages of Proper Unit Testing and Coding
Writing testable code requires discipline, focus, and effort. However, the reward is a clean, maintainable, loosely coupled, reusable codebase that is easier to understand and work with.
Ultimately, the benefits of testable code extend beyond testability itself. It leads to improved code quality, reduced maintenance burden, and increased developer productivity.