Unit Testing C# File Access Code with System.IO.Abstractions

It can be difficult  to write unit tests for code that accesses the file system.

It’s possible to write integration tests that read in an actual file from the file system, do some processing, and check the resultant output file (or result) for correctness. There are a number of potential problems with these types of integration tests including the potential for them to more run slowly (real IO access overheads), additional test file management/setup code, etc. (this does not mean that some integration tests wouldn’t be useful however).

The System.IO.Abstractions NuGet package can help to make file access code more testable. This package provides a layer of abstraction over the file system that is API-compatible with existing code.

Take the following code as an example:

using System.IO;
namespace ConsoleApp1
{
    public class FileProcessorNotTestable
    {
        public void ConvertFirstLineToUpper(string inputFilePath)
        {
            string outputFilePath = Path.ChangeExtension(inputFilePath, ".out.txt");

            using (StreamReader inputReader = File.OpenText(inputFilePath))
            using (StreamWriter outputWriter = File.CreateText(outputFilePath))
            {
                bool isFirstLine = true;

                while (!inputReader.EndOfStream)
                {
                    string line = inputReader.ReadLine();

                    if (isFirstLine)
                    {
                        line = line.ToUpperInvariant();
                        isFirstLine = false;
                    }

                    outputWriter.WriteLine(line);
                }
            }
        }
    }
}

The preceding code opens a text file, and writes it to a new output file, but with the first line converted to uppercase.

This class is not easy to unit test however, it is tightly coupled to the physical file system with the calls to File.OpenText and File.CreateText.

Once the System.IO.Abstractions NuGet package is installed, the class can be refactored as follows:

using System.IO;
using System.IO.Abstractions;

namespace ConsoleApp1
{
    public class FileProcessorTestable
    {
        private readonly IFileSystem _fileSystem;

        public FileProcessorTestable() : this (new FileSystem()) {}

        public FileProcessorTestable(IFileSystem fileSystem)
        {
            _fileSystem = fileSystem;
        }

        public void ConvertFirstLineToUpper(string inputFilePath)
        {
            string outputFilePath = Path.ChangeExtension(inputFilePath, ".out.txt");

            using (StreamReader inputReader = _fileSystem.File.OpenText(inputFilePath))
            using (StreamWriter outputWriter = _fileSystem.File.CreateText(outputFilePath))
            {
                bool isFirstLine = true;

                while (!inputReader.EndOfStream)
                {
                    string line = inputReader.ReadLine();

                    if (isFirstLine)
                    {
                        line = line.ToUpperInvariant();
                        isFirstLine = false;
                    }

                    outputWriter.WriteLine(line);
                }
            }
        }
    }
}

The key things to notice in the preceding code is the ability to pass in an IFileSystem as a constructor parameter. The calls to File.OpenText and File.CreateText are now redirected to _fileSystem.File.OpenText and _fileSystem.File.CreateText  respectively.

If the parameterless constructor is used (e.g. in production at runtime) an instance of FileSystem will be used, however at test time, a mock IFileSystem can be supplied.

Handily, the System.IO.Abstractions.TestingHelpers NuGet package provides a pre-built mock file system that can be used in unit tests, as the following simple test demonstrates:

using System.IO.Abstractions.TestingHelpers;
using Xunit;

namespace XUnitTestProject1
{
    public class FileProcessorTestableShould
    {
        [Fact]
        public void ConvertFirstLine()
        {
            var mockFileSystem = new MockFileSystem();

            var mockInputFile = new MockFileData("line1\nline2\nline3");

            mockFileSystem.AddFile(@"C:\temp\in.txt", mockInputFile);

            var sut = new FileProcessorTestable(mockFileSystem);
            sut.ConvertFirstLineToUpper(@"C:\temp\in.txt");

            MockFileData mockOutputFile = mockFileSystem.GetFile(@"C:\temp\in.out.txt");

            string[] outputLines = mockOutputFile.TextContents.SplitLines();

            Assert.Equal("LINE1", outputLines[0]);
            Assert.Equal("line2", outputLines[1]);
            Assert.Equal("line3", outputLines[2]);
        }
    }
}

To see this in action or to learn more about file access, check out my Working with Files and Streams in C# Pluralsight course.

You can start watching with a Pluralsight free trial.

SHARE:

Comments (4) -

  • Eric Bäckhage

    10/19/2018 4:37:41 AM | Reply

    Thanks for sharing! I have been looking for an in-memory file system to use for unit testing. This looks exactly like what I need.
    Well written post as well. Mind if I link to it from my own blog, http://www.ericbackhage.net ?

    • Jason Roberts

      10/19/2018 5:31:36 AM | Reply

      Thanks Eric - link away! Smile

  • Jim Tomney

    4/27/2020 5:50:12 PM | Reply

    Looks great - but unfortunately if you're working with code that is at .NET 4.5 then you'll have to wait until you upgrade. Bummer...

  • Tarmo Pikaro

    1/23/2021 7:44:22 AM | Reply

    This indeed looks intriguing, but it would require re-writing substantial amount of code - I'm not talking only about file system abstractions, but also everything else what uses file system, like logging libraries, and so on. Once the code it already there - re-writing it without direct API support (like .NET and logging libraries) sounds like huge amount of work. Also it's possible that you don't want only to  mock file system code, but you want your code to work faster. Would it make much sense if we could use ram disk instead ? Then all operations will be faster, and no need to re-write everything.

    As a ram disk imdisk could be used
    www.maketecheasier.com/setup-ram-disk-windows/

    with additional trick to mount as a user:
    www.nirsoft.net/.../...tion_without_elevation.html:~:text=You%20can%20use%20the%20AppCompatibilityView,Option%20%2D%3E%20Run%20As%20Invoker.

    One that is set up, all testing application need to do - is just mount virtual R: disk and do whatever it does normally on drive R:

    This approach is of course not faster than mocking, because most probably it involves NTFS file system creation, where RAM write would also do some byte formatting & encoding on the way - but this requires much less code in a turn than file system mocking.

Add comment

Loading