1. Сценарії та інваріанти сховища
Коли ми говоримо про тестованість, багато хто уявляє собі щасливий шлях: викликали Save(), отримали nil, пішли пити чай. Але файлове сховище — це як кіт: поки дивитеся, воно пристойне, а варто відвернутися — і вже впустило вазу, зʼїло ваші права доступу та залишило .tmp-12345 на робочому столі. Нам потрібні тести, які перевіряють властивості результату, а не лише факт відсутності помилки.
У контексті сховища майже завжди важливо тестувати не рядок коду, а сценарій: що було на диску до операції, що ми зробили і що має бути після. І так, іноді сценарій — це «після помилки все одно не залишили сміття». Це теж частина контракту.
Давайте зафіксуємо, які властивості (інваріанти) зазвичай перевіряють.
Міні-таблиця: «сценарій → що перевіряємо»
| Сценарій | Вхідний стан | Дія | Що має бути істинним |
|---|---|---|---|
| Перший запис | файла немає | |
файл зʼявився, вміст коректний |
| Перезапис | є стара версія | |
файл містить new, а не «шматок old + шматок new» |
| Політика резервного копіювання | є стара версія | |
file.bak містить old, file містить new |
| Відновлення | file відсутній, file.bak є | |
file відновлено з .bak |
| Lock | lock уже існує | |
повертаємо помилку «зайнято», не перезаписуємо lock |
Ключовий момент лекції: усі ці сценарії мають виконуватися в тесті в ізольованій директорії, інакше тести почнуть залежати від вашого комп’ютера, прав, випадкових файлів і настрою операційної системи.
2. Пісочниця для тестів: t.TempDir()
Коли ви тестуєте файлову систему, найчастіший спосіб випадково зробити щось не так — писати тестові файли поруч із проєктом або в якусь ./testdata/tmp. Це швидко призводить до ефекту «у мене проходить, у тебе — ні», бо оточення у вас різне.
У Go для цього є простий і дуже практичний інструмент: t.TempDir(). Він створює унікальну тимчасову директорію для конкретного тесту й гарантовано видаляє її після завершення тесту. Це не лише зручно, а й робить тести відтворюваними: кожен запуск стартує з чистого аркуша.
Мініприклад: створюємо шлях до файла всередині тимчасової директорії.
package storage_test
import (
"path/filepath"
"testing"
)
func TestPathsWithTempDir(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "data.txt")
_ = path // далі будемо працювати з цим файлом
}
Тут важливі два дрібні, але справді корисні моменти. По-перше, filepath.Join акуратно складає частини шляху відповідно до вашої ОС (розділювачі, слеші — усе враховано). По-друге, ви перестаєте думати про прибирання сміття: тест упав — директорію все одно буде прибрано.
3. Проєктуємо код сховища під тести
Зазвичай тестованість ламається не тому, що тести погано написали, а тому, що API сховища спроєктовано так, що його складно ізолювати. Наприклад, функція сама вирішує, що файл завжди "./data/tasks.txt", а ви потім у тесті намагаєтеся не зіпсувати реальні дані. Це вже нагадує фільм жахів, тільки без бюджету.
Практичне правило: функції сховища мають приймати шлях або директорію як параметр і повертати error, не друкуючи нічого в stdout/stderr. Тоді в тесті ми просто підставимо шлях із t.TempDir().
Уявімо, що ми далі розвиваємо навчальний застосунок (умовний міні-todo), і в нас є простий формат: одне завдання — один рядок. Серіалізація детермінована.
package storage
import "strings"
func EncodeLines(lines []string) []byte {
return []byte(strings.Join(lines, "\n") + "\n")
}
Чому така функція хороша? Тому що її легко тестувати окремо: вхід → вихід, без файлової системи. І навіть якщо ви поки не пишете окремий тест на EncodeLines, саме виділення серіалізації в окремий крок робить «справжні» файлові тести простішими й зрозумілішими.
4. Перевіряємо сценарії запису: вміст і відсутність сміття
Файлові тести особливо виграють від структури «підготували — зробили — перевірили». Інакше за тиждень ви відкриєте тест і побачите кашу з os.WriteFile, Rename, ReadFile, а в голові буде лише одне запитання: «А що ми взагалі хотіли довести?».
У Go це часто виглядає так:
- Arrange: dir := t.TempDir(), підготували файли.
- Act: викликали нашу функцію сховища.
- Assert: перевірили вміст, існування .bak, відсутність сміття.
Перевіряємо, що запис справді потрапив у файл
Зробімо маленький тест для функції AtomicWriteFile (яку ми писали в попередніх лекціях): вона має записати дані у файл.
package storage_test
import (
"os"
"path/filepath"
"testing"
)
func TestAtomicWriteFile_WritesContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "data.txt")
err := AtomicWriteFile(path, []byte("hello"), 0644)
if err != nil {
t.Fatalf("AtomicWriteFile: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(got) != "hello" {
t.Fatalf("unexpected content: %q", string(got))
}
}
Зверніть увагу: ми перевіряємо саме інваріант «підсумковий вміст коректний». Це базовий рівень, без якого всі розмови про надійність не мають сенсу.
Перевіряємо, що тимчасові файли не залишилися
Тепер трохи «доросліша» перевірка. Протокол temp+rename зазвичай використовує тимчасові файли з прозорим шаблоном, наприклад .tmp-*. Інваріант нашого протоколу такий: після успішної операції тимчасових файлів не має лишитися.
Щоб не заглиблюватися в обхід директорій (ми детально й системно розбираємо це окремо), можна зробити простий і достатньо читабельний варіант через filepath.Glob.
package storage_test
import (
"path/filepath"
"testing"
)
func TestAtomicWriteFile_NoTempFilesLeft(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "data.txt")
if err := AtomicWriteFile(path, []byte("ok"), 0644); err != nil {
t.Fatalf("AtomicWriteFile: %v", err)
}
matches, err := filepath.Glob(filepath.Join(dir, ".tmp-*"))
if err != nil {
t.Fatalf("Glob: %v", err)
}
if len(matches) != 0 {
t.Fatalf("temp files left: %v", matches)
}
}
Чому це важливо? Тому що залишений .tmp-* — це тихий борг: він не ламає програму одразу, але потім хтось побачить десятки сміттєвих файлів і почне підозрювати все підряд, включно з вами та вашою клавіатурою.
Хелпери в тестах і t.Helper()
Коли ви кілька разів поспіль пишете os.ReadFile, потім if err != nil { t.Fatalf(...) }, тест стає довшим за вашу бізнес-логіку. Це прикро: ви хотіли перевірити сховище, а написали мінібібліотеку для читання файлів.
Зазвичай вихід — маленькі допоміжні функції: mustReadFile, mustWriteFile, assertFileContent. І ось тут корисна деталь: у testing є метод t.Helper(), який позначає функцію як helper, щоб у разі помилки Go показував рядок виклику хелпера, а не рядок усередині нього. Це суттєво спрощує діагностику.
Приклад helper-функції:
package storage_test
import (
"os"
"testing"
)
func mustReadFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s): %v", path, err)
}
return string(b)
}
Тепер тести стають коротшими й читабельнішими, а повідомлення про помилку — кориснішими.
5. Backup і відновлення
Backup — це не «ще один файл поруч». Backup — це контракт: якщо ми записали нову версію поверх старої, ми зобов’язані зберегти стару в .bak (якщо така наша політика). Тести тут особливо корисні, бо руками такий сценарій перевіряти незручно: треба пам’ятати порядок операцій і не переплутати версії.
Перевіряємо, що .bak — це попередня версія
Зробімо тест: підготуємо старий файл, викличемо WriteWithBackup, перевіримо обидва файли.
package storage_test
import (
"os"
"path/filepath"
"testing"
)
func TestWriteWithBackup_CreatesBak(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cfg.txt")
if err := os.WriteFile(path, []byte("old"), 0644); err != nil {
t.Fatalf("prepare old: %v", err)
}
if err := WriteWithBackup(path, []byte("new"), 0644); err != nil {
t.Fatalf("WriteWithBackup: %v", err)
}
bak, _ := os.ReadFile(path + ".bak")
cur, _ := os.ReadFile(path)
if string(bak) != "old" || string(cur) != "new" {
t.Fatalf("bak=%q cur=%q", string(bak), string(cur))
}
}
Тут я навмисно зробив читання файлів коротким, щоб приклад залишався компактним. У справжніх тестах краще перевіряти помилки читання, але в лекції зараз важливо вловити думку: тест перевіряє семантику .bak, а не просто факт існування файла.
Відновлення з .bak як окремий сценарій
Дуже часта помилка проєктування — спробувати зробити одну функцію на все, яка і пише, і бекапить, і відновлює, і читає, і ще готує каву. Такі функції важко тестувати, бо ви не можете поставити систему в потрібний стан і перевірити строго один контракт.
Набагато спокійніше, коли відновлення — окрема операція: якщо основного файла немає, але є .bak, віднови. Тоді сценарій тесту простий і відтворюваний.
package storage_test
import (
"os"
"path/filepath"
"testing"
)
func TestRestoreFromBackup_Restores(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "data.txt")
if err := os.WriteFile(path+".bak", []byte("backup"), 0644); err != nil {
t.Fatalf("prepare bak: %v", err)
}
if err := RestoreFromBackup(path); err != nil {
t.Fatalf("RestoreFromBackup: %v", err)
}
got, err := os.ReadFile(path)
if err != nil || string(got) != "backup" {
t.Fatalf("got=%q err=%v", string(got), err)
}
}
Зверніть увагу на красу ідеї: ми повністю керуємо станом файлів у тимчасовій директорії й можемо відтворити сценарій «як у бойовому середовищі», тільки без ризику щось зламати. Це особливо приємно.
Табличні тести для backup-сценаріїв
Коли сценаріїв стає багато, окремі тести починають повторювати одну й ту саму рамку: TempDir, path, виклик, перевірка. У цей момент зручно використовувати table-driven підхід: набір кейсів, які відрізняються даними й очікуваннями.
Важливо не перетворювати таблицю на нечитабельний суп. Хороша таблиця для файлових сценаріїв зазвичай містить промовисту назву кейса, вхідний стан (як функцію підготовки), дію та перевірку.
Мініскелет:
package storage_test
import (
"path/filepath"
"testing"
)
func TestRestoreFromBackup_Cases(t *testing.T) {
cases := []struct {
name string
}{
{name: "bak exists, file missing"},
{name: "file exists, do nothing"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
_ = filepath.Join(dir, "data.txt")
// тут: підготовка -> дія -> перевірка (залежно від tc.name)
})
}
}
Так, усередині все одно будуть перевірки на кшталт «якщо ім’я кейса таке-то» або, краще, окремі функції підготовки. Це нормально, якщо таблиця робить тест зрозумілішим, а не загадковішим.
6. Lock-file і конкурентний запис
Концепція lock-file — це домовленість: якщо lock уже існує, ми не починаємо запис. Це легко перевірити тестом: створити lock, спробувати захопити lock ще раз, очікувати помилку.
Нехай у нас є AcquireLock(lockPath string) (*os.File, error).
package storage_test
import (
"path/filepath"
"testing"
)
func TestAcquireLock_Busy(t *testing.T) {
dir := t.TempDir()
lockPath := filepath.Join(dir, "data.txt.lock")
lock1, err := AcquireLock(lockPath)
if err != nil {
t.Fatalf("AcquireLock #1: %v", err)
}
defer ReleaseLock(lock1)
if _, err := AcquireLock(lockPath); err == nil {
t.Fatalf("expected lock error, got nil")
}
}
Цей тест не намагається довести всі нюанси блокувань ОС (ми їх і не обговорюємо на цьому рівні). Він перевіряє простий контракт нашого протоколу: одночасно писати не можна.
7. Відтворювані негативні сценарії
Файлові помилки не завжди зручно відтворювати по-справжньому (наприклад, імітувати зникнення живлення посеред запису). Але багато класів помилок можна відтворити логічно: створити такий стан, за якого операція гарантовано не зможе виконатися.
Найпростіший трюк — створити директорію на місці файла й спробувати записати файл у неї.
package storage_test
import (
"os"
"path/filepath"
"testing"
)
func TestAtomicWriteFile_FailsWhenPathIsDir(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "data.txt")
if err := os.Mkdir(path, 0755); err != nil {
t.Fatalf("Mkdir: %v", err)
}
if err := AtomicWriteFile(path, []byte("x"), 0644); err == nil {
t.Fatalf("expected error, got nil")
}
}
Чому це корисно? Тому що такий тест стабільно відтворюється в CI й не залежить від випадкових факторів. Він перевіряє, що ваша функція коректно повертає помилку, а не, скажімо, мовчки «успішно» робить щось дивне.
8. Карта відтворюваного сценарію
Коли ви пишете тест на сховище, корисно буквально уявляти сценарій як послідовність кроків. Нижче схема, яку зазвичай тримають у голові, коли пишуть такі тести.
flowchart TD
A[Підготовка: t.TempDir() і підготовка файлів] --> B[Дія: виклик функції сховища]
B --> C[Перевірка: файли, backup і lock існують?]
C --> D[Перевірка: вміст коректний?]
D --> E[Перевірка: сміття відсутнє?]
Якщо тест складно «вкласти» в цю схему, це часто сигнал, що або тест треба розбити, або API функції бере на себе занадто багато, і її час декомпозувати.
9. Типові помилки
Помилка № 1: тести пишуть у справжню директорію проєкту.
Це виглядає зручно («нехай буде ./tmp/test.txt»), але ламає відтворюваність. Один розробник запускає тести — папка лишилася, в іншого — немає прав на запис, третій — випадково комітить сміття в репозиторій. t.TempDir() вирішує це радикально: кожен тест отримує чисту пісочницю й не залишає слідів.
Помилка № 2: перевіряють лише факт «помилки немає», але не перевіряють інваріанти результату.
Функція могла записати порожній файл, могла забути Flush()/Close(), могла затерти дані, могла залишити .tmp-*. Якщо тест обмежився if err != nil, він перевірив лише те, що «нібито не впало». Для сховища майже завжди треба перевіряти хоча б вміст файла, а інколи — наявність .bak і відсутність тимчасових файлів.
Помилка № 3: тест випадково залежить від оточення (прав, umask, ОС).
Перевірка прав доступу може відрізнятися між системами, особливо у Windows. Якщо ви робите такі перевірки, намагайтеся тестувати саме те, що є вашим контрактом на цільових платформах, і не перетворюйте тест на битву з файловою системою. У навчальних прикладах часто достатньо перевірити існування файла і вміст.
Помилка № 4: великі тести без структури, де неможливо зрозуміти, що було до і що має бути після.
Файлові сценарії мають бути читабельними, інакше вони перестають бути страховкою й перетворюються на ще один шматок коду, який ніхто не чіпає. Допомагають явні блоки Arrange–Act–Assert, промовисті імена змінних (path, bakPath, lockPath) і невеликі допоміжні функції.
Помилка № 5: допоміжні функції є, але без t.Helper(), і повідомлення про помилки вказують «у порожнечу».
Коли у вас mustReadFile() усередині себе викликає t.Fatalf, без t.Helper() Go показуватиме рядок усередині хелпера. Це уповільнює налагодження: ви бачите, що «впало в mustReadFile», але не бачите, який тест і на якій перевірці. Використовуйте t.Helper() — це маленька деталь, яка економить багато часу.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