Содержание урока · 10 разделов
- assert.JSONEq: сравнение JSON в Go как данных
- JSONEq под капотом: Unmarshal и DeepEqual
- Числа в JSON: float64 и граница 2^53
- JSONContains: подмножество полей без готового ассерта
- Способ → когда
- Строгая альтернатива: unmarshal в структуру
- Чек-лист сравнения JSON
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Пять минут назад ответ хендлера и вывод curl совпадали до символа, а assert.Equal уже красный: сериализатор переставил ключи и добавил переводы строк — две строки с одинаковыми данными разъехались. Для сравнения JSON в go-тестах сверка сырых строк не годится в принципе: строка — лишь одна из тысяч законных записей одного и того же документа. Отступы, порядок ключей, пробелы после двоеточий — для данных это шум, а строковое равенство убивается любым из них. У testify есть штатный ответ: assert.JSONEq принимает две строки, распаковывает их и сравнивает как данные. В уроке — механика под капотом, ловушка с числами, о которой узнают на первой же большой величине, проверка подмножества полей и строгая альтернатива с типизированной структурой. На HTTP-ответы из прошлого урока оглядывайтесь: там recorder собирал тело, здесь научимся его сверять.
assert.JSONEq: сравнение JSON в Go как данных#
Сигнатура из assert/assertions.go: func JSONEq(t TestingT, expected string, actual string, msgAndArgs ...interface{}) bool. Оба аргумента — строки: JSON из файла-фикстуры, литерал в тесте или rr.Body.String() из HTTP-теста. Нормализация происходит сама: JSONEq распаковывает обе строки и сравнивает распакованное, поэтому порядок ключей, отступы и переводы строки роли не играют — сверяются данные, а не их запись. Философия та же, что в уроке про assert и require: сначала привести к каноническому виду, потом сверять. Строку-ожидание удобно держать прямо в тесте: маленький документ читается с экрана, большой живёт в файле — о файлах речь в следующем уроке. Механика у JSONEq двухступенчатая, и вторую ступень почти никто не проверял: чем именно сравниваются распакованные значения. Разберёмся сейчас.
Первый тест на JSONEq
GOfunc TestProfileJSON(t *testing.T) {
expected := "{\"id\": 42, \"name\": \"Anna\", \"age\": 29}"
req := httptest.NewRequest("GET", "/profile?id=42", nil)
rr := httptest.NewRecorder()
Profile(rr, req)
assert.JSONEq(t, expected, rr.Body.String())
}Ожидание — маленькая JSON-строка прямо в тесте; кавычки экранированы, потому что строка обычная, а не raw-литерал. JSONEq берёт обе стороны строками: expected целиком из головы, actual — из тела ответа recorder'а, собранного в прошлом уроке. Поменяйте в expected порядок ключей или отступы — тест останется зелёным: сверяются данные. Тестовые пакеты с testify не запускаются в песочнице — нужен локальный go test с testify в go.mod.
JSONEq под капотом: Unmarshal и DeepEqual#
По исходникам testify шаги такие. Сначала json.Unmarshal([]byte(expected), &expectedJSONAsInterface) в interface{}: битый JSON в ожидании — немедленный провал с сообщением про «is not valid json». Дальше шорткат: если строки совпали байт в байт, функция вернёт true, не распаковывая actual — тривиальный случай отрабатывает бесплатно. Затем Unmarshal второй строки и на финише — assert.Equal над распакованными значениями. Equal зовёт ObjectsAreEqual, а для карт и слайсов тот выполняет reflect.DeepEqual: рекурсивное сравнение структур данных. DeepEqual сравнивает карты по ключам, а не по порядку записи — отсюда и главное свойство JSONEq: переставленные ключи неразличимы, лишние пробелы исчезли при распаковке. Провал при этом рисует читаемый diff — наследие Equal. Знать механику стоит хотя бы ради следующей ловушки: во что json.Unmarshal превращает числа.
Проверка: битая строка-ожидание
Что вернёт assert.JSONEq(t, "{id: 42}", "{\"id\": 42}") — в ожидании ключ без кавычек?
Числа в JSON: float64 и граница 2^53#
Документация encoding/json фиксирует: Unmarshal в interface{} хранит все числа как float64. Для типовых значений незаметно, но у поведения два хвоста. Первый — точность: float64 без потерь представляет целые до 2^53, значения выше — идентификаторы из систем вроде Snowflake, суммы в копейках за год — округляются, и два разных числа могут превратиться в одно. JSONEq при этом честно скажет «равно»: под ним float64 против float64, расхождение потерялось ещё при распаковке. Второй хвост — int64 не влезает в float64 вовсе: у максимального int64 младшие биты теряются всегда. Лечение по ситуации: типизированная структура с полем int64 — Unmarshal парсит его точно; json.Decoder с UseNumber — числа становятся json.Number и сравниваются без потерь. Внутри JSONEq эти пути недоступны: он зашит на дефолтный Unmarshal, и большой ID в нём потерян до всякого сравнения.
Что напечатает тест?
Прочитайте код и выберите, что он выведет:
func TestBigID(t *testing.T) {
expected := "{\"id\": 9007199254740993}"
actual := "{\"id\": 9007199254740992}"
ok := assert.JSONEq(t, expected, actual)
fmt.Println(ok)
}История с реального сервиса: фронтенд пожаловался, что age приходит строкой. Виновник — рефакторинг, поле сериализовалось как «29» вместо 29. Тест на хендлер при этом был зелёным месяцами. Разбор: expected в JSONEq когда-то скопировали из фактического ответа — уже с ошибкой, со строкой вместо числа. JSONEq сверял честно: две одинаковые структуры данных, обе с багом. Урок: expected — спецификация, а не копия вывода. Скопировать ответ для старта можно, но затем сверить типы с контрактом, а надёжнее — взять строгую структуру, её разбор ниже.
JSONContains: подмножество полей без готового ассерта#
Разработчики, пришедшие из других экосистем, ищут в testify JSONContains — ассерт вида «в документе есть эти поля, остальное не важно». По исходникам assert-пакета его там нет: ни в выпусках 1.8–1.9, ни в актуальном master — функция в библиотеку так и не попала. Это не беда: подмножество проверяется штатными средствами. Распакуйте ответ в map[string]any и возьмите assert.Subset: в современных testify он умеет карту в карте — каждый ключ подмножества обязан найтись в карте и совпасть по значению, сверка идёт через тот же ObjectsAreEqual. Точечные проверки через got["id"] — тоже вариант, когда полей два-три. Для обратного требования — ключа быть не должно — есть assert.NotContains: на карте он проверяет отсутствие ключа, что удобно для паролей и токенов, просочившихся в ответ.
Подмножество полей через Subset
GOfunc TestProfileFields(t *testing.T) {
req := httptest.NewRequest("GET", "/profile?id=42", nil)
rr := httptest.NewRecorder()
Profile(rr, req)
var got map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
assert.Subset(t, got, map[string]any{
"id": 42.0,
"name": "Anna",
})
assert.NotContains(t, got, "password")
}Распаковка в map[string]any делает типы явными: число из JSON становится float64, поэтому в подмножестве пишем 42.0 — иначе Subset не сверит int с float64 и тест покраснеет сам по себе. NotContains закрывает обратное требование: ключа password в карте быть не должно. Прогон — локальный go test: testify и encoding/json в песочнице не исполняются.
Способ → когда#
| Способ | Что проверяет | Когда берём |
|---|---|---|
| assert.Equal по сырым строкам | Байтовое равенство записей | Почти никогда: краснеет на порядке ключей |
| assert.JSONEq | Равенство данных после распаковки | Полный документ, порядок ключей не важен |
| Unmarshal в map + assert.Subset | Подмножество ключей и значений | Важна часть полей, остальное не фиксируем |
| assert.NotContains по карте | Отсутствие ключа | Пароли и токены не должны просачиваться |
| Unmarshal в структуру + Equal | Типизированные поля | Стабильная схема, важны типы и большие числа |
Строгая альтернатива: unmarshal в структуру#
Когда схема ответа стабильна, честнее обойтись без any: типизированная структура ловит потерю типов — строка вместо числа не уместится в поле int, и Unmarshal вернёт ошибку. Схема знакома по уроку про сравнение структур: распаковка в структуру, дальше assert.Equal по полям. HTTP-версия этого приёма разобрана в уроке про HTTP-хендлеры. У подхода есть цена: неизвестные поля теряются молча — структура игнорирует лишнее, и тест не заметит, что ответ разросся. Если рост ответа важен, добавьте проверку количества ключей в карте или Subset поверх. Зато большие числа точны: поле int64 парсится без float64-округления, и ловушка из середины урока исчезает. Правило выбора — в таблице выше, а спор о том, что держать в тестах, — под ней.
Типизированная распаковка вместо JSONEq
GOtype profileResp struct {
ID int
Name string
Active bool
}
func TestProfileTyped(t *testing.T) {
req := httptest.NewRequest("GET", "/profile?id=42", nil)
rr := httptest.NewRecorder()
Profile(rr, req)
var got profileResp
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
assert.Equal(t, 42, got.ID)
assert.Equal(t, "Anna", got.Name)
assert.Equal(t, true, got.Active)
}Типы зафиксированы: строка вместо числа развалит Unmarshal с ошибкой ещё до ассертов, а большие числа парсятся в int точно. require на ошибке распаковки обязателен — с битым телом проверять поля бессмысленно. Слабое место одно: незнакомые поля структура молча отбрасывает, поэтому при растущем ответе добавьте к этому тесту сверку количества ключей.
Проверка: часть полей важна
В ответе десяток полей, но вам важно только id и name, лишнее не должно ронять тест. Что берёте?
Я развёл так: JSONEq — для контрактов с внешними системами и снимков, структура — для собственных API. JSONEq описывает данные глазами потребителя: свободная схема, ничего не теряется, обновил ожидание — тест жив. Но свобода обходит типы: строка вместо числа для JSONEq — то же самое данное. Структура наоборот: типы зафиксированы, рефакторинг модели тянет тест за собой, зато лишнее поле в ответе она не заметит. Условие моей правоты: если ответ — ваша же структура из соседнего пакета, дублировать её JSON-строкой и сравнивать строки — работа ради работы. Если это внешняя спецификация, JSONEq честнее.
Чек-лист сравнения JSON#
- Ожидание — спецификация: не копируйте фактический ответ без сверки типов.
- Полные документы — JSONEq: данные без порядка ключей и отступов.
- Помните про float64: целые выше 2^53 JSONEq сравнит неверно.
- Подмножество — Subset по map[string]any; отсутствие ключа — NotContains.
- Стабильная схема — структура и Equal по полям: типы и большие числа под контролем.
- Битый JSON в ожидании ловится сразу: «is not valid json» — про строку-expected.
Соберите проверку ответа
Допишите сверку фактического тела с ожиданием.
Соберите строгую проверку
Расставьте строки типизированной распаковки и сверок.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: JSON в тестах
Правила простые: данные сверяем как данные — JSONEq; типы фиксируем структурой; подмножество — Subset по распакованной карте; ожидание не копируем из фактического вывода без сверки типов. Остался вопрос, откуда берутся большие expected: держать документ на сотню строк внутри теста тесно. В следующем уроке — фикстуры: файлы в testdata, embed и конструкторы данных, включая тяжёлые JSON-документы. Полная карта уроков — на странице testify-курса. И последняя деталь: JSONEq принимает именно строки — для []byte оберните в string(...), компилятор напомнит о сигнатуре раньше, чем тест упадёт по мнимой причине.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Строковое сравнение JSON хрупко: порядок ключей и пробелы не имеют отношения к данным.
- assert.JSONEq распаковывает обе строки и сверяет данные: порядок ключей и отступы не важны.
- Под капотом: Unmarshal в interface{}, шорткат на равные строки и Equal — то есть reflect.DeepEqual.
- Числа после Unmarshal — float64: целые выше 2^53 округляются, JSONEq может назвать равными разные значения.
- JSONContains в testify нет: подмножество проверяет Subset по распакованной карте или точечные assert.
- Типизированная структура ловит потерю типов и парсит большие числа точно, но игнорирует лишние поля.
- Expected — спецификация, а не копия вывода: скопировали — проверьте типы.
Домашнее задание
Возьмите JSON-ответ своего хендлера и напишите два теста: assert.JSONEq на полный документ и unmarshal в структуру с assert.Equal по полям. Затем замените в фактическом ответе одно число строкой и посмотрите, какой из двух тестов это поймает, а какой промолчит.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему JSONEq принимает строки, а не []byte?
Сигнатура фиксирует string: func JSONEq(t, expected, actual string, ...). Для HTTP-ответов это естественно — rr.Body.String(). Байтовый срез оборачивается в string(b): преобразование дешёвое, а единый тип аргументов не даёт перепутать их порядок. Внутри оба аргумента всё равно приводятся к []byte для json.Unmarshal.
Как сравнить JSON с числами больше 2^53?
JSONEq их не спасёт: под ним float64, точность теряется до сравнения. Распакуйте документ в структуру с полем int64 — типизированный Unmarshal парсит большие числа точно, — либо примените json.Decoder с UseNumber, чтобы получить json.Number без потерь. Строгая структура из этого урока — самый короткий путь.
JSONEq упал по большому документу — как читать отчёт?
Под JSONEq работает Equal, поэтому провал рисует diff: ожидаемое против фактического. Для документа в сотни строк diff громоздкий — тогда распакуйте обе стороны в карты и сравните поля по отдельности: падение укажет на конкретный ключ. Это тот же подход, что в строгой альтернативе со структурой.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Фикстуры и тестовые данные: где их хранить
- Testify и стандартная библиотека: границы
- Тесты в CI: гонки, покрытие и Testify
- Итоговый проект: тестовый комплект для пакета
- assert.Eventually: тесты асинхронного кода
- Тестирование HTTP-хендлеров с testify
- mockery: генерация моков из интерфейсов
- AssertExpectations: проверяем, что мок вызван
- Все курсы по Go
Следующая ступень учебного пути
Viper: конфигурация приложений на Go
Интерактивный курс Viper для Go: YAML и env-переменные, приоритеты источников, Unmarshal, WatchConfig, профили окружений и секреты. 25 уроков с примерами.
- Собирать конфиг из файлов, окружения и флагов в один реестр
- Читать значения типизированно: Get, GetString, Unmarshal в структуры
- Задавать приоритеты источников: флаг > env > файл > default
- Перечитывать конфиг на лету через WatchConfig и OnConfigChange