Урок курса

Табличные тесты через пакет testing и go test

Golang с нуля

В прошлом уроке мы вынесли функцию Square в пакет mathutil. Теперь у неё есть exported-имя и отдельная папка — всё готово, чтобы написать к ней полноценные тесты. Именно сюда и смотрит go test.

Файл _test.go, сигнатура TestXxx и выбор между t.Errorf и t.Fatalf

Создайте в папке mathutil файл mathutil_test.go. Суффикс _test.go — это не соглашение команды, а правило инструментальной цепочки Go: такие файлы компилируются только при вызове go test и не попадают в обычный бинарник пакета. Внутри объявляйте тот же пакет:

package mathutil

import "testing"

func TestSquare(t *testing.T) {
    got := Square(3)
    if got != 9 {
        t.Errorf("Square(3) = %d, want 9", got)
    }
}

Почему go test запустит TestSquare, но не запустит testSquare или squareTest? Правило discovery состоит из двух частей.

Часть 1 — имя функции. Имя должно начинаться ровно с Test, а символ сразу после Test не должен быть строчной буквой. Это означает, что подходят: TestSquare (заглавная S), Test_square (знак подчёркивания), Test (конец имени). Не подходят: Testsquare (строчная s), testSquare (сам Test написан со строчной t). Функции с нераспознанным именем go test игнорирует — они остаются обычным вспомогательным кодом.

Часть 2 — сигнатура. Если имя распознано как тест, функция обязана принимать ровно один параметр *testing.T и ничего не возвращать. Нарушение сигнатуры — это уже не тихое игнорирование: go test выдаёт ошибку сборки вида wrong signature for TestXxx. То есть неверная сигнатура при правильном имени ломает прогон, а не просто пропускается.

Теперь о выборе между двумя методами фиксации ошибок.

t.Errorf помечает тест как провальный и записывает сообщение, но выполнение функции продолжается дальше. Это полезно, когда один тест проверяет несколько независимых условий и вы хотите увидеть все нарушения сразу:

func TestSquare(t *testing.T) {
    if Square(0) != 0 {
        t.Errorf("Square(0) = %d, want 0", Square(0))
    }
    if Square(-2) != 4 {
        t.Errorf("Square(-2) = %d, want 4", Square(-2))
    }
}

Оба нарушения попадут в отчёт за один прогон.

t.Fatalf делает то же самое, но после этого немедленно останавливает текущую тест-функцию — технически через runtime.Goexit. Используйте его, когда продолжать бессмысленно: например, если первая проверка вернула nil вместо объекта, а следующий код будет разыменовывать этот указатель и вызовет панику.

result, err := ParseConfig(input)
if err != nil {
    t.Fatalf("ParseConfig вернул ошибку: %v", err)
}
// без t.Fatalf здесь был бы nil-deref
if result.Name != "expected" {
    t.Errorf("Name = %q, want \"expected\"", result.Name)
}

Практическое правило простое: если следующие строки теста зависят от того, что предыдущая проверка прошла — t.Fatalf. Если проверки независимы — t.Errorf.

Таблица тестовых случаев: срез анонимных структур с name, input, expected

Одна тест-функция с хардкодом Square(3) == 9 — это слабое покрытие. Что насчёт нуля, отрицательного числа, большого значения? Дублировать блоки if для каждого случая неудобно: при изменении логики проверки придётся редактировать их все.

Решение — описать случаи данными, а не кодом. В Go для этого используют срез анонимных структур:

tests := []struct {
    name     string
    input    int
    expected int
}{
    {name: "zero", input: 0, expected: 0},
    {name: "positive", input: 3, expected: 9},
    {name: "negative", input: -2, expected: 4},
    {name: "large", input: 100, expected: 10000},
}

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

Важно понять, почему поля называются именно так и имеют именно такие типы. name — строка, потому что она нужна как метка для идентификации случая в выводе. inputint, потому что Square принимает int. expected — тоже int, потому что Square возвращает int. Язык не навязывает названия input и expected; это ваши имена. Если тестируете функцию с двумя аргументами, добавьте два поля:

tests := []struct {
    name     string
    a, b     int
    expected int
}{
    {"add positives", 2, 3, 5},
    {"add negative", -1, 4, 3},
}

Структура таблицы — зеркало сигнатуры тестируемой функции. Это и делает подход масштабируемым: добавить новый сценарий значит добавить одну строку в срез, а не копировать блок проверки.

t.Run с литералом функции: запуск каждого случая как именованного подтеста

