Урок курса
Табличные тесты через пакет 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 — строка, потому что она нужна как метка для идентификации случая в выводе. input — int, потому что 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 даёт три конкретных преимущества:
-
Имя в отчёте. Каждый подтест получает собственную строку
--- FAIL: TestSquare/positive, а не безликое сообщение внутри одной большой тест-функции. При большой таблице сразу видно, какие именно случаи не прошли. -
Отдельный статус. Каждый подтест — это самостоятельный
*testing.Tсо своим статусом PASS/FAIL. Провал одного не влияет на итоговый статус других; в отчёте они перечислены независимо. -
Адресный запуск. Любой подтест можно запустить отдельно по имени через флаг
-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?
Продолжить с проверкой и прогрессом
Откройте интерактивный раннер с заданиями урока.
