1. Scenarios and storage invariants
When we talk about "testability", many people imagine the happy path: called Save(), got nil, went to drink tea. But file storage is like a cat: while you watch it, it's well-behaved, but the moment you look away, it has already knocked over a vase, eaten your permissions, and left .tmp-12345 on the desktop. We need tests that verify the properties of the result, not just the fact that "there is no error".
In the context of storage, it is almost always important to test not "a line of code", but a scenario: what was on disk before the operation, what we did, and what must be true afterward. And yes, sometimes the scenario is "after an error, no junk was left behind anyway". That is also part of the contract.
Let's fix what properties (invariants) are usually checked.
Mini table: "scenario → what we check"
| Scenario | Initial state | Action | What must be true |
|---|---|---|---|
| First write | no file | |
the file appears, contents are correct |
| Overwrite | an old version exists | |
the file contains new, not "a piece of old + a piece of new" |
| Backup policy | an old version exists | |
file.bak contains old, file contains new |
| Restore | file is missing, file.bak exists | |
file is restored from .bak |
| Lock | lock already exists | |
return an "occupied" error, do not overwrite lock |
The key point of the lecture: all these scenarios must run in a test inside an isolated directory, otherwise the tests will start depending on your computer, permissions, random files, and the mood of the operating system.
2. A sandbox for tests: t.TempDir()
When you test the file system, the most common way to accidentally do something bad is to write test files "next to the project" or into some ./testdata/tmp. This quickly leads to the "it passes for me, not for you" effect, because your environments differ.
In Go, there is a simple and very practical tool for this: t.TempDir(). It creates a unique temporary directory for a specific test and is guaranteed to delete it after the test finishes. This is not only convenient, it also makes tests reproducible: every run starts with a clean slate.
Mini example: create a path to a file inside the temp directory.
package storage_test
import (
"path/filepath"
"testing"
)
func TestPathsWithTempDir(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "data.txt")
_ = path // We will work with this file next
}
Two "small" but genuinely useful points matter here. First, filepath.Join neatly joins path parts for your OS (separators, slashes, all that). Second, you stop thinking about cleaning up junk: the test fails, and the directory will still be removed.
3. Designing storage code for tests
Usually, testability breaks not because the tests were "written badly", but because the storage API was designed in a way that makes it hard to isolate. For example, a function itself decides that the file is always "./data/tasks.txt", and then in a test you try not to damage real data. That already feels like a horror movie, just without the budget.
A practical rule: storage functions should accept a path (or directory) as a parameter and return error without printing anything to stdout/stderr. Then in a test we simply substitute the path from t.TempDir().
Imagine we are continuing a training application (a simple mini-todo), and we have a simple format: one task — one line. Serialization is deterministic.
package storage
import "strings"
func EncodeLines(lines []string) []byte {
return []byte(strings.Join(lines, "\n") + "\n")
}
Why is such a function good? Because it is easy to test separately: input → output, no file system involved. And even if you are not writing a separate test for EncodeLines yet, just pulling serialization into a separate step makes "real" file tests simpler and clearer.
4. Checking write scenarios: contents and absence of junk
File tests benefit especially from the structure "prepared → executed → checked". Otherwise, in a week you will open the test and see a mess of os.WriteFile, Rename, ReadFile, and the only question in your head will be: "What were we trying to prove anyway?"
In Go, it often looks like this:
- Arrange: dir := t.TempDir(), prepared files.
- Act: called our storage function.
- Assert: checked contents, existence of .bak, absence of junk.
Check that the write actually reached the file
Let's make a small test for the function AtomicWriteFile (which we wrote in an earlier lecture): it should write the data to the file.
package storage_test
import (
"os"
"path/filepath"
"testing"
)
func TestAtomicWriteFile_WritesContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "data.txt")
err := AtomicWriteFile(path, []byte("hello"), 0644)
if err != nil {
t.Fatalf("AtomicWriteFile: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(got) != "hello" {
t.Fatalf("unexpected content: %q", string(got))
}
}
Note that we check exactly the invariant "final contents are correct". This is the basic level, without which all talk about reliability makes no sense.
Check that temporary files were not left behind
Now for a slightly more "grown-up" check. The temp+rename protocol usually uses temporary files with a clear pattern, for example .tmp-*. The invariant from our protocol is: after a successful operation, no temporary files should remain.
To avoid getting into directory traversal (we cover it separately and systematically in detail), we can use a simple and fairly readable variant via filepath.Glob.
package storage_test
import (
"path/filepath"
"testing"
)
func TestAtomicWriteFile_NoTempFilesLeft(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "data.txt")
if err := AtomicWriteFile(path, []byte("ok"), 0644); err != nil {
t.Fatalf("AtomicWriteFile: %v", err)
}
matches, err := filepath.Glob(filepath.Join(dir, ".tmp-*"))
if err != nil {
t.Fatalf("Glob: %v", err)
}
if len(matches) != 0 {
t.Fatalf("temp files left: %v", matches)
}
}
Why is this important? Because a leftover .tmp-* is "silent debt": it does not break the program right now, but later someone will see dozens of junk files and start suspecting everything, including you and your keyboard.
Helpers in tests and t.Helper()
When you repeatedly write os.ReadFile, then if err != nil { t.Fatalf(...) }, the test becomes longer than your business logic. That is frustrating: you wanted to test storage, but instead you wrote a "mini file-reading library".
Usually the solution is small helper functions: mustReadFile, mustWriteFile, assertFileContent. And here is a useful detail: in testing there is a method t.Helper(), which marks the function as a helper so that, when an error occurs, Go shows the helper call site rather than the line inside it. That greatly simplifies diagnosis.
Example helper function:
package storage_test
import (
"os"
"testing"
)
func mustReadFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s): %v", path, err)
}
return string(b)
}
Now the tests become shorter and more readable, and error messages become more useful.
5. Backup and restore
Backup is not "just another file nearby". Backup is a contract: if we wrote a new version over the old one, we must preserve the old one in .bak (if that is our policy). Tests are especially useful here because manually checking such a scenario is unpleasant: you need to remember the order of operations and not mix up versions.
Check that .bak is the previous version
Let's write a test: prepare an old file, call WriteWithBackup, and check both files.
package storage_test
import (
"os"
"path/filepath"
"testing"
)
func TestWriteWithBackup_CreatesBak(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cfg.txt")
if err := os.WriteFile(path, []byte("old"), 0644); err != nil {
t.Fatalf("prepare old: %v", err)
}
if err := WriteWithBackup(path, []byte("new"), 0644); err != nil {
t.Fatalf("WriteWithBackup: %v", err)
}
bak, _ := os.ReadFile(path + ".bak")
cur, _ := os.ReadFile(path)
if string(bak) != "old" || string(cur) != "new" {
t.Fatalf("bak=%q cur=%q", string(bak), string(cur))
}
}
Here I intentionally kept file reading short so the example remains compact. In "real" tests, it is better to check read errors, but in the lecture it is more important to grasp the idea: the test checks the semantics of .bak, not just the fact that the file exists.
Restoring from .bak as a separate scenario
A very common design mistake is trying to make "one function for everything" that writes, backs up, restores, reads, and also makes coffee. Such functions are hard to test because you cannot put the system into the right state and check exactly one contract.
It is much calmer when restoration is a separate operation: "if the main file is missing, but .bak exists, restore it." Then the test scenario is simple and reproducible.
package storage_test
import (
"os"
"path/filepath"
"testing"
)
func TestRestoreFromBackup_Restores(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "data.txt")
if err := os.WriteFile(path+".bak", []byte("backup"), 0644); err != nil {
t.Fatalf("prepare bak: %v", err)
}
if err := RestoreFromBackup(path); err != nil {
t.Fatalf("RestoreFromBackup: %v", err)
}
got, err := os.ReadFile(path)
if err != nil || string(got) != "backup" {
t.Fatalf("got=%q err=%v", string(got), err)
}
}
Notice the beauty of the idea: we fully control the file state in the temp directory and can reproduce the "like on prod" scenario, just without the risk of breaking prod (which is especially nice).
Table-driven tests for backup scenarios
When the number of scenarios grows, separate tests start repeating the same "frame": TempDir, path, call, check. At that point, it is convenient to use a table-driven approach: a set of cases that differ in data and expectations.
It is important not to turn the table into unreadable soup. A good table for file scenarios usually contains a readable case name, the initial state (as a preparation function), the action, and the assertion.
Mini skeleton:
package storage_test
import (
"path/filepath"
"testing"
)
func TestRestoreFromBackup_Cases(t *testing.T) {
cases := []struct {
name string
}{
{name: "bak exists, file missing"},
{name: "file exists, do nothing"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
_ = filepath.Join(dir, "data.txt")
// here: prepare -> act -> assert (based on tc.name)
})
}
}
Yes, inside you may still have checks like "if the case name is such-and-such" or, better, separate preparation functions. That is fine if the table makes the test clearer, not more mysterious.
6. Lock-file and concurrent writing
The lock-file concept is a contract: if the lock already exists, we do not start writing. This is easy to check with a test: create a lock, try to acquire the lock again, expect an error.
Suppose we have AcquireLock(lockPath string) (*os.File, error).
package storage_test
import (
"path/filepath"
"testing"
)
func TestAcquireLock_Busy(t *testing.T) {
dir := t.TempDir()
lockPath := filepath.Join(dir, "data.txt.lock")
lock1, err := AcquireLock(lockPath)
if err != nil {
t.Fatalf("AcquireLock #1: %v", err)
}
defer ReleaseLock(lock1)
if _, err := AcquireLock(lockPath); err == nil {
t.Fatalf("expected lock error, got nil")
}
}
This test does not try to prove all the nuances of OS locks (and we are not discussing them at this level). It checks the simple contract of our protocol: "two writers at the same time is not allowed".
7. Reproducible negative scenarios
File errors are not always easy to reproduce "for real" (for example, simulating a power failure in the middle of a write). But many classes of errors can be reproduced logically: create a state in which the operation is guaranteed to fail.
The simplest trick: create a directory in place of a file and try to write a "file" into it.
package storage_test
import (
"os"
"path/filepath"
"testing"
)
func TestAtomicWriteFile_FailsWhenPathIsDir(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "data.txt")
if err := os.Mkdir(path, 0755); err != nil {
t.Fatalf("Mkdir: %v", err)
}
if err := AtomicWriteFile(path, []byte("x"), 0644); err == nil {
t.Fatalf("expected error, got nil")
}
}
Why is this useful? Because such a test is stably reproducible in CI and does not depend on random factors. It checks that your function returns an error correctly, rather than, say, silently doing something strange "successfully".
8. Map of a reproducible scenario
When you write a storage test, it is useful to literally imagine the scenario as a sequence of steps. Below is the scheme that is usually in your head when you write such tests.
flowchart TD
A[Arrange: t.TempDir + file preparation] --> B[Act: call storage function]
B --> C[Assert: do file/backup/lock exist?]
C --> D[Assert: is the content correct?]
D --> E[Assert: is there no junk left?]
If it is hard to "fit" a test into this scheme, that is often a sign that either the test should be split up or the API of the function is taking on too much and should be decomposed.
9. Common mistakes
Mistake #1: tests write to the "real" project directory.
This looks convenient ("let's just use ./tmp/test.txt"), but it destroys reproducibility. One developer runs the tests — the folder is still there; another has no write permission; a third accidentally commits junk to the repository. t.TempDir() solves this radically: each test gets a clean sandbox and leaves no traces.
Mistake #2: only the fact that "there is no error" is checked, but the result invariants are not checked.
The function could have written an empty file, could have forgotten Flush()/Close(), could have overwritten data, could have left .tmp-*. If the test stopped at if err != nil, it only checked "seems not to have crashed". For storage, you almost always need to check at least the file contents, and sometimes the presence of .bak and the absence of temporary files.
Mistake #3: the test "accidentally depends" on the environment (permissions, umask, OS).
Permission checks may differ between systems, especially on Windows. If you do such checks, try to test exactly what is part of your contract on the target platforms, and do not turn the test into a "battle with the file system". In educational examples, it is often enough to check that the file exists and its contents are correct.
Mistake #4: large tests without structure, where it is impossible to understand "what was before" and "what should be after".
File scenarios should be readable, otherwise they stop being a safety net and turn into "just another piece of code nobody touches". Clear Arrange–Act–Assert blocks, meaningful variable names (path, bakPath, lockPath), and small helper functions help.
Mistake #5: helper functions exist, but without t.Helper(), and error messages point "into the void".
When your mustReadFile() calls t.Fatalf inside itself, without t.Helper() Go will show the line inside the helper. That slows debugging: you see "failed in mustReadFile", but you do not see which test and which check failed. Use t.Helper() — it is a small detail that saves a lot of time.
GO TO FULL VERSION