4. Table driven tests

Конец копипасты: Идиоматичный подход

Дублирование в продуктовом коде справедливо считается грехом, однако дублирование в тестах многие разработчики ошибочно принимают за норму. Написание десятков отдельных функций вида TestAddPositiveNumbers, TestAddNegativeNumbers и TestAddZero порождает чудовищное количество бойлерплейта: повторяющиеся инициализации, одинаковые вызовы методов и бесконечные блоки проверок утверждений.

В языках вроде Java (JUnit) или PHP (PHPUnit) для параметризованных тестов изобретают тяжеловесные надстройки вроде аннотаций @DataProvider. Философия Go предлагает иное решение — лаконичное, выразительное и не требующее сторонних фреймворков: Table-Driven Tests (Табличные тесты).

Табличный тест — это канонический паттерн проектирования тестов в Go, при котором тестовые сценарии объявляются как строки единой таблицы данных (среза анонимных структур). Обычный цикл for выполняет обход этой таблицы, передавая входные аргументы в тестируемую функцию и сопоставляя полученный результат с эталоном.


Анатомия табличного теста

Рассмотрим типичную задачу из бэкенд-практики: конечный автомат валидации переходов жизненного цикла транзакции в платежном шлюзе.

Go
package payment

import (
    "errors"
    "testing"
)

var ErrInvalidTransition = errors.New("invalid state transition")

// Transition проверяет легальность перехода между статусами платежа
func Transition(current, next string) error {
    if current == "PENDING" && next == "SUCCESS" {
        return nil
    }
    if current == "SUCCESS" && next == "REFUNDED" {
        return nil
    }
    return ErrInvalidTransition
}

А теперь спроектируем для этой бизнес-логики эталонный табличный тест:

Go
func TestTransition(t *testing.T) {
    // 1. Определение таблицы тестовых сценариев через срез анонимных структур
    tests := []struct {
        name    string // Человекочитаемое имя сценария
        current string
        next    string
        wantErr error
    }{
        // 2. Декларативное наполнение таблицы сценариев
        {
            name:    "valid pending to success",
            current: "PENDING",
            next:    "SUCCESS",
            wantErr: nil,
        },
        {
            name:    "valid success to refund",
            current: "SUCCESS",
            next:    "REFUNDED",
            wantErr: nil,
        },
        {
            name:    "invalid success to pending",
            current: "SUCCESS",
            next:    "PENDING",
            wantErr: ErrInvalidTransition,
        },
    }

    // 3. Итерация по таблице и изолированный запуск каждого сценария
    for _, tt := range tests {
        // Регистрация подтеста через t.Run()
        t.Run(tt.name, func(t *testing.T) {
            err := Transition(tt.current, tt.next)

            // Проверка ошибки через errors.Is
            if !errors.Is(err, tt.wantErr) {
                t.Errorf("Transition() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Разбор компонентов

  1. Анонимная структура ([]struct{...} или []struct): В Go считается дурным тоном создавать глобальные именованные типы вроде TestCase на уровне пакета. Определение анонимной структуры по месту использования локализует типы данных, предотвращая загрязнение пространства имен пакета.
  2. Поле name: Обязательный атрибут каждой записи. При падении сценария в CI-логах вы мгновенно увидите имя сломавшегося кейса без утомительного сопоставления номеров строк.
  3. Поля want и wantErr: Общепринятый стандарт именования ожидаемых значений. Использование корня want делает логирование ошибок самодокументируемым по каноническому шаблону: got %v, want %v.
  4. Конструкция t.Run(name, func): Метод, превращающий каждую строчку таблицы в полноценный изолированный подтест со своим именем и жизненным циклом.
flowchart LR
    SliceHeader["Заголовок slice<br/>(Pointer, Len, Cap)"] --> Heap["Непрерывный сегмент памяти в куче"]

    subgraph Heap ["Непрерывный блок памяти"]
        direction LR
        C1["Case 1: {name, current, next}"] --- C2["Case 2: {name, current, next}"] --- CN["Case N: {name, current, next}"]
    end

    classDef header fill:#0f172a,stroke:#38bdf8,stroke-width:1px,color:#38bdf8;
    classDef block fill:#1e293b,stroke:#475569,stroke-width:1px,color:#e2e8f0;
    class SliceHeader header;
    class C1,C2,CN block;
  

Когда ядро процессора выполняет цикл for _, tt := range tests, аппаратный блок предвыборки (Hardware Data Prefetcher) мгновенно фиксирует линейный шаблон обхода адресов. Он заранее прокачивает следующие элементы tt в кэш первого уровня L1d 64-байтовыми кэш-линиями (Cache Lines).
В объектно-ориентированных языках, где каждый кейс представляет собой отдельный класс-объект, разбросанный сборщиком мусора по разным адресам кучи, процессор постоянно страдает от промахов мимо кэша (Cache Misses). В Go табличный тест пролетает по ядрам процессора на максимальной частоте шины памяти.


Ловушка замыкания: tt := tt

При оптимизации времени выполнения тестов разработчики часто параллелят табличные проверки с помощью t.Parallel(). И именно здесь кроется одна из самых драматичных ошибок в истории программирования на Go:

Go
func TestParallelTable(t *testing.T) {
    tests := []struct {
        name string
        val  int
    }{
        {"case 1", 1},
        {"case 2", 2},
        {"case 3", 3},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // Параллельный запуск подтеста

            // ОПАСНОСТЬ: Какое значение val будет прочитано?
            if tt.val < 0 { 
                t.Fail()
            }
        })
    }
}

Как это решалось до Go 1.22:

Инженеры создавали локальную копию переменной в теле цикла (Variable Shadowing):

Go
for _, tt := range tests {
    tt := tt // Локальное затенение: новая ячейка памяти для каждой итерации
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // Безопасное обращение к локальной копии
    })
}

Современный Go (1.22+):

Начиная с Go 1.22, семантика циклов for в спецификации языка была кардинально исправлена: переменные, объявленные в конструкции range, автоматически создаются заново для каждой отдельной итерации. Костыль tt := tt ушел в историю, однако его знание обязательно для сопровождения кодовых баз на старых версиях Go и технических интервью.


Продвинутые техники табличных тестов

1. Использование map вместо slice

Иногда в проектах можно встретить организацию таблицы тестов в виде словаря map, где ключ — это строка с именем кейса:

Go
func TestWithMap(t *testing.T) {
    tests := map[string]struct {
        input int
        want  int
    }{
        "positive": {input: 5, want: 10},
        "negative": {input: -5, want: -10},
    }

    for name, tt := range tests {
        t.Run(name, func(t *testing.T) {
            // Тело проверки...
        })
    }
}

2. Динамическая генерация таблиц

Табличные данные не обязаны быть зашиты в исходный код. Если у вас сотни тестовых сценариев или граничных комбинаций, вы можете динамически считывать фикстуры из каталога testdata/ и заполнять срез перед запуском цикла for:

Go
func TestComplexParser(t *testing.T) {
    // Считывание и парсинг тестовых сценариев из JSON-файла
    tests := loadTestCasesFromDisk(t, "testdata/cases.json")

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Проверка...
        })
    }
}

Такой подход позволяет аналитикам и QA-инженерам расширять набор тестовых кейсов без необходимости перекомпиляции Go-кода.


Итог

  1. Table-Driven Tests — идиоматичный стандарт Go, объединяющий компактность и высокую читаемость.
  2. Использование среза анонимных структур обеспечивает высокую локальность данных в кэше CPU (L1/L2).
  3. Помните об эволюции семантики замыканий: конструкция tt := tt была обязательной до версии Go 1.22 при запуске t.Parallel().
  4. Всегда отдавайте предпочтение срезам []struct перед map, гарантируя строгую воспроизводимость и детерминированный порядок.

Метод t.Run() открывает огромные возможности: он не просто форматирует вывод, но и позволяет гибко фильтровать тесты из командной строки. Давайте детально исследуем внутреннюю механику подтестов в следующей главе: 5. Subtests и t.Run.