The problem with testing file I/O
You are writing a configuration loader. It reads a YAML file, parses it, and returns a struct. You want to test it. The naive approach is to create a real file on disk, run the test, and delete it. That works once. It breaks when tests run in parallel. It slows down CI pipelines. It leaves temporary files behind when a test panics. You need a file system that behaves exactly like the real one but lives entirely in memory.
Go's standard library anticipates this exact friction. The testing/fstest package ships with a virtual file system implementation that satisfies the io/fs.FS interface. You define paths and contents as a simple map, hand it to your function, and let the test runner verify behavior without touching the disk. The real file system and the fake one share the same contract. Your code does not need to know which one it is using.
What fstest actually does
The io/fs package defines a minimal interface for reading files and directories. Any type that implements Open(name string) (File, error) qualifies as a file system. The standard library's os package implements it for the real disk. The testing/fstest package implements it for a map in RAM.
Think of fstest.MapFS as a mock server for files. Instead of spinning up a container or writing temporary files, you declare a dictionary of paths pointing to byte slices. The package wraps that dictionary in a type that implements Open, Stat, and ReadDir. When your code calls fs.Open("config.yaml"), the virtual file system looks up the key, creates a synthetic File object backed by a bytes.Reader, and returns it. The rest of your code reads from it exactly as it would from a disk file.
This design follows a core Go convention: interfaces are accepted, structs are returned. Your production function accepts fs.FS. Your test passes fstest.MapFS. The compiler guarantees they match. The runtime guarantees they behave identically for read-only operations.
A minimal in-memory file system
Here is the simplest way to create a virtual file system and verify it works.
package main
import (
"io/fs"
"testing"
"testing/fstest"
)
// TestMapFSBasics demonstrates creating a virtual file system and reading from it.
func TestMapFSBasics(t *testing.T) {
// MapFS stores paths as keys and MapFile structs as values.
// The map lives in heap memory, not on disk.
virtualFS := fstest.MapFS{
"notes.txt": &fstest.MapFile{
// Data holds the raw bytes that will be returned on Read.
Data: []byte("buy milk\nbuy eggs"),
// Mode sets the file permissions returned by Stat.
Mode: fs.ModePerm,
},
}
// Open returns a synthetic file backed by a bytes.Reader.
// It implements the io/fs.File interface automatically.
file, err := virtualFS.Open("notes.txt")
if err != nil {
t.Fatalf("open failed: %v", err)
}
// Close is required by the io.Closer interface.
// MapFS tracks it but does not enforce disk cleanup.
defer file.Close()
// Stat returns metadata without reading the file contents.
// It pulls the size and mode directly from the MapFile struct.
info, err := virtualFS.Stat("notes.txt")
if err != nil {
t.Fatalf("stat failed: %v", err)
}
// Verify the virtual file reports the correct byte length.
if info.Size() != 15 {
t.Errorf("expected size 15, got %d", info.Size())
}
}
The compiler checks that virtualFS satisfies fs.FS at compile time. You do not need to assert the interface manually. Go's structural typing handles it. When Open is called, fstest allocates a small wrapper that delegates Read to the underlying byte slice. No system calls are made. No disk I/O occurs. The entire operation runs in user space.
fstest.MapFS is intentionally read-only. It does not implement Create, Write, or Remove. If your code tries to write to the virtual file system, the operation fails with a fs.ErrPermission error. This matches the typical testing pattern: you verify how your code reads and interprets files, not how it modifies them. If you need writable tests, you use a temporary directory instead.
Testing a real reader function
Real code rarely calls fs.Open directly. It wraps the call in a helper that handles parsing, validation, and error wrapping. Here is a function that reads a configuration file and returns a struct.
package main
import (
"encoding/json"
"io/fs"
)
// Config holds parsed application settings.
type Config struct {
Port int `json:"port"`
Name string `json:"name"`
}
// LoadConfig reads a JSON file from the provided file system.
// It accepts fs.FS so tests can inject a virtual disk.
func LoadConfig(root fs.FS, path string) (*Config, error) {
// Open delegates to the underlying FS implementation.
// It returns an error if the path does not exist.
data, err := fs.ReadFile(root, path)
if err != nil {
// Wrap the error to preserve the original cause.
// This follows the standard error handling convention.
return nil, fmt.Errorf("read config %s: %w", path, err)
}
// Unmarshal parses the JSON bytes into the Config struct.
// It returns an error if the structure does not match.
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
// Return the populated struct to the caller.
return &cfg, nil
}
Now we test it with fstest. The test defines a virtual file, passes it to LoadConfig, and verifies the output.
package main
import (
"testing"
"testing/fstest"
)
// TestLoadConfigSuccess verifies that valid JSON parses correctly.
func TestLoadConfigSuccess(t *testing.T) {
// Create a virtual file system with a single JSON file.
// The map key is the path relative to the root.
mockFS := fstest.MapFS{
"app.json": &fstest.MapFile{
Data: []byte(`{"port": 8080, "name": "test"}`),
},
}
// Call the function under test with the virtual file system.
// The function sees fs.FS, not fstest.MapFS.
cfg, err := LoadConfig(mockFS, "app.json")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Assert the parsed values match the expected configuration.
if cfg.Port != 8080 {
t.Errorf("expected port 8080, got %d", cfg.Port)
}
if cfg.Name != "test" {
t.Errorf("expected name 'test', got %s", cfg.Name)
}
}
The test runs in microseconds. It does not create files. It does not depend on the host operating system's path separators. It isolates the parsing logic from disk latency. When you run go test, the virtual file system is allocated, used, and garbage collected without leaving traces.
Pitfalls and compiler behavior
Virtual file systems are convenient, but they are not perfect mirrors of the real disk. The first trap is missing files. If your test requests a path that does not exist in the MapFS, Open returns fs.ErrNotExist. Your production code should handle this gracefully. If it panics instead, the test fails loudly. That is good. It catches bugs early.
The second trap is directory handling. fstest.MapFS treats paths as flat keys. If you define "dir/file.txt", the package automatically synthesizes the "dir" directory when you call ReadDir. You do not need to define directories explicitly. If you do define a directory entry, it must have Mode: fs.ModeDir. The compiler will not catch a missing mode flag, but ReadDir will skip entries that lack the directory bit.
The third trap is type mismatches. If you accidentally pass a *os.File or a string where fs.FS is expected, the compiler rejects it with cannot use x (type *os.File) as fs.FS value in argument. Go's type system prevents silent failures here. You must explicitly wrap or convert the value.
Error handling in Go is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. When testing, you will see if err != nil { t.Fatalf(...) } repeatedly. That is correct. Tests should fail fast. Use t.Fatalf for setup errors that make further assertions meaningless. Use t.Errorf for assertion failures that should not stop the test. The testing package tracks both, but only Fatalf halts execution.
One convention pays dividends here: always name your fs.FS parameter root or fsys, not fs. The fs identifier collides with the io/fs package name in the same scope, which forces you to write fs.FS or fs.File with an ambiguous prefix. Naming it root keeps the code clean and matches the standard library's own examples.
Goroutines are cheap. Virtual file systems are not magic. They simulate read paths, not disk quotas, not network mounts, not permission boundaries. If your code relies on chmod or symlink, fstest will not help you. Switch to a temporary directory instead.
When to reach for fstest
Use fstest.MapFS when you need to test read-only file parsing without disk I/O. Use t.TempDir when your code writes files, creates directories, or relies on OS-level permissions. Use httptest.NewServer when your code fetches files over HTTP instead of reading them locally. Use plain string literals or byte slices when you are testing pure parsing logic that does not need a file system abstraction. Use fstest.TestFS when you want to run the standard library's own io/fs test suite against a custom implementation.