Таблица готова. Теперь нужно запустить каждый случай и получить отдельный результат для каждого. Именно для этого существует t.Run.

func TestSquare(t *testing.T) {
    tests := []struct {
        name     string
        input    int
        expected int
    }{
        {"zero", 0, 0},
        {"positive", 3, 9},
        {"negative", -2, 4},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            got := Square(tc.input)
            if got != tc.expected {
                t.Errorf("Square(%d) = %d, want %d", tc.input, got, tc.expected)
            }
        })
    }
}

t.Run принимает два аргумента: строку-имя и функцию с сигнатурой func(t *testing.T). Вместо того чтобы объявлять отдельную именованную функцию где-то в файле, мы пишем литерал функции — анонимную функцию прямо на месте вызова. Разница не в поведении, а в удобстве и области видимости: литерал захватывает переменную tc из окружающего цикла и видит её без явной передачи аргументов.

Чем t.Run принципиально полезнее простого цикла с t.Errorf на внешнем t? Дело не в продолжении выполнения — обычный цикл тоже не останавливается на первом провале. t.Run даёт три конкретных преимущества:

  1. Имя в отчёте. Каждый подтест получает собственную строку --- FAIL: TestSquare/positive, а не безликое сообщение внутри одной большой тест-функции. При большой таблице сразу видно, какие именно случаи не прошли.

  2. Отдельный статус. Каждый подтест — это самостоятельный *testing.T со своим статусом PASS/FAIL. Провал одного не влияет на итоговый статус других; в отчёте они перечислены независимо.

  3. Адресный запуск. Любой подтест можно запустить отдельно по имени через флаг -run:

go test -run TestSquare/positive ./...

Это удобно при отладке: не нужно гонять всю таблицу ради одного случая.

В выводе go test -v каждый подтест виден как TestSquare/zero, TestSquare/positive, TestSquare/negative. Слеш и имя добавляет сам t.Run — именно то значение, которое вы передали первым аргументом.

Мутационная проверка теста и чтение вывода go test ./... и go test -v ./...

Тест, который всегда проходит, ничего не гарантирует. Прежде чем доверять своим проверкам, убедитесь, что они действительно умеют ловить ошибку.

Внесите целевую мутацию в Square — намеренно сломайте именно то поведение, которое тест должен проверять:

// мутант: возвращает input вместо input*input
func Square(n int) int {
    return n  // было: return n * n
}

Теперь запустите:

go test ./...

Вывод:

--- FAIL: TestSquare (0.00s)
    --- FAIL: TestSquare/positive (0.00s)
        mathutil_test.go:18: Square(3) = 3, want 9
    --- FAIL: TestSquare/negative (0.00s)
        mathutil_test.go:18: Square(-2) = -2, want 4
FAIL
FAIL    example.com/mymod/mathutil  0.001s

Обратите внимание: случай zero прошёл (Square(0) возвращает 0 даже на мутанте), а positive и negative упали. Это именно то, что нужно: тест обнаружил конкретную мутацию.

Восстановите реализацию:

func Square(n int) int {
    return n * n
}

И снова запустите:

go test ./...
ok  	example.com/mymod/mathutil  0.001s

При успехе go test ./... выводит только одну строку на пакет. Никакого упоминания отдельных тест-функций и подтестов — минимальный сигнал «всё хорошо».

Если хотите видеть полную картину даже при успехе:

go test -v ./...
=== RUN   TestSquare
=== RUN   TestSquare/zero
--- PASS: TestSquare/zero (0.00s)
=== RUN   TestSquare/positive
--- PASS: TestSquare/positive (0.00s)
=== RUN   TestSquare/negative
--- PASS: TestSquare/negative (0.00s)
--- PASS: TestSquare (0.00s)
PASS
ok  	example.com/mymod/mathutil  0.001s

Флаг -v добавляет строки === RUN при старте и --- PASS / --- FAIL при завершении каждого подтеста. Именно по этим строкам удобно читать, какой конкретный случай из таблицы не прошёл — особенно когда таблица большая.

Главный вывод из мутационной проверки: если тест не упал на сломанной реализации, он не доказывает корректность функции. Добавьте в таблицу случай, который покрывает пропущенный контракт, и повторите.

Попробуйте решить

Файл называется calculator_test.go, пакет объявлен package calculator, функция называется func testAdd(t *testing.T). Будет ли эта функция запущена командой go test?

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

Перейти к интерактивному уроку
Тесты в Go: go test и табличные тесты