In this article, we are going to learn how to mock the file system and unit test a component that interacts with the file system. This last one is a dependency in our application’s classes and we can’t include it in a unit test. We are going to see that correctly designing the component under test makes all the difference.
Let’s start with an example!
Why Is Unit-Testing the File System Methods Complex?
Let’s imagine we have a method that reads the content of a file and writes the number of its lines, words, and bytes in a new file. This implementation uses sync APIs for the sake of simplicity:
public void WriteFileStats(string filePath, string outFilePath) { var fileContent = File.ReadAllText(filePath, Encoding.UTF8); var fileBytes = new FileInfo(filePath).Length; var fileWords = Regex.Matches(fileContent, @"\s+").Count + 1; var fileLines = Regex.Matches(fileContent, Environment.NewLine).Count + 1; var fileStats = $"{fileLines} {fileWords} {fileBytes}"; File.AppendAllText(outFilePath, fileStats); }
Unit testing a method like this one would increase the test complexity and, therefore, would cause code maintenance issues. Let’s see the two main problems.
The first problem is that methods File.ReadAllText()
and File.AppendAllText()
are static.
But why should this be a problem?
Because most of the time, we use constrained testing frameworks, like Moq, which are limited by design. For example, with Moq, when we run new Mock<OurClass>()
, we get an instance of a generated proxy class that extends OurClass
. File
, or any other static class, cannot be subclassed, which is why Moq does not support static methods mocking.
Even if we manage to solve the first limit, we would still need to replace each file system method with a test double. One option is to create a fake object that emulates the file system with an in-memory representation of it. The other possibility is to set up a test spy/mock for each test method. Considering the initial code snippet, both approaches are inappropriate.
Let’s see how dependency injection can help us.
Dependency Injection Is the Answer, But How?
Dependency injection often comes in handy when we have to unit test a class with external dependencies. In our case, the file system is the dependency we need to deal with. Since injecting static classes would not make sense for test purposes, the alternative is to use a wrapper class. Let’s see one example of such a class:
public class FileWrapper : IFile { public override void AppendAllLines(string path, IEnumerable<string> contents) { File.AppendAllLines(path, contents); } public override void AppendAllLines(string path, IEnumerable<string> contents, Encoding encoding) { File.AppendAllLines(path, contents, encoding); } // ... }
The IFile
interface and the File
class both have the method definitions in common, with the difference that the interface methods are not static. The wrapper implementation of this interface is very simple: for each instance method, we just need to call the corresponding static method of the File
class. We can also use this strategy for Directory
, FileInfo
, and so on.
Now that we have the wrapper, we can inject it into the constructor of the component to test. In this way, we can at least mock its implementation. But we can design it even better, by following the dependency inversion principle. Basically, instead of injecting the implementation, that is the FileWrapper
, we use its interface IFile
:
public class FileStatsUtility { private IFile _fileWrapper; public FileStatsUtility(IFile fileWrapper) { _fileWrapper = fileWrapper; } public void WriteFileStats(string filePath, string outFilePath) { var fileContent = _fileWrapper.ReadAllText(filePath, Encoding.UTF8); // ... } }
This means getting a twofold advantage: we can very easily mock that interface, but we could also provide a custom implementation for it. For example, an in-memory implementation of IFile
would make the tests’ temporary stubs unnecessary.
But do we have to write all these wrappers from scratch?
The New File System Dependency
Starting from .NET 5, the System.IO.Abstractions
package takes care of wrapping the classes operating on the file system, just like we have done with the FileWrapper
above. Here is an example of how to use it:
using System.IO.Abstractions; public class FileStatsUtility { private IFileSystem _fileSystem; public FileStatsUtility(IFileSystem fileSystem) { _fileSystem = fileSystem; } public void WriteFileStats(string filePath, string outFilePath) { var fileContent = _fileSystem.File.ReadAllText(filePath, Encoding.UTF8); var fileBytes = _fileSystem.FileInfo.FromFileName(filePath).Length; var fileWords = this.CountWords(fileContent); var fileLines = this.CountLines(fileContent); var fileStats = $"{fileLines} {fileWords} {fileBytes}"; _fileSystem.File.AppendAllText(outFilePath, fileStats); } private int CountLines(string text) => Regex.Matches(text, Environment.NewLine).Count + 1; private int CountWords(string text) => Regex.Matches(text, @"\s+").Count + 1; }
The entry point for any file system method is an object of type FileSystem
. It contains properties like File
and Directory
, which return wrappers of the corresponding static classes. For example, _fileSystem.File.ReadAllText()
does nothing more than call File.ReadAllText()
with the same arguments provided to its wrapper.
We can instantiate FileStatsUtility
with:
var fileStatsUtil = new FileStatsUtility(new FileSystem())
However, the component constructor should accept an abstraction, i.e. IFileSystem
. In this way, in our test project, instead of injecting a FileSystem
object, we pass a fake implementation of that interface, which is the MockFileSystem
. Note that this class is found in the System.IO.Abstractions.TestingHelpers
package.
Shortly, we are going to see an example of how to use MockFileSystem
, but the idea is very simple. Instead of creating physical files and directories, we are going to initialize the MockFileSystem
with MockFileData
s and MockDirectoryData
s, respectively. These objects behave like files or directories, but the OS never allocates space for them on the secondary storage. They are referenced by the MockFileSystem
instance, which stores them in a dictionary indexed by their virtual path.
Let’s see them in action!
A Fully Testable Utility for File Statistics
The rewritten WriteFileStats()
is easier to unit test, starting from the test initialization:
[TestInitialize] public void TestSetup() { _fileSystem = new MockFileSystem(); _util = new FileStatsUtility(_fileSystem); }
The file system dependency setup consists of instantiating a MockFileSystem
, while the cleanup is not even necessary. In the “arrange” phase of each test, we are going to add virtual files and directories using AddFile()
and AddDirectory()
:
[TestMethod] public void GivenExistingFileInInputDir_WhenWriteFileStats_WriteStatsInOutputDir() { var fileContent = $"3 lines{Environment.NewLine}6 words{Environment.NewLine}24 bytes"; var fileData = new MockFileData(fileContent); var inFilePath = Path.Combine("in_dir", "file.txt"); var outFilePath = Path.Combine("out_dir", "file_stats.txt"); _fileSystem.AddDirectory("in_dir"); _fileSystem.AddDirectory("out_dir"); _fileSystem.AddFile(inFilePath, fileData); _util.WriteFileStats(inFilePath, outFilePath); var outFileData = _fileSystem.GetFile(outFilePath); Assert.AreEqual("3 6 24", outFileData.TextContents); }
We can retrieve any file attached to the mocked file system using GetFile()
, with the virtual path passed as the first argument. Each mocked file then has a TextContents
property that returns the file content as a string, especially useful for assertions. If we need the content in a binary format, we can access the Contents
property.
Unfortunately, some interfaces like IFileSystemWatcher
do not currently have the corresponding fake implementation. In this case, we can use Moq
to configure temporary stubs.
Conclusion
In this article, we’ve learned how to mock the File System in .NET. Testing a class that depends on the file system is not complex at all once we follow these basic guidelines. The upside of this approach is that we can quickly refactor any method that directly depends on static classes like File
or Directory
by leveraging dependency injection and the System.IO.Abstractions
package.