C# – Unit testing code that does File IO

If your code does File IO, such as reading text from a file, then it’s dependent on the file system. This is an external dependency. In order to make the unit tests fast and reliable, you can mock out the external dependencies.

To mock out the file system dependency, you can wrap the File IO method calls, extract an interface for this wrapper, and dependency inject the wrapper. In this article, I’ll show how to do this technique to be able to unit test code that does File IO.

First, the code under test

Here’s an example of code that does File IO. It’s calling static methods in System.IO.File to interact with the file system. It’s checking if a file exists, reading the text, and elsewhere it’s saving text to a file.

using System.IO;

public class WordCountService
{
	public Dictionary<string, int> GetWordCounts(string path)
	{
		if (!File.Exists(path))
		{
			throw new FileNotFoundException(path);
		}

		var wordCounts = File.ReadAllText(path)
			.Split()
			.GroupBy(s => s).ToDictionary(word => word.Key, word => word.Count());

		wordCounts.Remove(""); //better than the verbose Split() overload to ignore empties

		return wordCounts;

	}

	public void SaveWordCounts(Dictionary<string, int> wordCounts, string path)
	{
		StringBuilder sb = new StringBuilder();
		
		foreach(var wordCount in wordCounts)
		{
			sb.AppendLine($"{wordCount.Key}={wordCount.Value}");
		}

		File.WriteAllText(path, sb.ToString());
	}
}
Code language: C# (cs)

If you tried to test this without mocking out the file system, you’d need to deal with actual files in the tests, which would make things more complicated. Furthermore, if you’re using real files, you could run into speed and reliability problems.

1 – Wrap the File IO methods

The first step to making this code unit testable is to wrap the File IO methods in a wrapper class, and extract out an interface for that wrapper.

Here is the wrapper class. It wraps the three File IO methods that the code is calling.

using System.IO;

public class FileIOWrapper : IFileIOWrapper
{
	public bool Exists(string path)
	{
		return File.Exists(path);
	}
	public string ReadAllText(string path)
	{
		return File.ReadAllText(path);
	}
	public void WriteAllText(string path, string text)
	{
		File.WriteAllText(path, text);
	}
}
Code language: C# (cs)

Here’s the interface for the wrapper. This is necessary to be able to mock out the wrapper class in the unit tests.

public interface IFileIOWrapper
{
	bool Exists(string path);
	string ReadAllText(string path);
	void WriteAllText(string path, string text);
}
Code language: C# (cs)


2 – Dependency inject the wrapper interface

In order to be able to pass in the mock in the unit tests, you have to add the wrapper interface as a constructor parameter. This is referred to as dependency injection (which has other benefits besides making the code unit testable).

public class WordCountService
{
	private readonly IFileIOWrapper FileIOWrapper;
	public WordCountService(IFileIOWrapper fileIOWrapper)
	{
		FileIOWrapper = fileIOWrapper;
	}
	
	//rest of class
}
Code language: C# (cs)

Now update the code to use the FileIOWrapper methods instead of the System.IO.File methods:

public Dictionary<string, int> GetWordCounts(string path)
{
	if (!FileIOWrapper.Exists(path))
	{
		throw new FileNotFoundException(path);
	}

	var wordCounts = FileIOWrapper.ReadAllText(path)
		.Split()
		.GroupBy(s => s).ToDictionary(word => word.Key, word => word.Count());

	wordCounts.Remove(""); //better than the verbose Split() overload to ignore empties

	return wordCounts;
}

public void SaveWordCounts(Dictionary<string, int> wordCounts, string path)
{
	StringBuilder sb = new StringBuilder();
	
	foreach(var wordCount in wordCounts)
	{
		sb.AppendLine($"{wordCount.Key}={wordCount.Value}");
	}

	FileIOWrapper.WriteAllText(path, sb.ToString());
}
Code language: C# (cs)


3 – Write a unit test and mock out the wrapper

In the unit test, create the mock IFileIOWrapper and configure it as desired depending on what you’re testing. Pass the mock as a constructor parameter to the code under test (WordCountService in this case).

In this example, it’s verifying that the code throws an exception when the file doesn’t exist:

using Moq;

