---
title: "JSONify и сравнение JSON в тестах"
description: "Сравнение JSON в go-тестах: assert.JSONEq выравнивает порядок ключей и пробелы, под капотом Unmarshal и DeepEqual, ловушка float64 и Subset вместо JSONContains."
course: "Testify: тестирование Go с assert и require"
url: "https://golangmaster.ru/courses/testify/lessons/21-json-testy/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Testify: тестирование Go с assert и require», урок 21 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/testify/lessons/21-json-testy/*

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

Пять минут назад ответ хендлера и вывод 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](/courses/testify/lessons/03-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}") — в ожидании ключ без кавычек?

- ✅ false и сообщение «is not valid json» про строку-ожидание
- false и diff «Not equal» по распакованным данным
- true — JSONEq прощает ключи без кавычек
- Панику: JSONEq не умеет обрабатывать ошибки Unmarshal

JSON не признаёт ключей без кавычек: первый же json.Unmarshal на строке-ожидании падает, и JSONEq возвращает false с сообщением «is not valid json» про expected. Порядок важен: ожидание проверяется раньше факта, так что битая фикстура диагностируется мгновенно, даже не глядя на actual. До DeepEqual и diff дело в такой ситуации не доходит.

### Числа в 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 в нём потерян до всякого сравнения.

### Что напечатает тест?

```go
func TestBigID(t *testing.T) {
        expected := "{\"id\": 9007199254740993}"
        actual := "{\"id\": 9007199254740992}"

        ok := assert.JSONEq(t, expected, actual)
        fmt.Println(ok)
}
```

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

- false — числа различаются на единицу, DeepEqual это видит
- ✅ true — оба числа распаковались в один и тот же float64
- Ошибку компиляции: JSONEq не принимает такие литералы
- false — JSONEq использует UseNumber и сравнивает точно

Оба числа распаковываются в float64: 9007199254740993 не представим точно (граница 2^53), округляется к чётному и превращается в 9007199254740992 — как и второе число. DeepEqual видит два одинаковых float64, JSONEq возвращает true, тест печатает true — при различии на единицу. Лечение — типизированное поле int64 или json.Number: тогда расхождение поймается.

> **JSONEq зелёный, а поле — строка вместо числа.** История с реального сервиса: фронтенд пожаловался, что 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

```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 вернёт ошибку. Схема знакома по [уроку про сравнение структур](/courses/testify/lessons/05-sravnenie-struktur/): распаковка в структуру, дальше assert.Equal по полям. HTTP-версия этого приёма разобрана в [уроке про HTTP-хендлеры](/courses/testify/lessons/19-http-testy/). У подхода есть цена: неизвестные поля теряются молча — структура игнорирует лишнее, и тест не заметит, что ответ разросся. Если рост ответа важен, добавьте проверку количества ключей в карте или 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, лишнее не должно ронять тест. Что берёте?

- assert.JSONEq со строкой на весь документ
- assert.Equal по сырой строке ответа
- ✅ Unmarshal в map[string]any и assert.Subset с двумя ключами
- assert.Empty на ответе — данные сверит продакшен

JSONEq строг ко всему документу — лишний ключ в фактическом ответе его покрасит. Когда важна часть, распаковка в карту плюс Subset: перечисленные ключи обязаны найтись и совпасть, остальное не фиксируется. Точечные assert по got["id"] — вариант для пары полей, NotContains добивает запрет ключа.

> **Спор: JSONEq против строгой структуры.** Я развёл так: JSONEq — для контрактов с внешними системами и снимков, структура — для собственных API. JSONEq описывает данные глазами потребителя: свободная схема, ничего не теряется, обновил ожидание — тест жив. Но свобода обходит типы: строка вместо числа для JSONEq — то же самое данное. Структура наоборот: типы зафиксированы, рефакторинг модели тянет тест за собой, зато лишнее поле в ответе она не заметит. Условие моей правоты: если ответ — ваша же структура из соседнего пакета, дублировать её JSON-строкой и сравнивать строки — работа ради работы. Если это внешняя спецификация, JSONEq честнее.

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

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

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

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

```go
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.___())
}
```

Подсказка: Ассерт — JSONEq, тело recorder'а читается методом String.

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

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

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

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

1. `var got profileResp`
2. `err := json.Unmarshal(rr.Body.Bytes(), &got)`
3. `require.NoError(t, err)`
4. `assert.Equal(t, 42, got.ID)`
5. `assert.Equal(t, "Anna", got.Name)`
6. `assert.Equal(t, true, got.Active)`

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

- **assert.JSONEq(t, expected, actual)** — Сравнивает две JSON-строки как данные: распаковывает обе, порядок ключей, отступы и переводы строк не важны. Оба аргумента — string.
- **JSONEq под капотом** — Unmarshal обеих строк в interface{} (битая — «is not valid json»), шорткат на байтовое равенство строк, затем Equal — то есть reflect.DeepEqual.
- **Числа в interface{}** — json.Unmarshal кладёт все числа как float64: целые выше 2^53 округляются, int64 теряет младшие биты до всякого сравнения.
- **json.Number** — Тип из json.Decoder с UseNumber: число без потери точности, сравнивается как строка. Внутри JSONEq не используется.
- **assert.Subset(t, got, sub)** — Для карт: каждый ключ sub обязан найтись в got и совпасть по значению. Штатная замена несуществующему в testify JSONContains.
- **Unmarshal в структуру** — Строгая альтернатива: типы зафиксированы, большие числа точны, но лишние поля ответа игнорируются молча.

Правила простые: данные сверяем как данные — JSONEq; типы фиксируем структурой; подмножество — Subset по распакованной карте; ожидание не копируем из фактического вывода без сверки типов. Остался вопрос, откуда берутся большие expected: держать документ на сотню строк внутри теста тесно. В следующем уроке — фикстуры: файлы в testdata, embed и конструкторы данных, включая тяжёлые JSON-документы. Полная карта уроков — на странице [testify-курса](/courses/testify/). И последняя деталь: JSONEq принимает именно строки — для []byte оберните в string(...), компилятор напомнит о сигнатуре раньше, чем тест упадёт по мнимой причине.

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

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