Содержание урока · 10 разделов
- Буфер вместо writer-а: zerolog.New и bytes.Buffer
- Поля, уровень и сообщение: что проверять в карте
- Очередь событий: несколько записей в одном буфере
- Что проверяем и чем
- Чек-лист теста логов одним списком
- Глобальный логгер и уровни: подмена с восстановлением
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Поломанный лог долго не выдаёт себя: тесты зелёные, дашборд мигает, а в ленте тем временем потерялся уровень, уехало поле или исчезла подпись модуля. Обнаруживается это ночью, на разборе, когда запись уже нужна была вчера. Выход тот же, что и для остального кода, — тест, и тесты логов zerolog строятся безо всякой магии: writer подменяется буфером, события складываются в него JSON-строками, дальше — стандартный encoding/json и проверки по ключам. Готовых утверждений в zerolog нет: ни zerolog/test, ни zerolog/assert не существуют, и это честный факт поставки 1.34.0, а не недоработка — JSON-лог достаточно проверять картой. Приём знаком вам из урока про сублоггеры: там буферный сублоггер проверял подпись module, здесь тот же приём разворачивается в полный набор — одиночная запись, очередь событий, уровень с честной историей про NoLevel, подмена глобального логгера с восстановлением и хук-счётчик без разбора JSON. К концу урока — чек-лист теста логов и три рабочих шаблона: карта, очередь, хук.
Буфер вместо writer-а: zerolog.New и bytes.Buffer#
Конструктор zerolog.New принимает io.Writer и не знает ничего про файлы и сеть — буфер подходит наравне с остальными. В тестах пишут logger := zerolog.New(&buf), и здесь важен именно указатель: метод Write объявлен на *bytes.Buffer и растит внутренний слайс, поэтому значением буфер в конструктор не подойдёт — компилятор напомнит сразу. Внутри New обычный writer заворачивается в LevelWriterAdapter, и логгер ставит себе порог TraceLevel: собственный порог никого не режет, а вот глобальный уровень по умолчанию равен DebugLevel — trace-записи без настройки до буфера не дойдут, но это уже раздел про глобальную подмену. Каждое событие уходит в буфер одной JSON-строкой с переводом строки на конце — так работают AppendEndMarker и AppendLineBreak в методе write события, и потому буфер после трёх вызовов Msg содержит три строки. Читается результат через buf.Bytes(); между подтестами буфер сбрасывается buf.Reset() — иначе в него доедут хвосты прошлых проверок, и очередь разбора собьётся. Нюанс конструктора: zerolog.New(nil) не паникует, а молча подставляет io.Discard — записи просто исчезнут. Если события пишут из горутин, буфер оборачивается в SyncWriter: сам bytes.Buffer под параллельными Write рвёт внутренний слайс, и это правило из урока про сублоггеры действует в тестах так же, как в проде.
Тест одной записи через map[string]any
GOpackage main
import (
"bytes"
"encoding/json"
"errors"
"testing"
"github.com/rs/zerolog"
)
func TestPaymentLog(t *testing.T) {
var buf bytes.Buffer
logger := zerolog.New(&buf)
err := errors.New("quota exceeded")
logger.Error().
Err(err).
AnErr("cause", err).
Int("amount", 150).
Msg("payment rejected")
var m map[string]any
if err := json.Unmarshal(buf.Bytes(), &m); err != nil {
t.Fatal(err)
}
if m["level"] != "error" {
t.Fatalf("level: %v", m["level"])
}
if m["message"] != "payment rejected" {
t.Fatalf("message: %v", m["message"])
}
if m["error"] != "quota exceeded" || m["cause"] != "quota exceeded" {
t.Fatalf("error/cause: %v / %v", m["error"], m["cause"])
}
if m["amount"] != float64(150) {
t.Fatalf("amount: %v", m["amount"])
}
}Логгер получает &buf — указатель, именно он реализует io.Writer. После Msg буфер содержит одну JSON-строку: json.Unmarshal превращает её в карту, и проверки идут по ключам level, message, error и cause из урока про ошибки. Числовое поле amount возвращается как float64, поэтому сравнение с float64(150), а не с int. Такой тест падает на первой же поломке поля, а не в проде.
Если сравнить m["amount"] == 150, то проверка всегда ложно провалится: json.Unmarshal кладёт все числа в map[string]any как float64, а интерфейс с float64 не равен интерфейсу с int — нужно приведение или сравнение с float64(150). Если ключ мог не добавиться — как cause при nil-ошибке из урока про ошибки — то чтение m["cause"] вернёт nil без паники, и тест упадёт на сравнении, а не на чтении. Печатайте %v в сообщениях Fatalf: сообщение об ошибке обязано показывать фактическое значение ключа, иначе тест скажет, что что-то не так, но не что именно.
Поля, уровень и сообщение: что проверять в карте#
Контракт ключей в записи фиксирован глобальными переменными zerolog: LevelFieldName равен level, MessageFieldName — message, ErrorFieldName — error. Поле level пишется у всех уровней, включая Trace: в newEvent уровня проверяется только условие level != NoLevel и непустое имя поля, поэтому trace-событие уезжает в буфер как level со значением trace — константа LevelTraceValue именно такова в globals.go. Не пишется уровень ровно в одном случае: события метода log.Log() создаются с уровнем NoLevel, и ключа level в их JSON нет вовсе. Событий Disabled не существует как записей: should() отсекает их до создания события — тест проверяет отсутствие строки, а не null-уровень. Сообщение — последний ключ строки: msg() дописывает его перед самым write, после всех полей цепочки. Порядок самих полей zerolog соблюдает в порядке добавления: сначала level, затем контекст из With, затем поля цепочки, в конце message. Но map[string]any этот порядок не сохраняет — раскладка по карте стирает след добавления, поэтому проверяйте по ключам, а не сравнением строки целиком; если порядок полей действительно нужен, читайте поток токенов json.Decoder.Token(). Разбор полей error и cause уже готов у вас из урока про ошибки — здесь он просто переехал в тест.
Очередь событий: несколько записей в одном буфере#
Один буфер за время теста накапливает столько строк, сколько событий прошло через пороги, и это меняет способ чтения. json.Unmarshal разбирает только первый объект и падает на втором с сообщением про недопустимый символ после верхнего значения — классическая ошибка при первом же переходе от одной записи к очереди. Для потока берётся json.NewDecoder(&buf): метод More() отвечает, есть ли следующий объект, Decode достаёт его в очередную карту, и цикл собирает слайс событий. Порядок событий при последовательном логировании детерминирован: каждый вызов Msg дописывает свою строку в буфер сразу, — сравнивайте очередь уровней или сообщений с ожидаемым списком. Альтернатива без json — bufio.Scanner: построчно читает буфер, а каждую строку вы отдаёте Unmarshal. Полезна и обратная проверка: после ожидаемого числа событий More() обязан вернуть false — так ловятся лишние события в конце ленты.
Проверка порядка уровней через json.Decoder
GOpackage main
import (
"bytes"
"encoding/json"
"testing"
"github.com/rs/zerolog"
)
func TestEventsOrder(t *testing.T) {
var buf bytes.Buffer
logger := zerolog.New(&buf)
logger.Info().Str("step", "checkout").Msg("start")
logger.Warn().Str("step", "checkout").Msg("retry")
logger.Error().Msg("gave up")
dec := json.NewDecoder(&buf)
want := []string{"info", "warn", "error"}
for i, w := range want {
if !dec.More() {
t.Fatalf("event %d missing", i)
}
var m map[string]any
if err := dec.Decode(&m); err != nil {
t.Fatal(err)
}
if m["level"] != w {
t.Fatalf("event %d: %v, want %s", i, m["level"], w)
}
}
if dec.More() {
t.Fatal("extra events in buffer")
}
}Три вызова Msg дали три строки в буфере — по одной на событие. json.NewDecoder читает буфер как поток: More() говорит, есть ли следующий объект, Decode достаёт его в карту. Цикл сравнивает уровни с ожидаемой очередью info, warn, error — порядок записей в одной горутине детерминирован. Финальный dec.More() ловит лишние события, дописанные в цепочку после проверяемых.
Тест логов фиксирует контракт полей, а не правду о транспортном слое: ротация файла, цвета ConsoleWriter, диод и сеть за границей буфера не проверяются — для них существуют отдельные интеграционные прогоны. Золотые строки — сравнение буфера с сохранённой строкой целиком — работают, но хрупки: любое новое поле ломает все образцы разом. Практичнее свои хелперы-утверждения поверх карты: wantStr(t, m, "user_id", "u1") с t.Helper внутри, который достаёт ключ, печатает %v полученного и валит тест с внятным сообщением. Такие хелперы — ровно то, чего в zerolog нет, и пишутся они один раз на проект: три функции на строки, числа и наличие ключа закрывают девять десятых проверок. Отсутствующий ключ и ключ с нулевым значением здесь разные случаи: nil после чтения карты означает, что поле не писалось вовсе, — на этом строится проверка контрактных пропусков вроде молчаливой обработки nil-ошибки.
Что проверяем и чем#
| Что проверяем | Инструмент | Подводный камень |
|---|---|---|
| Поля события | ключи в map[string]any | числа приходят float64, ключ может отсутствовать |
| Уровень записи | m["level"] | у NoLevel-записей log.Log() ключа level нет |
| Сообщение | m["message"] | message — последний ключ строки |
| Несколько событий | json.Decoder: More и Decode | json.Unmarshal читает только первый объект |
| Порядок событий | сравнение очереди уровней | порядок полей внутри записи в карту не доезжает |
| Записи из горутин | общий буфер плюс SyncWriter | без обёртки JSON ломается посередине |
Чек-лист теста логов одним списком#
- логгер в буфер: logger := zerolog.New(&buf); буфер сбрасывайте buf.Reset между подтестами
- разбор: json.Unmarshal для одной записи, json.NewDecoder с More и Decode для очереди
- проверяйте ключами level, message и поля события; карту не сравнивайте со строкой целиком
- числа после Unmarshal — float64: приведение или сравнение с float64
- уровень есть у всех записей, кроме log.Log(): у NoLevel ключа level нет
- глобальные log.Logger и SetGlobalLevel — сохранить и вернуть defer-ом; дефолт глобального уровня — DebugLevel
- хук zerolog.Hook собирает уровни и сообщения без разбора JSON: Run получает их параметрами
- готовых ассертов в zerolog нет: буфер, encoding/json и свои хелперы — вся инфраструктура
Глобальный логгер и уровни: подмена с восстановлением#
Код под тестом часто зовёт log.Info() напрямую из пакета github.com/rs/zerolog/log — то есть пишет через глобальный log.Logger. Его подменяют как любую глобальную переменную: old := log.Logger, затем log.Logger = zerolog.New(&buf) и defer-восстановление в конце теста — без него следующий тест пакета унаследует буфер и начнёт видеть чужие записи. Вторая ручка — zerolog.SetGlobalLevel: она меняет атомарную переменную gLevel, чьё нулевое значение равно DebugLevel, и trace-записи по умолчанию до буфера не доходят. Тест, проверяющий поле level у трассировки, поднимает планку SetGlobalLevel(zerolog.TraceLevel) и возвращает её defer-ом — восстановленное значение и есть дефолт DebugLevel. Та же механика — основа тестов конфигурации из урока про окружение: собрали логгер по переменным окружения, прогнали событие, проверили уровень в карте. Когда нужно посчитать события по уровням без JSON, берётся хук: интерфейс zerolog.Hook с методом Run(e *Event, level Level, message string) и адаптер HookFunc для функции. Хук срабатывает в момент Msg до записи в writer — собирайте уровень и сообщение из параметров, а не из буфера события: JSON в этот момент ещё не дописан. Логгер с io.Discard и хуком-счётчиком — тихий детектор шумных веток: ни одной строки в stdout, зато точные числа по уровням.
Подмена глобального логгера и проверка Trace
GOpackage main
import (
"bytes"
"encoding/json"
"testing"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func TestTraceVisible(t *testing.T) {
old := log.Logger
defer func() { log.Logger = old }()
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
zerolog.SetGlobalLevel(zerolog.TraceLevel)
var buf bytes.Buffer
log.Logger = zerolog.New(&buf)
log.Trace().Str("op", "probe").Msg("tick")
var m map[string]any
if err := json.Unmarshal(buf.Bytes(), &m); err != nil {
t.Fatal(err)
}
if m["level"] != "trace" {
t.Fatalf("level: %v", m["level"])
}
}Глобальный log.Logger сохранён в old и возвращён defer-ом — тест не портит соседей по пакету. Второй defer возвращает глобальный уровень: gLevel в globals.go — новая нулевая int32, то есть DebugLevel по умолчанию. Trace-событие видно только при поднятой планке SetGlobalLevel(zerolog.TraceLevel); m["level"] сравнивается со строкой trace — без подъёма тест честно падает. Два defer сработают в обратном порядке: сначала уровень, затем логгер.
Тесты с пакетом testing в песочнице сайта не собрать — они требуют локального Go и go test. Тематическая программа на чистом Go покажет механику: логгер пишет события в буфер, карта разбирается и печатается — zerolog.New(&buf) виден без тестового каркаса. Проверки урока запускаются локально: go test -run TestPaymentLog -v, и при падении Fatalf печатает %v значения ключа.
Проверка: числа в карте
Почему m["amount"] == 150 ложно, хотя в записи amount равен 150?
Проверка: что есть в zerolog для тестов
Какой тестовый помощник действительно есть в zerolog 1.34.0?
Что напечатает разбор NoLevel-записи?
Прочитайте код и выберите, что он выведет:
var buf bytes.Buffer
logger := zerolog.New(&buf)
logger.Log().Str("op", "poll").Msg("tick")
var m map[string]any
_ = json.Unmarshal(buf.Bytes(), &m)
fmt.Println(m["level"], m["op"])Соберите тест уровня
Восстановите цепочку: логгер в буфер, событие, разбор и проверка уровня.
Тело теста очереди событий
Расставьте строки тела теста в правильном порядке: от буфера до проверки порядка уровней.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: тесты логов
Что дальше по курсу#
Проверка логов в тестах — фундамент, на который дальше ложатся метрики и трейсы: урок про Prometheus добавит счётчик рядом с событием, урок про трейсы — корреляцию записей одного запроса. Справочное закрепление: возьмите функцию с двумя ветками — успех и отказ — и напишите тест, который в одной очереди проверяет оба события: поля, уровни, отсутствие лишних записей. Затем сломайте контракт — уберите поле или смените уровень — и убедитесь, что тест падает с внятным сообщением, а не молча проходит. Если сам go test для вас новый инструмент, вернитесь к основам в курсе по основам Go: один тест на буфер стоит дешевле одной ночной пересборки инцидента.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Тест логов zerolog — writer в буфер и encoding/json: готовых ассертов в пакете нет.
- zerolog.New(&buf): запись — одна JSON-строка; buf.Reset между подтестами, SyncWriter при горутинах.
- map[string]any: ключи level, message и поля события; числа приходят float64.
- Уровень пишется у всех записей, кроме log.Log(): у NoLevel ключа level нет, у trace — значение trace.
- Несколько событий: json.Decoder с More и Decode; Unmarshal читает только первый объект.
- Порядок событий в одной горутине детерминирован: сравнивайте очередь уровней, не строки.
- Глобальные log.Logger и SetGlobalLevel — сохранить и вернуть defer-ом; дефолт — DebugLevel.
- Хук zerolog.Hook получает уровень и сообщение параметрами до записи — счётчик без JSON.
Домашнее задание
Соберите тест из урока на свою функцию с двумя ветками: успех пишет Info с полями, отказ — Error с error и cause. Проверьте в одной очереди оба события через json.Decoder и добавьте финальную проверку dec.More() на отсутствие лишних записей. Добавьте trace-ветку и убедитесь, что без SetGlobalLevel(zerolog.TraceLevel) она не доходит до буфера, а с поднятой планкой — доходит и восстанавливается defer-ом. Напишите хелперы wantStr и wantFloat с t.Helper и перепишите на них прямые сравнения. Подмените глобальный log.Logger, забудьте восстановление и посмотрите, как упадёт следующий тест пакета, — затем верните defer. Напоследок проверьте NoLevel-запись: ключ level в карте должен отсутствовать.
Личные заметкиПусто
Вопросы и ответы по уроку
Есть ли в zerolog готовые хелперы для тестов логов?
Пакетов zerolog/test и zerolog/assert не существует — проверка по составу поставки 1.34.0. Из тестового в writer.go живут TestWriter и NewTestWriter: они пересылают готовые строки в t.Log через интерфейс TestingLog, обрезая перевод строки, и годятся для отладочного показа, а не для проверок. Всё остальное — bytes.Buffer, encoding/json и собственные хелперы с t.Helper.
Почему тест не видит Trace-записи?
Глобальный уровень по умолчанию равен DebugLevel: переменная gLevel в globals.go инициализируется нулевым значением, а DebugLevel — ноль. Trace-события отсекаются в should() ещё до создания события. Поднимите планку zerolog.SetGlobalLevel(zerolog.TraceLevel) внутри теста и верните её defer-ом — восстановленное значение и есть дефолтный DebugLevel.
Можно ли проверять порядок полей внутри одной записи?
Zerolog пишет ключи в порядке добавления: level, поля контекста из With, поля цепочки, message последним. Но map[string]any порядок не сохраняет, поэтому обычные тесты проверяют по ключам. Если порядок действительно нужен — читайте поток токенов json.Decoder.Token() или сравнивайте префикс строки до разбора: карта после Unmarshal про порядок уже не помнит.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Валидация в Go: go-playground/validator
Курс validator для Go: теги required/min/max, email, dive, вложенные структуры, свои валидаторы, Gin и Echo, русские сообщения, тесты. 25 уроков.
- Валидировать структуры тегами binding и validate
- Понимать ValidationErrors и писать понятные сообщения
- Работать с вложенностью, срезами и картами через dive
- Писать свои функции-валидаторы и StructLevel-правила