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

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

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

Урок 5 из 25 ~18 мин +50 XP

Сравнение структур и слайсов в тестах

Сравнение структур в тестах на Go выглядит тривиально, пока assert.Equal не красит тест на объектах, которые «одинаковые»: в отчёте ожидание совпадает с фактом до последнего поля. Под капотом Equal работает через ObjectsAreEqual — и у этой механики есть границы: байты, тайм-зоны, unexported-поля и типы вроде int64 против int. Разберём, когда Equal честен, когда лжёт и чем его заменить.

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

Тест на структуру профиля красный, а в отчёте 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 на структуре со слайсом и картой

GO
type 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, о нём ниже.

Два стиля рядом

GO
func 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, который сравнивает только открытые поля.

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, который красит честный тест

GO
func 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-метками.

Разные типы и 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))
}

Соберите проверки на структуру и время

Допишите имена проверок и метод: числовое поле из базы, сверка инстантов времени и точный отчёт по полю.

func TestSnapshot(t *testing.T) { got := collect() // Snapshot{Count int64, At time.Time, ...} assert.(t, 42, got.Count) // константа int против int64 assert.True(t, want.At.(got.At)) // сверка мгновений time.Time assert.(t, want.Name, got.Name) // отчёт укажет конкретное поле }

Соберите тест на тайм-зоны

Расставьте строки теста: фиксированный момент в UTC, тот же момент в MSK, сверка мгновения и сверка представлений.

Строки

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

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

Карточки: сравнение структур и слайсов

Термин

ObjectsAreEqual

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

Механика assert.Equal: nil-проверка, шорт-кат bytes.Equal для пары []byte, для всего остального — reflect.DeepEqual.

1 / 6

Что дальше#

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

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

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

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

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

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

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

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

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

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

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