Skip to content
gohacks.dev
Testing

Test Filesystem Code in Go With an In Memory fstest MapFS

Test Go code that reads files without touching disk: accept an fs.FS, build a MapFS in the test, inject errors with a wrapper and validate with TestFS.

8 min read
Summarize in AI
Flat illustration of a test report with one failing test.

Tests that touch the real disk need directories to be created, cleaned up and kept apart from other tests, and they behave a little differently on Windows. Go 1.16 added a way to avoid most of that: the io/fs package, with testing/fstest for building file trees in memory. If a function takes an fs.FS rather than a path, a test can hand it a map.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article builds a small scanner and tests it that way on Go 1.25.6. When a test genuinely needs the disk, use t.TempDir; for expected-output files see golden files. Most reading code does not need either.

Take an fs.FS, not a path

The function walks a tree and reports every TODO comment in Go files, skipping vendor. Its first argument is an fs.FS:

scan.goGo
package main

import (
	"bufio"
	"fmt"
	"io/fs"
	"path"
	"sort"
	"strings"
)

// TODOs returns "file:line: text" for every TODO in .go files under root.
func TODOs(fsys fs.FS, root string) ([]string, error) {
	var out []string
	err := fs.WalkDir(fsys, root, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			if d.Name() == "vendor" {
				return fs.SkipDir
			}
			return nil
		}
		if path.Ext(p) != ".go" {
			return nil
		}
		f, err := fsys.Open(p)
		if err != nil {
			return err
		}
		defer f.Close()
		sc := bufio.NewScanner(f)
		for n := 1; sc.Scan(); n++ {
			if i := strings.Index(sc.Text(), "TODO"); i >= 0 {
				out = append(out, fmt.Sprintf("%s:%d: %s", p, n, strings.TrimSpace(sc.Text()[i:])))
			}
		}
		return sc.Err()
	})
	sort.Strings(out)
	return out, err
}

func main() {}

Nothing in TODOs mentions the operating system. It uses fs.WalkDir, fsys.Open and path.Ext. Note path, not path/filepath: fs.FS paths always use forward slashes, whatever the platform, so the path package is the correct one and the code behaves the same on Windows.

In production the call is TODOs(os.DirFS("."), "."). os.DirFS returns a read-only view of a directory. In a test, the argument is a fstest.MapFS.

Build the tree in the test

MapFS is a map[string]*fstest.MapFile. A key is a slash-separated path, and a MapFile holds the bytes, the mode, the modification time and, optionally, system-specific data:

scan_test.goGo
package main

import (
	"errors"
	"io/fs"
	"reflect"
	"testing"
	"testing/fstest"
)

func TestTODOs(t *testing.T) {
	fsys := fstest.MapFS{
		"main.go":              {Data: []byte("package main\n// TODO: wire flags\nfunc main() {}\n")},
		"internal/db/db.go":    {Data: []byte("package db\n\n// TODO: pool size\n")},
		"vendor/x/x.go":        {Data: []byte("// TODO: not ours\n")},
		"README.md":            {Data: []byte("TODO in docs\n")},
		"internal/empty/.keep": {Data: nil},
	}
	got, err := TODOs(fsys, ".")
	if err != nil {
		t.Fatal(err)
	}
	want := []string{
		"internal/db/db.go:3: TODO: pool size",
		"main.go:2: TODO: wire flags",
	}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("got %q\nwant %q", got, want)
	}
}

// failingFS returns an error for one path, to test the error branch.
type failingFS struct {
	fs.FS
	bad string
}

func (f failingFS) Open(name string) (fs.File, error) {
	if name == f.bad {
		return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrPermission}
	}
	return f.FS.Open(name)
}

func TestTODOsOpenError(t *testing.T) {
	base := fstest.MapFS{"a.go": {Data: []byte("// TODO x\n")}}
	_, err := TODOs(failingFS{base, "a.go"}, ".")
	if !errors.Is(err, fs.ErrPermission) {
		t.Fatalf("got %v", err)
	}
}

// fstest.TestFS checks that a file system obeys the fs.FS contract.
func TestMapFSIsValid(t *testing.T) {
	fsys := fstest.MapFS{
		"main.go":           {Data: []byte("package main\n")},
		"internal/db/db.go": {Data: []byte("package db\n")},
	}
	if err := fstest.TestFS(fsys, "main.go", "internal/db/db.go"); err != nil {
		t.Fatal(err)
	}
}

