Табличні тести¶
Домінантний стиль тестів у Go: зріз (slice) випадків, один цикл, один підтест на кожен. Саме так тестується стандартна бібліотека, і додавання нового випадку коштує один рядок.
tests := []struct {
name string
in string
want string
}{
{"trims and lowers", " Go ", "go"},
{"already fine", "go", "go"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Normalize(tt.in); got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
Анонімна структура¶
Тип випадку оголошується прямо в коді, бо існує лише для цього тесту.
Імена полів роблять кожен випадок читабельним у місці виклику, і поле
name за домовленістю йде першим.
Тримайте одну поведінку на таблицю. Якщо половині випадків потрібен інший набір полів — це два тести, замасковані під один; розділіть їх.
t.Run дає кожному випадку власний тест¶
Підтести — не косметика. Кожен із них:
- має ім'я, тож провал повідомляє, який саме випадок зламався, а не який рядок;
- провалюється незалежно, тож
t.Fatalfв одному випадку не ховає решту; - можна запустити окремо через
-run.
=== RUN TestNormalizeTable
=== RUN TestNormalizeTable/trims_and_lowers
=== RUN TestNormalizeTable/already_fine
--- PASS: TestNormalizeTable (0.00s)
--- PASS: TestNormalizeTable/trims_and_lowers (0.00s)
--- PASS: TestNormalizeTable/already_fine (0.00s)
Зауважте, що пробіли в імені стають підкресленнями. Саме таке ім'я
ви передаєте в -run:
Патерн — це regex для кожного сегмента шляху, тож -run 'TestX/.*empty'
теж працює.
Тестування помилок у таблиці¶
Покладіть очікувану помилку в кейс і порівнюйте через errors.Is:
tests := []struct {
name string
in string
want string
wantErr error
}{
{"trims and lowers", " Go ", "go", nil},
{"empty", " ", "", ErrEmpty},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Normalize(tt.in)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("err = %v, want %v", err, tt.wantErr)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
errors.Is(nil, nil) дорівнює true, тож успішні випадки працюють із
тим самим порівнянням — окреме поле wantErr bool не потрібне.
Не порівнюйте тексти помилок. Формулювання повідомлення не є API; API — це сигнальна помилка (sentinel) або тип, у чому й полягає суть власних типів помилок.
t.Parallel¶
Додавання одного рядка запускає випадки одночасно:
=== RUN TestParallelSubtests/lower
=== PAUSE TestParallelSubtests/lower
=== RUN TestParallelSubtests/already
=== PAUSE TestParallelSubtests/already
=== CONT TestParallelSubtests/lower
=== CONT TestParallelSubtests/already
Пари PAUSE/CONT показують механізм: паралельний підтест ставиться
на паузу, батьківський тест дочікується запуску всіх підтестів, а
потім вони відновлюються разом.
Звідси випливають два наслідки, і обидва важливі:
Батьківський тест завершується раніше за дочірні. Код після циклу
виконується, поки підтести ще тривають. Усе, що потрібне підтестам,
має прибиратися через t.Cleanup, а не через defer у батьківському
тесті.
Випадки не повинні ділити змінний стан. Паралельні випадки, що
торкаються однієї й тієї самої мапи чи лічильника — це гонитва даних
(data race). Запустіть -race у CI, і вона виявиться; без нього тест
проходить, аж поки не перестане.
Паралелізм окупається, коли випадки роблять I/O. Для чистих функцій він лише додає шум від планувальника без жодної користі — не додавайте його рефлекторно.
Тримайте цикл тупим¶
Тіло циклу має робити те саме для кожного випадку. Щойно в ньому
з'являється if tt.special { ... }, таблиця перестає описувати дані й
починає кодувати керування потоком виконання, і окремий тест буде
зрозумілішим.
Поле-функція — чесний спосіб урізноманітнити поведінку:
tests := []struct {
name string
setup func(*testing.T) *Store
want int
}{
{"empty", func(t *testing.T) *Store { return NewStore() }, 0},
{"seeded", seededStore, 3},
}
Це зберігає цикл однорідним, дозволяючи кожному випадку самостійно себе налаштувати.
З досвіду Python: це
@pytest.mark.parametrize, написаний вручну. Багатослівніше, але випадки — звичайні значення, які можна будувати, фільтрувати чи генерувати в Go, а полеnameробить те саме, що згенеровані ідентифікаториpytest— тільки ви обираєте його самі.
Швидка довідка¶
| Питання | Форма |
|---|---|
| таблиця | зріз вбудованої анонімної структури, name першим |
| один підтест на випадок | t.Run(tt.name, func(t *testing.T) { ... }) |
| запустити один випадок | -run 'TestX/case_name' — пробіли стають підкресленнями |
| очікувані помилки | поле wantErr error плюс errors.Is |
| паралельність | t.Parallel() усередині підтесту |
| прибирання з паралельними випадками | t.Cleanup, ніколи не defer у батьківському тесті |
| випадок, що відрізняється | поле setup func(*testing.T), а не if |