3 Priceless Ways to Unit Testing File I/O in C#


Any code can include File I/O operations like reading from a file, saving texts to a file, appending data to a file, etc.

When it comes to writing unit tests for such File I/O operations, it can be challenging because of their external dependency on the file system. If you are trying to deal with the real files in the file system, your unit test suit can become slower and more difficult to run.

There are three main ways to test File I/O in C#:

  • wrapping the File IO operations,
  • using the system.IO.Abstractions,
  • and writing an integration test.

Let’s find out how you can use these methods to write unit tests for File I/O operations.

Approach 1 – Create a wrapper class for File IO operations

The first approach is wrapping your File I/O operations in a wrapper class and creating an interface for the wrapper to use mock objects for the unit tests. Lets’ learn how to do it using the following example.

The code passes the file path as an argument for the FileOperation method. The method first checks the file’s existence and throws an exception if the file is not found.

Upon successfully passing the check, the program reads the file content, outputs it to the console, and returns the content to the program. Thus, there are two file operations in this example that you need to cover with unit tests.

public class FileManagerOriginal
{
    public string FileOperation(string path)
    {
        if (!File.Exists(path))
        {
            Console.WriteLine("the file does not exist");
            throw new FileNotFoundException(path);
        }

        string content = File.ReadAllText(path);
        Console.WriteLine(content);
        return content;
    }
}

The first step is to create an interface that will wrap the File I/O operations. Declare the interface IFileOperationsWrapper.

The following code shows how to write a wrapper interface for the above example. It contains the wrapper Exists and ReadAllText for the corresponding file I/O methods.

public interface IFileOperationsWrapper
{
    bool Exists(string path);
    string ReadAllText(string path);
}

Next, create a wrapper class that will implement the IFileOperationsWrapper interface.

public class FileOperationsWrapper : IFileOperationsWrapper
{
    public bool Exists(string path)
    {
        return File.Exists(path);
    }
    public string ReadAllText(string path)
    {
        return File.ReadAllText(path);
    }
}

Now you can use the FileOperationsWrapper classes’ wrapper methods in your initial class instead of directly using the File I/O operations like in the following example.

public class FileManagerWithWrapper
{
    private readonly IFileOperationsWrapper _fileOperationsWrapper;

    public FileManagerWithWrapper(IFileOperationsWrapper fileOPerationsWrapper)
    {
        _fileOperationsWrapper = fileOPerationsWrapper;
    }

    public string FileOperation(string path)
    {
        if (!_fileOperationsWrapper.Exists(path))
        {
            throw new FileNotFoundException(path);
        }

        string content = _fileOperationsWrapper.ReadAllText(path);
        Console.WriteLine(content);
        return content;
    }
}

Finally, you can mock the file I/O operations using the mocking library. Let’s see how you can do it. We will use the moq library for this example.

First, create a mock object from the IFileOperationsWrapper and pass it as an object to the class constructor.

Declare the file name in a separate variable and configure the I/O methods you want to test, as in the following code. For the above-used example, the code first asserts that it throws an exception if it cannot find the file. The second unit test checks that the content in the file is exactly what you have declared as the expected output.

[Fact]
public void FileManager_throws_exception_when_file_doesnt_exist()
{
    string filePath = "file.txt";
    var mock = new Mock<IFileOperationsWrapper>();
    mock.Setup(t => t.Exists(filePath)).Returns(false);

    FileManagerWithWrapper fileManager = new FileManagerWithWrapper(mock.Object);

    Assert.Throws<FileNotFoundException>(() => fileManager.FileOperation(filePath));
}

[Fact]
public void FileManager_returns_correct_file_content()
{
    string filePath = "file.txt";
    string expectedOutput = "test";

    var mock = new Mock<IFileOperationsWrapper>();

    mock.Setup(t => t.Exists(filePath)).Returns(true);
    mock.Setup(t => t.ReadAllText(filePath)).Returns(expectedOutput);

    FileManagerWithWrapper fileManager = new FileManagerWithWrapper(mock.Object);

    string actual = fileManager.FileOperation(filePath);

    Assert.Equal(expectedOutput, actual);
}

Approach 2 – Use System.IO.Abstractions

Sometimes, writing wrapper methods for every File I/O operation can become cumbersome, especially if you have a lot of File I/O methods in your code. You need a simplified way to run unit tests for many file I/O operations.

