Improve Testability by Wrapping Static Classes
Certain parts of the .NET framework are exposed as static classes, for example File
, Directory
, and Path
. Because static classes cannot be mocked, it is very difficult to test components that use them. Furthermore, some static classes may have external side effects or dependencies, for example File.ReadAllText
requires an actual physical file to reside at the specified location or the call will fail. This complicates our testing code by requiring us to create a file in a known location for the test, and then clean up afterwards. This also violates the "Test in Isolation" rule because the test now has a dependency on an actual underlying filesystem.
This post demonstrates a clean, relatively low-effort way to improve testability for classes that consume static classes.
GitHub Repository
The project is hosted in a GitHub Repository containing two branches:
- difficult-to-test: This branch directly uses
System.Io.File
to read a file and illustrates a couple of common challenges when testing code that consumes static classes - with-framework-wrappers: This branch introduces a simple wrapper to encapsulate calls to
System.Io.File
and improve testability
Solution Overview
The solution is a simple example, consisting of two projects:
- Tdd.FrameworkWrappers.Lib: The implementation under test. It contains a single class,
FileReader
, which reads a file from storage and returns its contents as a string. - Tdd.FrameworkWrappers.Lib.Tests: NUnit tests for the
Tdd.FrameworkWrappers.Lib
project.
difficult-to-test
branch
Walkthrough: We'll start by cloning the difficult-to-test
branch and opening the solution.
If we take a look at FileReader.cs, we can see that its ReadText
method simply delegates to System.Io.File.ReadAllText
and returns its result:
Things get more convoluted, however, when we dig into FileReaderTests.cs
. The direct usage of System.Io.File
complicates our testing in a few ways:
- We cannot verify the interaction between
FileReader
andSystem.Io.File
because static classes cannot be mocked - We need to add pre-and-post test methods to create and clean up a test file on the system. This in itself can be problematic since reading/writing/deleting files can be error-prone (i.e. IoExceptions), so our test could conceivably fail for reasons outside the bounds of the test.
Wrappers to the Rescue!
We can solve this testability issue by wrapping System.Io.File
in another, non-static class that we control. By doing so, we can expose an IFile
interface (so we can mock and support dependency injection). The IFile
interface is implemented by FileImpl
, which would be the concrete type injected into instances of FileReader
by our IoC container.
So without further ado, let's dig in!
with-framework-wrappers
branch
Walkthrough: Now let's clone the with-framework-wrappers
branch and open the solution.
We'll start in the same place as before, in FileReader.cs
. Note that there is now a constructor that takes an IFile
and ILogger
instance, and that the ReadText
method now invokes IFile.ReadAllText
instead of File.ReadAllText
.
You'll also notice that there is a new FrameworkWrappers
folder, so let's dig into that. FrameworkWrappers
contains IFile
and its implementation, FileImpl
. Looking at the interface, we see that it exposes a ReadAllText
method whose signature matches that of System.Io.File.ReadAllText
:
Moving on to FileImpl
, we see that it's doing exactly what FileReader
was doing before: Simply delegating the call to System.Io.File.ReadAllText
and returning the result:
If we now move on to the tests, we'll see some more changes, specifically:
- The
CreateTestFile
andCleanupTestFile
methods are gone because we no longer have to write to a filesystem in order to execute this test (e.g. we have removed the test's dependency on the underlying filesystem) SetUp
has been modified to instantiate aMock<IFile>
andMock<ILogger>
and inject them into theFileReader
under testReadText_WhenFileExists_ReturnsFileContents
is substantially simplifiedReadText_Always_PerformsExpectedWork
can now be implemented - because we're using a mock, we can verify that theReadText
method does, in fact, call theReadAllText
method onIFile
with the expected arguments.- We can also now test 'sad' flows, e.g. "Does
FileReader
react appropriately if an exception is thrown byIFileReader.ReadAllText
?"ReadText_WhenIoExceptionThrown_PerformsExpectedWork
illustrates this new capability - Test cases are parameterized using
TestCaseSourceAttribute
. This isn't really specific to testing with Framework Wrappers, but it's just a way to reuse test cases.
You may notice that there is no corresponding FileImplTests
in the test project, this is normal for pure wrappers, e.g. classes that simply delegate calls to an external class (in this case a Microsoft library) without adding any logic. The reasoning behind this is the expectation that Microsoft (or the third-party vendor) would be responsible for testing their own libraries, so testing the wrapper would be redundant.
Wind-Down
So in a nutshell, what we did here was improve testability by adding a thin layer of indirection around the System.Io.File
class, enabling us to introduce an interface (for DI and Mocking), implemented by a class we control. The end result is that we can effectively test our FileReader
class in complete isolation from external dependencies.
I tend to follow this pattern almost any time I need to consume a static class, for example File
, Directory
, Path
, ConfigurationManager
, etc. I've also used a variation of this pattern with WPF applications, wrapping non-WPF-friendly UI components in an Attached Behavior to facilitate communications between ViewModels and legacy UI components.
Great advice!
One thing I would add is maybe I wouldn’t call the interface
IFile
. At least to me it sounds like it represents a single File. MaybeIFileManager
? Or perhaps even multiple interfaces in order to adhere to the Interface Segregation Principle.There’s also a library for this.