JavaRush /Courses /Go SELF /Filesystem testing: t.TempDir() and fstest.MapFS

Filesystem testing: t.TempDir() and fstest.MapFS

Go SELF
Level 53, Lesson 4
Available

1. Why file-based tests “break by themselves”

When you write a CLI for the first time, it’s very tempting to do this: “I’ll read tasks.txt from the current folder—after all, it’s right next to the project.” On your machine it may even work, and you’ll feel like a winner. And then the tests run in CI, where there’s nothing “next to” anything, the working directory is different, permissions are different, and in general someone may already have created a file with that name (or may not have).

The problem here isn’t Go and it isn’t testing. The problem is that the filesystem is an external environment, and external environments are unpredictable. Our job as test authors is to make the environment predictable: create our own sandbox and operate only inside it. Then the same test runs the same way on your laptop, on a CI server, and on a colleague’s machine running tests on Windows, Linux, and “in a special mood.”

We’ve already discussed that changes to the “expected output” can break tests if tests are coupled to an overly specific format. The same observation applies to the environment: the more a test depends on “the world around it,” the easier it is to break without any change in business logic.

t.TempDir() — a personal on-disk sandbox

testing.T can give you a temporary directory: dir := t.TempDir(). This is a small gift from the standard library that saves you from the eternal pain of “created a folder—forgot to delete it.” The folder is created uniquely, and after the test Go cleans everything up. That is, you get an honest disk (real os.ReadFile, os.WriteFile), but without littering your project.

It’s important to understand the philosophy: t.TempDir() is not “just another way to create a folder.” It’s a way to make an agreement with the test runner: “this is my temporary zone; please don’t leave traces.” If you’re testing a CLI that reads/writes files, this is usually the simplest and most reliable option: you test the real behavior of the code, including filesystem errors, encodings, line endings, and all the other joys of life.

And here’s a small style nuance: when you move reading/writing into helpers, don’t forget to call t.Helper() so that when the test fails, the line number points to the helper call site, not the helper internals. This behavior is explicitly supported in testing.

2. Continuing todo-cli: add the -file flag

To properly test file scenarios, we need a way to tell the app: “use this file that I prepared for you.” The simplest way is the -file flag. Then in real life you can write todo list -file tasks.txt, and in a test—todo list -file <tempDir>/tasks.txt.

Below are small pieces of code that assemble into a single application: the cli package remains the entry point for tests, and the todo package does the filesystem work.

Task model and “string” serialization

Let’s make a super-simple format: id|text. This isn’t an “ideal forever format,” but for learning and testing it’s great: easy to read with your eyes and easy to parse.

package todo

import (
	"fmt"
	"strings"
)

type Task struct {
	ID   int
	Text string
}

func (t Task) String() string {
	// Example line: "1|buy milk"
	return fmt.Sprintf("%d|%s", t.ID, strings.ReplaceAll(t.Text, "\n", " "))
}

Note: we remove \n from the task text ahead of time. This isn’t “paranoia,” it’s a way to keep the file format predictable.

Saving and loading tasks

Minimally: read the whole file, parse line by line, write the whole file. For an educational project, this is sufficient.

package todo

import (
	"os"
	"strings"
)

func Save(path string, tasks []Task) error {
	var b strings.Builder
	for _, t := range tasks {
		b.WriteString(t.String())
		b.WriteString("\n")
	}
	return os.WriteFile(path, []byte(b.String()), 0o644)
}
package todo

import (
	"os"
)

func Load(path string) ([]Task, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	return Parse(string(b))
}

And we’ll move parsing out separately so it can be tested independently.

package todo

import (
	"strconv"
	"strings"
)

func Parse(data string) ([]Task, error) {
	data = strings.TrimSpace(data)
	if data == "" {
		return nil, nil
	}

	lines := strings.Split(data, "\n")
	out := make([]Task, 0, len(lines))

	for _, line := range lines {
		parts := strings.SplitN(line, "|", 2)
		id, err := strconv.Atoi(parts[0])
		if err != nil {
			return nil, err
		}
		out = append(out, Task{ID: id, Text: parts[1]})
	}
	return out, nil
}

add command: reads the file, adds a task, saves

package cli

