1. Зачем нужны табличные тесты
Если вы пишете тесты впервые, естественная стратегия такая: «ну у меня же три сценария — напишу три теста». Это нормально. Проблема появляется чуть позже, когда сценариев становится 10, 20, 50… и вы внезапно обнаруживаете, что тесты у вас растут как борода у разработчика перед релизом: быстро, неравномерно и почему-то в самых неудобных местах.
Представим, что в нашем учебном приложении todo есть валидация заголовка задачи. Пока требований мало, мы можем написать тесты «в лоб».
// title.go
package todo
import "errors"
var ErrEmptyTitle = errors.New("empty title")
func ValidateTitle(s string) error {
if s == "" {
return ErrEmptyTitle
}
return nil
}
И «ручные» тесты:
// title_test.go
package todo
import "testing"
func TestValidateTitle_OK(t *testing.T) {
if err := ValidateTitle("read book"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
package todo
import "testing"
func TestValidateTitle_Empty(t *testing.T) {
if err := ValidateTitle(""); err == nil {
t.Fatalf("expected error, got nil")
}
}
Пока всё выглядит прилично. Но как только добавятся сценарии вроде " " (только пробелы), слишком длинный заголовок, запрещённые символы, «в конце точка с запятой потому что я так чувствую», — вы либо начнёте плодить тесты-клоны, либо перестанете добавлять тесты вообще (второе встречается чаще, но мы сделаем вид, что не видели).
И тут появляется table-driven подход: кейсы становятся данными, а тест — «движком», который одинаково прогоняет все кейсы.
2. Как устроен table-driven тест
Кейсы как данные в []struct{...}
Table-driven тест в Go — это договор с самим собой: «я описываю сценарии компактно, а повторяющуюся механику проверки пишу один раз». Причём Go тут особенно удобен: литералы структур и срезы вы уже знаете, for range тоже, так что магии не будет.
Начнём с самой маленькой «таблицы»: вход и ожидаем, будет ли ошибка.
// title_test.go
package todo
import "testing"
func TestValidateTitle_Table(t *testing.T) {
tests := []struct {
in string
wantErr bool
}{
{in: "read book", wantErr: false},
{in: "", wantErr: true},
}
for i, tt := range tests {
err := ValidateTitle(tt.in)
if tt.wantErr && err == nil {
t.Errorf("case %d: ValidateTitle(%q): expected error, got nil", i, tt.in)
}
if !tt.wantErr && err != nil {
t.Errorf("case %d: ValidateTitle(%q): unexpected error: %v", i, tt.in, err)
}
}
}
Обратите внимание на пару вещей.
Во-первых, []struct{...} — это типобезопасная таблица. Вы не таскаете map[string]any и не пытаетесь вспомнить, что же вы туда положили (и где потеряли). Поля структуры — это «колонки» вашей таблицы.
Во-вторых, мы добавили case %d через индекс i, потому что без этого сообщение об ошибке часто выглядит как «не работает». Индекс — самый дешёвый способ понять, какой сценарий упал.
Но индекс — не самый удобный для чтения, и дальше мы научимся давать кейсам имена.
Имена кейсов: чтобы ошибки читались как отчёт
Когда тест падает, ваша цель — за минимальное время понять: какой сценарий сломался и что именно не совпало. Индекс case 3 — лучше, чем ничего, но через неделю вы будете смотреть на него так же, как на чужой код без комментариев: «кто это сделал и почему меня не предупредили?»
Поэтому следующий шаг — добавить поле name и использовать его в сообщениях. Это не subtests (их мы трогать пока не будем), это просто человеческая метка.
package todo
import "testing"
func TestValidateTitle_Table_Names(t *testing.T) {
tests := []struct {
name string
in string
wantErr bool
}{
{name: "ok", in: "read book", wantErr: false},
{name: "empty", in: "", wantErr: true},
}
for _, tt := range tests {
err := ValidateTitle(tt.in)
if tt.wantErr && err == nil {
t.Errorf("%s: ValidateTitle(%q): expected error, got nil", tt.name, tt.in)
}
if !tt.wantErr && err != nil {
t.Errorf("%s: ValidateTitle(%q): unexpected error: %v", tt.name, tt.in, err)
}
}
}
Теперь если тест упадёт, вы увидите что-то вроде:
- empty: ValidateTitle(""): expected error, got nil
И это уже похоже на нормальный отчёт.
Отдельно замечу про %q. Для строк это почти всегда лучше, чем %s, потому что пустая строка, пробелы и спецсимволы становятся видимыми. В тестах это спасает нервы.
Проверка ошибок: wantErr и смысл ошибки
Ошибки в Go — это значения, и с ними принято работать осмысленно: проверять err != nil, иногда сравнивать с «известной» ошибкой или искать её в цепочке.
В table-driven тесте важно не смешивать два мира: «ошибка ожидается» и «ошибка не ожидается». Самая частая поломка новичков — продолжать делать проверки результата, даже когда ошибка уже «не та». В идеале логика должна читаться так:
- Если ожидали ошибку — проверяем, что err != nil, и на этом сценарий заканчиваем.
- Если не ожидали — проверяем err == nil, и только потом проверяем результат.
Давайте сделаем пример, где мы проверяем смысл ошибки, а не просто факт наличия.
Сначала усилим нашу валидацию: пусть она возвращает разные ошибки.
// title.go
package todo
import "errors"
var (
ErrEmptyTitle = errors.New("empty title")
ErrTooLongTitle = errors.New("title too long")
)
func ValidateTitle(s string) error {
if s == "" {
return ErrEmptyTitle
}
if len(s) > 10 {
return ErrTooLongTitle
}
return nil
}
Теперь table-driven тест может выглядеть так:
// title_test.go
package todo
import (
"errors"
"testing"
)
func TestValidateTitle_Errors(t *testing.T) {
tests := []struct {
name string
in string
want error
}{
{name: "ok", in: "read", want: nil},
{name: "empty", in: "", want: ErrEmptyTitle},
{name: "too_long", in: "read book now", want: ErrTooLongTitle},
}
for _, tt := range tests {
err := ValidateTitle(tt.in)
if !errors.Is(err, tt.want) {
t.Errorf("%s: ValidateTitle(%q): err=%v, want %v", tt.name, tt.in, err, tt.want)
}
}
}
Почему это удобно?
Если want == nil, то errors.Is(err, nil) вернёт true только когда err == nil. То есть одна проверка покрывает и «ошибки нет», и «ошибка должна быть конкретной». Получается компактно.
Почему не сравнивать текст ошибки? Потому что текст — это UX‑деталь. Вы можете поменять формулировку («empty title» → «title is empty»), и тест упадёт, хотя логика не сломалась. Обычно это не то, чего вы хотите от unit‑теста.
Функция вида (int, error): got/want без лишнего шума
Очень типичный сценарий в Go — функция возвращает значение и ошибку. В нашем todo это может быть парсинг приоритета задачи из строки: в CLI, в HTTP, в файле — где угодно.
Сделаем простую функцию:
// priority.go
package todo
import (
"fmt"
"strconv"
)
func ParsePriority(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parse priority: %w", err)
}
if n < 0 {
return 0, fmt.Errorf("priority must be >= 0")
}
return n, nil
}
Теперь тест. Здесь важно: мы не хотим сравнивать весь err «как строку», но хотим чётко разделить сценарии. Для учебного уровня удобно начать с wantErr bool, а значения проверять только когда ошибки быть не должно.
// priority_test.go
package todo
import "testing"
func TestParsePriority_Table(t *testing.T) {
tests := []struct {
name string
in string
want int
wantErr bool
}{
{name: "zero", in: "0", want: 0, wantErr: false},
{name: "positive", in: "5", want: 5, wantErr: false},
{name: "negative", in: "-1", want: 0, wantErr: true},
{name: "not_a_number", in: "x", want: 0, wantErr: true},
}
for _, tt := range tests {
got, err := ParsePriority(tt.in)
if tt.wantErr {
if err == nil {
t.Errorf("%s: ParsePriority(%q): expected error, got nil", tt.name, tt.in)
}
continue
}
if err != nil {
t.Errorf("%s: ParsePriority(%q): unexpected error: %v", tt.name, tt.in, err)
continue
}
if got != tt.want {
t.Errorf("%s: ParsePriority(%q)=%d, want %d", tt.name, tt.in, got, tt.want)
}
}
}
Здесь два continue — это не «сложность ради сложности», а способ сделать отчёт чище. Если в сценарии неожиданная ошибка, то сравнивать got с want бессмысленно: got мог быть нулём просто потому, что мы так договорились возвращать при ошибке. Без continue вы легко получите «двойную» ошибку на один кейс, и отчёт станет шумным.
Читаемые сообщения об ошибках: что писать в t.Errorf
Сообщение в t.Errorf — это не формальность. Это ваш дебаг‑лог, который появляется именно тогда, когда всё уже плохо, времени мало, а кофе закончился. Хорошее сообщение должно отвечать на три вопроса: «какой кейс», «какой вход», «что получили и что ожидали».
Начнём с общего шаблона «функция(вход)=got, want want»:
t.Errorf("%s: ParsePriority(%q)=%d, want %d", tt.name, tt.in, got, tt.want)
Для ошибок почти всегда полезно писать unexpected error: %v и печатать сам err, чтобы видеть контекст (например, из fmt.Errorf("...: %w", err)).
t.Errorf("%s: ParsePriority(%q): unexpected error: %v", tt.name, tt.in, err)
Ещё один маленький, но важный момент: когда вы печатаете строки, используйте %q. Это особенно важно для «пусто» и «пробелы».
t.Errorf("%s: ValidateTitle(%q): expected error, got nil", tt.name, tt.in)
Наконец, если в кейсе несколько входов, печатайте их все. Серьёзно. Вы не вспомните их «по имени кейса», если кейсов станет 30.
Мини-пример: сборка задачи todo
Чтобы table-driven тесты не были «тестами ради тестов», давайте свяжем это с маленьким кусочком бизнес‑логики. У нас есть задача (task) и есть ввод пользователя: заголовок и приоритет в виде строки. Удобно иметь одну функцию, которая делает «прочитать → распарсить → проверить → собрать структуру».
Сначала модель:
// task.go
package todo
type Task struct {
Title string
Priority int
}
Теперь функция сборки:
// build.go
package todo
import "fmt"
func BuildTask(title, priorityStr string) (Task, error) {
if err := ValidateTitle(title); err != nil {
return Task{}, fmt.Errorf("title: %w", err)
}
p, err := ParsePriority(priorityStr)
if err != nil {
return Task{}, fmt.Errorf("priority: %w", err)
}
return Task{Title: title, Priority: p}, nil
}
И table-driven тест, который проверяет как «успех», так и «ошибка ожидается». Здесь мы не будем упираться в точные типы ошибок (мы уже оборачиваем их), поэтому оставим уровень проверки «ошибка должна быть / не должна быть», а успех проверим полностью.
// build_test.go
package todo
import "testing"
func TestBuildTask_Table(t *testing.T) {
tests := []struct {
name string
title string
prioStr string
want Task
wantErr bool
}{
{name: "ok", title: "read", prioStr: "2", want: Task{Title: "read", Priority: 2}},
{name: "bad_title", title: "", prioStr: "2", wantErr: true},
{name: "bad_prio", title: "read", prioStr: "x", wantErr: true},
}
for _, tt := range tests {
got, err := BuildTask(tt.title, tt.prioStr)
if tt.wantErr {
if err == nil {
t.Errorf("%s: expected error, got nil", tt.name)
}
continue
}
if err != nil {
t.Errorf("%s: unexpected error: %v", tt.name, err)
continue
}
if got != tt.want {
t.Errorf("%s: got=%v, want=%v", tt.name, got, tt.want)
}
}
}
Обратите внимание: мы смогли сравнить Task через !=, потому что структура содержит только сравнимые поля (string и int). Это очень приятный бонус для тестов: got/want читается проще.
А вот маленькая схема, как «движок теста» работает с таблицей:
flowchart TD
A[Берём кейс tt из tests] --> B[Вызываем функцию: got, err]
B --> C{tt.wantErr?}
C -->|да| D{err == nil?}
D -->|да| E[Ошибка: expected error, got nil]
D -->|нет| F[Кейс пройден]
C -->|нет| G{err != nil?}
G -->|да| H[Ошибка: unexpected error]
G -->|нет| I{got == want?}
I -->|нет| J[Ошибка: got vs want]
I -->|да| K[Кейс пройден]
Эта логика почти везде одинакова. Когда вы её «набьёте рукой», table-driven тесты начинают писаться очень быстро.
3. Типичные ошибки при table-driven тестах
Ошибка №1: не добавлять идентификатор кейса в сообщения.
Когда в цикле падает проверка, без name или хотя бы case %d вы получаете сообщение без контекста и вынуждены вручную угадывать, какой именно сценарий был. Это особенно больно, когда входы похожи. Лечится просто: всегда печатайте tt.name или i.
Ошибка №2: смешивать сценарии «ошибка ожидается» и «ошибки быть не должно» в одной ветке.
Новички часто сначала сравнивают got, потом проверяют err, а иногда вообще забывают, что при ошибке got может быть «заглушкой». Правильнее сначала решить судьбу err: если ошибка ожидается — проверили и вышли из кейса; если нет — убедились, что err == nil, и только потом сравниваем got с want.
Ошибка №3: сравнивать текст ошибки как основной способ проверки.
Текст ошибки может меняться без изменения смысла, особенно если вы добавляете контекст через fmt.Errorf("...: %w", err). Стабильнее проверять «ошибка есть/нет», а если нужно проверить смысл — использовать распознаваемую ошибку (sentinel) и errors.Is, а не err.Error() == "...".
Ошибка №4: делать «жирные» кейсы на 15 полей.
Когда структура кейса становится огромной, тест становится трудно читать: вы уже не видите, чем сценарии отличаются. Хороший table-driven тест обычно содержит только те поля, которые реально участвуют в сценарии. Если полей много — это сигнал, что вы тестируете слишком много логики сразу и стоит чуть упростить тестируемую функцию или разделить проверку.
Ошибка №5: использовать t.Fatalf внутри цикла и случайно «обрывать» остальные кейсы.
Иногда Fatal уместен, но в table-driven тесте без отдельного запуска каждого кейса (пока мы не используем subtests) t.Fatalf остановит весь тест целиком, и вы увидите только первую проблему. Чаще хочется собрать несколько падений за один запуск. Поэтому в цикле обычно используют t.Errorf и continue, оставляя Fatal для совсем «невозможных» ситуаций.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