JavaRush /Курси /Go SELF /Тестованість сховища: сценарії з t.TempDir()

Тестованість сховища: сценарії з t.TempDir()

Go SELF
Рівень 42 , Лекція 4
Відкрита

1. Сценарії та інваріанти сховища

Коли ми говоримо про тестованість, багато хто уявляє собі щасливий шлях: викликали Save(), отримали nil, пішли пити чай. Але файлове сховище — це як кіт: поки дивитеся, воно пристойне, а варто відвернутися — і вже впустило вазу, зʼїло ваші права доступу та залишило .tmp-12345 на робочому столі. Нам потрібні тести, які перевіряють властивості результату, а не лише факт відсутності помилки.

У контексті сховища майже завжди важливо тестувати не рядок коду, а сценарій: що було на диску до операції, що ми зробили і що має бути після. І так, іноді сценарій — це «після помилки все одно не залишили сміття». Це теж частина контракту.

Давайте зафіксуємо, які властивості (інваріанти) зазвичай перевіряють.

Міні-таблиця: «сценарій → що перевіряємо»

Сценарій Вхідний стан Дія Що має бути істинним
Перший запис файла немає
Save(data)
файл зʼявився, вміст коректний
Перезапис є стара версія
Save(new)
файл містить new, а не «шматок old + шматок new»
Політика резервного копіювання є стара версія
SaveWithBackup(new)
file.bak містить old, file містить new
Відновлення file відсутній, file.bak є
Restore()
file відновлено з .bak
Lock lock уже існує
AcquireLock()
повертаємо помилку «зайнято», не перезаписуємо 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() — це маленька деталь, яка економить багато часу.

1
Опитування
Надійне зберігання, рівень 42, лекція 4
Недоступний
Надійне зберігання
Надійне зберігання
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