import (
	"flag"
	"fmt"
	"io"
	"strings"

	"example.com/todo/todo"
)

func runAdd(args []string, out, errOut io.Writer) int {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	fs.SetOutput(errOut)

	path := fs.String("file", "tasks.txt", "path to tasks file")

	if err := fs.Parse(args); err != nil {
		return exitUsage
	}
	if fs.NArg() == 0 {
		fmt.Fprintln(errOut, "missing task text")
		return exitUsage
	}

	text := strings.Join(fs.Args(), " ")
	tasks, _ := todo.Load(*path) // simplifying: we’ll handle “no file” later
	tasks = append(tasks, todo.Task{ID: len(tasks) + 1, Text: text})

	if err := todo.Save(*path, tasks); err != nil {
		fmt.Fprintln(errOut, "save:", err)
		return exitFail
	}

	fmt.Fprintln(out, "ok")
	return exitOK
}

Yes, there’s a simplification here: we ignore the Load error. Ideally, you should distinguish between “file does not exist” and a “real error,” but that’s not the goal of today’s lecture. Today we’re learning to isolate the FS in tests, not to design perfect storage.

4. Testing CLI + files via t.TempDir()

Now the most important part: how to test this so the test is independent of your machine. The idea is simple: create a temporary directory, build a path to the file inside it, run Run(...) (or runAdd/runList—depending on your structure), check the exit code and output. Then separately read the file and make sure it really was created and contains the expected data.

Testing a command with a real file

package cli

import (
	"bytes"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

func TestAdd_CreatesFileInTempDir(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "tasks.txt")

	var out bytes.Buffer
	var errOut bytes.Buffer

	code := Run([]string{"add", "-file", path, "buy", "milk"}, strings.NewReader(""), &out, &errOut)

	if code != exitOK {
		t.Fatalf("code=%d, stderr=%q", code, errOut.String())
	}
	if out.String() != "ok\n" {
		t.Fatalf("stdout=%q", out.String())
	}

	b, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read file: %v", err)
	}
	if string(b) != "1|buy milk\n" {
		t.Fatalf("file=%q", string(b))
	}
}

What matters here is that the test does not create any files in the project. Run it 100 times—the repository stays clean.

Why we read the file separately instead of trusting ok\n

From a UX standpoint, ok is just a message to the user. But from a functionality standpoint, what matters is that the app actually wrote the data where it promised. That’s why, in tests for file scenarios, it’s useful to verify not only stdout/stderr but also the “trace” in the FS: did the file appear, what is its content.

This check is conceptually similar to the idea that “stdout is checked by the test runner” in example tests: there too, the important contract is “what was actually printed,” not what we thought we printed.

5. Multiple scenarios: “added twice → IDs grow”

If you add tasks multiple times, it’s important to verify that the file is read correctly and new entries are appended “logically correctly.” Even if you physically rewrite the whole file each time—the result must be correct.

package cli

import (
	"bytes"
	"path/filepath"
	"strings"
	"testing"
)

func TestAdd_Twice(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "tasks.txt")

	var out1, err1 bytes.Buffer
	_ = Run([]string{"add", "-file", path, "a"}, strings.NewReader(""), &out1, &err1)

	var out2, err2 bytes.Buffer
	_ = Run([]string{"add", "-file", path, "b"}, strings.NewReader(""), &out2, &err2)

	var out bytes.Buffer
	var errOut bytes.Buffer
	code := Run([]string{"list", "-file", path}, strings.NewReader(""), &out, &errOut)

	if code != exitOK {
		t.Fatalf("code=%d, stderr=%q", code, errOut.String())
	}
	if out.String() != "1. a\n2. b\n" {
		t.Fatalf("stdout=%q", out.String())
	}
}

Here we do an important thing: we test the program “like a user,” but still without launching a separate process. That’s exactly the point of our CLI-level command architecture.

6. Useful nuances: fstest.MapFS, io/fs, and paths

Sometimes you don’t need a “real disk.” For example, if you want to test a parser or data loading and speed/isolation matters more than validating real os.WriteFile. In such moments, it’s useful to use io/fs and testing/fstest.

The core idea: instead of a function “reaching into os by itself,” it accepts an fs.FS and reads the file via fs.ReadFile. Then in the test you give it fstest.MapFS—“files in memory.” This isn’t magic; it’s standard dependency substitution, just at the filesystem level.

