Перейти к содержанию

Тест висит

go test не завершается, CI job упирается в глобальный таймаут, локально Ctrl+C — единственный выход. Ниже — как быстро локализовать и починить. Конвенции тестов — ../conventions/testing.md.

Правило №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 -adocker 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.md.

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.md — пирамида тестов, table-driven, eventually, race detector.
  • ../conventions/shutdown.md — как писать worker'ы, которые корректно завершаются по ctx.Done() (те же правила применяются в тестах).
  • ../onboarding/03-local-stack.md — как проверить, что Docker локально работает.