Дайджесты новостей
Предметный натюрморт с открытым блокнотом архитектора и схемой виртуальной файловой системы Go в оперативной памяти

Тестирование файлов в Go без временных папок: виртуальная файловая система fstest.MapFS

Вспомни, как обычно выглядит модульный тест для функции, которая парсит файл конфигурации, читает шаблон письма или валидирует 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 есть потрясающие свойства:

  1. Она автоматически синтезирует промежуточные директории: путь "config/app.json" сразу понимает, что каталог config существует, и метод fs.ReadDir(mockFS, "config") отработает корректно без явного объявления папки.
  2. При запросе несуществующего пути она возвращает стандартную ошибку fs.ErrNotExist, позволяя точно проверять краевые условия.
  3. Ты можешь тонко эмулировать права доступа (Mode), время модификации (ModTime) и системные дескрипторы.
Ограничения MapFS: только чтение и гонки данных

Помни о двух важных нюансах:

  • Интерфейс fs.FS и тип MapFS спроектированы исключительно для операций чтения. Если твоя функция должна записывать данные в файлы, комбинируй fs.FS с интерфейсом io.Writer (например, передавая bytes.Buffer в тестах).
  • Базовая карта MapFS не является потокобезопасной: параллельная модификация карты во время чтения приведет к панике data race.

В итоге твои тесты выполняются за микросекунды, получают полную изоляцию памяти и не зависят от операционной системы разработчика.