Loading from fs.FS

package todo

import (
	"io/fs"
)

func LoadFS(fsys fs.FS, name string) ([]Task, error) {
	b, err := fs.ReadFile(fsys, name)
	if err != nil {
		return nil, err
	}
	return Parse(string(b))
}

Disk-free unit test via fstest.MapFS

package todo

import (
	"testing"
	"testing/fstest"
)

func TestLoadFS_MapFS(t *testing.T) {
	fsys := fstest.MapFS{
		"tasks.txt": {Data: []byte("1|buy milk\n2|learn go\n")},
	}

	tasks, err := LoadFS(fsys, "tasks.txt")
	if err != nil {
		t.Fatalf("err: %v", err)
	}
	if len(tasks) != 2 || tasks[1].Text != "learn go" {
		t.Fatalf("tasks=%v", tasks)
	}
}

Here you test the “pure logic” of reading and parsing without creating temporary directories. This is convenient when you want the test to be as fast as possible and not depend on filesystem quirks.

An important path nuance: filepath and “paths inside FS” are different worlds

When you work with the real disk, use filepath.Join because it respects the current OS rules (separators, path specifics). When you work with fs.FS (and especially with fstest.MapFS), file names are usually written in a “virtual FS” style: with / and without absolute paths.

The simplest way not to get confused is to keep the rule: “in TempDir I build an OS path via filepath, and in MapFS I use short relative names (tasks.txt, data/input.txt).” If you try to feed C:\...\tasks.txt into fs.ReadFile, it will almost certainly end weirdly, and the test will read like a detective story missing its last page.

How to choose: t.TempDir() or fstest.MapFS

At this point you typically want to produce a list of 20 items, but we’re peaceful people, so let’s reduce the idea to a table:

Test goal Best fit Why
Verify that the CLI really creates/changes a file
t.TempDir()
This is a “real disk”; you test behavior as in production
Verify parsing/reading logic
fstest.MapFS
Fast, isolated, no I/O and no temporary folders
Verify permissions, “file does not exist,” weird OS errors
t.TempDir()
Only a real FS can produce realistic errors
Verify a pure function LoadFS/Parse
fstest.MapFS
You need logic, not environment

7. Common mistakes

Mistake No. 1: the test writes files into the repository (next to go.mod) and “forgets to clean up.”
This looks harmless until you catch a weird test failure because the file was left over from a previous run. A test should create its own world and destroy it itself. t.TempDir() solves this almost completely: files live in the sandbox and disappear automatically.

Mistake No. 2: the test depends on the current directory (os.Getwd()/“well, I run from the project root”).
As soon as the test runs from an IDE, from CI, or from another package, the “current directory” may be different. It’s better to design the CLI so that the file path is specified explicitly (via the -file flag) and in the test is always built via filepath.Join(t.TempDir(), ...).

Mistake No. 3: stdout/stderr are checked, but the file contents are not.
The ok message can be printed even when the write did not happen (especially if you messed up handling err). If a command promises to write data, the test should verify the file result too. Otherwise, you’re testing not behavior, but self-reassurance.

Mistake No. 4: test helpers call t.Fatalf but are not marked as t.Helper().
As a result, on failure you see the line inside the helper rather than the place it was called, and you start “looking in the wrong place.” Marking helpers with t.Helper() is a small habit that makes diagnostics much nicer.

Mistake No. 5: trying to use OS paths inside fstest.MapFS.
fstest.MapFS is not your disk, and it is not required to understand absolute paths. In MapFS use relative names and a /-style path inside the virtual FS. But for the real disk—use filepath.Join and t.TempDir().

1
Task
Go SELF, level 53, lesson 4
Locked
Greeting to a File
Greeting to a File
1
Task
Go SELF, level 53, lesson 4
Locked
Uppercase
Uppercase
1
Task
Go SELF, level 53, lesson 4
Locked
Reading from memory
Reading from memory
1
Task
Go SELF, level 53, lesson 4
Locked
Append a line
Append a line
1
Survey/quiz
CLI Testing, 53 level, 4 lesson
Unavailable
CLI Testing
CLI Testing
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION