What is Unit Testing? Unit testing is simple; the real issues that make unit testing more difficult and expensive are caused by poorly-designed, untestable code. We'll explore what makes code difficult to test, which anti-patterns and bad practices to avoid to increase testability, and what other advantages we may get from developing testable code. We'll see that developing testable code is about more than simply making testing easier; it's also about making the code more robust and maintainable.

Unit testing is an essential tool in any professional software developer's toolbox. However, writing an effective unit test for a specific piece of code can be challenging at times.

When developers have trouble testing their own or someone else's code, they frequently believe that their problems are due to a lack of core testing knowledge or secret unit testing approaches.

What is Unit Testing? This unit testing tutorial will establish that unit tests are simple to write; the real issues that make unit testing difficult and expensive are caused by poorly-designed, untestable code. We'll talk about what makes code difficult to test, which anti-patterns and bad practices to avoid to increase testability, and what other advantages we may get from developing testable code. We'll see that creating unit tests and producing testable code isn't only about making testing easier; it's also about making the code more robust and maintainable.

Table of Contents

  1. What is Unit Testing?
  2. Unit Test vs. Integration Test
  3. What Makes a Good Unit Test?
  4. Testable and Untestable Code
  5. Common Warning Signs of a Hard to Test Code
  6. Benefits of Unit Testing

What is Unit Testing?

A unit test is essentially a method that instantiates a tiny component of our application and evaluates its behavior independently of the rest of it.

  1. It initializes a tiny component of an application to be tested (also known as the system under test, or SUT).
  2. It gives the system under test a stimulus (typically by invoking a method).
  3. It observes the ensuing behavior. A typical unit test consists of three phases:

The unit test passes if the observed behavior matches the expectations; otherwise, it fails, indicating a fault in the system under test.

Arrange, Act, and Assert, or simply AAA, are the three phases of unit testing.

A unit test can verify various system features under test, but it will almost certainly fall into one of two categories: state-based or interaction-based. State-based unit testing confirms that the system under test delivers accurate results or that its resultant state is right. In contrast, interaction-based unit testing verifies that it correctly invokes specified methods.

Consider a mad scientist who wants to create a magical chimera with frog legs, octopus tentacles, bird wings, and a dog's head as a metaphor for proper software unit testing. (This metaphor is fairly accurate in terms of what programmers do at work.) How would that scientist ensure that each portion (or unit) he chose is functional? He can, for example, administer an electrical stimulus to a single frog's leg and check for correct muscular activity. The only distinction is that he's performing the identical Arrange-Act-Assert processes as a unit test; the only difference is that unit refers to a tangible thing rather than an abstract concept from which we create our programs.

We'll use C# for all examples in this post, but the concepts apply to any object-oriented programming language.

A simple unit test would look something like this:

unit-test-testable-code-example.png

Unit Test vs. Integration Test

Another element to consider is the distinction between unit and integration testing.

A unit test is a sort of software test that is used to validate the behavior of a tiny piece of software that is not dependent on other components.Unit tests are limited in scope, allowing us to cover all scenarios and ensure that every component functions properly.

On the other hand, integration tests show how different pieces of a system interact in a real-world setting. They frequently involve external resources, like databases or web servers, to validate complex scenarios (we can think of integration tests as a user executing high-level activity within our system).

Let's return to our mad scientist metaphor and assume that he has successfully mixed all of the chimera's components. He wants to put the generated monster through an integration test to see if it can, for example, walk on different sorts of terrain. First and foremost, the scientist must create an environment where the creature can walk. The creature is then thrown into that habitat and poked with a stick to see if it walks and moves as it should. After completing a test, the mad scientist cleans up all of the dirt, sand, and rocks that have become strewn across his magnificent laboratory.

It's important to note the distinction between unit and integration tests: An integration test covers interactions between different components in a close-to-real-life environment and requires more effort, including additional setup and teardown phases. A unit test corrects the behavior of a small part of the application, isolated from the environment and other parts, and is relatively easy to implement. A reasonable blend of unit and integration tests ensures that each unit works correctly, independently of others. These units play nicely together, giving us great confidence that the entire system functions as planned.

However, we must always specify whether we are performing a unit or integration test. The distinction can be deceptive at times. Something is wrong if we think we're developing a unit test to verify a subtle edge case in a business logic class and discover that it requires external resources like web services or databases to be present. We're essentially trying to crack a nut with a sledgehammer. And that entails poor design.

