Unit testing is a critical component of every professional software developer’s toolkit. However, it may be very challenging to create a decent unit test for a specific piece of code at times. When developers have difficulties testing their own or another’s code, they often believe that their problems result from a lack of basic testing knowledge or hidden unit testing methods.
This testing article will demonstrate that unit testing is straightforward; the issues that complicate unit testing and add costly complexity are caused by poorly written, untestable code. The primary objectives of this post are to examine what makes code difficult to test, which anti-patterns and poor practices to avoid, to enhance testability, and what additional advantages you may get from testable code. You shall see that creating testable code is about more than simply making testing more straightforward; it is also about making the code more resilient and maintainable.
Unit Testing – What Is It?
A unit test is essentially a method that instantiates a tiny piece of your program and validates its behavior independently of the rest of the application. A typical unit test consists of three stages: It begins by initializing a small portion of the program being tested (referred to as the system under test, or SUT), then applying a stimulus to the system under test (often by executing a method on it), and lastly observing the subsequent behavior. The unit test succeeds if the observed behavior matches the expectations; otherwise, it fails, suggesting a fault somewhere in the system under test. These three stages of the unit test are Arrange, Act, and Assert, or simply AAA.
A unit test may validate various behavioral characteristics of the system under test. Still, it will most likely fall into one of two categories: state-based or interaction-based. For example, state-based unit testing confirms that the system under test generates accurate results or maintains the correct state. On the other hand, verifying that specific functions are invoked correctly is called interaction-based unit testing.
Consider a crazy scientist who wishes to create a magical chimera with frog legs, octopus tentacles, bird wings, and a dog’s head as a metaphor for effective software unit testing. (This is a very realistic depiction of what programmers do on the job). How would that scientist ensure that each component (or unit) he chose is functional? To illustrate, he could take a single frog’s leg, administer an electrical stimulation to it, and examine the muscle contraction for proper contraction. What he is doing is identical to the unit test’s Arrange-Act-Assert stages; the only difference is that in this instance, the unit refers to an actual item rather than an abstract entity from which you construct your programs.
Comparing Unit Tests and Integration Tests
Another critical distinction to make is between the unit and integration testing.
In software engineering, the goal of a unit test is to evaluate the behavior of a tiny piece of software in isolation from other components. Unit tests are focused and enable you to cover all possible scenarios, guaranteeing that every detail functions appropriately.
On the other hand, integration tests show how well the various components of a system interact in a real-world context. They verify complicated scenarios (imagine a user executing some high-level action inside your system) and often need the presence of external resources such as databases or web servers.
Would you please return to the mad scientist metaphor and assume that he has successfully merged all the chimera’s components? Next, he wants to do an integration test on the resultant monster, ensuring that it can, for example, walk on various terrains. To begin, the scientist must create a habitat in which the creature may walk. He next places the animal in the surroundings and pokes it with a stick to see whether it walks and moves typically. Following the conclusion of a test, the crazy scientist meticulously cleans up the dirt, sand, and pebbles that have accumulated in his beautiful laboratory.
Take note of the critical distinction between unit and integration tests: A unit test validates the behavior of a small subset of the application, isolated from the environment and other components, and is relatively simple to implement, whereas an integration test covers interactions between multiple elements in a close-to-real-world climate and requires additional effort, including different setup and teardown phases.
An appropriate mix of unit and integration tests guarantees that each unit operates properly independently of the others. All these units function well together, providing you with a high degree of confidence that the whole system works as anticipated.
However, you must constantly keep in mind the type of test you perform: unit or integration. The distinction may sometimes be deceptive. For example, suppose you believe you are creating a unit test to validate a minor edge case in a business logic class and discover that it needs the presence of external resources such as web services or databases. In that case, something is wrong – you are employing a sledgehammer to crack a nut. And it equates to poor design.
What Constitutes an Effective Unit Test?
Before you get into the meat of this lesson and begin creating unit tests, now briefly review the characteristics of a good unit test. Unit testing principles require that a good test must be the following:
Simple to write. Developers usually create unit tests to cover various scenarios and facets of an application’s behavior; thus, code all test procedures should be trivial.
Readable. A unit test’s purpose should be obvious. A good unit test tells a narrative about some element of your application’s behavior, so it should be clear which scenario is being tested and — if the test fails — how to resolve the issue. You can fix a problem without debugging the code with a well-written unit test!
Reliable. Unit tests should fail only if the system being tested has a problem. That may seem self-evident, yet programmers often get into trouble when their tests fail despite the absence of the issues. For instance, tests may pass individually but fail when performed in bulk or may pass on your development machine but fail on the continuous integration server. These instances indicate a design fault. In addition, unit tests should be repeatable and unaffected by external variables such as the environment or the sequence in which they are performed.
Fast. Developers create unit tests to run them frequently and ensure no problems have been introduced. When unit tests take a long time to execute, developers are more inclined to bypass them on their computers. One sluggish test will make little difference; add a thousand more, and we’ll almost certainly be waiting a long time. Slow unit tests may also suggest that the tested system or the test itself interacts with external systems, thus becoming environment-dependent.
It is a genuine unit, not an integration. As previously stated, unit and integration tests serve distinct functions. For example, to remove the impact of external variables, neither the unit test nor the system under test should have access to network resources, databases, or the file system.
That is all there is to unit testing – there are no hidden tricks. Nevertheless, some methods enable you to create testable programs.
Codes that are Testable and Untestable
Certain types of code are designed so that writing a decent unit test for them is difficult, if not impossible. What, therefore, contributes to the difficulty of testing code? Now, go through some anti-patterns, code smells, and poor practices to avoid while creating testable code.
Non-deterministic Factors Poisoning the Codebase
Begin with a simple illustration. Consider that you are developing software for a microcontroller in an intelligent house. One of the requirements is to automatically switch on the light in the backyard whenever motion is detected there during the evening or night. You began by creating a function that returns a string representation of the approximate time of day (“Night,” “Morning,” “Afternoon,” or “Evening”):
In essence, this function retrieves the current system time and returns a value depending on it. So, what is the flaw in this code?
When seen through the lens of unit testing, it becomes clear that it is impossible to construct a suitable state-based unit test for this function. DateTime. It will almost certainly change throughout the program’s execution or between test runs. As a result, future calls to it will return different values.
Due to this non-deterministic behavior, it is difficult to verify the GetTimeOfDay() method’s underlying logic without altering the system date and time. Consider how such a test would need to be implemented:
Such tests would violate a significant number of the preceding criteria. It would be prohibitively expensive to build (because of the non-trivial setup and takedown logic), unreliable (it may fail even though the tested system has no vulnerabilities, for example, due to system permission issues), and not guaranteed to run fast. Finally, this test would not be a unit test — it would be a hybrid of a unit and integration test since it appears to test a simple edge case but needs a specific environment configuration. The end product is insufficient to justify the effort.
It turns out that the source of all of these testability issues is the low-quality GetTimeOfDay() API. This technique, in its present version, has many flaws:
- It is inextricably linked to the specific data source. This function cannot be reused to process the date and time obtained from other sources or given as a parameter; the method works only with the date and time of the machine that runs the code. Tight coupling is the primary source of the majority of testability issues.
- It breaches the Principle of Single Responsibility (SRP). This is because the method performs two functions: it consumes and processes data. Another indication of an SRP violation is when a single class or function changes for many reasons. For example, from this vantage point, the GetTimeOfDay() function may be modified to accommodate internal logic changes or update the date and time source.
- It fabricates information necessary to perform its function. Developers must examine every line of the actual source code to determine which hidden inputs are being utilized and where they originate. The method signature alone does not provide sufficient information about the method’s functionality.
- It’s challenging to forecast and maintain. The behavior of a method dependent on a changeable global state cannot be anticipated just by reading the source code; it must consider the present value of the international stage and the whole chain of events that may have altered it before. In a real-world application, unraveling all of that becomes a tremendous pain.
Now finally, fix the API once you’ve reviewed it! Fortunately, this is much simpler than debating all of its faults – you need to separate the closely linked issues.
Implementing Changes to the API: Introducing a Method Argument
The most transparent and straightforward approach to modify the API is to add a method argument:
Instead of covertly obtaining this information, the procedure now needs the caller to supply a DateTime argument. This is excellent from a unit testing standpoint; since the function is now deterministic (i.e., the result returned by this function is completely dependant on the input), state-based testing is as simple as providing a DateTime value and inspecting the result:
Notably, this simple refactoring also resolved all of the API problems mentioned before (tight coupling, SRP violation, confusing and difficult-to-understand API) by establishing a clear boundary between what data should be handled and how it should be processed.
Excellent — the method is tested, but what about the clients? Now, the caller has the final decision to provide the date and time to the GetTimeOfDay(DateTime dateTime) function, which means they may become untestable if you are not vigilant. See how you may address this.
Fixing the Client Api: Injection of Dependencies
Assume you continue working on the innovative home system and build the following client of the GetTimeOfDay(DateTime dateTime) function — the aforementioned intelligent home microcontroller code responsible for turning on or off lights according to the time of day and motion detection:
You have a DateTime that is similarly concealed. The input issue is identical to the output problem — the only difference is situated at a minor higher abstraction level. To solve this issue, you may add another argument and delegate the duty for providing a DateTime value to the user of a custom method with the signature ActuateLights (bool motionDetected, DateTime dateTime). However, instead of relocating the issue higher in the call stack, use another approach to maintain the testability of both the ActuateLights(bool motionDetected) function and its clients: Inversion of Control, or IoC, is a term that refers to the inversion of control of a system.
Inversion of Control is a simple but highly effective method for decoupling code, particularly unit testing. (After all, it is critical to maintaining things loosely linked to being analyzed separately.) IoC’s primary objective is to decouple decision-making code (when to do something) from action code (what to do when something happens). This approach improves your code’s flexibility, modularizes it, and decreases component coupling.
Inversion of Control can be done in various ways; now examine one specific implementation — Dependency Injection through a constructor — and how it may aid in developing a tested SmartHomeController API.
To begin, build an IDateTimeProvider interface with the following function signature:
Then, create SmartHomeController a reference to an implementation of IDateTimeProvider and assign it the duty of acquiring date and time:
Now you see why Inversion of Control is thus named: control over which method to use to read date and time has been inverted and now belongs to the client of SmartHomeController, not to SmartHomeController itself. Thus, the ActuateLights(bool motionDetected) function is entirely dependent on two externally manageable variables: the motionDetected input and a concrete implementation of IDateTimeProvider given into the SmartHomeController constructor.
What significance does this have for unit testing? This implies that various implementations of IDateTimeProvider may be used in production and unit test code. Some real-world deployments will be inserted into the production environment (e.g., one that reads actual system time). However, you may inject a “fake” implementation into the unit test that returns a constant or preset DateTime value appropriate for testing the specific situation.
A fictitious implementation of IDateTimeProvider could look like follows:
This class enables the isolation of SmartHomeController from non-deterministic elements and the execution of a state-based unit test. Verify that, if motion is detected, the LastMotionTime property contains the time of the activity:
Excellent! This kind of test was not feasible before refactoring. Do you believe that SmartHomeController is completely testable now that you’ve removed non-deterministic variables and validated the state-based scenario?
Poisoning the Codebase with Unwanted Consequences
Even after resolving the problems caused by the non-deterministic hidden input and testing particular functionality, the code (or at the very least a piece of it) remains untestable! For example, consider the following section of the ActuateLights(bool motionDetected) method, which is responsible for turning on or off the light:
As you can see, SmartHomeController delegated the task of turning on and off the light to a BackyardLightSwitcher object that follows the Singleton pattern. So what is the flaw in this design?
To completely unit test the ActuateLights(bool motionDetected) function, you need to include interaction-based testing in addition to state-based testing; that is, you should verify that methods for turning on or off the light are called only when the proper circumstances are fulfilled. Unfortunately, the present architecture prevents you from doing so: the BackyardLightSwitcher’s TurnOn() and TurnOff() functions cause-specific state modifications in the system, or other words, has undesirable consequences. The only way to validate that these techniques were invoked is to determine whether or not their associated side effects occurred, which may be unpleasant.
Indeed, assume the motion sensor, backyard light, and microcontroller for the intelligent house are linked to an Internet of Things network and communicate through a wireless protocol. A unit test may try to receive and evaluate the corresponding network traffic in this instance. Alternatively, if the hardware components are linked through a wire, the unit test may verify that the proper electrical circuit was powered. Alternatively, it may use an extra light sensor to confirm that the light was switched on or off.
As you can see, unit testing side-effecting methods may be just as difficult, if not impossible, as unit testing non-deterministic methods. Any effort will result in difficulties identical to those previously encountered. Consequently, the test will be challenging to implement, unreliable, possibly slow, and not unitary. And, after all that, the light blinking every time the test suite is performed will ultimately drive you insane!
Again, all of these issues with testability are the result of a poor API, not the developer’s ability to create unit tests. Regardless of how light control is handled precisely, the SmartHomeController API suffers from the following well-known issues:
- It is inextricably linked to the actual implementation. The API makes use of a hard-coded, concrete BackyardLightSwitcher object. It is not feasible to utilize the ActuateLights(bool motionDetected) function to turn on any other light except the backyard light;
- It is a violation of the Principle of Single Responsibility. The API is changing for two reasons: First, modifications to the internal logic (for example, selecting to turn on the light just at night but not during the day) and, second, if the light-switching mechanism is replaced with another;
- It conceals its reliances. Apart from delving into the source code, developers have no way of knowing that SmartHomeController is dependent on the hard-coded BackyardLightSwitcher component; and
- It isn’t easy to comprehend and sustain. What if the light will not illuminate when the circumstances are ideal? You might spend some time attempting unsuccessfully to repair the SmartHomeController, only to discover that a fault caused the issue in the BackyardLightSwitcher (or, even more amusingly, a burnt-out lightbulb!).
As expected, the solution to testability and low-quality API issues is to separate tightly coupled components, and with the previous example, using Dependency Injection would resolve these issues. For example, add an ILightSwitcher component to the SmartHomeController, delegate responsibility for flipping the light switch to it, and pass a bogus, test-only ILightSwitcher implementation that will log whether the appropriate methods were called under the right conditions. However, rather than revisiting Dependency Injection, take a look at an intriguing alternative method for decoupling the responsibilities.
Implementing the API: Adding Higher-Order Functions
This technique is compatible with any object-oriented programming language that supports first-class functions. Use Cfunctional #’s capabilities and add two more parameters to the ActuateLights(bool motionDetected) method: a pair of Action delegates referring to the processes that should be called to turn the light on and off. This technique will be converted to a higher-order function using the following formula:
This is a more functional solution than the previous object-oriented Dependency Injection method; nevertheless, it achieves the same goal with less code and more expressiveness than Dependency Injection. It is no longer essential to implement a class that conforms to an interface to provide the needed functionality to SmartHomeController; instead, you may provide a function definition. Consider higher-order functions as another method of achieving Inversion of Control.
Now, you can feed readily verifiable false actions into the resultant method to conduct an interaction-based unit test:
Finally, you’ve made the SmartHomeController API completely testable, enabling you to run state-based and interaction-based unit tests on it. Again, note how, in addition to increasing testability, creating a seam between the decision-making and action code assisted in resolving the issue of tight coupling and resulted in a more precise, reusable API.
Now, to obtain complete unit test coverage, you can simply write a large number of similar-looking tests to verify all conceivable scenarios – hardly a huge problem, given how simple unit tests have become.
Impurity and Testability
Uncontrolled non-determinism and side effects both have a detrimental impact on the codebase. When used irresponsibly, they result in misleading, difficult to comprehend and maintain, tightly linked, non-reusable, and untestable code.
On the other hand, deterministic and side-effect-free techniques are much simpler to test, reason about, and reuse in more extensive applications. In functional programming, they are referred to as pure functions. As a result, unit testing a refined process is seldom a problem; you have to provide some inputs and verify the output for validity. What makes code untestable is the presence of hard-coded, impure elements that cannot be changed, modified, or abstracted away in any manner.
Impurity is poisonous: if method Foo() is dependent on the non-deterministic or side-effecting method Bar(), then Foo() itself becomes non-deterministic or side-effecting. Moreover, you may eventually contaminate the whole codebase. Multiply all of these issues by the scale of a complicated real-world program, and you’re left with a difficult-to-maintain codebase rife with smells, anti-patterns, hidden dependencies, and all manner of other unsightly and unpleasant things.
However, impurity is unavoidable; every real-world program must read and modify its state at some point via interaction with the environment, databases, configuration files, web services, or other external systems. Therefore, instead of attempting to remove impurity entirely, it’s a good idea to restrict these elements, prevent allowing them to poison your codebase, and minimize hard-coded dependencies as much as possible to analyze and unit test independently.
Warning Signs of Difficult-to-Test Code
Are they having difficulty writing tests? The issue does not exist in your test suite. Instead, it is included in your code.
Finally, go through some of the most frequent warning signals that your code may be tough to test.
Properties and Fields that are Static
Static properties and fields may obfuscate and complicate code understanding and testability by concealing information necessary for a method to perform its function, introducing non-determinism, and encouraging excessive use of side effects. Inherently impure are functions that read or change mutable global states.
For instance, it’s difficult to justify the following code, which is dependent on a globally available property:
What if you are sure that the HeatWater() function was not called when it should have been? Because any program component might have modified the CostSavingEnabled value, you must locate and evaluate all locations where that value was adjusted to determine what went wrong. Additionally, as previously shown, it is not feasible to set some static attributes for testing reasons (e.g., DateTime.Now, or Environment.MachineName; they are read-only but still non-deterministic).
On the other hand, a global state that is immutable and deterministic is perfectly acceptable. Indeed, this is referred to by a more common term – a constant. Constant values, similar to Math. PI introduces no non-determinism and, since their importance cannot be altered, they prevent the occurrence of any side effects:
In essence, the Singleton pattern is a variant of the global state. Singletons encourage cryptic APIs that conceal actual dependencies and excessively tight coupling between components. They also violate the Single Responsibility Principle since they manage their initialization and lifetime in addition to their primary responsibilities.
Because singletons maintain state over the lifespan of the application or unit test suite, they may easily create unit tests order-dependent. Consider the following example:
If a test for the cache-hit scenario is performed first, it will create a new user in the cache, causing a later difficulty for the cache-miss strategy to fail because it thinks the cache is empty. To address this, you’ll need to add extra teardown code to clear the UserCache after each unit test run.
Singletons are a poor practice and should be avoided in most situations; nevertheless, it is critical to differentiate between the Singleton design pattern and a single instance of an object. In the latter scenario, the application generates and maintains a single model. Typically, this is accomplished via a factory or Dependency Injection container, which produces a single instance near the application’s “top” (i.e., closer to the application’s entry point) and then distributes it to all objects that need it. This approach is exactly right, both testability and API quality.
The new Operator
Creating a new instance of an object to do a task presents the same issues as the Singleton anti-pattern: ambiguous APIs with hidden dependencies, tight coupling, and low testability.
For instance, to verify that the following loop terminates when a 404 status code is received, the developer should configure a test web server as follows:
However, sometimes, new is entirely harmless: It is acceptable, for instance, to construct basic entity objects:
Additionally, it is acceptable to build a tiny, transient object with no side effects other than to change its state and then return the result depending on that state. You don’t care if Stack methods were called or not in the following example; all that matters is that the final result is correct:
Another source of non-deterministic or side-effecting behavior is static methods. They have a high potential for introducing tight coupling and rendering your code untestable.
For instance, to validate the following method’s functionality, unit tests must modify environment variables and read the console output stream to confirm that the correct data was printed:
On the other hand, pure static functions are acceptable: any combination remains a pure function. For instance:
The Advantages of Unit Testing
Writing testable code, of course, takes some discipline, attention, and more work. However, software creation is a complicated mental activity in and of itself, and you should constantly exercise caution and avoid haphazardly slamming new code together from scratch.
As a result of this act of reasonable software quality assurance, you will end up with clean, easy-to-maintain, loosely linked, and reusable APIs that will not harm developers’ brains when they attempt to comprehend them. After all, the ultimate benefit of testable code is its testability and ease of understanding, maintenance, and extension.