9. Логирование в тестах
Сигнал сквозь шум: Зачем отдельное логирование в тестах?
В предыдущей главе 8. Debugging тестов мы убедились, что прямолинейный вызов fmt.Println в конкурентных тестах порождает трудночитаемую кашу в консоли, и сформулировали базовое правило перехода на методы t.Log и t.Logf. Однако логирование в процессе тестирования — это не просто временная подпорка при отладке. Это полноценная система наблюдаемости (Observability) вашего тестового контура.
«Самым эффективным инструментом отладки по-прежнему остаётся ясное мышление в сочетании с разумно расставленными вызовами печати.»
— Брайан В. Керниган
Когда пайплайн CI падает в полночь на удалённом изолированном раннере, у инженера нет возможности подключить интерактивный отладчик Delve. Единственный след, который оставляет после себя упавший процесс — это текстовый журнал (Log Stream). Если тесты непрерывно «спамят» в консоль десятками тысяч служебных сообщений, извлечь полезный сигнал из океана белого шума становится невозможно.
В этой статье мы спроектируем культуру чистого логирования в Go: разберём механику буферизации testing.T, научимся бесшовно интегрировать production-логгеры (Uber Zap и log/slog) в тестовый рантайм и освоим паттерны аварийного сброса контекста (Dump on Failure).
sequenceDiagram
autonumber
participant G as Тестовая горутина
participant T as testing.T (Буфер)
participant Out as os.Stdout (Терминал)
Note over G,T: Параллельный запуск тестов (t.Parallel)
G->>T: t.Logf("State at step 2...")
Note over T: Запись в изолированный буфер памяти
alt Тест завершился успешно (PASS) без флага -v
T->>T: Очистка буфера из памяти (Zero Noise)
else Тест упал (FAIL) или передан флаг -v
T->>Out: Сброс буфера в консоль с именем теста
end
1. Фундамент: Механика t.Log и t.Logf
Ключевое архитектурное свойство методов семейства t.Log — потокобезопасная буферизация на уровне экземпляра теста.
При прямом вызове fmt.Println или os.Stdout.WriteString() байты немедленно отправляются в файловый дескриптор 1 (os.Stdout). Если в рантайме одновременно работают 16 параллельных тестов, фрагменты их строк перемешаются на уровне ядра операционной системы. Разобрать, какой ответ сервера к какому запросу относится, становится физически невозможно.
Когда вы вызываете t.Log или t.Logf, рантайм testing перехватывает сообщение и аппендит его во внутренний байтовый срез, защищённый мьютексом и строго привязанный к конкретному экземпляру *testing.T (или вложенному сабтесту t.Run).
2. Интеграция Production-логгеров
Здесь возникает классическая архитектурная дилемма: чистая бизнес-логика не должна зависеть от тестовых пакетов и ничего не знает о структуре *testing.T. Сервисы принимают абстракции или конкретные типы структурированных логгеров — например, *zap.Logger или стандартный *slog.Logger.
// Бизнес-логика сервиса платежей:
func ProcessPayment(amount int, logger *zap.Logger) error {
logger.Info("starting payment processing", zap.Int("amount", amount))
// ... проведение транзакции ...
if err != nil {
logger.Error("payment failed", zap.Error(err))
return err
}
return nil
}
Если передать в этот метод обычный экземпляр, созданный через zap.NewProduction() или slog.Default(), логгер начнёт писать JSON-строки напрямую в os.Stdout или os.Stderr. Вся магия буферизации go test испарится: при параллельном прогоне консоль вновь утонет в мусоре.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph App["Бизнес-логика"]
S["Service.ProcessPayment"]:::process
end
subgraph Adapter["Слой адаптации к тестам"]
SL["*slog.Logger / *zap.Logger"]:::process
TW["testingWriter (io.Writer) / zaptest"]
end
subgraph Runner["Рантайм тестирования"]
TB["testing.T (буфер сообщений)"]
end
S --> SL --> TW --> TB
Решение для Uber Zap: zaptest
Авторы высокопроизводительной библиотеки go.uber.org/zap предусмотрели эту потребность и создали пакет zaptest. Он создаёт специальный логгер, который перехватывает все вызовы Info/Error и перенаправляет их под капот прямо в t.Log:
package payment_test
import (
"testing"
"go.uber.org/zap/zaptest"
"yourproject/internal/payment"
)
func TestProcessPayment(t *testing.T) {
// Создаем логгер, неразрывно привязанный к жизненному циклу данного теста:
logger := zaptest.NewLogger(t)
// Передаем его в доменную логику:
err := payment.ProcessPayment(100, logger)
// Если ProcessPayment упадет, мы увидим структурированные Zap-логи,
// отформатированные и привязанные строго к данному упавшему сценарию!
_ = err
}
Решение для slog (Go 1.21+)
Начиная с Go 1.21, стандартным инструментом структурированного логирования стал пакет log/slog. Его обработчики (Handlers) принимают интерфейс io.Writer.
Поскольку структура *testing.T напрямую не реализует метод Write([]byte) (int, error), нам достаточно реализовать компактный адаптер (Adapter Pattern):
package testutil
import (
"bytes"
"log/slog"
"testing"
)
// testingWriter адаптирует *testing.T под стандартный контракт io.Writer
type testingWriter struct {
t *testing.T
}
func (w testingWriter) Write(p []byte) (n int, err error) {
// bytes.TrimRight исключает дублирование символов перевода строки,
// так как метод t.Logf автоматически форматирует завершение строки.
w.t.Logf("%s", bytes.TrimRight(p, "\n"))
return len(p), nil
}
// NewSlogLogger создает slog логгер для тестов
func NewSlogLogger(t *testing.T) *slog.Logger {
t.Helper()
writer := testingWriter{t: t}
// Настраиваем TextHandler или JSONHandler с детализацией Debug:
handler := slog.NewTextHandler(writer, &slog.HandlerOptions{
Level: slog.LevelDebug, // В тестах мы хотим видеть максимальный контекст
})
return slog.New(handler)
}
Теперь в любом тесте достаточно написать logger := testutil.NewSlogLogger(t) и пробросить его в тестируемые структуры.
3. Что логировать при падении (Test Observability)
Типичное сообщение об ошибке начинающего разработчика выглядит так:
Error: expected 200, got 500.
Такая диагностика бессильна. Почему сервер вернул код 500? Какой URL был запрошен? Какие заголовки переданы? Что содержало тело ответа?
Инженеры высокой квалификации внедряют в интеграционные тесты паттерн Dump on Failure (аварийный сброс контекста).
Паттерн: HTTP Dump
При тестировании HTTP-хэндлеров и сквозных API-маршрутов с использованием httptest.Server сохраняйте полный дамп протокола через пакет net/http/httputil:
func TestAPI_CreateUser(t *testing.T) {
// ... Инициализация тестового сервера ts := httptest.NewServer(handler) ...
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/users", body)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
// Читаем тело ответа для последующих проверок:
respBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
// ТЕСТ УПАЛ!
// Сбрасываем весь контекст сетевого взаимодействия в буфер t.Log:
reqDump, _ := httputil.DumpRequestOut(req, true)
t.Logf("--- DUMP REQUEST ---\n%s\n--------------------", string(reqDump))
t.Logf("--- DUMP RESPONSE BODY ---\n%s\n-----------------------", string(respBody))
t.Fatalf("Expected 201 Created, got %d", resp.StatusCode)
}
}
Паттерн: State Dump (Сброс состояния БД)
Если интеграционный тест репозитория падает из-за конфликта ключей или неожиданного количества строк, сбросьте текущий снимок таблицы:
if err != nil {
// Выполняем диагностический SELECT строго для вывода в журнал падения:
rows, _ := db.Query("SELECT id, status FROM users")
// ... форматируем выборку в строку dbStateString ...
t.Logf("DB State at failure:\n%s", dbStateString)
t.Fatal(err)
}
4. Этапы жизненного цикла теста
Сложные интеграционные тесты, длящиеся более 10–15 секунд, сложно читать в консоли, если они падают по таймауту. В таких сценариях рекомендуется структурировать лог по фазам классического шаблона Arrange-Act-Assert:
func TestComplexIntegration(t *testing.T) {
t.Log("[SETUP] Initializing test database...")
db := setupDB(t)
t.Log("[SETUP] Seeding 10,000 test users...")
seedUsers(t, db)
t.Log("[ACT] Running migration background worker...")
runWorker()
t.Log("[ASSERT] Verifying user statuses...")
// ... проверки инвариантов ...
}
Если сценарий неожиданно зависнет на 30-й секунде, последний префикс [ACT] в журнале сразу подскажет дежурному инженеру, какая именно фоновая операция заблокировала процесс.
Итог
- Никакого
fmt.Printlnв тестах. Используйте исключительноt.Logиt.Logfдля потокобезопасного, буферизованного вывода без засорения терминала. - Адаптеры логгеров: Никогда не оставляйте production-логгеры писать напрямую в
os.Stdoutилиos.Stderrво время тестов. Оборачивайтеgo.uber.org/zapчерезzaptest.NewLogger(t), аlog/slog— через кастомный адаптерio.Writer. - Богатый контекст падений: Логируйте дампы HTTP-запросов (
httputil.DumpRequest/httputil.DumpRequestOut), тела ответов и срезы состояния БД при срабатывании ассертов. - Разметка этапов: В длинных сквозных сценариях явно маркируйте переходы между фазами Arrange, Act и Assert.
Грамотно выстроенная культура логирования тестов превращает красный статус пайплайна в CI из повода для растерянности в точную, исчерпывающую инструкцию по локализации и исправлению дефекта.