Introduction to System.IO.Abstractions

One of the most important things about writing testable code is the elimination or reduction of dependencies between objects or components. Why is this important? Simply put, when one object depends on another (which in turn may rely on another, and so on) you can't truly say, "This is exactly how this works, I can guarantee consistent results, and I can do it quickly."

Take, for example, testing a piece of code that interacts with a database. An all-too-common approach is to create a SQL query that sets the database to some known state, and then run that query prior to your test. That may work but, if you're working against a database with which others may be interacting, there's always the chance that between running your SQL query and executing your test that the state of the database has changed.

Going a step further, there's the question of manageability. Are you really willing to write and maintain dozens of these setup queries as your project grows? And, let's not forget -- each of those queries is in itself a piece of code and can be affected by others' test or development queries.

Plus, can the setup query be integrated into an automated process, thus opening the door to regular builds and continuous integration? Maybe. Maybe not.

And, finally, let's not forget that any interaction with a database is slow, relative to the other stuff your code is doing. Unit tests should be quick.  There are at least two database transactions happening here. First, running a query to set up your test. Second, the query within the code you  originally wanted to test!

Every issue above also affects file system operations. Instead of a database query that puts the appropriate rows in place you now have a batch file that positions files in the correct place. You get the picture. Indeed, quite a bit has been written about the evils of testing dependency-laden code.

So, yes, reducing or eliminating dependencies is important to writing testable code.

How do we accomplish this when interacting with a file system? You could mock System.IO with something like:

interface IFileSystem {
    bool FileExists(string fileName);
    DateTime GetCreationDate(string fileName);
}

But that's a lot of code you're going to have to write (and test!).

Introducing System.IO.Abstractions

Someone has already done all the work and they have done it well. Head over to Github and take a look at System.IO.Abstractions. Tatham Oddie et al. have done the heavy lifting by creating an interface (IFileSystem) and a class (FileSystem) plus a bevy of test helpers which cover many of the scenarios you’re likely to encounter. Their README says it best:

At the core of the library is IFileSystem and FileSystem. Instead of calling methods like File.ReadAllText directly, use IFileSystem.File.ReadAllText. We have exactly the same API, except that ours is injectable and testable.

The README also provides a few examples, which proved helpful in getting my own use of System.IO.Abstractions off the ground.

An Example

In my case, I was reading and writing a Microsoft Excel spreadsheet (an interesting exercise in OpenXML and ClosedXML in itself) and wanted to unit test the method I was using to read a path and file name from a configuration file, find the file, open it, and then save it.

(My first step was to describe my method and note the use of the word “and.” Anytime “and” makes an appearance in your description of a method, it’s a sign that you’re asking the method to do too much. If you’re asking a method to do more than one thing, how can you test it? A good test should only cover one thing.)

Once I refactored my code so, among other new methods, I had a method that did nothing more than take in a string (path) and return a Stream. With that done, it was fairly easy to design a test that described the expected result, provided a string (again, “path”), grabbed the actual result, and compared the expected stream to the actual stream.

What I ended up with was the class I wanted to test:

/// <summary>
/// I really should document this stuff better
/// </summary>
public class MeterDataRepository
{
    private static readonly ILog _logger = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType);
    private readonly IFileSystem _fileSystem;
 
    /// <summary>
    /// The default constructor
    /// </summary>
    public MeterDataRepository()
    {
        _fileSystem = new FileSystem();
    }
 
    /// <summary>
    /// A constructor that will accept an System.IO.Abstractions interface.
    /// </summary>
    /// <param name="fileSystem"></param>
    public MeterDataRepository(IFileSystem fileSystem)
    {
        _fileSystem = fileSystem;
    }
 
    /// <summary>
    /// A constructor that opens one or more files AND extracts XML document(s)
    /// </summary>
    /// <returns></returns>
    public List<MeterData> GetMeterDataFromFiles(string folderName, List<string> fileNames)
    {
        List<MeterData> results = new List<MeterData>();
        if (fileNames == nullreturn results;
        foreach (string fileName in fileNames)
        {
            Stream fileStream = _fileSystem.File.OpenRead(Path.Combine(folderName, fileName));
            XmlDocument doc = ConvertStreamToXmlDoc(fileStream);
            MeterData meter = ConvertXmlToMeter(doc);
            results.Add(meter);
        }
        return results;
    }
 
    /// <summary>
    /// Takes a Stream and returns an XmlDocument
    /// </summary>
    /// <param name="stream"></param>
    /// <exception cref="ArgumentNullException"></exception>
    /// <returns></returns>
    internal XmlDocument ConvertStreamToXmlDoc(Stream stream)
    {
        if (stream == null)
            throw new ArgumentNullException("stream");
        XmlDocument doc = new XmlDocument();
        doc.Load(stream);
        return doc;
    }
 
    /// <summary>
    /// Takes a Stream and returns [severely abridged] gas meter data
    /// </summary>
    /// <param name="doc"></param>
    /// <returns></returns>
    public MeterData ConvertXmlToMeter(XmlDocument doc)
    {
        XmlNode serialNumberNode = doc.SelectSingleNode("/XML/DATA/SRL_NUM");
        MeterData meter = new MeterData();
        if (serialNumberNode != null)
        {
            meter.MeterNumber = serialNumberNode.InnerText;
        }
        XmlNode usageNode = doc.SelectSingleNode("/XML/DATA/SB/C0");
        if (usageNode != null)
        {
            meter.Usage = Int32.Parse(usageNode.InnerText);
        }
        return meter;
    }
}

And my test code:

[TestMethod]
public void GetMeterDataFromFilesTest()
{
    // arrange
    const string folder = "C:";
    List<string> fileNames = new List<string> { "meter01.xml""meter02.xml""meter03.xml" };
 
    MeterData meter01 = new MeterData { MeterNumber = "1234", Usage = 100 };
    MeterData meter02 = new MeterData { MeterNumber = "5678", Usage = 200 };
    MeterData meter03 = new MeterData { MeterNumber = "9012", Usage = 300 };
    List<MeterData> expected = new List<MeterData> { meter01, meter02, meter03 };
 
    var mockFileSystem = new MockFileSystem(new Dictionary<stringMockFileData>
    {
        { @"c:\meter01.xml"new MockFileData(Meter01) },
        { @"c:\meter02.xml"new MockFileData(Meter02) },
        { @"c:\meter03.xml"new MockFileData(Meter03) }
    });
    MeterDataRepository meterDataRepository = new MeterDataRepository(mockFileSystem);
 
    // act
    List<MeterData> actual = meterDataRepository.GetMeterDataFromFiles(folder, fileNames);
 
    // assert
    CollectionAssert.AreEquivalent(expected, actual);
}

The values for Meter01, Meter02, and Meter03 are constants defined elsewhere in my test class.

As you can see, System.IO.Abstractions makes it pretty easy to mock the file system and produce a test which truly doesn’t have a dependency on the real file system.

An additional benefit is that it’s pretty easy to check all this into Team Foundation Server’s source code control (or whatever source control system you’re using) – there’s no need to add check in some sample files, a batch file that moves stuff around, and a README file that explains how to set up everything.


System.IO.Abstractions is available via NuGet.

Update: In reviewing my notes on this topic, I remembered Jonathan Channon's excellent post, Abstracting the File System. Check it out.

Comments

Popular posts from this blog

Using Reference Aliases

List of Visual Studio Keyboard Shortcuts