If that is the case, you can go for this second approach which uses the System.IO.Abstractions third-party library. System.IO.Abstractions is a library that provides an abstraction layer for the file system. It contains wrappers and interfaces for the File I/O operations, which you can directly utilize, so you do not have to write everything from scratch.

First, install the System.IO.Abstractions NuGet package using the dot net CLI (or you can use NuGet package manager):

dotnet add package System.IO.Abstractions --version 17.2.3

Then add it to all your project files that use File I/O operations. Then you can use the IFileSystem and FileSystem to call the File I/O methods without calling them directly in the code.

For example, to call the ReadAllText method, use the FileSystem.File.ReadAllText wrapper method. The following code shows how to use this package for the previous example.

public class FileManagerWithAbstractions
{
    private readonly IFileSystem _fileSystem;

    public FileManagerWithAbstractions(IFileSystem fileOPerationsWrapper)
    {
        _fileSystem = fileOPerationsWrapper;
    }

    public string FileOperation(string path)
    {
        if (!_fileSystem.File.Exists(path))
        {
            throw new FileNotFoundException(path);
        }

        string content = _fileSystem.File.ReadAllText(path);
        Console.WriteLine(content);
        return content;
    }
}

Then, create a new Mock object in the unit test class for the IFileSystem interface. Then you can call the wrapped file I/O methods instead of direct methods.

[Fact]
public void FileManager_returns_correct_file_content_with_system_abstractions()
{
    string filePath = "file.txt";
    string expectedOutput = "test";

    var mock = new Mock<IFileSystem>();

    mock.Setup(t => t.File.Exists(filePath)).Returns(true);
    mock.Setup(t => t.File.ReadAllText(filePath)).Returns(expectedOutput);

    FileManagerWithAbstractions fileManager = new FileManagerWithAbstractions(mock.Object);

    string actual = fileManager.FileOperation(filePath);

    Assert.Equal(expectedOutput, actual);
}

The package also has many test helpers to start unit tests easily, so you do not need to mock the basic I/O operations.

Approach 3 – Write an integration test

The above-discussed approaches are for testing only the I/O operations with a mock file system. It does not necessarily reflect the dependency on the actual file system. It will get you the fastest possible test execution, but the main disadvantage is that you have to write mocks. And mocks lead to more fragile tests.

To test real file I/O operations that access the real file system, you need to do an integration test rather than a unit test.

What is integration testing?

An integration test is a comprehensive test that checks the real I/O operations of all the integrated modules or application infrastructure. The application infrastructure usually includes databases, file I/O, requests, responses, etc.

The integration tests let users test how the whole system works together, focusing the data communication as well. However, compared to unit tests with fake in-memory data, integration tests can include lengthy code and more processing power. Thus, integration tests are generally slower than unit tests.

Why use integration testing for testing File IO operations

Suppose you want to test the actual file system or the impact the file system has on real file I/O operations for your application.

Real file I/O operations depend on the file system that is integrated with it. In that case, the isolated nature of a unit test does not necessarily include the dependency on the file system. But in case you want to check how a class works with the file environment, then use an integration test instead of a unit test for such requirements.

How to write an integration test for File IO access

The most important thing to consider in writing an integration test for File I/O access is ensuring you have a dedicated test directory with a set of files you want to test. That directory should not interfere with other tests in the test suits.

Following is a simple integration test example using the xUnit library. The integration test asserts that the actual file contains the expected content using an assertion. Here, the unit test calls the real file I/O operations without mocking the file system.

[Fact]
public void FileManager_returns_correct_file_content_from_real_file()
{
    string path = @"C:\Temp";
    var filePath = Path.Combine(path, "test.txt");

    string expectedOutput = "some content";

    FileManagerOriginal fileManager = new FileManagerOriginal();

    string actual = fileManager.FileOperation(filePath);

    Assert.Equal(expectedOutput, actual);
}

In the above test, I would probably put the test file in the test project instead of relying on the outside file system in a real-world project.

Conclusion

File I/O operations are mandatory for some programs. However, unit testing can seem difficult because they depend on the file system, making unit tests slower and more complicated to run. There are three ways you can unit test file I/O in C#.

First, wrap the file I/O operations in a wrapper class with an interface for the wrapper methods and mock the interface.

The next method is using the System.IO.Abstractions NuGet package, which provides wrappers and interfaces for the File I/O operations so that you do not need to write them on your own.

The third way is considering the code with file I/O operations as an integration test rather than a unit test so that you can simulate your system’s real file access behavior.

Recent Posts