Testing File Systems: How I use MockFS, MapFS, and SimpleFS in Go
When building tools in Go that interact heavily with the file system, having a solid strategy for testing those interactions is critical. Direct coupling to os functions like os.MkdirAll or os.WriteFile makes testing cumbersome and slow.
In this post, I want to detail how I approach this by designing minimal file system interfaces and using in-memory implementations like MapFS and MockFS for tests.
Why This Matters in the Era of Agentic Coding
Before diving into the code, it’s worth highlighting why robust, abstract testing environments are becoming even more critical today. With the rise of AI, LLMs, and agentic coding, reviewers are facing a paradigm shift. We aren’t just getting more pull requests than ever before; we are getting larger ones.
Almost all development is trending towards being agentic, which inherently creates a greater disconnect between the code and the developer/reviewer. Because LLMs are doing the heavy lifting, we need to push the burden of proof onto the LLMs themselves. This means enforcing stricter end-to-end testing and higher minimum PR requirements. We need more type checking, clearer input-to-output mappings, and greater test coverage baked directly into the codebase.
The more comprehensive our minimum PR requirements are (like proving file system interactions work without touching the real disk), the more the submitter (whether human or agent) has to do to ensure their solution is correct before submitting. This effectively decreases low-quality PRs and gives the reviewer much greater certainty over the completeness of the solution.
The Core Idea: Define Minimal Interfaces
It is okay to use fs.FS as a parent interface. However, it is often too limited in its definition for tasks that require writing or manipulating files. We don’t want to be typecasting for something we know is rather concrete. Because the interfaces and structs are usually defined close to the source, extreme genericness is unnecessary (especially when we aren’t making a public library).
Instead, I define exactly what my code needs. For example, if a function needs to create directories, check if files exist, and write data, I might define a WritableFS (or SimpleFS):
1// WritableFS provides a minimal interface for file system operations needed by overlay init.
2type WritableFS interface {
3 MkdirAll(path string, perm os.FileMode) error
4 Stat(name string) (os.FileInfo, error)
5 WriteFile(name string, data []byte, perm os.FileMode) error
6}
By passing WritableFS into the business logic, the production code can use a real OS-backed implementation, while tests can pass an in-memory mock.
Production Implementation: The OS Wrapper
The production implementation is usually a simple wrapper around the os package that implements the required interface. Often, this wrapper is bound to a specific base directory to prevent accidental modifications outside the intended scope.
1type OSFS struct {
2 baseDir string
3}
4
5func NewOSFS(baseDir string) *OSFS {
6 return &OSFS{baseDir: baseDir}
7}
8
9func (fs *OSFS) MkdirAll(path string, perm os.FileMode) error {
10 return os.MkdirAll(filepath.Join(fs.baseDir, path), perm)
11}
12
13func (fs *OSFS) Stat(name string) (os.FileInfo, error) {
14 return os.Stat(filepath.Join(fs.baseDir, name))
15}
16
17func (fs *OSFS) WriteFile(name string, data []byte, perm os.FileMode) error {
18 return os.WriteFile(filepath.Join(fs.baseDir, name), data, perm)
19}
Testing Implementation: MockFS and MapFS
For testing, I often use a MockFS that internally uses a map to store files in memory. This is similar to testing/fstest.MapFS, but often augmented to support writes (since fstest.MapFS is read-only).
1type MockFS struct {
2 MapFS map[string]MockFileInfo
3}
4
5func NewMockFS() *MockFS {
6 return &MockFS{MapFS: make(map[string]MockFileInfo)}
7}
8
9func (m *MockFS) MkdirAll(path string, perm os.FileMode) error {
10 // For simple tests, we might just ignore directories or record them
11 return nil
12}
13
14func (m *MockFS) Stat(name string) (os.FileInfo, error) {
15 if fi, ok := m.MapFS[name]; ok {
16 return fi, nil
17 }
18 return nil, os.ErrNotExist
19}
20
21func (m *MockFS) WriteFile(name string, data []byte, perm os.FileMode) error {
22 m.MapFS[name] = MockFileInfo{
23 name: name,
24 Data: data,
25 mode: perm,
26 }
27 return nil
28}
And MockFileInfo might look like:
1type MockFileInfo struct {
2 name string
3 Data []byte
4 mode os.FileMode
5}
6
7// ... implement os.FileInfo methods ...
Why this approach?
- Speed: Tests run entirely in memory.
- Isolation: No accidental writes to the developer’s disk.
- Simplicity: It’s easy to assert on the final state of the file system by simply inspecting the
MapFSmap.
1func TestInitOverlay(t *testing.T) {
2 fs := NewMockFS()
3
4 // ... call the function under test ...
5 InitOverlay(fs, args)
6
7 // ... verify the results ...
8 fileInfo, ok := fs.MapFS["profiles/repo_name"]
9 if !ok {
10 t.Fatalf("Failed to find profiles/repo_name")
11 }
12 if string(fileInfo.Data) != "expected content\n" {
13 t.Errorf("Unexpected content")
14 }
15}
Integrating with the Variadic Args Pattern
As discussed in a previous post, you can combine this approach with type-switched variadic arguments. This allows you to inject the MockFS during testing without changing the required parameters of your production functions, preserving backward compatibility.
1func InitOverlay(args OverlayInitArgs, ops ...any) error {
2 var targetFs WritableFS = NewOSFS(cwd)
3
4 for _, opt := range ops {
5 switch o := opt.(type) {
6 case WritableFS:
7 targetFs = o
8 }
9 }
10
11 // ... use targetFs ...
12}
Conclusion and Alternatives
Using small, targeted interfaces for file system operations, backed by robust memory implementations like MockFS for testing, creates a clean boundary between business logic and side effects.
It’s worth mentioning that txtar can be a powerful substitute when there are a lot of files for the input and expected states. This is especially true if you use directory prefixes like input/*.* and expected/*.*, which allow for deep directory comparison. This approach also leaves the rest of the / namespace in the txtar file open for configuration, toggles, or metadata. Using two separate .txtar files for a single test is also perfectly acceptable, as you might reuse a common input.txtar file across multiple scenarios. Because txtar adheres to standard interfaces like fs.FS and fs.ReadDirFS, it integrates smoothly into this pattern.
Whether you are using txtar for complex test layouts (see Txtar Patterns for Agents) or simple map-based filesystems for unit testing writes, abstracting the file system is a critical step towards maintaining a testable Go codebase.