Содержание урока · 15 разделов
- Тестирование в Go: тест — это тоже код, который врёт последним
- Первый тест: func TestXxx и файл *_test.go
- t.Error против t.Fatal: пометить и жить или пометить и выйти
- Table driven tests: срез кейсов вместо копипасты
- t.Run: под-тесты, фильтр -run и t.Parallel
- t.Helper: честные номера строк в отчёте
- t.Skip: честное «не сегодня»
- t.Cleanup: уборка после теста
- Покрытие: go test -cover и что оно на самом деле меряет
- Шпаргалка: API testing.T и команда go test
- Правила гигиены тестов
- Что дальше: тесты отвечают «правильно ли», бенчмарки — «быстро ли»
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Тестирование в Go: тест — это тоже код, который врёт последним#
После пятнадцатого урока у нас на руках полный протокол честности для ошибок: обёртка с %w, errors.Is и errors.As. Осталось сделать последний шаг — перестать проверять эти обещания вручную. До сих пор мы убеждались, что функция работает, запуская go run и глядя на печать глазами. Это работает ровно до того дня, когда правка в одной функции ломает три другие, а вы узнаёте об этом от пользователя. Тест — та же проверка, только записанная кодом: она не устаёт, не пропускает и её можно запустить тысячу раз за день.
По запросу «тестирование go» новичок обычно ищет, какой фреймворк поставить. Ответ разочаровывает своей простотой: ничего ставить не нужно. В языке есть пакет testing, у команды go есть подкоманда go test, а конвенция одна: тесты живут в файлах с суффиксом _test.go рядом с проверяемым кодом. Никаких assert-функций тоже нет — тест просто делает if и в ветке «не совпало» зовёт t.Errorf или t.Fatalf. Звучит примитивно, но именно эта примитивность сделала го-тесты легендарно читаемыми: тест — обычный код, без магии, и пишется за минуту.
Что будет в уроке. Сначала контракт: файл _test.go, сигнатура func TestXxx(t testing.T), что go build игнорирует, а go test — компилирует и запускает. Потом различие, которое путают все: t.Error — «пометить провал и продолжить», t.Fatal — «пометить провал и выйти» (разница в runtime.Goexit, разберём до буквы). Затем главная идиома — table driven tests: срез структур с ожиданиями, цикл и t.Run для под-тестов; по этой схеме написано большинство тестов в самой стандартной библиотеке. После неё — инструменты вокруг: t.Helper для честных номеров строк, t.Skip для честного «не сегодня», t.Cleanup для уборки, t.Parallel для параллельности. Финишь — покрытие: go test -cover покажет, какие строки ваш прогон так и не потревожил.
Одна оговорка заранее, чтобы дальше не отвлекаться. Запуск тестов — это работа настоящего go test: он собирает пакет с тестовым файлом, находит Test-функции и прогоняет каждую в своей горутине. Ни резервный, ни основной движок песочницы этого не делают (первый пакет testing вообще не знает, у второго нет команды test), поэтому Test-функции урока — листинги для чтения и локального запуска, а интерактивы гоняют тестируемый код и мини-эмуляции семантики Error/Fatal. Честнее не придумаешь: смотрите на настоящие тесты и тут же трогаете руками их логику.
Первый тест: func TestXxx и файл *_test.go#
Контракт тестирования в Go держится на трёх правилах, и все три проверяет сам go test. Правило первое — файл: тесты кладут рядом с кодом, в файл, имя которого кончается на _test.go — phone.go и phone_test.go лежат в одном каталоге и одном пакете. Команда go build при сборке программы все *_test.go молча пропускает: тесты не попадают в бинарник и не утяжеляют поставку. Команда go test — наоборот: собирает пакет вместе с тестовыми файлами и запускает найденные тесты. Один и тот же каталог, два режима, нулевая настройка.
Правило второе — имя. Тест — это функция с префиксом Test и продолжением с заглавной буквы: TestAdd, TestNormalizePhone, TestParseDuration. Продолжение после Test обязано начинаться с прописной буквы — testadd не считается, компилятор пакетов тестов молча не увидит такую функцию. Через имена вы директивно отбираете, что прогонять: go test -run TestNormalize запустит только совпавшие с шаблоном тесты. Это делает имена маленьким API отчёта — по ним вы потом ищете провал в сотне зелёных строк.
Правило третье — сигнатура: func TestXxx(t *testing.T). Один параметр — указатель на testing.T, «руль» теста: через него тест сообщает о провале (t.Error, t.Fatal), печатает заметки (t.Log), заводит под-тесты (t.Run) и регистрирует уборку (t.Cleanup). Возвращать тест ничего не должен — результат выражается не return, а вызовами на t: тест без ни одного такого вызова считается пройденным. Отсюда и стиль сообщений: го-тесты не «упали с исключением», а аккуратно перечислили, что получили и что ждали — «Add(2, 3) = 4; want 5».
Как это выглядит на командной строке (всё — текстом, запуск локальный). go test в каталоге пакета прогоняет его тесты и печатает одну строку: ok example.com/phone 0.312s — пакет собрался, тесты прошли. Провал выглядит как: --- FAIL: TestAdd (0.00s), ниже — файл:строка и ваше сообщение, в конце FAIL example.com/phone. Флаг ./... разгоняет прогон на весь проект: go test ./... обходит все пакеты от текущего каталога вниз — именно эту команду вы будете запускать после каждой правки. Флаг -v (verbose) добавляет построчный рассказ: === RUN TestAdd, --- PASS: TestAdd, и то же самое с отступом для каждого под-теста. Без -v успешный тест молчит — шумят только провалы; правило отчётности то же, что у t.Log: молчи, пока здоров.
Листинг: первый тест — два файла рядом
GO// ---- phone.go: обычный файл пакета, ничего тестового ----
package phone
// Add — тестируемая функция: складывает и возвращает.
func Add(a, b int) int {
return a + b
}
// ---- phone_test.go: тесты живут рядом, суффикс _test.go обязателен ----
package phone
import "testing"
// TestAdd — префикс Test + имя с заглавной буквы; параметр *testing.T.
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
// t.Errorf: пометить провал, напечатать сообщение и продолжить.
t.Errorf("Add(2, 3) = %d; want %d", got, want)
}
// Ни одного вызова t.Error здесь — тест считается пройденным.
}Листинг показывает два файла одним куском (в реальном каталоге это phone.go и phone_test.go). Настоящий go test -v напечатает «=== RUN TestAdd» и «--- PASS: TestAdd (0.00s)». Сломайте want на 4 — и в отчёте появится phone_test.go:16: Add(2, 3) = 5; want 4, затем FAIL. В песочнице листинг не исполняется: пакет testing движкам недоступен, тесты запускает только настоящий go test — скопируйте два файла и проверьте локально.
Проверьте себя
Какие функции команда go test считает тестами и запускает?
t.Error против t.Fatal: пометить и жить или пометить и выйти#
Вот здесь спотыкаются почти все, кто приходит из языков с assert. В Go два режима провала, и выбирать нужно осознанно. t.Error (и t.Errorf с форматированием) помечает тест проваленным и продолжает выполнение: следующие проверки тоже отработают, и вы получите полный список расхождений за один прогон. t.Fatal (и t.Fatalf) сначала помечает провал, а затем останавливает тест — выполняется как «Log + FailNow», а FailNow внутри зовёт runtime.Goexit: горутина теста аккуратно завершается, все defer в ней отрабатывают, но код после вызова уже не исполняется.
Выбор между ними — вопрос здравого смысла про «есть ли смысл жить дальше». Проверяете поля структуры — t.Errorf: пусть отчёт покажет все несоответствия разом. А если Split(in) вернул ошибку или nil, и следующие строки обязаны разыменовывать результат — только t.Fatalf: продолжать нечем, каждая следующая строка лишь добавит панику к провалу. Правило: Fatal — когда продолжение не проверка, а падение; Error — когда вы собираете полный список претензий.
У Fatal есть два неочевидных следствия, о которых честно предупреждает документация пакета testing. Первое: FatalNow-семантика убивает только ТЕКУЩУЮ тест-функцию (или под-тест), а не прогон: соседние кейсы таблицы выполнятся, родительский тест продолжит цикл, следующие Test-функции запустятся как ни в чём не бывало. Именно поэтому Fatal безопасен в цикле табличного теста: один сломанный кейс не топит остальных — их вердикты вы всё равно увидите. Второе: FailNow обязан вызываться из той горутины, которая выполняет тест. Если проверки делает фоновая горутина, созданная тестом, — там доступен только t.Error (и t.Error обязан), потому что Goexit в чужой горутине не остановит тест, а просто тихо убьёт её.
Младшие братья, чтобы картина была полной. t.Log и t.Logf — заметка в отчёт без провала: печатается, только если тест провалился или запущен с -v. t.Fail — пометить провал без выхода, лысая пара к t.Error без аргументов. t.FailNow — сам выход без записи сообщения (почти всегда вам нужен Fatal, а не голый FailNow). И обратите внимание на симметрию Skip-семейства, которая скоро понадобится: t.Skip определён как «Log + SkipNow», где SkipNow — тот же runtime.Goexit, только тест помечается не проваленным, а пропущенным.
Первая проверка без testing: пометить и продолжить
Пакет testing в песочнице недоступен, поэтому здесь — тестируемый код и ручная проверка с семантикой t.Errorf: провал помечается печатью, выполнение продолжается. Запустите, затем сломайте ожидание во втором if (замените 0 на 1) — увидите ровно ту форму отчёта, которую вам дал бы t.Errorf: провал засчитан, соседние проверки живы.
Запустите код, чтобы увидеть вывод.
Что напечатает мини-эмуляция Error и Fatal?
Прочитайте код и выберите, что он выведет:
package main
import "fmt"
// шаги, которые «делает» тест
var steps = []string{"раз", "бум", "два"}
type fakeT struct{ log []string }
// Error помечает провал и продолжает работу (в духе t.Error).
func (f *fakeT) Error(msg string) {
f.log = append(f.log, "ERROR: "+msg)
}
// Fatal помечает провал и требует выйти из кейса (в духе t.Fatal):
// возвращает true — «выходи немедленно».
func (f *fakeT) Fatal(msg string) bool {
f.log = append(f.log, "FATAL: "+msg)
return true
}
// runSoft: мягкий режим — после сбоя идём дальше, как t.Error.
func (f *fakeT) runSoft() {
for _, s := range steps {
if s == "бум" {
f.Error("на шаге " + s)
continue
}
f.log = append(f.log, "ok: "+s)
}
}
// runHard: жёсткий режим — на сбое выходим, как t.Fatal.
func (f *fakeT) runHard() {
for _, s := range steps {
if s == "бум" && f.Fatal("на шаге " + s) {
return
}
f.log = append(f.log, "ok: "+s)
}
}
func main() {
var a fakeT
a.runSoft()
var b fakeT
b.runHard()
for _, l := range a.log {
fmt.Println(l)
}
for _, l := range b.log {
fmt.Println(l)
}
}Fatal внутри табличного цикла
Табличный тест из пяти кейсов зовёт t.Fatalf внутри t.Run. Первый же кейс провалился. Что напечатает go test -v?
Table driven tests: срез кейсов вместо копипасты#
Первая проверка Add выглядела невинно, но у неё есть экспоненциальная болезнь: на каждый новый вход — новый блок if, новый got и want, новая строка шума. Десять проверок — десять почти одинаковых кусков кода, отличающихся только данными. А данные — это и есть тест. Отсюда идиома, которой пропитана вся экосистема, включая стандартную библиотеку: table driven tests — табличные тесты. Ожидания складываются в срез структур, а единственный цикл прогоняет все кейсы одним каркасом проверки. Вики-страница go.dev/wiki/TableDrivenTests описывает её в трёх строках: определяете структуру кейса, заполняете срез, итерируете с t.Run.
Структура кейса — зеркало того, что вы проверяете. Поле name — человеческое имя кейса, которое попадёт в отчёт и в имя под-теста (без него провал пятого кейса из семи скажет вам только «пятой не понял»). Поля входа — in, сырые аргументы. Поля ожиданий — want и его родственники: wantErr, wantLen, wantCode — по конвенции всё, что начинается с want. Булево wantErr — классика для функций, возвращающих (значение, error): true значит «здесь мы ждём отказ», и тогда проверяется сама ошибка (сквозь errors.Is — пятнадцатый урок как раз об этом), а не значение.
Второй столп идиомы — цикл с t.Run(name, func(t *testing.T) {...}): каждый кейс становится под-тестом со своим именем, своим вердиктом и своим журналом. Провал одного кейса не мешает остальным (Fatal выходит только из под-теста), отчёт -v перечисляет кейсы по именам, а флаг -run умеет выбирать одиночный кейс по имени — с копипастой так не умеет никто. Табличный тест масштабируется бесплатно: добавить поведение — значит добавить строку данных, не трогая логику проверки.
Одна тонкость, о которой стоит знать заранее: захват переменной цикла. Исторически (до Go 1.22) переменная tt из for _, tt := range была одна на весь цикл, и в замыкании под-теста к моменту выполнения она могла показывать уже следующий кейс — лечилось теневой копией tt := tt первой строкой замыкания. Начиная с Go 1.22 (при go 1.22 и выше в go.mod) переменная итерации своя на каждую итерацию, и подтенение не нужно; встречая tt := tt в старом коде, вы теперь знаете, чей это шрам. В наших листингах go.mod modern — копии не заводим.
Листинг: табличный тест в полном боевом виде
GO// phone_test.go — один каркас проверки, шесть кейсов данных.
package phone
import (
"errors"
"fmt"
"testing"
)
// ErrNoDigits — sentinel валидатора; тесты находят его через errors.Is.
var ErrNoDigits = errors.New("нет ни одной цифры")
// NormalizePhone чистит строку и приводит номер к виду +7XXXXXXXXXX.
func NormalizePhone(raw string) (string, error) {
digits := ""
for _, r := range raw {
if r >= '0' && r <= '9' {
digits += string(r)
}
}
if len(digits) == 0 {
return "", fmt.Errorf("телефон «%s»: %w", raw, ErrNoDigits)
}
if len(digits) == 11 && (digits[0] == '7' || digits[0] == '8') {
digits = digits[1:]
}
if len(digits) != 10 {
return "", fmt.Errorf("неожиданная длина: %d", len(digits))
}
return "+7" + digits, nil
}
func TestNormalizePhone(t *testing.T) {
// Срез кейсов: данные отдельно, логика проверки — одна на всех.
cases := []struct {
name string // имя попадёт в отчёт и в имя под-теста
in string // вход
want string // ожидаемый номер
wantErr bool // true — ждём отказ
}{
{"городской", "495 123-45-67", "+74951234567", false},
{"с восьмёркой", "8-499-123-45-67", "+74991234567", false},
{"десять цифр", "4991234567", "+74991234567", false},
{"пустая строка", "", "", true},
{"буквы", "звоните!", "", true},
{"короткий", "12345", "", true},
}
for _, tt := range cases {
// Каждый кейс — под-тест: своя горутина, имя и вердикт.
t.Run(tt.name, func(t *testing.T) {
got, err := NormalizePhone(tt.in)
// Расхождение по факту ошибки — Fatal: дальше нечего проверять.
if (err != nil) != tt.wantErr {
t.Fatalf("NormalizePhone(%q) err = %v; wantErr = %v",
tt.in, err, tt.wantErr)
}
if err != nil {
return // отказ и ждали отказ — кейс пройден
}
if got != tt.want {
// Значение живо — Errorf: соберём все расхождения.
t.Errorf("NormalizePhone(%q) = %q; want %q",
tt.in, got, tt.want)
}
})
}
}Так выглядит большинство тестов стандартной библиотеки: срез кейсов + цикл + t.Run. Обратите внимание на пару Fatal/Errorf внутри под-теста: расхождение «ошибка не там» убивает кейс (проверять значение бессмысленно), расхождение значения — накапливается. Настоящий go test -v по этому листингу печатает иерархию «TestNormalizePhone/городской» и спутников (пробелы в именах — подчёркивания: с_восьмёркой); в песочнице не исполняется — движкам недоступен пакет testing, запуск только локальным go test.
Таблица ожиданий без testing: тот же цикл
Это то самое тело табличного теста, только вердикты печатаются Println вместо t.Errorf, а каждый кейс живёт в общем цикле без под-тестов. Запустите — пять ok. Затем исправьте want у кейса «городской» на +74950000000 и запустите снова: отчёт покажет ровно один FAIL с фактическим значением, соседи не пострадают — как при t.Errorf в настоящем тесте.
Запустите код, чтобы увидеть вывод.
t.Run: под-тесты, фильтр -run и t.Parallel#
t.Run(name, f) — команда, из-за которой табличные тесты стали стандартом. Механика: Run заводит под-тест с именем name, выполняет f в ОТДЕЛЬНОЙ горутине и блокируется до её завершения; возвращает булево «под-тест удался» (даже если он вышел через t.Skip — Skip считается успехом прогона). Имена складываются в путь через слэш: TestNormalizePhone/городской — так отчёт и флаги различают глубину. Под-тесты можно вкладывать: t.Run внутри под-теста даст TestX/слой1/слой2 — иерархию любой глубины, хотя чаще хватает двух уровней «тест/кейс».
Отдельная прелесть имён — фильтрация. go test -run TestNormalizePhone запускает только этот тест; go test -run 'TestNormalizePhone/городск' — только кейсы, чьё имя содержит «городск»: регулярное выражение применяется сначала к имени теста, затем к имени под-теста. Нюанс, о котором документация предупреждает честно: пробелы в именах под-тестов прогон заменяет на подчёркивания — кейс «с восьмёркой» в отчёте выглядит как TestNormalizePhone/с_восьмёркой, и фильтр нужно писать по подчёркнутой форме. По этой причине многие команды пишут имена кейсов без пробелов заранее.
t.Parallel — обещание «этот под-тест (или тест) можно выполнять параллельно с другими Parallel». Вызов t.Parallel() внутри t.Run останавливает под-тест в специальной точке: сначала выполняется ВСЯ последовательная часть родителя (цикл по кейсам проходит целиком, регистрируя параллельные под-тесты), и только когда родительская функция закончилась, припаркованные под-тесты просыпаются и бегут одновременно — параллельно только друг с другом и другими Parallel, не со всем подряд. Отсюда два практических правила: ресурсы, которые трогает параллельный под-тест, не должны делить изменяемое состояние без синхронизации (мьютексы из восьмого урока вам в помощь), и замыкание должно захватывать свой tt — в современном Go это бесплатно, а в до-1.22 требовало теневой копии. Флаг -count и переменные -cpu умножают прогоны: тест, помеченный Parallel, при этом не параллелится сам с собой — одинаковые копии идут по очереди.
t.Helper: честные номера строк в отчёте#
Провал в отчёте указывает файл и строку — но какую? Сначала сделаем боль видимой. Пусть несколько тестов делят общий хелпер:
func requirePositive(t *testing.T, got int) { if got <= 0 { t.Errorf("ожидали положительное, получено %d", got) } }
Провал укажет на строку с t.Errorf — на хелпер, который одинаков для всех вызовов. Пять тестов зовут его — пять отчётов показывают одну и ту же строку, и искать виновника приходится вручную. t.Helper() решает это одной строкой: вызовите его первой строкой хелпера — и фреймворк исключит его из отчёта о позиции; строка и файл укажут на ТЕСТИРОВАЕМЫЙ код вызова, то есть на то место, которое вы реально будете править.
Тонкость, которую стоит прочувствовать: t.Helper не меняет ничего в логике — ни провалов, ни остановок; он меняет только бухгалтерию отчёта (и только если хелпер принимает *testing.T — иначе ему нечем отчитываться). Поэтому конвенция такая: хелпер, вызывающий t.Error/t.Fatal, обязан принимать t параметром (не глобально!) и начинаться с t.Helper(). Встречали в этом уроке табличную идиому? t.Run решает ту же задачу с другой стороны: имя кейса само делает отчёт точным. Helper нужен там, где таблица не подходит — разрозненные проверки, общие приспособления, шаги «подготовь-провери-убери».
t.Skip: честное «не сегодня»#
Не всякий тест обязан бежать всегда. Тест интеграции с платёжным шлюзом — без ключей из окружения; тест под Windows — под Windows; тест длинного сценария — в ночном прогоне. Для таких случаев у t есть семейство Skip: t.Skip("нет ключей шлюза") печатает причину и останавливает тест через runtime.Goexit — тот же механизм, что у Fatal, но тест помечается не провалом, а пропуском. В отчёте -v это отдельный вердикт: --- SKIP: TestPayments (0.00s) с вашей причиной ниже; общий итог пакета остаётся ok — пропуск не красит прогон в красный.
Почему не просто return? Обычный return из теста молча засчитывает ПАСС: отчёт покажет зелёный тест, который на самом деле не проверил ничего. Skip — это честность: «проверки не было, вот почему». Разница между «прошёл» и «пропущен» — разница между «работает» и «не знаю»; в большом проекте второе хотя бы не врёт. t.Skipf даёт то же с форматированием: t.Skipf("пропуск: %v", err).
Осторожность, к которой обязывает слово «честно»: Skip — не анестезия для хронически красного теста. Тест, который провалился, а вы его скипнули «чтобы заработало», — это тест-призрак: вердикты зелёные, гарантий ноль. Правило приличия: пропуск указывает внешнюю причину (нет окружения, нет сети, не та платформа), а не внутреннюю (сломался — скипнули). Хронический провал лечат фиксом или удалением теста, но не Skip-ом.
t.Cleanup: уборка после теста#
Тесты заводят ресурсы: временный файл, каталог с фикстурой, соединение с тестовой базой, сервер на localhost. Убирать за собой нужно в любом исходе — прошли, провалились, вышли через Fatal. Для этого у t есть t.Cleanup(f func()): регистрирует функцию, которую фреймворк вызовет, когда тест (и все его под-тесты) завершатся — в обратном порядке регистрации, LIFO, ровно как defer из второго урока. Ключевое отличие от defer внутри теста: Cleanup, зарегистрированный в хелпере, сработает для вызывающего теста — функция-помощник может сама и создать ресурс, и записать его уборку, а тест об этом даже не вспомнит. И Cleanup срабатывает на любом исходе, включая провал и Goexit: Fatal посреди теста уборку не отменяет.
Порядок стоит запомнить буквально: регистрации разгружаются в обратном порядке — последний созданный ресурс закрывается первым. Если ресурсу нужен контекст, который живёт ровно столько, сколько тест, — из девятого урока вы знаете, что контекст отменяют явно; с Go 1.24 у t появился и t.Context(): контекст, который отменяется автоматически перед вызовом функций Cleanup. Для старых версий связка «ctx, cancel := context.WithCancel(...); t.Cleanup(cancel)» делает то же самое руками.
Когда что выбирать — правило одной строки. Уборка, привязанная к куску кода в той же функции, — defer (читается прямо на месте). Уборка, которую хелпер обязан вернуть вызывающему, — t.Cleanup. А если тест внезапно переусложнился уборкой — это обычно сигнал, что проверяется слишком много: часть приспособлений просится в отдельную функцию с t.Helper и t.Cleanup внутри.
Порядок уборки: LIFO на глазах
Cleanup зовёт зарегистрированные функции в обратном порядке — то же правило LIFO, что у defer из урока про defer. Запустите и проверьте порядок: «файл» закрылся раньше «соединения». Поменяйте регистрации местами и перезапустите — порядок уборки перевернётся, а тело теста останется прежним: уборка не зависит от исхода, она просто ДОЛЖНА случиться после.
Запустите код, чтобы увидеть вывод.
Провал как настоящий тест: errors.Is на sentinel
Резервный движок не знает errors.Is — при отказе «у пакета errors нет члена Is» переключитесь на основной движок: раннер нативный. Это третья проверка табличного кейса — не «ошибка есть/нет» и не текст, а ЛИЧНОСТЬ причины: валидатор обернул sentinel с %w (пятнадцатый урок), и errors.Is находит его сквозь обёртку. Поменяйте %w на %v — Is станет false: тест честно поймает регрессию, которую печать не показала бы вовсе.
Запустите код, чтобы увидеть вывод.
Пакет testing — единственная тема курса, которую движки песочницы не берут вовсе. Резервный движок на import "testing" отвечает отказом: пакет "testing" недоступен в песочнице (доступны: fmt, strings, strconv, math, sort, errors, time) — и Test-функции в нём не запускаются в принципе. Основной движок (yaegi) импорт testing допускает, но и он не заменяет go test: тестовый прогон — это отдельная команда, собирающая пакет и вызывающая Test-функции, а не часть программы. Поэтому тесты урока — листинги для чтения и локального прогона (go test -v ./...), а интерактивы честно гоняют то, что движкам по силам: тестируемые функции, тело табличного цикла, мини-эмуляцию семантики Error/Fatal и errors.Is на sentinel-е (последний раннер — только в основном движке: резервный отвечает «у пакета errors нет члена Is»).
Покрытие: go test -cover и что оно на самом деле меряет#
Тесты прошли — но какие строки они вообще трогали? На этот вопрос отвечает покрытие. go test -cover прогоняет тесты с инструментацией и дописывает к итогу пакета процент: ok example.com/phone 0.312s coverage: 92.3% of statements. Считаются ВЫПОЛНЕННЫЕ операторы: доля строк кода, через которые хоть раз прошёл хотя бы один ваш тест. Возьмите табличный тест NormalizePhone и уберите кейс «пустая строка» — процент проседает: ветка «нет ни одной цифры» осталась непройденной, и покрытие нашло дыру за вас.
Два уровня детализации поверх процента. go test -coverprofile=cover.out сохраняет пооператорную карту в файл, а go tool cover -func=cover.out печатает процент по каждой функции — мгновенно видно, что NormalizePhone покрыт на сто, а вспомогательный parseNote — на тридцать. go tool cover -html=cover.out открывает в браузере листинги пакета: зелёное — покрыто, красное — не тронуто. Красные строки в go-коде — это почти всегда недостающие кейсы таблицы; смотреть такой отчёт перед релизом полезнее любого чек-листа.
И обязательная оговорка, чтобы метрика не стала культом. Покрытие меряет «код исполнялся», а не «код проверен». Тест, который вызвал функцию и не сравнил результат, поднимает процент, не поймав ни одной ошибки; тест с неверным want — тоже. Стопроцентное покрытие гарантирует только одно: нет строк, которые прогон не исполнял, — но не то, что каждое исполнение что-то утверждал. Отсюда рабочая норма: покрытие — инструмент поиска дыр (куда добавить кейсы), а не целевая функция (набить 100 любой ценой). Дыры ищите там, где деньги: ветки ошибок, границы диапазонов, парсеры.
Шпаргалка: API testing.T и команда go test#
| Инструмент | Что делает | Как выглядит | Осторожно |
|---|---|---|---|
| t.Error / t.Errorf | пометить провал и продолжить | t.Errorf("got %d; want %d", got, want) | для сбора всех расхождений за прогон |
| t.Fatal / t.Fatalf | пометить провал и выйти (runtime.Goexit) | t.Fatalf("парсер вернул nil") | только из горутины теста; соседние кейсы живы |
| t.Log / t.Logf | заметка в отчёт без провала | t.Logf("медленно: %v", d) | видна при провале или с -v |
| t.Run | под-тест с именем в своей горутине | t.Run(tt.name, func(t *testing.T) {…}) | блокирует родителя; вернёт успех под-теста |
| t.Parallel | пометить «можно параллельно» | t.Parallel() внутри t.Run | стартует после последовательной части родителя |
| t.Helper | скрыть хелпер из номеров строк отчёта | t.Helper() первой строкой хелпера | хелпер обязан принимать *testing.T |
| t.Skip / t.Skipf | пропустить с причиной (SKIP) | t.Skip("нет ключей шлюза") | не анестезия для хронических провалов |
| t.Cleanup | уборка после теста и под-тестов | t.Cleanup(closeRes) | LIFO; срабатывает и при провале |
| go test -v -run | подробно, с фильтром имён | go test -v -run 'Phone/городск' | пробелы в именах под-тестов = подчёркивания |
| go test -cover | процент выполненных операторов | go test -coverprofile=cover.out | 100% не значит «проверено»: сравнение может отсутствовать |
Правила гигиены тестов#
- Один тест — одна забота: TestNormalizePhone не проверяет парсер дат; разнобой тем лечится отдельными Test-функциями или таблицей с полями под каждую историю.
- Имя кейса — часть отчёта: без name провал пятого кейса скажет «пятый не понял»; имя без пробелов спасает от сюрприза подчёркиваний в -run.
- t.Fatal — только когда продолжать нечем (nil, ошибка, пустой срез); все прочие расхождения — t.Errorf, чтобы отчёт собрал полный список претензий.
- want лежит рядом с кейсом: добавление поведения — строка данных, а не новый if; структура кейса (name/in/want/wantErr) — половина читаемости табличного теста.
- Хелперы принимают *testing.T и начинаются с t.Helper() — иначе отчёт показывает строки хелпера, а не виноватый вызов.
- Проверяйте ошибки по личности, а не по тексту: sentinel + errors.Is (и errors.As для типов) — единственный способ, который не ломается от обёртки fmt.Errorf с %w.
- Ресурсы — через t.Cleanup (или defer, если уборка живёт в том же куске кода); уборка обязана срабатывать на любом исходе, включая Fatal.
- Юнит-тесты детерминированы: никаких сети, времени, случайности и порядка выполнения без явной причины; живое окружение — это Skip с причиной, а не молчаливый PASS.
Соберите таблицу ожиданий
Восстановите табличный каркас: поле ожидаемого значения, проверку ошибочности и сравнение значения.
Соберите каркас первой проверки
Соберите программу: тестируемая функция, замер got, ожидание want и ветка провала с ранним выходом — скелет, из которого потом вырастает t.Errorf.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Что дальше: тесты отвечают «правильно ли», бенчмарки — «быстро ли»#
Соберём урок в одну картину. Тест в Go — обычная функция func TestXxx(t testing.T) в файле _test.go: go build её игнорирует, go test — компилирует и зовёт. Провал выражается двумя режимами: t.Error помечает и продолжает, t.Fatal помечает и выходит через runtime.Goexit — без выноса соседних кейсов и родителя. Главная идиома — table driven tests: срез структур с name/in/want/wantErr, один цикл и t.Run, дающий каждому кейсу имя, горутину и изолированный вердикт. Вокруг — сервисные ручки: t.Helper возвращает отчёту честные номера строк, t.Skip фиксирует честное «не сегодня», t.Cleanup убирает ресурсы в LIFO-порядке при любом исходе, t.Parallel разгоняет независимые кейсы. И метрика здоровья — покрытие go test -cover: инструмент поиска непройденных веток, но не гарантия осмысленных проверок.
Естественный вопрос после «правильно ли» — «а насколько быстро?». Тест на 50 миллисекунд и тест на 5 секунд проходят одинаково зелёно, а разница между ними — вся ваша латентность. Для ответа в стандартной библиотеке есть второй инструментарий того же семейства: бенчмарки func BenchmarkXxx(b *testing.B) с циклом по b.N, флаг -benchmem и профилировщик pprof, который покажет, на что ушла секунда. Семнадцатый урок разберёт их целиком: как b.N подгоняет число итераций, как читать ns/op и B/allocs, и как профиль превращает подозрения в факты. Проверка поведения и измерение скорости — две половины одного навыка; сегодня была первая.
До встречи на странице курса: а если захочется освежить, на чём стоят сегодняшние проверки, — вернитесь к уроку про оборачивание ошибок и перечитайте sentinel-раздел: errors.Is в табличных кейсах с wantErr теперь ваш ежедневный инструмент.
Карточки урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Тест — обычная функция func TestXxx(t *testing.T) в файле *_test.go рядом с кодом: go build пропускает тестовые файлы, go test компилирует и запускает; ./... разгоняет прогон по всему проекту.
- Результата у теста нет — есть вызовы на t: тест без них считается пройденным, а сообщения о провале выглядят как «got …; want …».
- t.Error/Errorf помечает провал и продолжает — отчёт собирает все расхождения; t.Fatal/Fatalf помечает и выходит через runtime.Goexit — когда продолжение невозможно.
- Fatal убивает только текущую тест-функцию или под-тест: соседние кейсы табличного цикла и родитель продолжают работу; FailNow допустим только из горутины теста.
- Table driven tests — срез структур с name/in/want/wantErr и один цикл с t.Run: новый сценарий — строка данных, а не новый if.
- t.Run даёт кейсу имя, отдельную горутину и изолированный вердикт; имена складываются через слэш, -run фильтрует по регулярке, пробелы в именах становятся подчёркиваниями.
- t.Parallel помечает под-тест параллельным: он просыпается после последовательной части родителя и бегает только с другими Parallel; в Go 1.22+ копия tt := tt не нужна.
- t.Helper убирает хелпер из номеров строк отчёта; t.Skip фиксирует честный пропуск с причиной, а t.Cleanup убирает ресурсы в LIFO-порядке при любом исходе.
- Покрытие go test -cover — доля выполненных операторов; -coverprofile и go tool cover -html показывают непокрытые строки; 100% не гарантирует, что проверки были осмысленными.
- В песочнице testing недоступен обоим движкам: тесты — листинги для настоящего go test, а интерактивы гоняют тестируемый код, тело таблицы и мини-эмуляцию Error/Fatal.
Домашнее задание
Часть первая — первый тест: создайте каталог с phone.go (функция Add(a, b int) int) и phone_test.go с TestAdd и проверкой через t.Errorf. Запустите go test -v и прочитайте вывод; сломайте want и запустите снова — убедитесь, что отчёт называет файл и строку провала. Часть вторая — табличный тест: перенесите NormalizePhone из урока в свой пакет и напишите TestNormalizePhone со срезом кейсов (name/in/want/wantErr) и t.Run; добавьте свой кейс с ведущей семёркой и кейс с пробелами по краям; проверьте, что фильтр go test -run 'NormalizePhone/с_восьмёркой' запускает один кейс. Часть третья — Error против Fatal: временно замените t.Fatalf на t.Errorf в ветке wantErr и заведите кейс, при котором NormalizePhone возвращает ошибку и пустую строку; объясните письменно, какая проверка паникует и почему Fatal обязателен; потом проверьте, что провал ОДНОГО кейса не останавливает остальных (один FAIL, остальные PASS). Часть четвёртая — сервисные ручки: вынесите создание временного файла в хелпер с t.Helper() и t.Cleanup(os.Remove) и убедитесь, что номер строки провала указывает на тест, а не на хелпер; добавьте тест с t.Skip("нет ключей") и найдите его вердикт SKIP в -v; закончите go test -cover и найдите через go tool cover -func самую непокрытую функцию. Комментарии в коде — по-русски.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем t.Fatal отличается от t.Error и когда какой нужен?
t.Error помечает тест проваленным и продолжает выполнение — так собирают полный список расхождений за один прогон. t.Fatal — это Log + FailNow: провал, после которого тест немедленно завершается через runtime.Goexit (defer отрабатывает). Fatal нужен, когда продолжать нечем: функция вернула ошибку или nil, и следующие строки обязаны разыменовывать результат. Внутри t.Run Fatal убивает только под-тест — цикл родителя продолжит остальные кейсы.
Зачем table driven tests, если можно написать несколько Test-функций?
Потому что таблица убирает копипасту и делает сценарии данными: один каркас проверки обслуживает любое число кейсов, новый сценарий — строка в срезе. Плюс t.Run даёт каждому кейсу имя и изолированный вердикт: отчёт показывает, какой именно кейс провалился, флаг -run запускает одиночный кейс, а Fatal одного кейса не топит соседей. Отдельные Test-функции остаются для историй с разной подготовкой, которую в таблицу не уложить.
Что показывает go test -cover и почему 100% покрытия не гарантия?
Покрытие — доля выполненных операторов: какие строки кода хоть раз исполнил хотя бы один тест. -coverprofile и go tool cover -html показывают непокрытые строки прямо в листингах. Но покрытие меряет исполнение, а не проверку: тест, вызвавший функцию без сравнения результата, и тест с неверным want поднимают процент, не поймав ни одной ошибки. Поэтому 100% означает лишь отсутствие непройденных строк — осмысленность проверок оценивают по want-ам и веткам ошибок.
Почему тесты в Go лежат рядом с кодом, а не в отдельной папке tests?
Файл *_test.go в том же каталоге — часть контракта инструментов: go build его игнорирует (в поставку тесты не попадают), go test — компилирует вместе с пакетом. Рядом с кодом тесты видят неэкспортируемые детали (white-box), переезжают вместе с рефакторингом и не дают коду и тестам разъехаться. Отдельные пакеты _test (например, package phone_test в том же каталоге) — законный вариант для black-box тестов внешнего API.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Fiber: экспресс-подобный веб-фреймворк на Fasthttp
Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.
- Поднимать HTTP-сервер на Fiber за минуты
- Проектировать маршруты, группы и версии API
- Писать middleware: логи, CORS, лимиты, recovery
- Принимать и отдавать JSON с валидацией