Verdict
Yes
Yes, give code that reads or writes files one unit test per file it handles, with real files in a temporary folder that the test framework creates and deletes, and fake the file system only to simulate errors such as a full disk.
Why
- Blast radiususers
- Change frequencyrarely
- Detectabilityeventually
- Reversibilitywith-effort
- Test costmoderate
Test minimally: a unit test of file code should read and write real files in a temporary folder. The typical case is a backend module that writes an export customers download, so Blast radius is users, and Change frequency is rarely, because the file format changes a few times a year. Detectability is eventually, because a file that lost its last rows still opens, and Reversibility is with-effort, because every wrong file is generated and delivered again. Test cost is moderate: pytest tmp_path and JUnit @TempDir give each test an empty folder, but the code must take the folder as a parameter and the test must read the file back. Rule R12 gives Test minimally: one read-back test for each file the code writes or reads.
| When | Decision | Why |
|---|---|---|
| The code writes a payment file that a bank executes, such as a payroll batch | Test mandatory: read the file back and assert its row count and total against the source records | Blast radius rises to money and Reversibility to costly, because a wrong payment ends in a claw-back |
| The code reads a configuration file at startup, refuses to start on a bad value, and the deploy keeps the old version until health checks pass | Do not test the file reading; keep the startup validation | Detectability moves to immediately and Reversibility to trivial, because a bad file stops the deploy |
| A script that only you run writes a report to your own disk | Do not test the script; open the report after each run | Blast radius falls to none, because only you read the report |
| The file format changes with most releases, such as an export that gains columns every month | Test: a read-back test for each format change, asserting every column of the last row | Change frequency rises to regularly, because each release can move or drop a column |
| The files live on a network share, and the failure is a new file that a second server sees minutes late | Test it differently: alert when the reading job finds a missing or partial file | Test cost rises to heavy, because CI has no copy of the share and cannot reproduce its timing |
What breaks if you don't test
The file code first meets a real disk in production. A file copied before its writer is closed, or written in the server's default encoding instead of UTF-8, reaches customers, who notice weeks later. Every file generated in the meantime is wrong.
What you lose if you over-test
A mock of open or Files.newBufferedWriter checks which calls the code made, so it passes when the bytes on disk are wrong and fails when you replace a loop of write calls with one call. Fixed paths such as /tmp/export.csv let two parallel CI jobs overwrite each other's file, and a file left from an earlier run makes a broken test pass.
How to test
- Give each test its own folder:
tmp_pathin pytest,@TempDirin JUnit. - Pass the folder into the code as a parameter, so tests and production run the same code.
- Read the file back and assert the row count and the last row, with enough input to fill the writer's buffer several times.
- For errors a real disk cannot produce on demand, use an in-memory file system such as Jimfs.
When the answer changes
- The file moves money, such as a payment batch.
- The file format changes with most releases.
- The code only reads a file at startup, and a bad file stops the deploy.
- The failure needs a disk or a share that CI does not have.
Real incident + Code example
The export that lost its last rows
On a helpdesk product I worked on, customers exported tickets to CSV, most of them monthly. The job wrote the file with a BufferedWriter, and the unit test mocked the writer and verified one write per ticket. A refactor moved the copy to the download folder inside the try-with-resources block, before the writer was closed, so rows still in the buffer never reached the copy. The mocked test stayed green. Eleven days later a small account got an empty file and wrote to support, and every larger export since the release had lost its newest tickets. We regenerated about 300 files and replaced the mock:
class TicketExportTest {
@TempDir Path downloads; // a new empty folder for each test, deleted afterwards
@Test
void publishedFileKeepsTheLastTicket() throws IOException {
List<Ticket> tickets = IntStream.rangeClosed(1, 5_000) // fills the buffer many times
.mapToObj(i -> new Ticket("T-" + i, "Printer offline"))
.toList();
Path file = new TicketExport(downloads).publish(tickets);
List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
assertEquals(5_001, lines.size()); // header plus one line per ticket
assertTrue(lines.get(5_000).startsWith("T-5000,"));
}
}
Related questions
FAQ
- Should I test code that reads files?
Yes, test code that reads files by giving it a real file in a temporary folder. Keep one small input file per format problem that has reached production, such as a byte order mark.
- Should I mock the file system in unit tests?
Mock the file system only for errors a real disk cannot produce on demand, such as a full disk. For reading and writing, a mock passes when the file on disk is truncated.
- Is a test that writes to a temporary folder still a unit test?
Yes: a test with its own small temporary folder is still a fast, isolated unit test, even though it breaks Michael Feathers' 2005 rule that a unit test never touches the file system. The rule aims at slow tests that cannot run in parallel, and a test with its own small folder is neither, so keep it in the fast suite.
- How do I test code that fails when the disk is full?
Test disk-full handling with an in-memory file system that has a size limit, such as Jimfs in Java, and assert what the code does when the write fails. One test for each error the code handles is enough.