[TestMethod()]
public void GetWordCountsTest_WhenFileDoesntExist_Throws()
{
	//arrange
	var path = @"C:\book.txt";

	var mockFileIO = new Mock<IFileIOWrapper>();
	mockFileIO.Setup(t => t.Exists(path)).Returns(false);

	var wordCountService = new WordCountService(mockFileIO.Object);

	//act and assert
	Assert.ThrowsException<FileNotFoundException>(() => wordCountService.GetWordCounts(path));
}
Code language: C# (cs)

Here’s another example. This is configuring the IFileIOWrapper mock to return a small amount of text, and then verifying that GetWordCounts() correctly parses the text into a word count map.

[TestMethod()]
public void GetWordCountsTest_WhenFileExists_ReturnsWordCountsFromFileText()
{
	//arrange
	var sb = new StringBuilder();
	sb.AppendLine("hello world");
	sb.AppendLine("hello code");

	var expectedCounts = new Dictionary<string, int>()
	{
		["hello"] = 2,
		["world"] = 1,
		["code"] = 1
	};

	var path = @"C:\book.txt";

	var mockFileIO = new Mock<IFileIOWrapper>();
	mockFileIO.Setup(t => t.Exists(path)).Returns(true);
	mockFileIO.Setup(t => t.ReadAllText(path)).Returns(sb.ToString());

	var wordCountService = new WordCountService(mockFileIO.Object);

	//act
	var wordCounts = wordCountService.GetWordCounts(path);

	//assert
	CollectionAssert.AreEquivalent(expectedCounts, wordCounts);
}
Code language: C# (cs)

Using System.IO.Abstractions instead of writing your own interfaces and wrappers

If you need to wrap lots of IO methods, you might want to consider using the System.IO.Abstractions library. This provides interfaces and wrappers that you can use instead of creating your own. You still have to dependency inject the interface wrappers and mock them out in the unit tests. The only difference is you don’t need to create your own interfaces / wrapper classes.

The downside is you have to add this third party dependency to all of your projects. As always, carefully decide which option is better in your specific scenario. I would suggest starting simple, and once it gets too tedious to keep wrapping IO methods yourself, then switch over to System.IO.Abstractions.

Here’s an example of how to use System.IO.Abstractions instead of writing your own interfaces / wrappers.

1 – Install the package

Add the System.IO.Abstractions package in all of the projects (this is using View > Other Windows > Package Manager Console):

Install-Package System.IO.Abstractions
Code language: PowerShell (powershell)

2 – Use the System.IO.Abstractions interfaces

In the code under test (WordCountService), update it to use IFileSystem from System.IO.Abstractions instead of the homemade IFileIOWrapper:

using System.IO.Abstractions;

public class WordCountService
{
	private readonly IFileSystem FileSystem;
	public WordCountService(IFileSystem fileSystem)
	{
		FileSystem = fileSystem;
	}
	public Dictionary<string, int> GetWordCounts(string path)
	{
		if (!FileSystem.File.Exists(path))
		{
			throw new FileNotFoundException(path);
		}

		var wordCounts = FileSystem.File.ReadAllText(path)
			.Split()
			.GroupBy(s => s).ToDictionary(word => word.Key, word => word.Count());

		wordCounts.Remove(""); //better than the verbose Split() overload to ignore empties

		return wordCounts;

	}
}
Code language: C# (cs)

Note: The wrapper class that implements IFileSystem is called FileSystem.

3 – Mock in the unit test

In the unit test, mock IFileSystem and pass it into the code under test:

using System.IO.Abstractions;
using Moq;

//arrange
var path = @"C:\book.txt";

var mockFileIO = new Mock<IFileSystem>();
mockFileIO.Setup(t => t.File.Exists(path)).Returns(false);

var wordCountService = new WordCountService(mockFileIO.Object);

//act and assert
Assert.ThrowsException<FileNotFoundException>(() => wordCountService.GetWordCounts(path));
Code language: C# (cs)

You can use your preferred mocking framework (like Moq in this example), or you can use mocks provided in the System.IO.Abstractions.TestingHelpers package. You’re probably already using a mocking framework, so I would recommend sticking to that.

Comments are closed.