Тест висит
go test не завершается, CI job упирается в глобальный таймаут, локально
Ctrl+C — единственный выход. Ниже — как быстро локализовать и починить.
Конвенции тестов — ../conventions/testing.
Правило №1: всегда запускай go test с -timeout. Без него тест,
написанный с багом, висит до killer’а CI-раннера и не даёт
никакой диагностики.
Как понять, где именно висит
-timeout + stacktrace
go test -timeout 60s -run TestName -v ./...По истечении таймаута 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— дампа не будет, но процесс прибьёт.
Изоляция конкретного теста
go test -run '^TestReviewHandler_Create$' -v -timeout 60s -race ./internal/handler/Работай с одним тестом — так проще читать дамп.
Типовые причины
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. Транзакция не закрыта
// плохо
tx, _ := pool.Begin(ctx)
// ... ошибка, возврат, забыли RollbackСоединение зависает в 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.
5. Исчерпан pgx pool
Параллельные тесты исчерпали MaxConns у пула — следующий Acquire
висит до освобождения.
Фикс.
- Уменьши
-parallel Nуgo test. - Увеличь
pool_max_connsв test-setup’е (testcontainers-Postgres принимает через DSN). - Проверь, что каждый тест закрывает свои транзакции (пункт 4).
6. time.Sleep в тесте
time.Sleep(5 * time.Second) // что-то асинхронное, подождёмТаких строк быть не должно. Запах:
../conventions/testing.md §Никаких time.Sleep.
Фикс. Замени на eventually:
eventually(t, 2*time.Second, func() bool {
return fakeSvc.called == 1
})Или закрой канал в 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 уже занят.
docker ps
docker rm -f $(docker ps -q --filter label=org.testcontainers)Тест использует случайный порт внутри контейнера, но если у тебя хост уже занят чем-то с тем же именем сервиса — провалится подключение. Очищай.
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— пирамида тестов, table-driven,eventually, race detector.../conventions/shutdown— как писать worker’ы, которые корректно завершаются поctx.Done()(те же правила применяются в тестах).../onboarding/03-local-stack— как проверить, что Docker локально работает.