1. Storage scenarios and invariants
When we say “testability,” many people picture the happy path: call Save(), get nil, go drink some tea. But file-based storage is like a cat: as long as you’re watching it, it behaves; the moment you look away, it has already knocked over a vase, eaten your access permissions, and left .tmp-12345 on your desktop. We need tests that verify properties of the result, not just the fact that “there was no error.”
In the storage context, 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 after. And yes, sometimes the scenario is “even after an error, we still didn’t leave garbage behind.” That is also part of the contract.
Let’s pin down which properties (invariants) are typically checked.
Mini table: “scenario → what we verify”
| Scenario | Initial state | Action | What must be true |
|---|---|---|---|
| First write | file does not exist | |
file appears, content is correct |
| Overwrite | there is an old version | |
file contains new, not “a piece of old + a piece of new” |
| Backup policy | there is an old version | |
file.bak contains old, file contains new |
| Restore | file is missing, file.bak exists | |
file is restored from .bak |
| Lock | lock already exists | |
return a “busy” error; do not overwrite lock |
The key point of this lecture: all these scenarios must run in the test inside an isolated directory; otherwise, tests will start depending on your computer, permissions, random files, and the operating system’s mood.
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 on my machine, fails on yours” effect, because your environments differ.
Go has a simple and very practical tool for this: t.TempDir(). It creates a unique temporary directory for a specific test and reliably deletes it after the test completes. This is not only convenient; it also makes tests reproducible: every run starts with a clean slate.
Mini example: create a file path inside a 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 cleanup: if the test fails, the directory will still be removed.
3. Designing storage code for tests
Usually testability breaks not because tests were “written poorly,” but because the storage API is designed in a way that makes it hard to isolate. For example, the function itself decides that the file is always "./data/tasks.txt", and then in the test you try not to mess up real data. That starts to feel like a horror movie, just without the budget.
A practical rule: storage functions should accept the path (or directory) as a parameter and return error, without printing anything to stdout/stderr. Then in the test we simply substitute the path from t.TempDir().
Let’s imagine we continue a learning app (a conditional mini to-do), and we have a simple format: one task is one line. Serialization is deterministic.
package storage
import "strings"
func EncodeLines(lines []string) []byte {
return []byte(strings.Join(lines, "\n") + "\n")
}
Why is this function good? Because it is easy to test separately: input → output, no file system. And even if you are not writing a separate test for EncodeLines yet, pulling serialization into a separate step makes the “real” file tests simpler and clearer.
4. Verifying write scenarios: content and absence of garbage
File tests especially benefit from the “prepare → do → verify” structure. Otherwise, a week later you will open the test and see a mess of os.WriteFile, Rename, ReadFile, and you will have only one question in your head: “what were we even trying to prove?”
In Go it often looks like this:
- Arrange: dir := t.TempDir(), prepare files.
- Act: call our storage function.
- Assert: verify content, existence of .bak, absence of garbage.
Check that the write actually made it into the file
Let’s make a small test for the AtomicWriteFile function (which we wrote in previous lectures): it must write 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))
}
}
Notice: we are checking the invariant “the final content is correct.” This is the baseline level; without it, all talk about reliability is meaningless.
Check that no temporary files were left behind
Now 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: after a successful operation, no temporary files should remain.
To avoid diving into directory traversal (we cover it separately in detail and systematically), you can use a simple and readable option 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 does this matter? 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 annoying: you wanted to verify storage, but you ended up writing a “mini file-reading library.”
A common solution is small helper functions: mustReadFile, mustWriteFile, assertFileContent. And here is a useful detail: in testing there is a method t.Helper() that marks a function as a helper so that on failure Go shows the call site line, not the line inside the helper. This significantly simplifies diagnostics.
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 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 an old one, we must preserve the old one in .bak (if that is our policy). Tests are especially helpful here, because checking such a scenario manually is unpleasant: you need to remember the operation order and not mix up versions.
Check that .bak is the previous version
Let’s write a test: prepare the old file, call WriteWithBackup, verify 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 the file reads short so the example stays compact. In “real” tests it’s better to check read errors, but for the lecture the important idea is: the test verifies the semantics of .bak, not just the fact that a 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 desired state and verify exactly one contract.
It is much calmer when restore 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 how nice the idea is: we fully control the file state in the temp directory and can reproduce a “production-like” scenario, just without the risk of breaking production (which is especially pleasant).
Table-driven tests for backup scenarios
When there are many scenarios, separate tests start repeating the same “frame”: TempDir, path, call, checks. At that point it’s convenient to use a table-driven approach: a set of cases that differ by data and expectations.
It is important not to turn the table into an unreadable soup. A good table for file scenarios usually includes a readable case name, input state (as a setup function), an action, and assertions.
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 will still have checks like “if the case name is X” or (better) separate setup functions. That is fine if the table makes the test clearer rather than more mysterious.
6. Lock-file and concurrent writes
The lock-file concept is an agreement: if a lock already exists, we do not start writing. This is easy to test: create a lock, try to acquire the lock again, expect an error.
Let’s say 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 OS-level locking nuances (we are not discussing them at this level). It checks a simple contract of our protocol: “two writers at the same time are not allowed.”
7. Reproducible negative scenarios
File errors are not always convenient to reproduce “for real” (for example, simulating a power loss in the middle of a write). But many classes of errors can be reproduced logically: create a state in which the operation is guaranteed not to be able to complete.
The simplest trick: create a directory where a file should be, 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 stable and reproducible in CI and does not depend on random factors. It verifies that your function returns an error correctly, rather than, say, silently “succeeding” while doing something weird.
8. A map of a reproducible scenario
When you write a storage test, it helps to literally picture the scenario as a sequence of steps. Below is a diagram that is usually in your head when writing such tests.
flowchart TD
A[Arrange: t.TempDir + preparing files] --> B[Act: calling the storage function]
B --> C[Assert: do file/backup/lock exist?]
C --> D[Assert: is the content correct?]
D --> E[Assert: is there no garbage left?]
If a test is hard to “fit” into this diagram, it is often a signal that either the test should be split up or the function API is taking on too much and it’s time to decompose it.
9. Common mistakes
Mistake #1: tests write into the project’s “real” directory.
This looks convenient (“let it be ./tmp/test.txt”), but it breaks reproducibility. One developer runs tests and the folder remains; another has no write permissions; a third accidentally commits junk into the repository. t.TempDir() solves this radically: every test gets a clean sandbox and leaves no traces.
Mistake #2: checking only “no error,” but not checking result invariants.
The function might write an empty file, might forget Flush()/Close(), might overwrite data, might leave .tmp-*. If the test stops at if err != nil, it only checks “it didn’t crash.” For storage you almost always need to at least check the file content, and sometimes also the presence of .bak and the absence of temporary files.
Mistake #3: the test “accidentally depends” on the environment (permissions, umask, OS).
Permission checks can differ between systems, especially on Windows. If you do such checks, try to test what is actually part of your contract on the target platforms, and do not turn the test into a “battle with the file system.” In learning examples it is often enough to check that the file exists and that its content matches.
Mistake #4: large unstructured tests where it’s impossible to tell “what was before” and “what must be after.”
File scenarios must be readable; otherwise they stop being a safety net and turn into “yet another chunk of code no one touches.” Clear Arrange–Act–Assert blocks help, as do descriptive variable names (path, bakPath, lockPath) and small helper functions.
Mistake #5: helper functions exist, but without t.Helper(), and error messages point “nowhere.”
When mustReadFile() calls t.Fatalf internally, without t.Helper() Go will show the line inside the helper. This slows debugging: you see “it failed in mustReadFile,” but you do not see which test and which assertion triggered it. Use t.Helper()—it’s a small detail that saves a lot of time.
GO TO FULL VERSION