What Makes a Good Unit Test?

Let's quickly go through the characteristics of a good unit test before moving on to the major section of this course and developing unit tests. A good test, according to unit testing principles, must be:

Simple to writeReadable ReliableFast Standalone
Developers often write many unit tests to cover various scenarios and features of the application's behavior. Thus coding all of those test procedures should be simple.A unit test's goal should be obvious. Since a good unit test provides a story about some part of our application's behavior, it should be simple to figure out which scenario is being tested and, if the test fails, how to fix the problem. We can repair a bug without debugging the code if we have a good unit test.Unit tests should be repeatable and unaffected by external factors such as the environment or the order in which they are conducted. Only if there is a flaw in the system under test should unit tests fail. That may seem self-evident, yet programmers frequently face difficulties when their tests fail despite the absence of flaws. Tests may, for example, pass when run one at a time but fail when run as a whole, or pass on our development machine but fail on the continuous integration server. These circumstances point to a design problem.Developers write unit tests to run them frequently to ensure no bugs have been introduced. Developers are more inclined to forego running unit tests on their workstations if they are slow. One slow test won't make much of a difference; add a thousand more, and we'll be waiting a long time. Slow unit tests could also mean that the tested system, or the test itself, interacts with external systems, making it environment-dependent.Unit and integration tests serve distinct goals, as we've just discussed. To avoid the influence of external factors, neither the unit test nor the system under test should access network resources, databases, or the file system.

There are, nevertheless, several strategies that can help us write testable code.

Additional great topics on software testing:

Static Code Analysis Tools Deliver Code Optimization and Compliance

MISRA C Rules & Coding Standards Compliance

ISO 26262 Compliance & Tools

DevSecOps: Software Security Testing at Speed

SAST: Software Security Testing Made Simple From the Start

API Security Testing: Identifying API Security Risks

Testable and Untestable Code

Some code is written so that it is difficult, or even impossible, to write a good unit test. What makes code hard to test?

Let us review some anti-patterns, code smells, and bad practices that one should avoid when writing testable code.

Poisoning the Codebase with Non-Deterministic Factors

Let us begin with an easy example. Imagine that we are writing an application program for a smart home microcontroller. One of the requirements is to turn on the light automatically in the backyard when motion is detected during the evening/night. We have begun from the bottom up by fulfilling an approach that returns a string representation of the approximate time of day, i.e., night, morning, afternoon, or evening.

example-of-bad-code.png

This method reads the current system time and returns a response based on it. What exactly is the problem with this code?

When we consider it from the standpoint of unit testing, we can see that this method's meaningful state-based unit test is not achievable. DateTime. Now is a hidden input that will most likely vary during the program or between test runs. As a result, subsequent calls to it will yield different outcomes.

Because of this non-deterministic behavior, it's hard to verify the GetTimeOfDay() method's internal logic without changing the system date and time.

Let us take a look at how such a test would be carried out:

example-of-problematic-code-test.png

Such tests would break many of the rules described before. It would be costly to write (because of the non-trivial setup and teardown logic), unreliable (it could fail even though the system under test has no bugs, for example, due to system permission concerns), and not guaranteed to execute quickly. Finally, this test would not be a unit test — it would be a cross between a unit and an integration test because it appears to test a simple edge case but necessitates a specific environment setup. Isn't the end product not worth the effort?

It turns out that the low-quality GetTimeOfDay() API is to blame for all of these testability issues. This approach has several flaws in its current form:

  • The major cause of most testability issues is tight coupling. This method cannot be used to process the date and time obtained from other sources or supplied as an argument; the method only works with the date and time of the computer that executes the code. It is closely linked to the physical data source.
  • The method performs several functions, including consuming and processing data. It violates the Principle of Single Responsibility (SRP). When a single class or function has multiple reasons to update, this is another evidence of SRP violation. The GetTimeOfDay() method could be altered in this case, either due to internal logic changes or the date and time source should be updated.
  • Understanding the behavior of a method requires more than just looking at its signature. It conceals the information it needs to do its task. To understand what secret inputs are used and where they come from, developers must examine every line of the real source code.
  • The behavior of a method that relies on a mutable global state cannot be anticipated solely by reading the source code; it must also consider the present value of the global state, as well as the complete chain of events that could have modified it before. Trying to untangle all of that in a real-world application is a huge pain. It's difficult to foresee and keep up with.

