Тест висит¶
go test не завершается, CI job упирается в глобальный таймаут, локально
Ctrl+C — единственный выход. Ниже — как быстро локализовать и починить.
Конвенции тестов — ../conventions/testing.md.
Правило №1: всегда запускай go test с -timeout. Без него тест,
написанный с багом, висит до killer'а CI-раннера и не даёт
никакой диагностики.
Как понять, где именно висит¶
-timeout + stacktrace¶
По истечении таймаута go test сам печатает дамп стеков всех
goroutine — это первый инструмент, к которому нужно тянуться. Ищи в
дампе:
- goroutine, стоящую на
chan receive/chan send— кто-то ждёт канал, который никто не закрывает/не пишет. - goroutine в
semacquire/sync.(*Mutex).Lock— deadlock на мьютексе. - goroutine в
net/http.(*conn).serveилиpgx.Pool.Acquire— зависший внешний I/O безctx. - goroutine в
testcontainers.waitForLog— контейнер не прошёл healthcheck.
SIGQUIT для дампа «вручную»¶
Если запустил go test без -timeout и процесс повис:
- Linux/macOS:
Ctrl+\в терминале с тестом (SIGQUIT) печатает стек-дамп и завершает процесс. - Отдельно:
kill -QUIT <pid>. - Windows:
taskkill /PID <pid> /F— дампа не будет, но процесс прибьёт.
Изоляция конкретного теста¶
Работай с одним тестом — так проще читать дамп.
Типовые причины¶
1. Docker не запущен / testcontainers застрял на healthcheck¶
Симптом. Первая goroutine стоит в testcontainers.Container.Start
или в wait.ForLog. docker ps пустой или показывает прошлый
«осиротевший» контейнер.
Фикс.
- Старт Docker Desktop / systemctl start docker.
- Если остался «осиротевший» контейнер от прошлого прогона:
docker ps -a → docker rm -f <id>.
- Увеличь таймаут wait.ForLog("ready to accept connections") до 60s —
первый старт образа может быть медленным (pull + init).
- Проверь версию образа: postgres:16-alpine стартует секунды, кастомный
образ с инициализацией — десятки секунд.
2. Канал без writer¶
// плохо
got := <-ch // никто не пишет — виснем навсегда
// хорошо
select {
case got := <-ch:
// ...
case <-ctx.Done():
t.Fatalf("timeout waiting for channel: %v", ctx.Err())
}
Writer стартанул? go func() { ch <- v }() — если goroutine упала до
send'а, канал останется пустым. Логируй внутри writer'а либо пиши
defer close(ch).
3. Kafka / gochannel consumer ждёт сообщение вечно¶
Consumer подписался, но тест забыл опубликовать, или publish ушёл не в
ту тему. Handler сидит на Subscribe и не возвращает управление.
Фикс.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
select {
case msg := <-messages:
// ok
case <-ctx.Done():
t.Fatal("no message received within 2s")
}
Никаких «подожду, может прилетит».
4. Транзакция не закрыта¶
Соединение зависает в idle in transaction. Следующий тест, берущий
соединение из того же пула, упирается в MaxConns.
Фикс.
tx, err := pool.Begin(ctx)
if err != nil { t.Fatal(err) }
defer func() { _ = tx.Rollback(ctx) }() // safe после Commit — вернёт ErrTxClosed
Или используй helper db.InTx — см.
../conventions/db-pgx.md.
5. Исчерпан pgx pool¶
Параллельные тесты исчерпали MaxConns у пула — следующий Acquire
висит до освобождения.
Фикс.
- Уменьши -parallel N у go test.
- Увеличь pool_max_conns в test-setup'е (testcontainers-Postgres
принимает через DSN).
- Проверь, что каждый тест закрывает свои транзакции (пункт 4).
6. time.Sleep в тесте¶
Таких строк быть не должно. Запах:
../conventions/testing.md §Никаких time.Sleep.
Фикс. Замени на eventually:
Или закрой канал в fake-зависимости, ждать <-ch.
7. Deadlock между goroutine'ами¶
Две goroutine берут два мьютекса в разном порядке. Stacktrace покажет
обе на sync.(*Mutex).Lock — одна ждёт A→B, другая B→A.
Фикс. Устанавливай глобальный порядок захвата мьютексов в тестовом
коде и в прод-коде. Если тест требует m1, m2 — всегда в одном и том
же порядке. Или используй один sync.RWMutex вместо двух.
8. -race ловит гонку, тест не падает, а виснет¶
Race detector сам не вызывает «зависание». Но код с data race может
корректно завершить одну goroutine и оставить другую в ожидании. Stacktrace
плюс отчёт WARNING: DATA RACE в stdout — смотри оба.
9. Осиротевшие testcontainers¶
После Ctrl+C Watcher Terminate мог не успеть отработать. В следующем запуске порт 5432/9092/6379 уже занят.
Тест использует случайный порт внутри контейнера, но если у тебя хост уже занят чем-то с тем же именем сервиса — провалится подключение. Очищай.
10. goleak нашёл утечку goroutine¶
Если в тесте подключён go.uber.org/goleak и handler не закрыл свой
worker — в конце теста goleak ждёт завершения всех goroutine → висит.
Фикс. Закрывай все worker'ы через ctx с cancel() в t.Cleanup:
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go worker.Run(ctx) // корректно завершится при cancel
Полезные инструменты¶
go test -run TestName -v -timeout 60s -race ./...— базовая команда отладки зависания.go test -count=1 -shuffle=on ./...—shuffle=onвыявляет тесты, зависящие от порядка (один оставил глобальный state, другой на нём висит).goleak.VerifyNone(t)вTestMain— ловит утечку goroutine на выходе.docker ps/docker logs <id>— проверить, стартовал ли testcontainers.pg_stat_activityвнутри testcontainer'а:docker exec -it <id> psql -U test -c "SELECT pid, state, query FROM pg_stat_activity"— видно висящие транзакции.
Превенция¶
- Всегда
-timeout 60s(или меньше) на все тесты. Это не «настройка CI», это дисциплина локальной разработки. defer cancel()сразу послеcontext.WithTimeout/context.WithCancel. Без исключений.- I/O в тестах bounded. Любое ожидание — с таймаутом не дольше 5 секунд в unit, 30 секунд в integration.
t.Parallel()включай только там, где тесты действительно независимы (не шарят общийtestcontainers, БД, Redis-ключ). Иначе получишь flaky hang'и от гонки за ресурс.-shuffle=onв CI. Ловит «тест A оставил state, тест B на нём висит».t.Cleanupдля любых созданных ресурсов: pool.Close, container.Terminate, worker cancel. Не полагайся наdefer— в table-driven он выполнится не тогда, когда ждёшь.
Пример настроенного TestMain¶
package service_test
import (
"os"
"testing"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
code := m.Run()
if code == 0 {
// проверяем утечки только при зелёных тестах —
// иначе goleak заслонит реальную ошибку
if err := goleak.Find(); err != nil {
_, _ = os.Stderr.WriteString(err.Error() + "\n")
os.Exit(1)
}
}
os.Exit(code)
}
Связанные разделы¶
../conventions/testing.md— пирамида тестов, table-driven,eventually, race detector.../conventions/shutdown.md— как писать worker'ы, которые корректно завершаются поctx.Done()(те же правила применяются в тестах).../onboarding/03-local-stack.md— как проверить, что Docker локально работает.