Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 16 из 25 ~24 мин +50 XP

Тестирование в Go

«Тестирование go» — запрос, за которым стоит самый простой и самый недооценённый инструмент языка: в стандартной библиотеке нет отдельного фреймворка, потому что тестовый уже встроен. Пакет testing плюс команда go test — и всё: никаких аннотаций, зависимостей и «правильного» стиля из вики фреймворка. В этом уроке разберём полный набор: как go test находит функции TestXxx в файлах *_test.go, чем t.Error отличается от t.Fatal (второй останавливает тест через runtime.Goexit, первый — нет), как из среза структур и t.Run собрать table driven tests — главную идиому проверки ожиданий, что делают t.Helper, t.Skip, t.Cleanup и t.Parallel, и как измерить покрытие командой go test -cover. Все Test-функции урока — честные листинги для настоящего go test: ни один движок песочницы тестовый прогон не заменяет, а тестируемый код и мини-эмуляции семантики Error/Fatal мы запустим прямо здесь.

Содержание урока · 15 разделов

Тестирование в 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: тест честно поймает регрессию, которую печать не показала бы вовсе.

Вывод

Запустите код, чтобы увидеть вывод.

Покрытие: 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.out100% не значит «проверено»: сравнение может отсутствовать

Правила гигиены тестов#

  • Один тест — одна забота: 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.

Соберите таблицу ожиданий

Восстановите табличный каркас: поле ожидаемого значения, проверку ошибочности и сравнение значения.

package main import ( "errors" "fmt" ) // 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 "", errors.New("нет ни одной цифры") } 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 main() { // Таблица ожиданий — то же, что видит табличный тест. cases := []struct { name string in string string // что валидатор должен вернуть wantErr bool }{ {"городской", "495 123-45-67", "+74951234567", false}, {"с восьмёркой", "8-499-123-45-67", "+74991234567", false}, {"пустая строка", "", "", true}, } for _, tt := range cases { got, err := NormalizePhone(tt.in) if (err != nil) != tt. { fmt.Printf("FAIL %s: %v\n", tt.name, err) continue } if got != tt. { fmt.Printf("FAIL %s: %v\n", tt.name, got) continue } fmt.Println("ok", tt.name) } }

Соберите каркас первой проверки

Соберите программу: тестируемая функция, замер 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 теперь ваш ежедневный инструмент.

Карточки урока

Термин

func TestXxx и *_test.go

нажмите, чтобы перевернуть
Определение

Тест — функция с префиксом Test и заглавным продолжением, единственный параметр *testing.T, никакого возврата. Живёт в файле с суффиксом _test.go рядом с кодом: go build пропускает такие файлы, go test — компилирует и запускает. Тест без вызовов на t считается пройденным.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 самую непокрытую функцию. Комментарии в коде — по-русски.

Личные заметкиПусто
Хранятся только в вашем браузере (Go Pro · Тестирование в Go)

Вопросы и ответы по уроку

Чем 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.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Fiber25 уроков~13 чпрактика25 уроков уже доступны

Fiber: экспресс-подобный веб-фреймворк на Fasthttp

Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.

  • Поднимать HTTP-сервер на Fiber за минуты
  • Проектировать маршруты, группы и версии API
  • Писать middleware: логи, CORS, лимиты, recovery
  • Принимать и отдавать JSON с валидацией