1. Why “a disk in tests” is a source of weird bugs
When we’re just learning to program, it feels natural: “Well, how else do you verify reading a file? You have to create a file, write test data into it, and read it.” Formally — true. Practically — you’ve just invited a whole crowd of random factors into your tests: current directory, access permissions, leftover files from a previous run, parallel test execution, different paths on Windows/Linux, and another dozen little things that pop up exactly five minutes before the deadline.
There’s another problem, more subtle: a disk-based test often verifies too much at once. Instead of “does my function parse the file contents correctly,” you’re testing “did the OS create the file correctly,” “did you build the path correctly,” “did you close the file correctly,” “are permissions getting in the way.” The result is a failing test, and you spend your time not on logic but on ritual dances around the environment. It’s like trying to verify a borscht recipe by growing beets on your balcony every time. Ambitious, but hungry.
That’s exactly why in Go (and in engineering culture in general) tests are seen as one of the main ways to keep a system working as it changes.
2. testing/fstest: swapping the file system in tests
The testing/fstest package is part of the standard library that helps you work in tests with “a file system that kind of doesn’t exist.” That is, the files exist, but not on disk: they’re described by an in-memory data structure and exposed through the same fs.FS interface that we use in production code.
Why is this nice? Because you get a simple and very practical setup:
flowchart LR
A["Our function<br/>accepts fs.FS"] --> B["In the test we substitute<br/>fstest.MapFS"]
B --> C["The function reads files<br/>as usual"]
C --> D["The test checks the result<br/>without a disk or permissions"]
Notice the “magic trick”: the function doesn’t know where the files come from. It just sees fs.FS. In prod you’ll give it os.DirFS(...), and in a test — fstest.MapFS. And that’s one of the most “Go-ish” ideas: separate logic from infrastructure via interfaces.
Mini data format for the example: “tasks” in a text file
To test something real, we need a small piece of “actual” logic. Let’s imagine that in our learning app (a hypothetical mini task tracker) tasks are stored in a simple text file, one per line:
1|Buy milk|0
2|Learn fs.FS|1
Where id|title|done, and done is 0 or 1. Not the greatest format ever invented, but it’s simple, and it’s convenient to parse with tools we already have: strings.Split, strconv.Atoi, conditionals, and errors.
Our goal here isn’t to “invent the perfect format,” but to learn how to test file logic without a disk. So the format will be intentionally simple — like a stool. Stools rarely fall over.
Application code: read tasks from fs.FS, not from disk
Let’s create a storage package and a LoadTasks function that accepts fs.FS and a file name. The key point here is that we do not use os.ReadFile directly: our function must be testable.
Data model Task
Let’s start with the struct. It’s as boring as possible — and that’s a compliment.
package storage
type Task struct {
ID int
Title string
Done bool
}
Parsing a single line
It’s better to move parsing into a separate function: this way we can test errors more precisely, and the code is easier to read.
package storage
import (
"fmt"
"strconv"
"strings"
)
func parseTaskLine(line string) (Task, error) {
parts := strings.Split(line, "|")
if len(parts) != 3 {
return Task{}, fmt.Errorf("bad task line: %q", line)
}
id, err := strconv.Atoi(parts[0])
if err != nil {
return Task{}, fmt.Errorf("bad id: %w", err)
}
return Task{ID: id, Title: parts[1], Done: parts[2] == "1"}, nil
}
Notice: we added context to errors and kept the underlying cause via %w. This is exactly the style that makes errors useful for analysis higher up the stack.
Reading a file from fs.FS
Now the function that reads the whole file and parses lines. For simplicity we use fs.ReadFile (yes, it’s “read all at once,” and we do it intentionally: the task file is small).
package storage
import (
"fmt"
"io/fs"
"strings"
)
func LoadTasks(fsys fs.FS, name string) ([]Task, error) {
b, err := fs.ReadFile(fsys, name)
if err != nil {
return nil, fmt.Errorf("read %q: %w", name, err)
}
var tasks []Task
for _, line := range strings.Split(string(b), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
t, err := parseTaskLine(line)
if err != nil {
return nil, fmt.Errorf("parse %q: %w", name, err)
}
tasks = append(tasks, t)
}
return tasks, nil
}
There’s an important philosophy hidden here: we wrote the code so it doesn’t care whether these are real files or “test phantoms.” At this point your code takes a small step into adulthood.
3. Testing without a disk: fstest.MapFS as “files in memory”
When you write a test, it helps to keep the “Arrange → Act → Assert” pattern in mind: prepare the environment, call the function, verify the result. That’s the basic mechanics, and it’s equally good for pure functions and for file logic.
fstest.MapFS is a map where the key is the file name (an FS path), and the value is a struct with the contents. In other words, instead of “create a file on disk,” you write “here’s a file named a.txt, and here are its contents.”
Happy path: the file exists, the data is valid
package storage
import (
"testing"
"testing/fstest"
)
func TestLoadTasks_OK(t *testing.T) {
fsys := fstest.MapFS{
"tasks.txt": {Data: []byte("1|Milk|0\n2|Go|1\n")},
}
tasks, err := LoadTasks(fsys, "tasks.txt")
if err != nil {
t.Fatalf("LoadTasks: %v", err)
}
if len(tasks) != 2 {
t.Fatalf("expected 2 tasks, got %d", len(tasks))
}
}
Note that the test doesn’t need os.MkdirAll, a temporary directory, or any cleanup afterward. It’s fast, clean, and leaves no junk on disk. Tests like this are pleasant to run 200 times in a row — and they don’t turn your laptop into a toaster.
Testing errors the right way: “file not found”
Error checking in Go isn’t “well, there’s some string there.” We try to stick to the cause.
If there’s no "tasks.txt" key in MapFS, the read attempt should end with a “does not exist” error. And we check that via errors.Is.
package storage
import (
"errors"
"io/fs"
"testing"
"testing/fstest"
)
func TestLoadTasks_NotExist(t *testing.T) {
fsys := fstest.MapFS{}
_, err := LoadTasks(fsys, "tasks.txt")
if err == nil {
t.Fatalf("expected error")
}
if !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("expected fs.ErrNotExist, got %v", err)
}
}
Why not compare strings? Because a string is “how the message is phrased right now,” while errors.Is is “what cause is inside.” And if we wrap errors via %w, the cause is preserved.
The file turned out to be a directory: also a useful case
fstest.MapFS lets you describe a directory by specifying the fs.ModeDir mode. Let’s verify that our function fails correctly if it’s given a directory instead of a file.
package storage
import (
"io/fs"
"testing"
"testing/fstest"
)
func TestLoadTasks_DirInsteadOfFile(t *testing.T) {
fsys := fstest.MapFS{
"tasks.txt": {Mode: fs.ModeDir},
}
_, err := LoadTasks(fsys, "tasks.txt")
if err == nil {
t.Fatalf("expected error")
}
}
Here we don’t cling to the exact shape of the error: what matters is that the function doesn’t pretend “everything is OK.” The exact message may depend on the MapFS implementation.
4. fstest.TestFS: a quick “inspection” of your FS
Sometimes tests start looking like this: you created a MapFS, threw 10 files into it, and then in the middle of the test there’s suddenly a wrong path, wrong file, a typo — and you stare at the code for half an hour thinking “why can’t it see config.txt, it’s right there!” Spoiler: because you named it confg.txt.
That’s what fstest.TestFS is for. It’s a function that checks that the FS behaves correctly and that the specified files actually open.
package storage
import (
"testing"
"testing/fstest"
)
func TestMapFS_Contract(t *testing.T) {
fsys := fstest.MapFS{
"tasks.txt": {Data: []byte("1|Milk|0\n")},
"readme.md": {Data: []byte("hi")},
}
if err := fstest.TestFS(fsys, "tasks.txt", "readme.md"); err != nil {
t.Fatalf("TestFS: %v", err)
}
}
This isn’t a replacement for your logic tests; it’s “checking the wiring”: the FS is assembled correctly, files are accessible, and then you test your business logic.
5. Test readability: helpers and t.Helper()
As tests grow in number, a new kind of pain appears: the tests work, but they’re impossible to read. It’s a special genre of suffering — “everything is green, but I don’t understand anything.”
In Go, to improve error messages there’s the t.Helper() mechanism: you mark a function as a helper, and then when the test fails Go will show the line where the helper was called, not the line inside the helper itself.
Let’s make a small helper that creates a MapFS with tasks:
package storage
import (
"testing"
"testing/fstest"
)
func fsWithTasks(t *testing.T, content string) fstest.MapFS {
t.Helper()
return fstest.MapFS{
"tasks.txt": {Data: []byte(content)},
}
}
And use it:
package storage
import "testing"
func TestLoadTasks_EmptyLinesAreIgnored(t *testing.T) {
fsys := fsWithTasks(t, "\n1|Milk|0\n\n")
tasks, err := LoadTasks(fsys, "tasks.txt")
if err != nil {
t.Fatalf("LoadTasks: %v", err)
}
if len(tasks) != 1 {
t.Fatalf("expected 1 task, got %d", len(tasks))
}
}
This small thing usually turns tests from a “wall of text” into a neat set of scenarios. It also reduces the chance that you’ll accidentally create copy-paste and forget to change a file name in one place.
6. Cheat sheet: what to test with what
Sometimes it’s useful not to “memorize,” but to have a short map — especially when you’re a beginner and your head is already full of new terms.
| Tool | Where it lives | What it’s good for | Main idea |
|---|---|---|---|
|
|
Substitute “in-memory” files instead of a disk | Test logic, not infrastructure |
|
|
Verify that the FS opens the required files | Quickly catch typos and a poorly assembled FS |
|
|
Check “file not found” as the cause | Don’t compare errors by strings |
|
|
Make test failures easier to understand | Helpers improve diagnostics |
7. Common mistakes when testing file logic via fstest
Mistake #1: the function calls os.ReadFile internally, and you try to test it via fstest.MapFS.
Classic: you did it “the usual way,” and then you’re surprised why MapFS doesn’t work. It shouldn’t: MapFS can only be substituted where your code accepts fs.FS. If the function works directly with os.*, you won’t be able to test it without disk — at least not in an honest and simple way.
Mistake #2: inconsistent file names in MapFS and in the function call.
It’s easy to miss details like "task.txt" instead of "tasks.txt" or "dir/tasks.txt" instead of "tasks.txt". On disk you’d browse the directory and look, but in MapFS directories are “virtual,” so the error can look surprising. In such cases fstest.TestFS helps: it quickly confirms that the file is actually accessible under the name you think it is.
Mistake #3: checking an error by comparing strings instead of using errors.Is.
Today the error text might be “open tasks.txt: file does not exist,” tomorrow “read tasks.txt: file does not exist,” the day after tomorrow someone adds wrapping, and everything breaks. But the cause “does not exist” stays the same, and the right way to catch it is errors.Is(err, fs.ErrNotExist). And to avoid losing the cause while adding context, use %w when wrapping.
Mistake #4: a “too big” test that verifies everything at once.
Beginners often write a single 80-line test: create FS, read the file, parse, compare every field, then also check sorting. Such a test is hard to fix. It’s much nicer (and faster) to live with a set of small scenarios: “read 2 lines,” “empty lines are ignored,” “a broken line returns an error,” “file missing.” This isn’t bureaucracy — it’s a way to localize the problem.
Mistake #5: you write helper functions for tests but forget t.Helper().
The test fails, and Go points to the line inside the helper, and you start “fixing the helper,” even though the bug is in the test itself. Marking t.Helper() is a small habit that pays off very quickly.
GO TO FULL VERSION