Let's repair it now that we've gone over the API! Fortunately, breaking down the closely connected concerns is far easier than discussing all its flaws.

Fixing the API: Introducing a Method Argument

The most obvious and straightforward way to improve the API is to add a method argument:

code-test-with-method-argument.png

Instead of discreetly looking for this information, the procedure now needs the caller to submit a DateTime argument. This is fantastic from the standpoint of unit testing; because the function is now deterministic (i.e., its return value is entirely dependent on the input), state-based testing is as simple as passing a DateTime value and inspecting the result:

code-test-with-refactor-input.png

By providing a clear refactor between what data should be processed and how it should be done, this simple rework also solves the API issues described before (tight coupling, SRP violation, unclear and hard to comprehend API).

Excellent — the approach can be tested, but what about the people who use it? The caller is now responsible for providing the date and time to the GetTimeOfDay(DateTime dateTime) method, which means they may become untestable if we aren't careful.

Let's have a look at what we can do about it.

Fixing the Client API: Dependency Injection

Assume we keep working on the smart home system and develop the following GetTimeOfDay(DateTime dateTime) method client — the aforementioned smart home microcontroller code for turning the light on or off based on the time of day and motion detection:

example-of-dependency-injection-in-client-api.png

We have a DateTime that is hidden in the same way. Now consider the input problem; the only difference is situated at a little higher abstraction level. To address this problem, we may add another argument, outsourcing the duty for supplying a DateTime value to the caller of a new methodology with the signature ActuateLights (bool motionDetected, DateTime dateTime). Rather than repositioning the problem higher in the call stack, let's try a different approach that will allow us to test both the ActuateLights(bool motionDetected) method and its clients: IoC stands for Inversion of Control.

Inversion of Control is a simple but powerful technique for decoupling code and, in particular, unit testing. (After all, being able to evaluate things independently from one another requires maintaining things loosely connected.) IoC's main goal is to separate decision-making code (when to do something) from action code (how to do something) (what to do when something happens). This strategy promotes flexibility, minimizes coupling between components, and makes our code more modular.

Inversion of Control can be achieved in various ways; let's look at one example in particular — Dependency Injection with a constructor — and how it might assist in developing a testable SmartHomeController API.

Let's start by making an IDateTimeProvider interface with a method signature for getting a date and time:

example-of-datetimeprovider-interface-in-unit-test-code.png

Proceed to make SmartHomeController reference an IDateTimeProvider implementation and delegate date and time retrieval to it:

example-of-using-datetimeprovider.png

Now we can see why it's named Inversion of Control: the control over which technique to use for reading date and time has been reversed, and it now belongs to the SmartHomeController client, not SmartHomeController itself. As a result, the motionDetected argument and a concrete implementation of IDateTimeProvider provided into a SmartHomeController constructor are required for the ActuateLights(bool motionDetected) method to work.

What does this mean in terms of unit testing? It means that several IDateTimeProvider implementations can be used in both production and unit test code. Some real-world implementation will be put into the production environment (e.g., one that reads actual system time). We can, however, inject a "fake" implementation in the unit test that returns a constant or predetermined DateTime value suited for evaluating the situation.

This is what a counterfeit implementation of IDateTimeProvider might look like:

counterfeit-implementation-of-datetimeprovider.png

It is possible to separate SmartHomeController from non-deterministic elements and do a state-based unit test with the help of this class. Let's make sure that if motion is detected, the lastMotionTime field records the time of that motion:

state-based-unit-test-example.png

Great! Do you think SmartHomeController is adequately testable now that we've eliminated non-deterministic factors and confirmed the state-based scenario? Prior to refactoring, such a test was not possible.

Poisoning the Codebase with Side Effects

Even though we were able to test specific functions and overcome the problems created by the non-deterministic concealed input, the code (or at least a portion of it) is still untestable!

Let's have a look at the part of the ActuateLights(bool motionDetected) method that controls whether the light is turned on or off:

example-of-problematic-code-test.png

SmartHomeController delegates turning on or off the light to a BackyardLightSwitcher object, which follows the Singleton pattern. What is the flaw in this design?

We should undertake interaction-based testing in addition to state-based testing to completely unit test the ActuateLights(bool motionDetected) method. We should guarantee that methods for turning the light on or off are called only if and only if proper criteria are fulfilled.

