Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 21 из 25 ~18 мин +20 XP

JSONify и сравнение JSON в тестах

Сравнение JSON в go-тестах ломается на пустом месте: хендлер и curl возвращают одни и те же данные, но строки не равны — сериализатор переставил ключи и добавил переводы строк. assert.JSONEq сравнивает JSON как данные: порядок ключей и пробелы игнорируются. Ниже — механика под капотом, ловушка с числами, проверка подмножества без JSONContains и строгая альтернатива со структурой.

Содержание урока · 10 разделов

Пять минут назад ответ хендлера и вывод 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

GO
func 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)
}

JSONContains: подмножество полей без готового ассерта#

Разработчики, пришедшие из других экосистем, ищут в testify JSONContains — ассерт вида «в документе есть эти поля, остальное не важно». По исходникам assert-пакета его там нет: ни в выпусках 1.8–1.9, ни в актуальном master — функция в библиотеку так и не попала. Это не беда: подмножество проверяется штатными средствами. Распакуйте ответ в map[string]any и возьмите assert.Subset: в современных testify он умеет карту в карте — каждый ключ подмножества обязан найтись в карте и совпасть по значению, сверка идёт через тот же ObjectsAreEqual. Точечные проверки через got["id"] — тоже вариант, когда полей два-три. Для обратного требования — ключа быть не должно — есть assert.NotContains: на карте он проверяет отсутствие ключа, что удобно для паролей и токенов, просочившихся в ответ.

Подмножество полей через Subset

GO
func 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

GO
type 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, лишнее не должно ронять тест. Что берёте?

Чек-лист сравнения JSON#

  • Ожидание — спецификация: не копируйте фактический ответ без сверки типов.
  • Полные документы — JSONEq: данные без порядка ключей и отступов.
  • Помните про float64: целые выше 2^53 JSONEq сравнит неверно.
  • Подмножество — Subset по map[string]any; отсутствие ключа — NotContains.
  • Стабильная схема — структура и Equal по полям: типы и большие числа под контролем.
  • Битый JSON в ожидании ловится сразу: «is not valid json» — про строку-expected.

Соберите проверку ответа

Допишите сверку фактического тела с ожиданием.

func TestPayload(t *testing.T) { expected := "{\"id\": 42, \"name\": \"Anna\"}" req := httptest.NewRequest("GET", "/api/user", nil) rr := httptest.NewRecorder() UserHandler(rr, req) assert.(t, expected, rr.Body.()) }

Соберите строгую проверку

Расставьте строки типизированной распаковки и сверок.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: JSON в тестах

Термин

assert.JSONEq(t, expected, actual)

нажмите, чтобы перевернуть
Определение

Сравнивает две JSON-строки как данные: распаковывает обе, порядок ключей, отступы и переводы строк не важны. Оба аргумента — string.

1 / 6

Правила простые: данные сверяем как данные — 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 по полям. Затем замените в фактическом ответе одно число строкой и посмотрите, какой из двух тестов это поймает, а какой промолчит.

Личные заметкиПусто
Хранятся только в вашем браузере (Testify · JSONify и сравнение JSON в тестах)

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

Почему 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 громоздкий — тогда распакуйте обе стороны в карты и сравните поля по отдельности: падение укажет на конкретный ключ. Это тот же подход, что в строгой альтернативе со структурой.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Viper25 уроков~9 чпрактика25 уроков уже доступны

Viper: конфигурация приложений на Go

Интерактивный курс Viper для Go: YAML и env-переменные, приоритеты источников, Unmarshal, WatchConfig, профили окружений и секреты. 25 уроков с примерами.

  • Собирать конфиг из файлов, окружения и флагов в один реестр
  • Читать значения типизированно: Get, GetString, Unmarshal в структуры
  • Задавать приоритеты источников: флаг > env > файл > default
  • Перечитывать конфиг на лету через WatchConfig и OnConfigChange