File I/O looks like one of those things that cannot be unit-tested properly.
A method that reads directories, scans files, compares extensions, and deletes files seems tied to the real machine where the test is running. That creates all the usual problems: tests become slow, fragile, dependent on local paths, and sometimes dangerous if the code deletes the wrong file.
The good news is that file I/O can be unit-tested cleanly. The key is not to make the file system disappear. The key is to make it a dependency.
Instead of calling File, Directory, and Path directly from System.IO, we call them through an abstraction. In production, the abstraction uses the real file system. In tests, it uses an in-memory file system.
This gives us fast unit tests without temporary folders, without cleanup code, and without risking real files.
The example: deleting duplicate audio files
Let’s use a simple example.
Suppose we have a music folder that contains both lossless and lossy versions of the same songs:
c:\music\album\track01.flac
c:\music\album\track01.mp3
c:\music\album\track02.mp3
The rule is:
If a lossless audio file exists, delete the lossy file with the same name in the same folder.
In the example above, track01.mp3 should be deleted because track01.flac exists. track02.mp3 should be kept because there is no lossless version of the same track.
This is a small example, but it is realistic enough to show the problem. The code must:
- enumerate files recursively;
- classify files by extension;
- compare files without their extension;
- delete only the lossy duplicates;
- leave unrelated files untouched.
That is exactly the kind of code we want to test.
Why direct file access makes testing harder
A first implementation might call System.IO directly:
var files = Directory.GetFiles(path, "*.*", SearchOption.AllDirectories);
foreach (var file in files)
{
var extension = Path.GetExtension(file);
if (extension == ".mp3")
{
File.Delete(file);
}
}
This code works, but it is awkward to unit-test.
A test would need to create real folders, create real files, run the method, check which files were deleted, and then clean everything up. That is no longer a small unit test. It is closer to an integration test with the local file system.
There is nothing wrong with integration tests, but they should not be the only way to test file-handling logic. Most of the logic does not need a real disk at all.
The logic we care about is:
Given these files, which lossy files should be deleted?
That decision can be tested entirely in memory.
Add a file system abstraction
A practical way to do this in .NET is to use System.IO.Abstractions.
Add the production package to the project that contains the code:
dotnet add package TestableIO.System.IO.Abstractions.Wrappers
Add the testing helpers package to the test project:
dotnet add package TestableIO.System.IO.Abstractions.TestingHelpers
The older package names System.IO.Abstractions and System.IO.Abstractions.TestingHelpers are also commonly seen in existing projects. The important idea is the same: use IFileSystem in your code, FileSystem in production, and MockFileSystem in tests.
Refactoring the code for testability
Here is a complete implementation.
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
public sealed class DuplicateAudioFileDeleter
{
private readonly IFileSystem _fileSystem;
private static readonly HashSet<string> LossyExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp3",
".mp4",
".aac",
".mpc",
".ogg",
".wma"
};
private static readonly HashSet<string> LosslessExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".flac",
".ape",
".wav",
".alac"
};
public DuplicateAudioFileDeleter(IFileSystem fileSystem)
{
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
}
public DuplicateAudioFileDeleter()
: this(new FileSystem())
{
}
public IReadOnlyCollection<string> FindLossyDuplicates(string directory)
{
if (string.IsNullOrWhiteSpace(directory))
{
throw new ArgumentException("A directory path is required.", nameof(directory));
}
var losslessFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var lossyFiles = new List<string>();
foreach (var file in _fileSystem.Directory.EnumerateFiles(directory, "*.*", SearchOption.AllDirectories))
{
var extension = _fileSystem.Path.GetExtension(file);
if (LosslessExtensions.Contains(extension))
{
losslessFiles.Add(GetFileIdentityWithoutExtension(file));
}
else if (LossyExtensions.Contains(extension))
{
lossyFiles.Add(file);
}
}
return lossyFiles
.Where(file => losslessFiles.Contains(GetFileIdentityWithoutExtension(file)))
.ToArray();
}
public int CleanupDirectory(string directory)
{
var duplicates = FindLossyDuplicates(directory);
foreach (var duplicate in duplicates)
{
_fileSystem.File.Delete(duplicate);
}
return duplicates.Count;
}
private string GetFileIdentityWithoutExtension(string file)
{
var directory = _fileSystem.Path.GetDirectoryName(file) ?? string.Empty;
var fileNameWithoutExtension = _fileSystem.Path.GetFileNameWithoutExtension(file);
return _fileSystem.Path.Combine(directory, fileNameWithoutExtension);
}
}
There are two important design choices here.
First, the class depends on IFileSystem instead of directly calling File, Directory, or Path. This makes the dependency injectable.
Second, the code separates the decision from the action:
FindLossyDuplicates(...)
CleanupDirectory(...)
FindLossyDuplicates decides which files should be deleted. CleanupDirectory performs the deletion.
That makes the code easier to test and easier to understand. It also gives you a natural place to add a dry-run mode later.
Unit-testing with an in-memory file system
Now we can write tests without touching the real disk.
The test creates a fake file system, adds fake files, runs the code, and verifies the result.
Here is an example using xUnit:
using System.Collections.Generic;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
public sealed class DuplicateAudioFileDeleterTests
{
[Fact]
public void CleanupDirectory_deletes_lossy_file_when_lossless_file_exists()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
[@"c:\music\album\track01.flac"] = new MockFileData("lossless"),
[@"c:\music\album\track01.mp3"] = new MockFileData("lossy"),
[@"c:\music\album\track02.mp3"] = new MockFileData("lossy only")
});
var deleter = new DuplicateAudioFileDeleter(fileSystem);
var deletedFiles = deleter.CleanupDirectory(@"c:\music");
Assert.Equal(1, deletedFiles);
Assert.True(fileSystem.File.Exists(@"c:\music\album\track01.flac"));
Assert.False(fileSystem.File.Exists(@"c:\music\album\track01.mp3"));
Assert.True(fileSystem.File.Exists(@"c:\music\album\track02.mp3"));
}
}
This is a real unit test.
It does not create a temporary folder. It does not depend on the current user profile. It does not leave files behind if the test fails. It does not require cleanup in a finally block.
The whole file system used by the test exists only in memory.
Testing that files in different folders are not treated as duplicates
One subtle requirement is that files should only be considered duplicates if they have the same name in the same folder.
These two files should not match:
c:\music\lossless\track01.flac
c:\music\mp3\track01.mp3
They have the same base name, but they are not in the same directory.
Let’s test that explicitly:
[Fact]
public void CleanupDirectory_does_not_delete_lossy_file_from_different_folder()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
[@"c:\music\lossless\track01.flac"] = new MockFileData("lossless"),
[@"c:\music\mp3\track01.mp3"] = new MockFileData("lossy")
});
var deleter = new DuplicateAudioFileDeleter(fileSystem);
var deletedFiles = deleter.CleanupDirectory(@"c:\music");
Assert.Equal(0, deletedFiles);
Assert.True(fileSystem.File.Exists(@"c:\music\lossless\track01.flac"));
Assert.True(fileSystem.File.Exists(@"c:\music\mp3\track01.mp3"));
}
This is the kind of edge case that is easy to miss when testing manually.
With an in-memory file system, adding the test is cheap.
Testing extension matching
File extensions are often inconsistent in real folders. Some files may use .flac, others .FLAC, .Mp3, or .MP3.
The implementation uses StringComparer.OrdinalIgnoreCase for extension matching, so the following test should pass:
[Fact]
public void CleanupDirectory_matches_extensions_case_insensitively()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
[@"c:\music\track01.FLAC"] = new MockFileData("lossless"),
[@"c:\music\track01.MP3"] = new MockFileData("lossy")
});
var deleter = new DuplicateAudioFileDeleter(fileSystem);
var deletedFiles = deleter.CleanupDirectory(@"c:\music");
Assert.Equal(1, deletedFiles);
Assert.True(fileSystem.File.Exists(@"c:\music\track01.FLAC"));
Assert.False(fileSystem.File.Exists(@"c:\music\track01.MP3"));
}
This is better than converting strings with ToUpper() or ToLower() without thinking about culture rules. For technical identifiers such as file extensions, an ordinal comparison is usually the clearest choice.
Testing the decision without deleting anything
Because the implementation separates finding duplicates from deleting them, we can test the decision directly.
[Fact]
public void FindLossyDuplicates_returns_only_files_that_should_be_deleted()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
[@"c:\music\track01.flac"] = new MockFileData("lossless"),
[@"c:\music\track01.mp3"] = new MockFileData("lossy"),
[@"c:\music\track02.mp3"] = new MockFileData("lossy only"),
[@"c:\music\cover.jpg"] = new MockFileData("image")
});
var deleter = new DuplicateAudioFileDeleter(fileSystem);
var duplicates = deleter.FindLossyDuplicates(@"c:\music");
Assert.Contains(@"c:\music\track01.mp3", duplicates);
Assert.DoesNotContain(@"c:\music\track02.mp3", duplicates);
Assert.DoesNotContain(@"c:\music\cover.jpg", duplicates);
}
This test is focused on the business rule.
It does not care how deletion is performed. It only checks which files the code identifies as duplicates.
That makes the test easier to read and less brittle.
Should we still write integration tests?
Yes, but not for every case.
An in-memory file system is excellent for testing your logic, but it is still a simulation. It may not reproduce every behavior of every real file system, especially around permissions, locked files, symbolic links, network shares, unusual path formats, or operating-system-specific case sensitivity.
A good testing strategy is:
- use unit tests with
MockFileSystemfor most logic; - add a small number of integration tests with real temporary folders;
- keep integration tests focused on the boundary between your code and the operating system.
For example, you may want one integration test that verifies the production constructor really uses the real file system:
using System;
using System.IO;
using Xunit;
public sealed class DuplicateAudioFileDeleterIntegrationTests
{
[Fact]
public void CleanupDirectory_can_delete_duplicate_from_real_temporary_folder()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
var flac = Path.Combine(root, "track01.flac");
var mp3 = Path.Combine(root, "track01.mp3");
File.WriteAllText(flac, "lossless");
File.WriteAllText(mp3, "lossy");
var deleter = new DuplicateAudioFileDeleter();
var deletedFiles = deleter.CleanupDirectory(root);
Assert.Equal(1, deletedFiles);
Assert.True(File.Exists(flac));
Assert.False(File.Exists(mp3));
}
finally
{
if (Directory.Exists(root))
{
Directory.Delete(root, recursive: true);
}
}
}
}
This test is slower and more fragile than the unit tests, but it verifies something different: that the code works when connected to the real file system.
That is useful, but it should be the exception, not the default.
Common pitfalls when unit-testing file I/O
Hiding file access too late
If most of the code already calls File.ReadAllText, Directory.GetFiles, or Path.Combine directly, adding tests becomes harder.
The abstraction should be introduced near the boundary of the class:
public MyClass(IFileSystem fileSystem)
{
_fileSystem = fileSystem;
}
Once that dependency is injected, the rest of the class can use _fileSystem.File, _fileSystem.Directory, and _fileSystem.Path.
Mocking every method call
A test should usually not verify that Directory.EnumerateFiles was called exactly once or that Path.GetExtension was called three times.
That makes the test too dependent on implementation details.
It is usually better to test observable behavior:
- which files are deleted;
- which files remain;
- which files are returned by the duplicate detection method;
- which exceptions are thrown for invalid input.
Mixing too much logic with file operations
If a method reads a file, parses it, validates it, transforms it, and writes output all in one place, testing becomes harder.
A better design is often:
Read file -> parse content -> apply business logic -> write result
Then the parsing and business logic can be tested without file I/O at all.
The file system abstraction is useful, but it is not a substitute for clean separation of responsibilities.
Forgetting path semantics
Paths are trickier than they look.
Think about:
- case sensitivity;
- relative paths;
- trailing separators;
- invalid path characters;
- different behavior on Windows, Linux, and macOS;
- whether two files with the same name in different folders should be treated as duplicates.
Unit tests are a good place to document these decisions.
Treating the fake file system as perfect
A fake file system is a test tool, not a complete operating system.
Use it for fast, deterministic unit tests. Use real temporary folders for a few integration tests where real OS behavior matters.
Conclusion
File I/O is not the enemy of unit testing. Hard-coded file I/O is.
Once the file system is treated as a dependency, the code becomes much easier to test. Production code can still use the real disk, while unit tests can run against an in-memory file system.
The pattern is simple:
// Production
new DuplicateAudioFileDeleter(new FileSystem());
// Test
new DuplicateAudioFileDeleter(new MockFileSystem());
This keeps tests fast, safe, and readable.
The main lesson is broader than file I/O: when code depends directly on the outside world, testing becomes difficult. When the outside world is behind an interface, the core logic becomes easy to verify.


