---
title: "Тестирование логов: capture и утверждения"
description: "Тестирование логов go: slog test с захватом вывода в буфер, Handler в память, проверка полей записи и золотые строки без времени в сравнении."
course: "Логирование в Go: структурные логи на slog"
url: "https://golangmaster.ru/courses/slog/lessons/20-testy-logov/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Тестирование логов: capture и утверждения

*Курс «Логирование в Go: структурные логи на slog», урок 20 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/slog/lessons/20-testy-logov/*

Тестирование логов go строится на простом приёме: Handler пишет в io.Writer, который контролирует тест, — в буфер для строк или в слайс для записей. Логи становятся проверяемым контрактом: уровень, поля, маскирование и формат вывода получают тесты, как и любой другой код сервиса. Разберём захват вывода в буфер, свой Handler в память, золотые строки и гонки, которые тесты логов поднимают при t.Parallel.

Логи редко попадают в тесты — и напрасно: для дежурного в инциденте лог — такой же контракт, как ответ API. Если поле request_id пропало из записей, поиск по запросу сломается без единой ошибки в коде; если маска секретов перестала срабатывать, токены поедут в хранилище молча. Механика slog делает логи удобными для тестов: обработчик принимает любой io.Writer, поэтому тест подставляет буфер или собственный Handler в память и проверяет записи обычными утверждениями. В этом уроке соберём оба приёма, разберём, что именно проверять, а что — лишняя хрупкость, и добавим золотые строки для формата вывода, когда он становится контрактом конвейера сбора.

### Тестирование логов go: захват вывода в буфер

Приём-минимум: в тесте создаётся bytes.Buffer, логгер поверх него — и вызовы функции под тестом пишут в буфер, а не в stdout. Дальше буфер разбирается: json.Unmarshal превращает запись в map[string]any, и поля проверяются обычными сравнениями. Это честный тест конца в конце: он проходит через Handler, форматирование и сериализацию, то есть проверяет то же, что увидит конвейер сбора. Для тестов без зависимостей от JSON подойдёт свой Handler в памяти — он собирает slog.Record напрямую, и проверка идёт по уровню и атрибутам без парсинга. Оба приёма живут рядом: буферный тест проверяет формат и поля вывода, Handler-тест — поведение кода: сколько записей, какие уровни, какие атрибуты. Важная оговорка про контекст: вызовы через slog.Debug на отключённом уровне не вызывают сборку атрибутов — тест может проверить и это: буфер пуст, значит дорогой атрибут не собирался, что перекликается с механикой Enabled из четырнадцатого урока.

### Захват в буфер и разбор записи

```go
func TestCheckoutLogs(t *testing.T) {
        var buf bytes.Buffer
        logger := slog.New(slog.NewJSONHandler(&buf, nil))

        processCheckout(logger, "a-42")

        var rec map[string]any
        if err := json.Unmarshal(buf.Bytes(), &rec); err != nil {
                t.Fatalf("запись не разобралась: %v", err)
        }
        if rec["msg"] != "заказ оплачен" {
                t.Errorf("msg = %v, хочу заказ оплачен", rec["msg"])
        }
        if rec["order"] != "a-42" {
                t.Errorf("order = %v, хочу a-42", rec["order"])
        }
}
```

Буфер подставлен как io.Writer — Handler не знает, что пишет в тест. json.Unmarshal отдаёт поля для проверок: msg и атрибуты сравниваются напрямую. Честная деталь: числа после Unmarshal приходят как float64, поэтому числовое поле сравнивают с float64(2), а не с int — иначе тест падает на типе, а не на значении.

### Проверка полей: уровень, msg и атрибуты

Проверять всё — путь к хрупким тестам, которые ломаются на каждом переименовании. Проверяют поля-контракты: уровень, по которому фильтрует дежурный; поля, по которым ищут в хранилище — request_id, order, user_id; поле err, которое встроенные обработчики сериализуют строкой Error(). Текст msg проверяют реже: сообщение — для человека, и его правки не должны ронять сборку, если это не сообщение-сигнал вроде кода ошибки. Несколько записей разбирают json.Decoder в цикле — буфер содержит поток записей по одной на строку, и декодер возвращает их по порядку: тест проверяет и состав, и последовательность, например, что перед Error был Warn с тем же request_id. Проверка отсутствия записи — тоже утверждение: после успешного выполнения в буфере не должно быть записей уровня Error, и это ловит регресс, при котором ошибки начинают логироваться там, где всё отработало.

### Handler в память: свой тестовый обработчик

Свой Handler снимает зависимость от формата: тест проверяет записи, а не строки. Реализация компактна: структура со слайсом записей и предзаданными атрибутами, Enabled возвращает true, Handle добавляет рекорд в слайс, WithAttrs подмешивает поля, WithGroup в упрощённой версии игнорируется — для полного контракта обработчик сначала прогоняют через log/slogtest. Ключевая деталь в Handle — r.Clone(): рекорд переиспользуется после возврата Handle, и без копии сохранённые записи могут показать чужие атрибуты. Проверка идёт по slog.Record: r.Level сравнивается с slog.LevelError, r.Message — с сообщением, атрибуты — циклом по Attr. Такой тест не привязан к JSON, кавычкам и порядку полей, и его уместно держать рядом с тестами маскирования: Handler в память покажет, что до вывода дошло значение или маска.

### TestHandler со слайсом записей

```go
type testHandler struct {
        records *[]slog.Record
        attrs   []slog.Attr
}

func (h *testHandler) Enabled(_ context.Context, _ slog.Level) bool { return true }

func (h *testHandler) Handle(_ context.Context, r slog.Record) error {
        r.AddAttrs(h.attrs...)
        *h.records = append(*h.records, r.Clone())
        return nil
}

func (h *testHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
        merged := append(slog.Attr{}, h.attrs...)
        return &testHandler{records: h.records, attrs: append(merged, attrs...)}
}

func (h *testHandler) WithGroup(string) slog.Handler { return h }
```

Handle сохраняет копию рекорда в общий слайс — указатель на слайс разделяется между потомками через WithAttrs и WithGroup, поэтому тест видит весь поток записей. r.Clone() обязателен: рекорд переиспользуется, и без копии атрибуты последующих записей затирают сохранённые. WithGroup возвращает приёмника без префиксов — упрощение для тестов, полный контракт проверяет log/slogtest.

> **Общий буфер и t.Parallel: гонка.** Кейс: пакетные тесты логировали в один общий logger с общим буфером на уровне пакета. После добавления t.Parallel в два теста CI начал падать редкими битыми записями — половина JSON на середине строки. Причина — гонка: два теста пишут в один буфер, append межуется, разбор видит смесь. go test -race ловит это сразу, но флаг часто включают уже после первого красного прогона. Фикс: конструктор newTestLogger(t), который создаёт свой буфер и свой логгер на каждый тест, — общий остаётся только Helper с кодом разбора. Правило: writer логгера живёт в пределах одного теста; если нужен общий список записей — наружу отдаётся копия, а не ссылка.

### Золотые строки вывода: сравнение целиком

Когда формат вывода становится контрактом — для парсера конвейера, для внешнего мониторинга, — записи сравнивают целиком: ожидаемая строка хранится в файле или в коде, и тест сравнивает буфер с ней посимвольно. Это самый строгий тест: любое изменение формата, порядок полей, экранирование — всё ловится сразу. И самый хрупкий: поле time меняется каждую секунду, поэтому в золотом сравнении время фиксируют — ReplaceAttr подменяет time на константу в тестовом HandlerOptions, — а иначе сравнение сравнивает часы. Второй подводный камень — порядок атрибутов: Handler печатает их в порядке передачи, и тест фиксирует этот порядок как часть контракта; для JSON его стоит зафиксировать осознанно, раз конвейер зависит от полей, а не от порядка. Золотые строки уместны на границе сервиса — там, где формат читает чужая система; внутри тесты полей дают больше пользы на меньше хрупкости.

### Спорное: тестировать ли каждый лог

Мнение, которое стоит оспорить на ретро: каждый вызов логирования заслуживает теста — иначе логи расползаются. Против: лог — побочный эффект, и тесты на каждое сообщение превращают любое улучшение формулировки в правку тестов; пользы в проверке текста msg почти нет, а хрупкость реальна. За: часть логов — инструмент инцидента, и его контракты стоят тестов: поля, по которым ищут, уровень, по которому фильтруют алерты, маскирование секретов. Компромисс, который прижился: тесты на поля-контракты и на отсутствие ошибочных записей в штатных сценариях, тест на маскирование — обязательный; тексты сообщений живут без тестов. Граница по потребителю: если запись читает конвейер или внешний монитор — тест есть; если запись подсобная отладочная — без теста, и это осознанное решение. Тот же принцип, что и у любых тестов: проверяются обещания, а не слова.

### Что проверяем и чем

| Что проверяем | Механика | Когда уместно |
| --- | --- | --- |
| Поля-контракты | json.Unmarshal в map, сравнение значений | request_id, order, err — поля поиска |
| Уровень записи | rec["level"] или r.Level в своём Handler | алерты и фильтры дежурных |
| Маскирование | фикстура с секретом, проверка маски | обязательный тест каждого сервиса |
| Несколько записей | json.Decoder в цикле по буферу | порядок и состав потока записей |
| Формат вывода | золотая строка с фиксированным time | формат читает чужая система |
| Свой Handler | log/slogtest плюс тесты формата | обработчик из двенадцатого урока |

> **log/slogtest: контракт своего Handler.** Для собственных Handler с Go 1.22 есть пакет log/slogtest: функция slogtest.Run прогоняет обработчик через сценарии — обычные записи, группы, WithAttrs, WithGroup — и проверяет, что вывод согласован между собой. Свой обработчик из двенадцатого урока стоит сначала прогнать через slogtest, а уже потом покрывать тестами формата: контракт с группами легко сломать, не заметив. Тестовый Handler в память из этого урока — упрощение, и log/slogtest честно укажет, что WithGroup без префиксов нарушает контракт.

### Чек-лист тестов логов

- Логгер в тесте получает свой io.Writer: буфер для строк, слайс для записей.
- Поля-контракты проверяются после json.Unmarshal: level, request_id, err.
- Числа из JSON приходят float64: сравнивайте с float64(2), а не с int.
- Свой testHandler копит slog.Record с r.Clone() в Handle.
- Writer живёт в пределах теста: общий буфер и t.Parallel дают гонку, -race подтверждает.
- Золотые строки — только с фиксированным time через ReplaceAttr.

> **Песочница: записи и сравнение на strings.Builder.** Пакетов testing и log/slog в песочнице нет — там работают fmt, strings, strconv, math, sort, errors и time. Механику захвата она разминает честно: соберите функцию, которая пишет записи в strings.Builder через fmt.Fprintf, и сравнения-утверждения поверх неё — strings.Contains для поля, strings.Count для числа записей, обнуление буфера через builder.Reset(). Превращение числа в строку записи и обратно разминается на strconv.ParseFloat и strconv.Itoa — это модель float64 после json.Unmarshal. Полные тесты с testing и slogtest запускаются локально через go test.

### Проверка: Clone в Handle

**Вопрос:** Зачем в Handle своего тестового Handler вызывать r.Clone() перед сохранением записи в слайс?

- ✅ Рекорд переиспользуется после возврата Handle: без копии сохранённые атрибуты затираются
- Clone ускоряет запись: без него Handle обязан вернуть ошибку
- Clone требуется компилятором: без него интерфейс Handler не реализован
- Clone заполняет поле time: без копии время записи пустое

Обработчик получает рекорд во временное владение: после возврата Handle его поля переиспользуются под следующую запись. Копия сохраняет текущие уровень, сообщение и атрибуты — тест видит настоящие записи, а не смесь.

### Проверка: число из JSON

**Вопрос:** В тесте после json.Unmarshal буфера сравнение rec["count"] с 2 даёт false, хотя в записи написано "count":2. Почему?

- ✅ json.Unmarshal возвращает числа как float64: сравнивать нужно с float64(2)
- JSONHandler пишет числа строкой: нужно прогнать значение через strconv.Atoi
- В буфере две записи: сравнивается поле не из той записи
- Числовое поле нельзя достать из map[string]any без привязки к структуре

Стандартный разбор в map[string]any кладёт все числа во float64 — сравнение с int не срабатывает, даже когда значение совпадает. Отсюда два пути: сравнивать с float64(2) или разбирать записи в структуру с типизированными полями.

### Что напечатает счётчик записей?

```go
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{
        Level: slog.LevelWarn,
}))

logger.Info("первая", "n", 1)
logger.Warn("вторая", "n", 2)

fmt.Println(strings.Count(buf.String(), "\n"))
```

**Что выведет программа?**

- ✅ 1: запись Info не прошла уровень Warn, Warn напечаталась одной строкой
- 2: обе записи прошли, уровень только меняет вид
- 0: Handler молчит, буфер остался пустым
- 3: Warn занимает две строки из-за атрибута

Обработчик с уровнем Warn пропускает только Warn: в буфере одна запись, и каждая запись JSONHandler заканчивается переводом строки — счётчик показывает единицу. Тот же приём работает в тестах: подсчёт записей в буфере проверяет, что штатный сценарий не пишет ошибок.

### Соберите захват в буфер

Допишите: создание буфера, JSON-обработчик и тип для разбора записи.

```go
var buf bytes.___
logger := slog.New(slog.New___Handler(&buf, nil))

logger.Info("заказ оплачен", "order", "a-42")

var rec map[string]___
if err := json.Unmarshal(buf.Bytes(), &rec); err != nil {
        t.Fatalf("запись не разобралась: %v", err)
}
```

Подсказка: Буфер создаёт bytes.Buffer, JSON-записи пишет NewJSONHandler, запись разбирается в map[string]any.

Правильные ответы в пропусках: `Buffer`, `JSON`, `any`

### Тест записи по строкам

Расставьте по порядку: буфер, логгер, запись под тестом, разбор, утверждение по полю.

Правильный порядок строк:

1. `var buf bytes.Buffer`
2. `logger := slog.New(slog.NewJSONHandler(&buf, nil))`
3. `logger.Info("заказ оплачен", "order", "a-42")`
4. `var rec map[string]any`
5. `json.Unmarshal(buf.Bytes(), &rec)`
6. `if rec["order"] != "a-42" { t.Fatal("заказа нет в записи") }`

### Карточки: тестирование логов

- **capture в буфер** — Handler пишет в bytes.Buffer: тест разбирает записи json.Unmarshal и проверяет поля обычными сравнениями.
- **поле-контракт** — Проверяют поля, по которым ищут дежурные: level, request_id, err. Текст msg без тестов — он для человека.
- **r.Clone()** — В Handle своего Handler: рекорд переиспользуется, копия сохраняет уровень, сообщение и атрибуты.
- **t.Parallel и буфер** — Общий writer даёт гонку и битые записи: свой буфер на тест, go test -race подтверждает проблему.
- **золотая строка** — Сравнение записи целиком: только с фиксированным time через ReplaceAttr, иначе тест сравнивает часы.
- **log/slogtest** — Пакет с Go 1.22: slogtest.Run проверяет контракт своего Handler на группах, WithAttrs и WithGroup.

Логи стали контрактом: захват в буфер проверяет поля вывода, Handler в память — поток записей, золотые строки — формат на границе, тест маскирования — секреты. Дальше по курсу: [двенадцатый урок про свой Handler](/courses/slog/lessons/12-svoj-handler/) даёт материал для slogtest, [тринадцатый](/courses/slog/lessons/13-konkurentnost/) — конкурентные сценарии под -race, [пятый](/courses/slog/lessons/05-text-vs-json/) — форматы, [девятнадцатый](/courses/slog/lessons/19-sekrety-v-logah/) — обязательный тест на маску, [двадцать пятый](/courses/slog/lessons/25-itogovyj-proekt/) — итоговый проект с тестами логов. Приёмы тестирования целиком — в [уроке про тестирование](/courses/go-advanced/lessons/16-testirovanie/) курса по продвинутому Go и в [курсе по testify](/courses/testify/). Карта курса — на [странице курса](/courses/slog/).

## Коротко: что запомнить

- Захват в буфер: bytes.Buffer как io.Writer, json.Unmarshal в map, сравнения по полям.
- Проверяют поля-контракты — уровень, поля поиска, err; тексты msg живут без тестов.
- Свой testHandler копит slog.Record: r.Clone() в Handle обязателен, рекорд переиспользуется.
- Числа после json.Unmarshal приходят float64: сравнение с int ложно даже при равных значениях.
- Общий буфер и t.Parallel дают гонку: writer живёт в пределах одного теста, -race подтверждает.
- log/slogtest с Go 1.22 проверяет контракт своего Handler: группы, WithAttrs, WithGroup.

## Домашнее задание

Возьмите функцию из своего проекта или из урока и покройте тремя тестами: захват в буфер с проверкой полей и уровня, свой testHandler с подсчётом записей и тест на отсутствие записей уровня Error в штатном сценарии. Затем запустите go test -race и добавьте t.Parallel в два теста с общим буфером — посмотрите на гонку и почините её конструктором newTestLogger с личным буфером на тест.

## Вопросы и ответы по уроку

### Что лучше в тестах: буфер с JSON или свой Handler в память?

Оба приёма работают вместе: буферный тест проверяет то, что увидит конвейер — формат, экранирование, поля; Handler в память проверяет поведение кода без привязки к формату — число записей, уровни, атрибуты. Для формата вывода уместен первый, для бизнес-логики логирования — второй.

### Как тестировать записи с полем time?

В буферном тесте поле просто игнорируется — сравниваются только контракты. В золотых строках время фиксируют ReplaceAttr в тестовых HandlerOptions: на выход идёт константа, и сравнение становится посимвольным. Проверять, что время стоит и выглядит RFC3339, лучше отдельным утверждением по формату, а не золотой строкой.

### Нужно ли тестировать формат текста сообщений?

Обычно нет: msg пишут для человека, и его правки не должны ломать сборку. Исключение — сообщения-сигналы, по которым строят алерты или поиск в хранилище: если дежурный ищет по подстроке, сообщение становится контрактом и получает тест, как любое поле.
