1. Чому «диск у тестах» — джерело дивних багів
Коли ви тільки вчитеся програмувати, природно виникає думка: «Ну а як інакше перевірити читання файлу? Треба створити файл, записати в нього тестові дані й прочитати». Формально — так. На практиці ви щойно запросили в тести цілу юрбу випадковостей: поточний каталог, права доступу, залишкові файли після попереднього запуску, паралельний запуск тестів, різні шляхи у Windows і Linux, а ще з десяток дрібниць, які спливають рівно за п’ять хвилин до дедлайну.
Є ще одна, тонша проблема: тест із диском часто перевіряє надто багато одразу. Замість «чи правильно моя функція розбирає вміст файлу» ви тестуєте «чи правильно ОС створила файл», «чи правильно ви зібрали шлях», «чи правильно закрили файл», «чи не заважають права доступу». У результаті падає тест, а ви витрачаєте час не на логіку, а на шаманство довкола оточення. Це трохи нагадує спробу перевірити рецепт борщу, щоразу вирощуючи буряк на власному балконі. Амбітно, але голодно.
Саме тому в Go й загалом в інженерній культурі тести вважають одним із головних способів підтримувати систему працездатною під час змін.
2. testing/fstest: підміна файлової системи в тестах
Пакет testing/fstest — це частина стандартної бібліотеки, яка допомагає в тестах працювати з «файловою системою, якої наче й немає». Тобто файли є, але не на диску: вони описані структурою даних у пам’яті й доступні через той самий інтерфейс fs.FS, який ми використовуємо в коді.
Чому це зручно? Бо виходить проста й дуже практична схема:
flowchart LR
A["Наша функція<br/>приймає fs.FS"] --> B["У тесті підставляємо<br/>fstest.MapFS"]
B --> C["Функція читає файли<br/>як завжди"]
C --> D["Тест перевіряє результат<br/>без диска й прав доступу"]
Зверніть увагу на «магічний трюк»: функція не знає, звідки беруться файли. Вона просто бачить fs.FS. У продакшені ви дасте їй os.DirFS(...), а в тесті — fstest.MapFS. І це одна з найприродніших для Go ідей: відокремлювати логіку від інфраструктури через інтерфейси.
Мінімальний формат даних для прикладу: «завдання» у текстовому файлі
Щоб тестувати щось живе, нам потрібен невеликий фрагмент справжньої логіки. Уявімо, що в нашому навчальному застосунку — умовному мінітрекері завдань — завдання зберігаються в простому текстовому файлі, по одному в кожному рядку:
1|Купити молоко|0
2|Вивчити fs.FS|1
Де id|title|done, а done — це 0 або 1. Це не найвидатніший формат, який винайшло людство, зате він простий і його легко розібрати тими інструментами, які в нас уже є: strings.Split, strconv.Atoi, умови й помилки.
Зараз наша мета — не вигадати ідеальний формат, а навчитися тестувати файлову логіку без диска. Тому формат буде навмисно простим, як табуретка. Зате табуретка рідко падає.
Код застосунку: читаємо завдання з fs.FS, а не з диска
Зробімо пакет storage і функцію LoadTasks, яка приймає fs.FS та імʼя файлу. Тут принципово важливо, що ми не використовуємо os.ReadFile напряму: наша функція має бути придатною до тестування.
Модель даних Task
Почнімо зі структури. Вона максимально нудна — і це комплімент.
package storage
type Task struct {
ID int
Title string
Done bool
}
Парсинг одного рядка
Розбір краще винести в окрему функцію: так ми зможемо точніше перевіряти помилки, а код читатиметься легше.
package storage
import (
"fmt"
"strconv"
"strings"
)
func parseTaskLine(line string) (Task, error) {
parts := strings.Split(line, "|")
if len(parts) != 3 {
return Task{}, fmt.Errorf("некоректний рядок завдання: %q", line)
}
id, err := strconv.Atoi(parts[0])
if err != nil {
return Task{}, fmt.Errorf("помилковий id: %w", err)
}
return Task{ID: id, Title: parts[1], Done: parts[2] == "1"}, nil
}
Зверніть увагу: ми додали контекст до помилок і зберегли причину через %w. Саме такий підхід робить помилки придатними для аналізу вище в стеку.
Читання файла з fs.FS
Тепер напишімо функцію, яка читає файл повністю й розбирає рядки. Для простоти використаємо fs.ReadFile — так, це «прочитати цілком», і ми робимо це свідомо: файл завдань маленький.
package storage
import (
"fmt"
"io/fs"
"strings"
)
func LoadTasks(fsys fs.FS, name string) ([]Task, error) {
b, err := fs.ReadFile(fsys, name)
if err != nil {
return nil, fmt.Errorf("помилка читання %q: %w", name, err)
}
var tasks []Task
for _, line := range strings.Split(string(b), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
t, err := parseTaskLine(line)
if err != nil {
return nil, fmt.Errorf("помилка розбору %q: %w", name, err)
}
tasks = append(tasks, t)
}
return tasks, nil
}
Тут захована важлива філософія: ми написали код так, що йому байдуже, реальні це файли чи «тестові фантоми». У цей момент ваш код робить маленький крок до дорослого життя.
3. Тестуємо без диска: fstest.MapFS як «файли в пам’яті»
Коли ви пишете тест, корисно тримати в голові схему «Arrange → Act → Assert»: підготували оточення, викликали функцію, перевірили результат. Це базова механіка, і вона однаково добре працює і для функцій, і для файлової логіки.
fstest.MapFS — це map, у якій ключ — імʼя файлу (FS-шлях), а значення — структура з вмістом. Тобто замість «створи файл на диску» ви пишете: «ось тобі файл a.txt, його вміст такий».
Успішний сценарій: файл існує, дані коректні
package storage
import (
"testing"
"testing/fstest"
)
func TestLoadTasks_OK(t *testing.T) {
fsys := fstest.MapFS{
"tasks.txt": {Data: []byte("1|Молоко|0\n2|Вивчити Go|1\n")},
}
tasks, err := LoadTasks(fsys, "tasks.txt")
if err != nil {
t.Fatalf("LoadTasks: %v", err)
}
if len(tasks) != 2 {
t.Fatalf("очікували 2 завдання, отримали %d", len(tasks))
}
}
Зверніть увагу, що тесту не потрібні ні os.MkdirAll, ні тимчасовий каталог, ні прибирання після себе. Він швидкий, чистий і не залишає сміття на диску. Такі тести приємно запускати хоч 200 разів підряд — і це не перетворює ваш ноутбук на тостер.
Перевіряємо помилки правильно: «файл не знайдено»
Перевірка помилок у Go — це не «ну там якийсь рядок». Ми намагаємося орієнтуватися на причину.
Якщо в MapFS немає ключа "tasks.txt", спроба читання має завершитися помилкою «не існує». І ми перевіряємо це через errors.Is.
package storage
import (
"errors"
"io/fs"
"testing"
"testing/fstest"
)
func TestLoadTasks_NotExist(t *testing.T) {
fsys := fstest.MapFS{}
_, err := LoadTasks(fsys, "tasks.txt")
if err == nil {
t.Fatalf("очікували помилку")
}
if !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("очікували fs.ErrNotExist, отримали %v", err)
}
}
Чому не порівнюємо рядки? Бо рядок — це «як зараз сформулювали повідомлення», а errors.Is — це «яка причина всередині». І якщо ми обгортаємо помилки через %w, причина зберігається.
Файл виявився директорією: теж корисний кейс
fstest.MapFS дозволяє описати директорію, вказавши режим fs.ModeDir. Перевірмо, що наша функція коректно повертає помилку, якщо їй підсунули директорію замість файлу.
package storage
import (
"io/fs"
"testing"
"testing/fstest"
)
func TestLoadTasks_DirInsteadOfFile(t *testing.T) {
fsys := fstest.MapFS{
"tasks.txt": {Mode: fs.ModeDir},
}
_, err := LoadTasks(fsys, "tasks.txt")
if err == nil {
t.Fatalf("очікували помилку")
}
}
Тут ми не чіпляємося до точного вигляду помилки: нам важливо, що функція не вдає, ніби все гаразд. А конкретне повідомлення може залежати від реалізації MapFS.
4. fstest.TestFS: швидкий «техогляд» вашої FS
Іноді тести починають виглядати так: ви створили MapFS, накидали туди 10 файлів, а потім посеред тесту раптом не той шлях, не той файл, друкарська помилка — і ви пів години дивитеся в код з обличчям «чому воно не бачить config.txt, він же ось він!». Спойлер: тому що ви назвали його confg.txt.
Для таких випадків є fstest.TestFS. Це функція, яка перевіряє, що FS поводиться коректно й що вказані файли справді відкриваються.
package storage
import (
"testing"
"testing/fstest"
)
func TestMapFS_Contract(t *testing.T) {
fsys := fstest.MapFS{
"tasks.txt": {Data: []byte("1|Молоко|0\n")},
"readme.md": {Data: []byte("привіт")},
}
if err := fstest.TestFS(fsys, "tasks.txt", "readme.md"); err != nil {
t.Fatalf("TestFS: %v", err)
}
}
Це не заміна вашим тестам логіки, а швидка перевірка «проводки»: FS зібрана правильно, файли доступні, і далі ви тестуєте вже свою бізнес-логіку.
5. Читабельність тестів: хелпери та t.Helper()
Коли тестів стає більше, з’являється новий вид болю: тести працюють, але читати їх неможливо. Це особливий жанр страждання — «усе зелене, але я нічого не розумію».
У Go для покращення повідомлень про помилки є механізм t.Helper(): ви позначаєте функцію як helper, і тоді при падінні тесту Go покаже рядок виклику хелпера, а не рядок усередині нього.
Зробімо маленький хелпер, який створює MapFS із завданнями:
package storage
import (
"testing"
"testing/fstest"
)
func fsWithTasks(t *testing.T, content string) fstest.MapFS {
t.Helper()
return fstest.MapFS{
"tasks.txt": {Data: []byte(content)},
}
}
І використаємо його:
package storage
import "testing"
func TestLoadTasks_EmptyLinesAreIgnored(t *testing.T) {
fsys := fsWithTasks(t, "\n1|Молоко|0\n\n")
tasks, err := LoadTasks(fsys, "tasks.txt")
if err != nil {
t.Fatalf("LoadTasks: %v", err)
}
if len(tasks) != 1 {
t.Fatalf("очікували 1 завдання, отримали %d", len(tasks))
}
}
Така дрібниця зазвичай перетворює тести з «простирадла» на охайний набір сценаріїв. А ще вона зменшує ймовірність, що ви випадково наробите копіпасту й забудете змінити імʼя файлу в одному місці.
6. Шпаргалка: що чим тестувати
Іноді корисно не запам’ятовувати, а тримати під рукою коротку карту місцевості — особливо коли ви новачок і в голові й так уже юрма нових термінів.
| Інструмент | Де живе | Для чого корисний | Головна думка |
|---|---|---|---|
|
|
Підсунути файли «в пам’яті» замість диска | Тестуємо логіку, а не інфраструктуру |
|
|
Перевірити, що FS відкриває потрібні файли | Швидко ловимо друкарські помилки й хиби в збиранні FS |
|
|
Перевірити «файл не знайдено» як причину | Не порівнюємо помилки за рядками |
|
|
Зробити помилки тестів зрозумілішими | Хелпери покращують діагностику |
7. Типові помилки під час тестування файлової логіки через fstest
Помилка № 1: функція всередині себе викликає os.ReadFile, а ви намагаєтеся тестувати через fstest.MapFS.
Це класика: ви зробили «як завжди», а потім дивуєтеся, чому MapFS не працює. А він і не має працювати: MapFS підставляється лише туди, де ваш код приймає fs.FS. Якщо функція напряму працює з os.*, то тест без диска ви не побудуєте — принаймні чесно й просто.
Помилка № 2: неузгоджені імена файлів у MapFS і під час виклику функції.
Око легко пропускає дрібниці на кшталт "task.txt" замість "tasks.txt" або "dir/tasks.txt" замість "tasks.txt". На диску ви б зайшли в каталог і подивилися, а в MapFS каталоги «віртуальні», тож помилка виглядає несподівано. У таких випадках допомагає fstest.TestFS: він швидко підтверджує, що файл справді доступний під тим імʼям, яке ви маєте на увазі.
Помилка № 3: перевірка помилки через порівняння рядків замість errors.Is.
Сьогодні текст помилки може бути «open tasks.txt: file does not exist», завтра — «read tasks.txt: file does not exist», післязавтра хтось додасть обгортку, і все зламається. Але причина «не існує» лишається тією самою, і правильний спосіб її впіймати — errors.Is(err, fs.ErrNotExist). А щоб причина не губилася під час додавання контексту, використовуйте %w під час обгортання.
Помилка № 4: тест «надто великий» і перевіряє все одразу.
Новачки часто пишуть один тест на 80 рядків: створюємо FS, читаємо файл, розбираємо, порівнюємо все поле за полем, а потім ще й перевіряємо сортування. Такий тест важко виправляти. Набагато приємніше — і швидше — жити з набором маленьких сценаріїв: «прочитали 2 рядки», «порожні рядки ігноруються», «некоректний рядок дає помилку», «немає файлу». Це не бюрократія — це спосіб локалізувати проблему.
Помилка № 5: ви робите helper-функції для тестів, але забуваєте t.Helper().
Тест упав, а Go показує рядок усередині хелпера, і ви починаєте виправляти хелпер, хоча помилка в самому тесті. Позначка t.Helper() — маленька звичка, яка дуже швидко окуповується.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