Модуль: Тестирование · Уровень: Middle+/Senior

TL;DR#

testify — самая популярная вспомогательная библиотека: assert (помечает fail и продолжает тест) и require (помечает fail и немедленно останавливает через t.FailNow). Используйте require для предусловий, без которых дальнейшие проверки бессмысленны/опасны (nil-разыменование), и assert для независимых проверок, где хочется увидеть все падения сразу. Стандартной библиотеки (if got != want { t.Errorf(...) }) часто достаточно — для простых сравнений она читаема и без зависимостей; testify окупается на множестве проверок и понятных дифф-сообщениях. Для больших/структурных выходов — golden files: эталон в testdata/*.golden, обновляемый флагом -update, сравнение через go-cmp.

Теория#

assert vs require#

import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestUser(t *testing.T) {
    u, err := repo.Get(ctx, 42)
    require.NoError(t, err)   // если err != nil — дальше идти нельзя, FailNow
    require.NotNil(t, u)      // защищаем от nil-разыменования ниже

    assert.Equal(t, "alice", u.Name)  // независимые проверки —
    assert.Equal(t, 42, u.ID)         // увидим оба падения за один прогон
    assert.True(t, u.Active)
}
  • require.* при провале вызывает t.FailNow() — останавливает текущую горутину теста. Применять, когда продолжение не имеет смысла или приведёт к панике (после получения err проверяем NoError, перед разыменованием — NotNil).
  • assert.* при провале вызывает t.Fail() — тест помечен проваленным, но выполняется дальше. Применять для группы независимых проверок, чтобы получить полную картину за один запуск.

Важно: require нельзя вызывать из не-тестовой горутины (как и t.FailNow) — FailNow делает runtime.Goexit, завершая только свою горутину. В горутинах используйте assert + синхронизацию и проверку в основной горутине.

Полезные функции testify#

assert.Equal(t, want, got)            // глубокое сравнение (ObjectsAreEqual)
assert.EqualValues(t, want, got)      // с приведением типов (int32 vs int64)
assert.ElementsMatch(t, a, b)         // равны как мультимножества (порядок не важен)
require.ErrorIs(t, err, ErrNotFound)  // errors.Is
require.ErrorAs(t, err, &target)      // errors.As
assert.ErrorContains(t, err, "denied")
assert.Eventually(t, cond, 2*time.Second, 50*time.Millisecond) // polling для асинхронного
assert.Panics(t, func(){ ... })
assert.InDelta(t, 1.0, x, 0.01)       // флоаты с допуском
assert.JSONEq(t, `{"a":1}`, body)     // семантическое сравнение JSON

assert.Equal(t, expected, actual) — порядок аргументов важен для читаемости дифф-сообщения (expected первым).

Когда стандартной библиотеки достаточно#

func TestAdd(t *testing.T) {
    if got := Add(2, 3); got != 5 {
        t.Errorf("Add(2,3) = %d, want 5", got)
    }
}

Стандартный подход выигрывает, когда:

  • сравнение простое (скаляры, строки),
  • проект сознательно избегает внешних зависимостей (стандарт в Go-сообществе и в самом Go SDK — testify не используется в исходниках Go),
  • нужен полный контроль над сообщением об ошибке.

Для сложных структур стандарт комбинируют с go-cmp:

if diff := cmp.Diff(want, got); diff != "" {
    t.Errorf("mismatch (-want +got):\n%s", diff)
}

cmp.Diff даёт читаемый дифф, поддерживает опции (cmpopts.IgnoreFields, cmpopts.EquateApprox, кастомные Equal), безопасен на неэкспортируемых полях (с опцией) — часто это лучше, чем assert.Equal для глубоких структур.

Позиция многих senior-инженеров: testify удобен и широко распространён, но добавляет зависимость и свой DSL; для библиотек предпочтительнее стандарт + go-cmp, для прикладных сервисов testify часто оправдан скоростью написания. Знание обоих и trade-off важнее догмы.

Golden files#

Эталонный вывод хранится в файле; тест сравнивает фактический вывод с ним. Подходит для больших/структурных артефактов: сгенерированный код, HTML/JSON-рендеры, форматтеры, отчёты.

var update = flag.Bool("update", false, "update golden files")

func TestRender(t *testing.T) {
    got := Render(input)

    golden := filepath.Join("testdata", t.Name()+".golden")
    if *update {
        require.NoError(t, os.WriteFile(golden, []byte(got), 0o644))
    }
    want, err := os.ReadFile(golden)
    require.NoError(t, err)

    if diff := cmp.Diff(string(want), got); diff != "" {
        t.Errorf("golden mismatch (-want +got):\n%s", diff)
    }
}

Обновление: go test -run TestRender -update. Затем эталон ревьюится в diff и коммитится.

Достоинства: легко поддерживать большие ожидаемые выходы, изменения видны в code review как diff golden-файла. Риски: бездумный -update коммитит регрессию как «новый эталон» — golden-файлы обязаны ревьюиться человеком; нестабильный вывод (таймстемпы, мапы с рандомным порядком, указатели) делает golden флаки — нормализуйте перед сравнением (сортировка ключей, замена времени плейсхолдером). testdata — специальное имя: go test игнорирует эту директорию при сборке.

Подводные камни / gotchas#

  • Путаница assert/require. assert.NoError не остановит тест — следующая строка разыменует nil и упадёт паникой с непонятным стеком. Перед разыменованием — require.
  • require из горутиныFailNow/Goexit завершит только горутину, тест продолжится в неопределённом состоянии. В горутинах — assert + сбор результатов.
  • Порядок аргументов Equal(t, expected, actual) перепутан → дифф «-want +got» вводит в заблуждение.
  • assert.Equal на функциях/каналах/несравнимых и на типах с указателями внутри даёт неинтуитивные результаты; для сложных структур надёжнее cmp.Diff с опциями.
  • assert.Equal различает типы: int(1) != int64(1). Нужен EqualValues или приведение.
  • Golden без ревью. -update без вдумчивого diff закрепляет баг. Golden-файлы — часть code review.
  • Нестабильный golden: время, рандомизированный порядок мап, абсолютные пути, указатели. Нормализуйте вывод детерминированным.
  • Зависимость ради ассертов. Для библиотеки добавление testify в go.mod навязывает зависимость потребителям тестов — взвешивайте.
  • assert.Error/NoError без проверки конкретной ошибки маскирует «не ту» ошибку. Используйте ErrorIs/ErrorAs.

Вопросы на собеседовании#

В: В чём разница между assert и require в testify? О: assert помечает тест проваленным (t.Fail) и продолжает выполнение — годится для независимых проверок, чтобы увидеть все падения. require вызывает t.FailNow и немедленно прекращает тест — для предусловий, без которых дальше идти нельзя (после получения ошибки, перед разыменованием nil).

В: Можно ли использовать require внутри горутины? О: Нет. require вызывает FailNow, который делает runtime.Goexit и завершает только текущую горутину, оставляя тест в неопределённом состоянии. В горутинах используют assert и собирают результаты, проверяя их в основной горутине теста.

В: Когда стандартной библиотеки достаточно и зачем тогда testify? О: Для простых сравнений скаляров/строк if got != want { t.Errorf } читаем и без зависимостей — так пишут в самом Go SDK. testify окупается на множестве проверок, даёт лаконичный API и понятные дифф-сообщения, ErrorIs/ErrorAs/Eventually. Для глубоких структур многие предпочитают стандарт + go-cmp.Diff.

В: Что такое golden files и когда они уместны? О: Эталонный вывод в testdata/*.golden, с которым сравнивается фактический результат; обновляется флагом -update. Уместны для больших/структурных артефактов (генерация кода, рендеры, форматтеры), где изменения удобно ревьюить как diff. Требуют детерминированного вывода и обязательного ревью при обновлении.

В: Чем cmp.Diff лучше assert.Equal для структур? О: Даёт человекочитаемый дифф (что именно отличается), поддерживает опции (игнор полей, допуск для флоатов, кастомный Equal, обработка неэкспортируемых полей), и явно паникует на несравнимом, заставляя задать опцию. assert.Equal для глубоких структур даёт менее наглядное сообщение.

В: Почему assert.Equal(t, int32(1), int64(1)) падает? О: testify сравнивает и значение, и тип через рефлексию; разные числовые типы не равны. Нужно assert.EqualValues (приводит типы) или явное приведение к одному типу.

В: Главный риск golden files и как его снизить? О: Бездумный -update закрепляет регрессию как новый эталон. Снижают обязательным ревью diff golden-файлов в PR и нормализацией нестабильных частей вывода (время, порядок ключей мап, пути), чтобы тест не был флаки.

На что копают на senior+#

  • Осознанный выбор: trade-off testify vs стандарт+go-cmp, отказ от зависимостей в библиотеках, единообразие в проекте.
  • Семантика fail: Fail vs FailNow, поведение в горутинах, корректная обработка ошибок (ErrorIs/As вместо «просто была ошибка»).
  • Сравнение значений: go-cmp опции, кастомные Equal, обращение с неэкспортируемыми полями, временами, протобуфами, флоатами.
  • Golden-дисциплина: детерминизм вывода, нормализация, ревью эталонов, защита от «обновил и закоммитил баг».
  • Качество сообщений: информативность ассертов для быстрой диагностики, t.Helper() в кастомных хелперах для правильного указания строки падения.