Mock the File System
Using the real file system in tests might seem convenient at first, but it leads to hidden state, slow execution, and an unmaintainable setup.
Join the DZone community and get the full member experience.
Join For FreeIt happens quite often that our applications need to interact with the file system. As a result, some components inevitably depend on it. When we test such code, we face a choice: mock the file system, or test against the real one?
There are several opposing views on this. Most developers avoid using the file system in unit tests. Tests that touch the disk are usually treated as an anti‑pattern because they are slow and brittle.
Characteristics of good unit testsUnit tests are standalone, can run in isolation, and have no dependencies on any outside factors, such as a file system or database (Jpreese, n.d.)
However, the world has changed, and modern file systems are extremely fast and robust. This means that, in most cases, we can safely use the actual file system in tests without significant losses in test speed.
It’s extremely rare nowadays for anyone to experience serious problems because of it. So, you might find the idea of using the file system in tests appealing:
The file system in modern computers is as reliable as memory. We don't mock memory managers — why mock the file system? (Bugayenko, 2025)
Well, it's hard to disagree with this point. Isn't it? Although the file system is still slower than memory, we can usually ignore a few milliseconds and use it directly in tests for the sake of simplicity and fast development.
But I totally disagree with this take, and I’ll try to show you that this “simplicity” leads to disappointment—and that it’s still a bad idea to extensively use the file system in tests.
First of all, the slowdown becomes noticeable as your project grows and accumulates thousands of tests that heavily rely on file system operations. At that scale, using the file system can lead to extremely slow test execution.
Also, testing with the file system tends to be verbose. When we write such tests, we have to account for many corner cases: checking that a file exists, ensuring all directories are created before writing a file, properly opening and closing files, and handling OS-specific differences such as path representations (for example, Unix vs Windows). Because of this, interacting with the file system typically requires extra checks to ensure nothing fails unexpectedly. At the code level, this leads to widespread use of try-catch
blocks (in Java) or explicit error
handling (in Go). Even if file system failures are rare, we still have to write boilerplate code to handle these risks—mostly due to how standard libraries are designed.
However, performance and verbosity, aren't the main concerns. The deeper issue is much more dangerous—one that can quietly poison your project over time. Let’s take a look at the following unit test:
@Test
void retrievesNextTask(@TempDir Path folder) throws IOException {
Files.write(
folder.resolve("tasks.csv"),
"Task-1\nTask-2\nTask-3".getBytes("UTF-8")
);
final TodoList list = new TodoList(folder);
assertEquals("Task-1", list.nextTask());
assertEquals("Task-2", list.nextTask());
assertEquals("Task-3", list.nextTask());
}
I hope the example is clear enough. We saved our to-do list to a CSV file, then our TodoList
object reads that file from a folder
, parses it, and allows us to retrieve tasks from it. So far, there’s nothing wrong. But things change quickly once we decide to build something on top of TodoList
. For example, if the requirements change and we now have to implement an Employee
class that interacts with TodoList
:
@Test
void startsWork(@TempDir Path folder) throws IOException {
Files.write(
folder.resolve("tasks.csv"),
"make coffee\nread news\nsend message to a friend".getBytes("UTF-8")
);
TodoList list = new TodoList(folder);
Employee employee = new Employee(list);
String[] tasks = employee.startWork();
Assertions.assertArrayEquals
new String[]{
"Working on 'make coffee'",
"Working on 'read news'",
"Working on 'send message to a friend'"
},
tasks
);
}
Did you notice? I had to initialize TodoList
again. To do that, I once more had to create a temporary directory, write a file to it, populate that file, and handle an IOException
. And I needed to know how to initialize TodoList
just to test Employee
—which isn’t even our concern here. We've already tested TodoList
.
Now let’s take it one step further. We can see that our Employee
works with a single instance of TodoList
, which isn’t shared with any other object. So why not "simplify" the implementation by initializing TodoList
inside the Employee
itself? And now, our code becomes pure magic:
@Test
void startsWork(@TempDir Path folder) throws IOException {
Files.write( // We still need it to correctly initialize Employee
folder.resolve("tasks.csv"),
"make coffee\nread news\nsend message to a friend".getBytes("UTF-8")
);
Employee employee = new Employee(folder); // TodoList is created inside the Employee constructor
String[] tasks = employee.startWork();
Assertions.assertArrayEquals
new String[]{
"Working on 'make coffee'",
"Working on 'read news'",
"Working on 'send message to a friend'"
},
tasks
);
}
If I had shown you this code first, you would have reasonably asked, “Why do we need to create a strange CSV file just to test Employee
?” And that would be a fair question. When we look at the final code snippet, it’s almost impossible to understand why such a file is necessary for the test and why it should contain exactly this text.
Of course, this is only a simple example. In reality, we often have a huge number of entities that depend on each other, and the situation is much worse.
Things deteriorate even further when new developers join your project. They have no idea about the internals or why they’re supposed to create the same CSV files for every test. So what do they do? They simply copy the initialization code from other tests—because that’s how it’s done throughout the project. After a few more months—or years—almost all of your “unit” tests will somehow rely on a temporary directory (TempDir (JUNiT 5.9.2 API), n.d.):
(@TempDir Path folder)
The next step is usually to create some kind of test harness that handles all the file system setup. At that point, it becomes extremely hard to go back and solve the problem properly—because we've already invested so much time into it, and technically, it works. Even if it slows down test execution, some developers are willing to tolerate it simply because, well, they’re just tests.
But testing the system becomes painful, because now you have to understand all the internal details and have a solid grasp of the new test harness just to test a small component. Instead of clear inputs (constructor parameters and method arguments) and observable outputs (return values), you're now dealing with an implicit global state that you must know exactly how to initialize.
Very briefly, it [global state] makes program state unpredictable. (Why Is Global State so Evil? [Online forum post], n.d.)
At some point, the tests become so rigid and fragile that changing them feels impossible. Even top experts who built the system from scratch often can’t fully comprehend what’s going on in their own tests. So what happens when a test fails—buried under layers of file system setup—and nobody knows how to fix it? They start ignoring it, or they delete it without even reading it. Touché.
As a result, the project becomes nearly unmaintainable.
What Could We Do Instead?
If using the file system introduces so many problems, why not just avoid it? Generally speaking, there’s a straightforward and well-known technique for decoupling components. The exact approach may vary between languages, but in most cases, it can be solved by introducing an interface (in Java or Go) or a header file (in C), and then providing multiple implementations—one real, and one mock:
@Test
void startsWorkWithMock() {
final TodoList list = new MockList(
"make coffee",
"read news",
"send message to a friend"
); // Now, our 'TodoList' is an interface and 'MockList' is an implementation
final Employee employee = new Employee(list);
final String[] tasks = employee.startWork();
Assertions.assertArrayEquals(
new String[]{
"Working on 'make coffee'",
"Working on 'read news'",
"Working on 'send message to a friend'"
},
tasks
);
}
TodoList
is an interface now, and we have two implementations: MockList
and CsvList
Now, you're no longer tied to the file system. This simple change protects your project from unnecessary complexity and avoids the traps discussed above. Tests become simpler—no heavy setup, often just a single line to create a mock object. Global state disappears, and everything stays isolated and predictable. This not only speeds up test execution but also greatly improves maintainability.
Despite their modern speed, file systems still introduce complications. It’s better to avoid them in tests. Like any external dependency, they should be decoupled from your core logic. Ignoring this leads to fragile tests, hidden dependencies, and long-term technical debt.
Keep your tests clean. Mock the file system.
Happy coding.
References
1. Jpreese. (n.d.). Best practices for writing unit tests - .NET. Microsoft Learn. https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-best-practices
2. Bugayenko, Y. (2025). Angry Tests. Independently published.
3. Why is Global State so Evil? [Online forum post]. (n.d.). stackexchange.com. https://softwareengineering.stackexchange.com/questions/148108/why-is-global-state-so-evil
4. TempDir (JUNiT 5.9.2 API). (n.d.). https://junit.org/junit5/docs/5.9.2/api/org.junit.jupiter.api/org/junit/jupiter/api/io/TempDir.html
Opinions expressed by DZone contributors are their own.
Comments