Testability is a key concern when following TDD practices. There are many different ways to accidentally write difficult-to-test code, but fortunately they tend to fall into fairly broad categories. In this post, we’ll examine some common coding pitfalls that introduce testing challenges, and future installments will discuss ways to work around or avoid these issues.
So What Do You Mean By Testability?
Testability is a measure of how easy (or difficult) it is to write tests for a given class. There are several development practices and patterns that work together to improve a given class’ testability, including Dependency Injection and SOLID Principles. In general, highly-testable classes will have several traits in common:
- Dependencies are specified as abstractions (either interfaces or abstract classes)
- Dependencies are injected rather than created internally
- Factories are used to create objects with runtime dependencies
- Avoidance of static classes and methods wherever possible
So let’s take a quick look at a class that is inherently difficult to test and identify the various problems…
A Difficult-to-Test Class:
Hmmm, that doesn’t look so awful to me…
True, this code is comparable to a vast majority of example code (and probably a lot of production code too…), but it has some fundamental issues that prevent effective testing. For example, let’s think about things we might want to test on the ReallyHardToTest class:
- Verify that the ReallyHardToTest constructor creates an instance of SomeModel
- Prove that the ReallyHardToTest constructor invokes otherDependency.DoSomething()
- Determine whether or not ReallyHardToTest.SetFilename invokes Path.GetFileName with the specified filename
- Demonstrate that ReallyHardToTest.SetFilename sets SomeModel.Filename to the value returned by Path.GetFileName
As currently written, we cannot verify any of these behaviors at test time.
So What Exactly is Impeding Testability?
- The constructor specifies a concrete type as a dependency. Most mocking frameworks only support abstractions, therefore when we test, we have to inject an actual instance of SomeOtherDependency. For example, if SomeOtherDependency.DoSomething() has side effects like adding a database entry, the database will become polluted with test entries. Similarly, if SomeOtherDependency.DoSomething() connects to a HTTP service, each test run will have to wait for an actual HTTP call to complete.
Use Mocks, Stubs, and Fakes to avoid external dependencies during testing. These generally require dependencies to be specified as abstractions so they can be mocked.
- The constructor creates an instance of SomeModel by directly using the new operator. Newing up objects introduces testing challenges because we cannot externally control the instance that was created. Furthermore, if that instance isn’t exposed publicly, we can’t even verify that our class interacts with SomeModel as we expected.
Use the various factory patterns to avoid this issue. A factory takes on the responsibility for object creation, and these factories can generally be mocked at test time.
- The SetFilename method uses the static Path.GetFileName() method from System.IO. Static classes and methods pose special concerns when testing because we can’t control or mock them at test time. In many cases such classes will also have dependencies on underlying infrastructure such as the filesystem.
Static dependencies are effectively hidden dependencies. We cannot tell that ReallyHardToTest has a dependency on System.IO.Path without examining the entire ReallyHardToTest codebase. A common solution to this challenge is to create a non-static wrapper class and explicitly inject it into the consuming class, enabling us to mock the formerly-static dependency.
And Now, The Really Hard Part:
It turns out that the hardest part of software testability is shifting engineers’ mindsets to consider testability first. It’s almost always easier to ensure that your code at least would be testable up front than it is to tear apart an entire subsystem so that it can be tested effectively. Like other coding-related skills, after enough practice it becomes second nature to write clean, testable code.
Conclusion and What’s Next
In this post, we have examined a variety of common coding practices that have a negative impact on software testability. Hopefully it’s clear that testability should be a primary concern, even when not strictly following TDD practices. In the next installment of this series, we’ll examine some of the ways we can clean up the ReallyHardToTest class and make it properly testable.