Содержание урока · 10 разделов
- ObjectsAreEqual под капотом: bytes.Equal и reflect.DeepEqual
- Сравнение структур в тестах: целиком или по полям
- time.Time: тайм-зоны и монотонные часы
- Разные типы и func-поля: где Equal красный принципиально
- Где Equal лжёт и чем заменить
- Equal красный: чек-лист поиска причины
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Тест на структуру профиля красный, а в отчёте expected и actual выглядят одинаково до последнего символа. Знакомая сцена: где-то в глубине объекта прячется поле, которого не видно в свёрнутом диффе, — или тип не тот, или время не то, или приватное состояние разошлось. Сравнение структур в тестах на Go сводится не к «похожим» объектам, а к строгой рекурсии, и её правила нужно знать наизусть. Здесь разберём механику под Equal — ObjectsAreEqual, который сперва меряет байты, потом зовёт reflect.DeepEqual, — затем сравним два стиля: проверка структуры целиком против проверки по полям. В конце — коллекция ловушек, на которых Equal красит тест при одинаковых значениях: time.Time с монотонными часами, unexported-поля, func-поля и int64 против int. Базовые проверки Equal и NotEqual мы впервые гоняли в уроке про первый assert, теперь заглянем под капот.
ObjectsAreEqual под капотом: bytes.Equal и reflect.DeepEqual#
У assert.Equal нет собственной математики сравнения — вся работа делается в функции ObjectsAreEqual, и она устроена в три шага. Шаг первый: если один из аргументов nil, равенство возможно только при двух nil сразу. Шаг второй: ожидаемое значение проверяется на тип []byte, и если это слайс байтов, включается bytes.Equal — сравнение содержимого байт за байтом, без отражений. Шорт-кат добавлен ради скорости на больших данных: JSON-ответы, чек-суммы, сериализованные тела. Нюанс в том, что срабатывает он только на паре []byte против []byte: assert.Equal(t, []byte("abc"), "abc") вернёт false, потому что строка под шорт-кат не попадает. Шаг третий для всего остального — reflect.DeepEqual: рекурсивный обход полей структур, элементов слайсов и ключей карт. В обсуждениях testify эту связку зовут assert.DeepEqual, и именно её правила порождают все ловушки ниже: DeepEqual сравнивает типы, поля и элементы, но ничего не знает про «момент времени», «деньги» или намерения разработчика.
Equal на структуре со слайсом и картой
GOtype Profile struct {
Name string
Tags []string
Links map[string]string
}
func TestProfile(t *testing.T) {
got := buildProfile("ann")
want := Profile{
Name: "ann",
Tags: []string{"admin", "writer"},
Links: map[string]string{"site": "ann.example.com"},
}
assert.Equal(t, want, got)
}DeepEqual обходит поля, элементы слайса и ключи карты: тест зелёный независимо от порядка ключей и от того, что слайсы построены в разных местах памяти. Адреса не сравниваются — сравнивается содержимое. Полный прогон этого теста требует локального Go: браузерная песочница не исполняет тестовые пакеты, зато go test на пакете с buildProfile проверит его за долю секунды.
Сравнение структур в тестах: целиком или по полям#
Сравнение структур в тестах ведётся двумя стилями, и у каждого есть цена. Стиль «целиком»: одна проверка на весь объект. Плюс — незапланированные изменения не пройдут мимо: добавили поле или оно начало заполняться — Equal упадёт, и это сигнал пересмотреть ожидания. Минус — отчёт: при провале печатается дифф всей структуры, и для объекта на двадцать полей виновника приходится искать глазами. Минус второй, коварнее: волатильные поля вроде createdAt заставляют выкинуть их из want, потом из него же выпадает id, и через месяц want описывает не объект, а огрызок. Стиль «по полям»: проверка на каждое значимое поле. Отчёт точный — упало конкретное поле с конкретным значением, волатильные поля осознанно пропущены. Плата — слепая зона: новое поле в структуру добавили, контракт изменился, тест остался зелёным и ничего не заметил. Для больших диффов в экосистеме есть google/go-cmp с cmpopts.IgnoreFields — отдельная зависимость; внутри testify рабочая замена — EqualExportedValues, о нём ниже.
Два стиля рядом
GOfunc TestOrder(t *testing.T) {
got := NewOrder("book", 2)
// целиком: весь объект одной проверкой
assert.Equal(t, Order{Item: "book", Qty: 2, Total: 20}, got)
// по полям: отчёт укажет конкретное поле
assert.Equal(t, "book", got.Item)
assert.Equal(t, 2, got.Qty)
assert.Equal(t, 20, got.Total)
}В одном тесте оба стиля уживаются нормально: целостная проверка ловит структурные изменения, полевые дают читаемый провал. Дублирование проверок не вредит — договоритесь в команде, какой стиль основной, иначе ревью будет спорить о вкусах, а не о багах. В табличных тестах полевой стиль чаще удобнее: один кейс — одно поле в отчёте.
reflect.DeepEqual обходит все поля, включая unexported — приватные для пакета. Следствие первое: две структуры с совпадающим экспортируемым состоянием могут быть не равны из-за приватного поля — кэша, счётчика вызовов, флага ленивой инициализации. В отчёте это выглядит как загадочный красный тест на «одинаковых» объектах. Следствие второе: тест в том же пакете видит приватные поля напрямую и может их инициализировать, поэтому want и got, собранные разными путями, расходятся как раз в невидимой части. Отключить поле в Equal нельзя — опции «игнорировать поле» у проверки нет. Рабочих выходов три: сравнивать по экспортируемым полям вручную; собирать want тем же конструктором, что тестируемый код, чтобы приватное состояние сходилось само; или взять EqualExportedValues, который сравнивает только открытые поля.
В testify есть assert.EqualExportedValues(t, want, got): типы обязаны совпадать, сравниваются только экспортируемые поля, включая вложенные структуры, — приватные игнорируются. Выручает на структурах со служебной обвязкой: мьютексы, кэши, счётчики вызовов, по которым сравнивать бессмысленно. Оборотная сторона — отчёт беднее: расхождение в приватной части не появится в диффе никогда, потому что эта часть в сверке не участвует.
time.Time: тайм-зоны и монотонные часы#
time.Time — самая частая причина ложного красного. Снаружи это одно значение, внутри — структура с приватными полями: wall и ext хранят момент (а ext иногда ещё и монотонные часы), loc — указатель на тайм-зону. Отсюда два расхождения с интуицией. Первое: одно и то же мгновение в разных зонах не равно. time.Date с time.UTC и time.Date с time.FixedZone("MSK", 3*3600) могут описывать один момент — метод t1.Equal(t2) скажет true, а DeepEqual — false: поля loc хранят разные указатели. Второе: монотонные часы. time.Now() на Linux, macOS и Windows несёт дополнительную метку; Round(0) и Truncate(0) — документированный способ её снять. DeepEqual(now, now.Round(0)) — false при одном мгновении. Практические правила: фикстуры собирайте через time.Date с фиксированным моментом, а не time.Now(); инстанты сверяйте методом .Equal через assert.True, Unix-метками или приводите обе стороны к .UTC().
time.Time, который красит честный тест
GOfunc TestParseStamp(t *testing.T) {
got, err := ParseStamp("2024-05-01T12:00:00Z")
require.NoError(t, err)
want := time.Date(2024, 5, 1, 12, 0, 0, 0, time.UTC)
assert.Equal(t, want, got) // зелёный: оба без меток и зон
msk := time.FixedZone("MSK", 3*3600)
same := time.Date(2024, 5, 1, 15, 0, 0, 0, msk)
assert.True(t, same.Equal(want)) // одно мгновение
assert.NotEqual(t, want, same) // но поля loc разные
}Parse и time.Date монотонные часы не прицепляют, поэтому пара want/got проходит Equal. Один и тот же момент в MSK проходит t.Equal — и честно краснеет в NotEqual: DeepEqual видит разные указатели тайм-зон, а не мгновение. Отсюда правило из блока выше: время в фикстурах — только через time.Date, сверка инстантов — методом Equal или Unix-метками.
Реальная история: тест нормализации времени краснел на «одинаковых» датах. want строился через time.Date с UTC, got приходил из парсера с зоной +03:00 — мгновение одно, t.Equal зелёный, assert.Equal красный. Полчаса сверяли даты в базе, пока кто-то не напечатал поля структуры: loc указывал на разные тайм-зоны. DeepEqual сравнивает структуру time.Time по полям, а не «когда это было». Лекарство: приводить обе стороны к .UTC(), сверять .Equal или Unix-метками — и фиксировать ожидания через time.Date, а не time.Now().
Разные типы и func-поля: где Equal красный принципиально#
reflect.DeepEqual сравнивает и типы: значения разных типов не равны никогда. int64(42) против 42 — красный, и дифф честно сообщит, что типы не совпали. В реальных тестах разные типы приходят сами: константа 42 имеет тип int, из базы драйвер отдаёт int64, а json.Unmarshal в interface{} превращает все числа в float64 — assert.Equal(t, 42, payload["age"]) краснеет на float64(42), хотя значение то же. Лечение — явное приведение ожидания к типу факта или assert.EqualValues, который приводит числовые типы друг к другу; правда, у него ниже строгость: float64(1.5) против int(1) он может счесть равными — для точных значений надёжнее Equal с приведением. Второй принципиальный случай — func. DeepEqual считает функции несравнимыми: равны только два nil, любые другие пары — false. Структура с полем-коллбэком не пройдёт Equal, пока оба коллбэка nil: обнуляйте поле в фикстуре или сравнивайте по остальным полям. А напрямую Equal(t, f1, f2) откажется вовсе: testify запрещает func в аргументах Equal с сообщением cannot take func type as argument.
Где Equal лжёт и чем заменить#
| Пара значений | assert.Equal | Рабочая замена |
|---|---|---|
| одно мгновение в разных тайм-зонах | красный: поля loc разные | t1.Equal(t2) через assert.True, Unix-метки, .UTC() с обеих сторон |
| now против now.Round(0) | красный: монотонные часы | Round(0) или Truncate(0) с обеих сторон |
| структура с func-полем (оба не nil) | всегда красный | обнулить поле в фикстуре, сравнивать по полям |
| int64(42) и 42 | красный: разные типы | явное приведение или assert.EqualValues |
| []byte("abc") и "abc" | красный: шорт-кат только для пары []byte | привести к одному типу |
| экспортируемые поля равны, приватные нет | красный: DeepEqual видит все поля | EqualExportedValues или полевое сравнение |
Equal красный: чек-лист поиска причины#
- Проверьте время: time.Time в полях? Снимите монотонные метки Round(0) и выровняйте тайм-зоны — или сверяйте .Equal и Unix-метки.
- Проверьте типы: int64 из базы против int-константы, float64 из JSON. Отладочный fmt.Printf("%T") скажет правду за секунду.
- Проверьте func-поля: коллбэки и хендлеры несравнимы, пока оба nil.
- Проверьте приватные поля: кэши, счётчики, ленивая инициализация — DeepEqual их видит.
- Проверьте []byte против строки: шорт-кат bytes.Equal работает только на паре байтовых слайсов.
- Собирайте want тем же конструктором, что и got: расходится чаще всего та часть, которая заполнялась разными путями.
Проверка: шорт-кат байтов
Что вернёт assert.Equal(t, []byte("abc"), "abc") и почему?
Проверка: типы из базы
Из базы пришло int64(7), в тесте — константа 7 (int). Какие проверки пройдут?
Что покажет прогон?
Прочитайте код и выберите, что он выведет:
func TestStamp(t *testing.T) {
now := time.Now()
fixed := now.Round(0) // снимает монотонные часы
assert.Equal(t, now, fixed)
assert.True(t, now.Equal(fixed))
}Соберите проверки на структуру и время
Допишите имена проверок и метод: числовое поле из базы, сверка инстантов времени и точный отчёт по полю.
Соберите тест на тайм-зоны
Расставьте строки теста: фиксированный момент в UTC, тот же момент в MSK, сверка мгновения и сверка представлений.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: сравнение структур и слайсов
Что дальше#
Структуры и время подчинились — очередь за коллекциями, где к вопросу о равенстве добавляется вопрос о составе. Следующий урок — ElementsMatch, Subset и Len: сравнение слайсов без оглядки на порядок, подмножества и контроль объёма, плюс ловушка nil против пустого слайса. Числа с плавающей точкой — 0.1+0.2 не равно 0.3 — разобраны отдельно в уроке про InDelta и InEpsilon: Equal на них краснеет заслуженно, там нужны дельты. Все примеры урока собираются в тестовый пакет и прогоняются локально через go test — песочница сайта тестовые пакеты не исполняет, зато интерактивы на странице закрепляют каждую ловушку. Полный маршрут из двадцати пяти тем — на странице курса по testify.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- assert.Equal сравнивает через ObjectsAreEqual: nil-проверка, bytes.Equal для пары []byte, иначе reflect.DeepEqual.
- DeepEqual обходит все поля, включая unexported, и значения разных типов не равны никогда: int64 не равен int.
- Сравнение структур целиком ловит новые поля, сравнение по полям даёт точный отчёт — выберите стиль и договоритесь о нём в команде.
- time.Time красит честные тесты: тайм-зоны и монотонные часы; сверяйте методом .Equal, Unix-метками или Round(0) с обеих сторон.
- func-поля несравнимы, пока оба nil: обнуляйте их в фикстурах или сравнивайте по остальным полям.
- EqualValues приводит числовые типы, EqualExportedValues сравнивает только открытое — обе проверки рабочие, но с оговорками.
- Игнорировать одно поле в Equal нельзя: либо полевое сравнение, либо конструктор-фикстура, либо EqualExportedValues.
Домашнее задание
Возьмите структуру с полем time.Time и func-коллбэком, соберите want и got с одним и тем же мгновением в разных тайм-зонах и добейтесь красного assert.Equal. Затем почините тест: Round(0), .UTC(), сверка методом .Equal. Прогон — локальным go test; сравните отчёт целостной проверки с полевым стилем.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему assert.Equal красный на time.Time с одинаковым значением?
Equal сравнивает через reflect.DeepEqual поля time.Time: wall, ext и loc. Разные time.Now() несут разные монотонные метки, а одно мгновение в разных тайм-зонах — разные указатели loc. Сверяйте методом t1.Equal(t2): assert.True(t, want.Equal(got)) или Unix-метки.
Чем EqualValues отличается от Equal и когда его брать?
EqualValues сначала пробует обычное сравнение, затем приводит числовые типы: пары int64 против int и float64 против int проходят. Плата — строгость: дробное может усечься до целого при конвертации. Для счётчиков из базы удобно, для денег оставьте Equal с явным приведением.
Можно ли сравнивать структуры с unexported-полями?
DeepEqual их сравнивает — в этом и ловушка: приватный кэш или счётчик делает «одинаковые» структуры разными. Если приватное состояние не часть контракта, берите EqualExportedValues: он сверяет только открытые поля, включая вложенные. Ещё вариант — собирать обе стороны одним конструктором.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- ElementsMatch, Subset и Len: коллекции под контролем
- InDelta и InEpsilon: числа с плавающей точкой
- True, False, Nil, NotNil и Empty: простые проверки
- Проверка паник: Panics и PanicsWithValue
- Проверка ошибок: Error, NoError и EqualError
- assert и require: когда тест должен падать сразу
- Первый тест: assert.Equal и go test
- Знакомство с Testify: зачем Go-тестам assert
- Все курсы по Go
Следующая ступень учебного пути
Viper: конфигурация приложений на Go
Интерактивный курс Viper для Go: YAML и env-переменные, приоритеты источников, Unmarshal, WatchConfig, профили окружений и секреты. 25 уроков с примерами.
- Собирать конфиг из файлов, окружения и флагов в один реестр
- Читать значения типизированно: Get, GetString, Unmarshal в структуры
- Задавать приоритеты источников: флаг > env > файл > default
- Перечитывать конфиг на лету через WatchConfig и OnConfigChange