Unfortunately, the current design prevents us from doing so: the BackyardLightSwitcher's TurnOn() and TurnOff() methods cause state alterations in the system, or in other words, produce side effects. The only way to know if these procedures were used is to look at whether or not their associated side effects occurred, which could be painful.

Assume that the motion sensor, backyard lantern, and smart house microcontroller are part of an Internet of Things network and interact over a wireless protocol.

In this scenario, a unit test can attempt to receive and evaluate such network traffic. Alternatively, if a wire connects the hardware components, the unit test can verify that the correct voltage was applied to the correct electrical circuit. Alternatively, it can use an extra light sensor to confirm that the light was turned on or off.

As shown, unit testing side-effecting methods are likely to be as difficult as unit testing non-deterministic methods, if not impossible. Any attempt will result in the same problems we've already witnessed.

The test results will be difficult to implement, unreliable, possibly sluggish, and not-really-unit. After all of that, the light flashing every time we run the test suite will eventually drive us insane!

Again, the terrible API is to blame for all of these testability issues, not the developer's ability to create unit tests. The SmartHomeController API suffers from the following drawbacks, regardless of how light control is implemented:

  • It is inextricably linked to the actual implementation. The API relies on a concrete instance of BackyardLightSwitcher that has been hard-coded. The ActuateLights(bool motionDetected) method cannot be used to switch any light other than the one in the backyard.
  • It violates the Principle of Single Responsibility. There are two reasons for the API to change: Changes to the internal logic (such as turning on the light only at night and not in the evening) and replacing the light-switching mechanism are two examples.
  • It exaggerates its reliance on others. Other than going into the source code, there is no way for developers to know that SmartHomeController relies on the hard-coded BackyardLightSwitcher component.
  • It is difficult to comprehend and maintain. What if the light won't turn on even though the conditions are ideal? We could waste a lot of time attempting to solve the SmartHomeController to discover that the issue is because of a flaw in the BackyardLightSwitcher (or, even funnier, a burned-out lightbulb!).

Not unexpectedly, the solution to both testability and low-quality API issues is to decouple tightly connected components. As in the previous example, adding an ILightSwitcher dependency to the SmartHomeController, delegating the responsibility of flipping the light switch to it, and passing a false, test-only ILightSwitcher implementation that will record whether the appropriate procedures were called under the right conditions would solve these issues. Instead of using Dependency Injection once more, let's have a look at an interesting alternative method for decoupling responsibilities.

Fixing the API: Higher-Order Functions

Any object-oriented language that allows first-class functions can use this method. Let's use C #'s functional features and add two extra parameters to the ActuateLights(bool motionDetected) method: a pair of Action delegates that point to methods that should be invoked to turn the light on and off. The method will be converted into a higher-order function using this solution:

higher-order-functions-example.png

This method has a more functional flavor than the standard object-oriented Dependency Injection technique we've seen before; yet, it allows us to accomplish the same effect with less code and more flexibility than Dependency Injection. To provide SmartHomeController with the desired functionality, we no longer need to implement a class that conforms to an interface; instead, we may just pass a function definition. Higher-order functions can implement Inversion of Control in a different method.

We can now input readily verifiable false actions into the resulting method to execute an interaction-based unit test:

interaction-based-unit-test-example.png

Finally, we've made the SmartHomeController API completely testable, with state-based and interaction-based unit tests available. Not only did establishing a seam between the decision-making and action code increase testability, but it also helped to alleviate the tight coupling problem and resulted in a clearer, reusable API.

Already, we can just construct a bunch of similar-looking tests to validate all conceivable situations to obtain full unit test coverage — not a big concern, given unit tests are now relatively simple to implement.

Impurity and Testability

The harmful effects of uncontrolled non-determinism and side effects on the codebase are comparable. When used irresponsibly, they provide deceptive, difficult to understand and maintain, tightly connected, non-reusable, and untestable code.

Methods that are both deterministic and side-effect-free, on the other hand, are considerably easier to test, reason about, and reuse in larger applications. Pure functions are the term used in functional programming to describe such approaches. Unit testing a pure function is rarely a problem; we have to pass some parameters and inspect the output for validity. Hard-coded, impure components that cannot be altered, overridden or abstracted away in any other way render programming untestable.

