Вспомни, как обычно выглядит модульный тест для функции, которая парсит файл конфигурации, читает шаблон письма или валидирует CSV-отчет на Go. В большинстве проектов до сих пор можно встретить привычный паттерн: вызов t.TempDir(), создание физической папки на диске операционной системы, открытие дескриптора через os.Create(), запись тестовой строки байтов и передача пути в тестируемый модуль.
Вроде бы всё работает, но по мере роста кодовой базы за это приходится платить:
- медленными системными вызовами к файловой подсистеме ОС при каждом прогоне;
- блокировками и коллизиями при попытке распараллелить тесты через
t.Parallel(); - накоплением временного мусора на диске CI-раннеров, если процесс аварийно завершается до вызова функции очистки.
Как мы отмечали, когда обсуждали Релиз Go 1.27: дженерик-методы, ускорение аллокаций памяти и пакет uuid в стандартной библиотеке, инженерия Go стремится к минимизации лишних аллокаций и системного оверхеда. И для работы с файлами в стандартной библиотеке есть великолепный инструмент, о котором многие забывают: абстракция io/fs и пакет testing/fstest.
Проектируем код через интерфейс fs.FS
Секрет чистых тестов заключается в том, чтобы отвязать бизнес-логику от физического диска. Вместо того чтобы принимать в функцию жесткую строку с путем к файлу (filePath string), научи ее работать с базовым интерфейсом fs.FS:
package config
import (
"encoding/json"
"fmt"
"io/fs"
)
type ServerConfig struct {
Port int `json:"port"`
Host string `json:"host"`
}
// Функция принимает абстрактную файловую систему fsys
func ReadServerConfig(fsys fs.FS, filename string) (*ServerConfig, error) {
data, err := fs.ReadFile(fsys, filename)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
var cfg ServerConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
return &cfg, nil
}
В боевом продакшене ты просто передаешь туда os.DirFS(".") или встроенные в скомпилированный бинарник ресурсы через директиву //go:embed.
Виртуальная файловая система в оперативной памяти
А вот в юнит-тестах начинается настоящая магия: мы передаем в функцию структуру fstest.MapFS, которая представляет собой обычную Go-карту map[string]*fstest.MapFile:
package config
import (
"errors"
"io/fs"
"testing"
"testing/fstest"
)
func TestReadServerConfig(t *testing.T) {
t.Parallel()
// Виртуальная файловая система живет только в RAM
mockFS := fstest.MapFS{
"config/app.json": &fstest.MapFile{
Data: []byte(`{"port": 8080, "host": "127.0.0.1"}`),
},
}
cfg, err := ReadServerConfig(mockFS, "config/app.json")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Port != 8080 || cfg.Host != "127.0.0.1" {
t.Errorf("unexpected config values: %+v", cfg)
}
// Проверяем обработку отсутствующего файла
_, err = ReadServerConfig(mockFS, "config/missing.json")
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("expected ErrNotExist, got %v", err)
}
}
У fstest.MapFS есть потрясающие свойства:
- Она автоматически синтезирует промежуточные директории: путь
"config/app.json"сразу понимает, что каталогconfigсуществует, и методfs.ReadDir(mockFS, "config")отработает корректно без явного объявления папки. - При запросе несуществующего пути она возвращает стандартную ошибку
fs.ErrNotExist, позволяя точно проверять краевые условия. - Ты можешь тонко эмулировать права доступа (
Mode), время модификации (ModTime) и системные дескрипторы.
Помни о двух важных нюансах:
- Интерфейс
fs.FSи типMapFSспроектированы исключительно для операций чтения. Если твоя функция должна записывать данные в файлы, комбинируйfs.FSс интерфейсомio.Writer(например, передаваяbytes.Bufferв тестах). - Базовая карта
MapFSне является потокобезопасной: параллельная модификация карты во время чтения приведет к панике data race.
В итоге твои тесты выполняются за микросекунды, получают полную изоляцию памяти и не зависят от операционной системы разработчика.
