1. Тесты — идеальный режим воспроизведения бага
Когда баг проявляется «иногда», очень хочется сделать то, что делает любой здоровый человек: слегка поплакать, потом добавить десять fmt.Printf, потом удалить девять из них (потому что они мешают жить), а потом… случайно починить не то. Тесты спасают тем, что превращают проблему в повторяемый сценарий: фиксированный вход → фиксированный код → предсказуемое падение или неправильный результат.
Тест как инструмент отладки удобен по-человечески. Во-первых, он гоняется быстро: вы не кликаете мышкой по меню, не вводите вручную параметры, не пытаетесь вспомнить «а я в прошлый раз вводил 10 или 100?». Во-вторых, тест — это уже почти документация бага: если завтра вы забудете, что именно ломалось, тест напомнит (иногда очень громко). В-третьих, тесты дают вам узкий коридор для экспериментов: вы меняете 1–2 строки, перезапускаете один тест, сразу видите эффект.
И главное: тесты очень хорошо сочетаются с тем, что мы уже умеем. Если тест падает паникой, вы получаете stack trace и применяете ровно тот же навык «ищем свой файл/строку». Если тест падает сравнением got/want, вы можете точечно добавить t.Logf или временный fmt.Printf. Если совсем тяжело — тест становится удобной точкой входа для отладчика.
2. Управляем запуском go test: -run, -v и -count=1
Чтобы отладка через тесты была приятной, нужно немного «приручить» запуск. По умолчанию go test старается быть полезным сразу для всего проекта: он собирает пакет, запускает тесты, может использовать кэш, а вывод делает относительно компактным. Для обычного дня это отлично. Для отладки — иногда хочется другого: запустить только один сценарий, увидеть больше подробностей и быть уверенным, что тест действительно перезапустился, а не взялся из кэша.
Во время отладки удобно осознанно контролировать три вещи: объём запуска (все тесты или один), объём вывода (молчаливо или подробно) и повторяемость (точно ли тест реально заново исполнился). Как только вы начинаете это контролировать, отладка перестаёт быть «магией» и становится ремеслом.
-run: ускоряем цикл «поправил → проверил»
Когда проект становится хоть чуть-чуть живым, тестов становится много. Это хорошо, но во время отладки гонять всё подряд — дорого по времени и по нервам. Именно тут появляется флаг -run.
Формально -run принимает регулярное выражение. По-человечески: -run — это фильтр по имени теста. Вы говорите: «Запусти только вот это», и go test запускает только подходящее.
Важно помнить две вещи.
Первая: -run — это регулярное выражение, а не «строгое имя». Поэтому -run Test запустит все тесты, у которых в имени встречается Test. А -run ^TestFoo$ запустит ровно TestFoo, потому что ^ — начало строки, $ — конец строки.
Вторая: когда появляются сабтесты (t.Run), их имена тоже участвуют в фильтрации, и пишутся они через /. То есть вы можете нацелиться не просто на тест, а на конкретный сценарий внутри него.
Небольшая шпаргалка:
| Что хотим сделать | Команда (идея) | Почему так |
|---|---|---|
| Запустить только один тест | |
«зажимает» regex, чтобы не матчить лишнее |
| Запустить группу похожих тестов | |
Матчит всё, где есть |
| Запустить конкретный сабтест | |
Имена сабтестов идут после |
Если регулярки ощущаются как отдельная форма магии (чуть-чуть да), не переживайте. На практике чаще всего хватает трёх режимов: «слово», «^строгое$», «тест/сабтест».
-v: видим, что реально запускалось
Флаг -v показывает подробный прогресс выполнения тестов и сабтестов: что именно запускалось и в каком порядке. Это особенно полезно, когда ваш -run матчится не так, как вы думаете.
Плюс, t.Logf обычно видно либо при падении теста, либо в verbose-режиме — так что -v помогает «увидеть глазами» то, что вы логируете.
-count=1: отключаем кэш на время отладки
go test умеет кэшировать успешные результаты. Это прекрасно, когда вы просто хотите быстро убедиться, что всё зелёное. Но во время отладки иногда нужно гарантированно гонять тест заново. Тогда вы говорите «не используй кэш» через -count=1.
Боевая команда для точечной отладки
Когда вы уже знаете, какой сценарий падает, удобно собирать флаги в одну команду:
go test -run TestFindByID/not_found -v -count=1 ./...
3. Сабтесты как микроскоп: один кейс из десяти
Когда вы пишете table-driven тесты, вы обычно гоняете много кейсов внутри одного TestXxx. Это хорошо: структура компактная, сценарии рядом. Но во время отладки иногда нужно запустить ровно один кейс, который падает, а не всю таблицу.
Именно поэтому важно давать сабтестам нормальные имена. Не case1, не case2, и уж точно не aaa. А что-то вроде not_found, empty_title, bad_id, unicode, чтобы через неделю вы сами себе сказали «спасибо, прошлый я».
Покажу на примере учебного мини-приложения Tasker (условный менеджер задач). У нас есть слайс задач, и мы хотим искать задачу по ID.
Код с багом: путаем ID и индекс
Представьте, что кто-то (не будем показывать пальцем) написал так:
package task
type Task struct {
ID int
Title string
}
func FindByID(tasks []Task, id int) (Task, bool) {
t := tasks[id] // BUG: id — это не индекс!
return t, true
}
Этот код выглядит правдоподобно, пока id случайно совпадает с индексом в слайсе. Потом наступает реальность, и тесты начинают кричать.
Тест с сабтестами: found и not_found
package task
import "testing"
func TestFindByID(t *testing.T) {
tasks := []Task{{ID: 10, Title: "buy milk"}, {ID: 20, Title: "learn Go"}}
t.Run("found", func(t *testing.T) {
_, ok := FindByID(tasks, 10)
if !ok {
t.Fatalf("expected ok=true")
}
})
t.Run("not_found", func(t *testing.T) {
_, ok := FindByID(tasks, 999)
if ok {
t.Fatalf("expected ok=false")
}
})
}
Даже если вы сейчас уже заметили, что тест found тоже странный (мы ищем по id=10, а в слайсе индекс 10 отсутствует), это как раз и есть смысл отладки: тесты подсвечивают несостыковки ожиданий и реальности.
Если падает только not_found, вы можете запускать только его:
go test -run TestFindByID/not_found ./...
И это резко ускоряет жизнь. Вам не нужно ждать, пока прогонятся остальные тесты проекта. Вы работаете как с лазерной указкой: светите ровно туда, где болит.
4. Минимальный воспроизводимый пример
Очень легко попасть в ловушку: баг проявляется в большом сценарии, и вы пытаетесь отлаживать весь большой сценарий целиком. Это как чинить машину, не открывая капот: можно, но обычно долго и с элементами театра.
Минимальный воспроизводимый пример (часто говорят MRE, minimal reproducible example) — это идея: оставить только то, без чего баг не воспроизводится, и выбросить всё остальное. В идеале вы приходите к ситуации, где у вас 10 строк кода, и на 7-й строке всё ломается. И вот тогда жизнь становится удивительно простой.
Самое приятное: минимальный пример обычно можно сделать прямо внутри теста. У вас был сложный ввод из файла? Заменили на строку-константу. У вас была загрузка кучи задач? Оставили две. У вас была сложная цепочка функций? Вызываете только ту, где реально ломается, и передаёте минимальный вход.
Полезно думать про это как про процесс «сжатия мира». Схема выглядит примерно так:
flowchart TD
A[Баг проявляется в большом сценарии] --> B[Фиксируем воспроизведение тестом]
B --> C[Убираем всё лишнее: ввод, данные, шаги]
C --> D{Баг всё ещё есть?}
D -- да --> E[Убираем ещё лишнее]
D -- нет --> F[Вернули последний убранный кусок]
E --> D
F --> G[Минимальный воспроизводимый пример готов]
Здесь нет «секретного шага». Это просто дисциплина: удалил → проверил → удалил → проверил.
И да, это звучит скучно. Но в момент, когда вы вместо 500 строк видите 12 и баг всё равно воспроизводится, вы испытываете почти физическое облегчение. Как будто кто-то выключил фоновый шум в голове.
5. Практический сценарий: от падения до фикса на одном сабтесте
Сейчас соберём всё в один последовательный сценарий, максимально приближенный к тому, как это происходит в реальной разработке. Важно не только знать команды, но и привыкнуть к ритму: «сузил запуск → получил сигнал → сузил вход → нашёл причину».
Запуск всего набора и первый сигнал
Вы запускаете тесты как обычно:
go test ./...
Они падают. Если падение — это panic, вы увидите stack trace и примените навык «ищем первую строку, относящуюся к вашему коду». Если падение — это t.Fatalf, вы увидите сообщение.
В нашем случае очень вероятно получить panic: runtime error: index out of range, потому что tasks[id] при id=999 улетит за границы.
Сужаем до одного теста
Чтобы не гонять всё подряд:
go test -run ^TestFindByID$ -v -count=1 ./...
Теперь вы видите, какие сабтесты стартовали, и где именно всё упало.
Сужаем ещё сильнее: один сабтест
Если падает not_found, работаем только с ним:
go test -run TestFindByID/not_found -v -count=1 ./...
На этом этапе у вас есть почти идеальная «песочница»: вы можете менять код, перезапускать, получать обратную связь за секунды.
Сжимаем вход до минимума
Если бы у нас были десятки задач, мы бы оставили две (как в тесте). Если бы была сложная генерация, мы бы заменили на литерал. Мы уже почти в минимальном варианте.
И вот теперь становится видно: мы используем id как индекс. Значит, реализация должна быть поиском по полю ID.
Исправляем реализацию
package task
func FindByID(tasks []Task, id int) (Task, bool) {
for _, t := range tasks {
if t.ID == id {
return t, true
}
}
return Task{}, false
}
После этого снова:
go test -run TestFindByID -v -count=1 ./...
Если тесты зелёные — вы не «поверили на слово», а проверили.
Бонус: t.Helper, чтобы ошибки показывали «правильную строку»
Когда тесты становятся сложнее, вы начинаете выносить повторяющиеся проверки в helper-функции. И тут появляется раздражающий момент: тест падает, но в сообщении показывается строка внутри helper-а, а не место, где helper вызвали.
В Go есть решение: t.Helper().
Пример:
package task
import "testing"
func mustFind(t *testing.T, tasks []Task, id int) Task {
t.Helper()
got, ok := FindByID(tasks, id)
if !ok {
t.Fatalf("task id=%d not found", id)
}
return got
}
Теперь если mustFind упадёт, go test укажет на строку в тесте, где вы вызвали mustFind, а не на внутренности helper-а. Для отладки это прям заметно приятнее.
6. Типичные ошибки при отладке тестов
Ошибка №1: запускать весь проект, когда падает один тест.
Это выглядит безобидно («пусть прогонится всё»), но на практике вы платите временем и вниманием. Пока крутится весь набор, вы забываете, что именно хотели проверить, а мозг начинает развлекаться сам — например, тревожиться. В момент отладки лучше сузиться до одного теста через -run и быстро крутить цикл изменений.
Ошибка №2: не “зажимать” имя теста и случайно запускать лишнее.
-run TestFind может матчить больше, чем вы думаете: например, TestFindByID и TestFindByTitle. Потом вы видите падение и уверены, что это «ваш» тест, а это соседний. Если вам нужен ровно один тест, привычка ^...$ экономит нервы.
Ошибка №3: бороться с кэшем, не понимая, что он существует.
Иногда вы поменяли код, а вывод как будто прежний. Частая причина — кэширование успешных результатов. Для отладки добавляйте -count=1, чтобы каждый запуск был честным, а не «из памяти». Особенно это актуально, когда вы дебажите вывод, время выполнения или зависимость от окружения.
Ошибка №4: превращать тест в “большую историю” вместо минимального воспроизведения.
Бывает соблазн: «сейчас я напишу тест, который полностью повторяет реальный сценарий». В результате тест сам становится сложнее, чем баг. Правильная мысль другая: тест во время отладки — это ваша лаборатория. Сначала оставьте минимальный вход и минимальный вызов, который ломается. Большие сценарии хороши, но позже.
Ошибка №5: добавлять тонну диагностического вывода и забывать убрать.
fmt.Printf — отличный скальпель, но плохой стиль жизни. Если вы оставляете диагностические принты навсегда, тесты начинают шуметь, а следующий человек (часто вы же через неделю) перестаёт понимать, где важное. На время отладки — нормально. После — либо убираем, либо переводим в t.Logf там, где это действительно помогает.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