func TestSubdirectoriesAreImplied(t *testing.T) {
	fsys := fstest.MapFS{"a/b/c.txt": {Data: []byte("x")}}
	info, err := fs.Stat(fsys, "a/b")
	if err != nil {
		t.Fatal(err)
	}
	t.Logf("a/b is a directory: %v, mode %v", info.IsDir(), info.Mode())
}
Shell
go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|=== PAUSE|=== CONT|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: #    #'
Text
--- PASS: TestTODOs
--- PASS: TestTODOsOpenError
--- PASS: TestMapFSIsValid
    a/b is a directory: true, mode dr-xr-xr-x
--- PASS: TestSubdirectoriesAreImplied

TestTODOs lays out a small repository: a main.go with a TODO on line 2, a nested internal/db/db.go with one on line 3, a vendor/x/x.go that must be skipped, a README that is not a Go file, and an empty .keep file with a nil data field. The assertions compare the complete result, so a regression that includes the vendor file or the README fails loudly. The whole fixture sits next to the assertion, which is the readability win over a testdata/ directory: you do not open a second file to see what the test is working with.

A few details to remember:

  • Parent directories do not need entries. internal/db/db.go implies internal and internal/db. The last test stats a/b, which was never listed, and finds a directory with mode dr-xr-xr-x. That read-only default matches what an fs.FS promises.
  • A MapFile with a nil Data is a valid empty file.
  • Map iteration order is random, but MapFS sorts directory entries for you, so fs.WalkDir visits files in lexical order. The function sorts its output anyway, since a result that depends on walk order is a bug waiting for a different FS.

Test the failures too

The error branch of a file reader is where most of the interesting bugs are, and a real disk makes them hard to trigger. With an interface this small, the fix is a wrapper:

The failingFS type in the test embeds an fs.FS and overrides Open for one path. The embedding supplies everything else, so the wrapper is five lines. TestTODOsOpenError points it at a.go and asserts with errors.Is that fs.ErrPermission made it out of WalkDir. The same trick works for fs.ReadFile failures by also implementing ReadFile, for slow reads by sleeping inside the returned file, and for a read that fails halfway by wrapping the fs.File in a reader that returns an error after N bytes. Checking the error with errors.Is rather than comparing strings is covered in testing errors.

Check your own FS with fstest.TestFS

If you write a type that implements fs.FS, an archive reader or an overlay, fstest.TestFS is the conformance test. It opens every file, walks every directory, checks that ReadDir and Stat agree, and exercises the optional interfaces your type implements. The test above passes a MapFS plus the names it expects to find. Run it against your own implementation in the same way and it catches the contract mistakes that otherwise surface in a downstream user's code, such as a directory listing that disagrees with Stat.

Many trees, one test

Because the fixture is a literal, it fits in a table. Give each case a name, a MapFS and the expected lines, and loop with t.Run as in table driven tests. Cases that are painful on disk become one-liners here: an empty tree, a tree with only a vendor directory, a file with Windows line endings, a very long line that trips bufio.Scanner's default token limit, a directory named x.go. That last one is a good example of why the in-memory approach finds bugs. On disk you would hesitate to create a directory called x.go in a temp folder; in a map it is one entry with Mode: fs.ModeDir, and it checks that the scanner tests d.IsDir() before the extension.

Keep helper functions small. A helper such as tree(t, "main.go", "package main") that takes alternating names and contents keeps big fixtures short, but resist a helper that parses a DSL; the point of a MapFS is that the test reads as plain Go.

Timing and modes

Each MapFile can carry a ModTime and a Mode. Code that sorts by modification time, skips files older than a cutoff or refuses world-writable files can be tested by setting those fields directly. Combine that with an injected clock and the cutoff comparison becomes deterministic as well. When you do not set them the zero values apply, which for ModTime is the zero time, so a test that expects "recent" files must set a time explicitly.

Limits of an in-memory file system

MapFS is read-only. fs.FS has no write operations, so code that creates, renames or deletes files cannot take one. For that code, the usual options are a small interface of your own (WriteFile(name string, data []byte) error), a third-party in-memory file system such as afero's MemMapFs, or a real temporary directory. I would keep the real directory for the few tests that check permissions, symlinks and rename semantics, because an in-memory map does not reproduce an operating system's behaviour there.

It also says nothing about file modes beyond what you set, no symlinks, and no concurrent access guarantees beyond reading. Treat it as a way to test your logic, not the platform.

When to choose this

Use fs.FS when the code only reads: configuration loaders, template parsers, migration runners, static site builders, linters. It pairs naturally with //go:embed, which produces an embed.FS, so the same function takes embedded assets in the binary, os.DirFS in development and a MapFS in tests. The Go documentation for testing/fstest and io/fs lists every function used here, and the original design is described in the Go 1.16 release notes.

Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.