If method Foo() is dependent on the non-deterministic or side-effecting method Bar(), then Foo() becomes non-deterministic or side-effecting. We might end up poisoning the entire codebase in the end. When we multiply all of these issues by the scale of a complicated real-world program, we end up with a difficult-to-maintain codebase full of smells, anti-patterns, hidden dependencies, and other unsightly and unpleasant things.

However, impurity is unavoidable; any real-world program must interface with the environment, databases, configuration files, web services, or other external systems at some point to read and alter information. Instead of attempting to eliminate impurity, it's a good idea to restrict these elements, prevent allowing them to poison your codebase, and break hard-coded dependencies as much as possible so that you can analyze and unit test things separately.

Common Warning Signs of Hard to Test Code

Do you have trouble writing tests? The issue isn't with your test suite. It's written in your code.

Finally, let's go over some frequent red flags that our code will be challenging to test.

Static Properties and Fields

Static properties and fields, or simply global state, can make code more difficult to understand and test because they hide information needed for a method to complete its job, introduce non-determinism, and encourage the use of side effects. Inherently impure are functions that read or alter mutable global states.

For example, the following code, which relies on a globally available attribute, is difficult to reason about:

code-with-static-properties-and-fields-example.png

What if the HeatWater() method isn't called even though we know it should be? Since any portion of the program could have altered the CostSavingEnabled value, we'll need to track down and examine all of the areas where it's changed to figure out what's wrong. Furthermore, as we've seen, some static properties cannot be set for testing purposes (For example, DateTime.Now, Environment.MachineName; they are read-only but still non-deterministic).

An unchanging and deterministic global state, on the other hand, is perfectly acceptable. In reality, there's a more well-known term for it: a constant.

Math has constant values. PI does not induce non-determinism and does not allow any side effects because their values cannot be changed:

math-constant-values-in-code-example.png

Singletons

The Singleton pattern is just a variant of the global state. Singletons promote obfuscated APIs that hide true dependencies and cause unnecessarily close coupling between components. They also break the Single Responsibility Principle by controlling their initialization and lifetime in addition to their primary responsibilities.

Because singletons carry state for the lifetime of the application or unit test suite, they can easily create order-dependent unit tests. Consider the following scenario:

singleton-pattern-in-code-example.png

If a test for the cache-hit scenario is run first, it will add a new user to the cache, causing a test for the cache-miss method to fail because it assumes the cache is empty. After each unit test run, we'll have to build additional teardown code to clean the UserCache to get around the issue.

Singletons are a terrible practice that can (and should) be avoided in most circumstances; yet, it's crucial to understand the difference between a single instance of an object and a design pattern called Singleton. In the latter case, the application is responsible for establishing and maintaining a single instance.

This is typically handled by a factory or Dependency Injection container, which generates a single instance towards the "top" of the program (i.e., closer to an application entry point) and then provides it to every object that requires it. From the standpoint of testability and API quality, this method is ideal.

The new Operator

Creating a new instance of an object to do a task has the same drawbacks as the Singleton anti-pattern: ambiguous APIs with hidden dependencies, tight coupling, and poor testability.

For example, the developer should set up a test web server to see if the following loop terminates when a 404 response code is returned:

test-loop-example.png

New, on the other hand, isn't always a bad thing: It's fine, for example, to make simple entity objects:

new-object-instance-example.png

It's also fine to make a small, transient object with no side effects other than changing its state and then return the outcome depending on that state. We don't need to worry whether Stack methods were called or not in the following example; all we worry about is whether the result is correct:

transient-object-in-code-example.png

Static Methods

Another source of non-deterministic or side-effecting behavior is static methods. They can easily cause tight coupling in our code, making it untestable.

Unit tests, for example, must change environment variables and read the console output stream to confirm that the required data was produced to verify the behavior of the following method:

verification-of-behavior-in-unit-test-example.png

On the other hand, pure static functions are acceptable: any combination of them will still be a pure function. Consider the following scenario:

pure-static-function-in-code-example.png

Benefits of Unit Testing

Writing testable code needs some discipline, dedication, and extra effort. However, software development is a complicated mental activity in and of itself, and we should always be cautious when slapping together new code off the top of our heads.

We'll end up with clean, easy-to-maintain, loosely connected, and reusable APIs that won't harm developers' brains when they try to grasp them as a result of this act of proper software quality assurance. After all, the ultimate benefit of testable code is not only its testability but also its ease of understanding, maintenance, and extension.