JavaRush /Курсы /Go SELF /Table-driven тесты — кейсы, имена, читаемые ошибки

Table-driven тесты — кейсы, имена, читаемые ошибки

Go SELF
29 уровень , 1 лекция
Открыта

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 тесте важно не смешивать два мира: «ошибка ожидается» и «ошибка не ожидается». Самая частая поломка новичков — продолжать делать проверки результата, даже когда ошибка уже «не та». В идеале логика должна читаться так:

  1. Если ожидали ошибку — проверяем, что err != nil, и на этом сценарий заканчиваем.
  2. Если не ожидали — проверяем 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 для совсем «невозможных» ситуаций.

1
Задача
Go SELF, 29 уровень, 1 лекция
Недоступна
Табло сумм
Табло сумм
1
Задача
Go SELF, 29 уровень, 1 лекция
Недоступна
Ограничитель диапазона
Ограничитель диапазона
1
Задача
Go SELF, 29 уровень, 1 лекция
Недоступна
Возраст анкеты
Возраст анкеты
1
Задача
Go SELF, 29 уровень, 1 лекция
Недоступна
Проверка логина
Проверка логина
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