Содержание урока · 10 разделов
Проверка ошибок в Go-тестах сводится к пяти вызовам — assert.NoError, assert.Error, assert.EqualError, assert.ErrorIs и assert.ErrorContains, — и они закрывают практически весь конвейер, от «ошибки быть не должно» до «sentinel найден сквозь три обёртки». Ручной вариант все помнят из базового курса: if err != nil { t.Fatalf(...) } — лесенка, которая на пятой проверке съедает экран и читается хуже кода, который она проверяет. Testify сжимает каждую развилку до одной строки, а отчёт при провале печатает то, что вернула функция, — диагноз ставится без открытия исходников. Разберём каждую проверку по назначению, сравним NoError с Nil — там прячется ловушка отчёта — и поймём, почему assert.Equal, который кажется универсальным, красит тесты на обёрнутых ошибках без вины кода. Предусловия мы в прошлом уроке отдали require: тот же принцип распространяется и на ошибки.
assert.NoError и assert.Error: базовая пара#
Две проверки, на которых держится большинство тестов. assert.NoError(t, err) проваливает тест, если err не nil, и при провале печатает текст самой ошибки: Received unexpected error и ниже то, что вернула функция. assert.Error(t, err) — зеркальный случай: тест красится, если err внезапно nil, то есть функция не отреагировала на заведомо плохой вход. Обе принимают только t, err и опциональное сообщение из семейства msgAndArgs — им нечего сравнивать, им важно лишь наличие или отсутствие значения. Второй аргумент-сообщение выручает, когда по отчёту неочевидно, какая из ошибок сценария произошла: assert.NoError(t, err, "конфиг должен парситься") выводит вашу подсказку после стандартного текста. Внутри обе проверки устроены тривиально — это сравнение с nil, — но именно они чаще всего оказываются в тестах: почти каждый вызов Go-функции возвращает error, и почти каждый тест начинается с проверки этого факта.
Пара NoError и Error в действии
GOfunc TestRegister(t *testing.T) {
err := Register("anna@example.com", 29)
assert.NoError(t, err) // корректный вход: ошибки быть не должно
err = Register("anna@example.com", -3)
assert.Error(t, err) // отрицательный возраст обязан вернуть ошибку
}Первый сценарий фиксирует счастливый путь, второй — защиту от плохого входа. При провале NoError в отчёт уйдёт текст ошибки от Register, а при провале Error сообщение честно скажет, что err оказался nil: функция приняла -3 и ничего не сказала.
assert.Nil против NoError: разница в отчёте#
assert.Nil(t, err) тоже проходит на nil-ошибке и тоже красит тест на настоящей ошибке — формально проверить ошибку им можно. Проблема вскрывается при провале: Nil печатает внутреннее представление значения, и в отчёте появляется что-то вроде Expected nil, but got: &errors.errorString{s:"connection refused"} — тип и содержимое структуры, из которых текст ошибки ещё надо выковырять глазами. NoError в той же ситуации выводит Received unexpected error: connection refused — сразу диагноз. Вторая причина — намерение: Nil говорит «значение пустое», NoError — «ошибки нет», и в тесте на ошибку вторая формулировка честнее описывает проверку. То же в другую сторону: для проверки, что err есть, Error читается яснее, чем NotNil. Грубое правило на каждый день: Nil и NotNil оставьте значениям — указателям, интерфейсам, слайсам, — а ошибки проверяйте парами Error и NoError.
EqualError: сверка текста целиком#
assert.EqualError(t, err, "expected text") — самая строгая проверка из семейства: она вызывает err.Error() и сравнивает результат со строкой дословно. Нюанс: err обязан быть не nil — на nil-ошибке проверка провалится с сообщением An error is expected but got nil. Рабочее место EqualError — там, где формулировка ошибки является частью контракта: валидация, которая обязана назвать проблемное значение; ошибка, чей текст попадает в ответ API; сообщение, по которому дежурный ищет инцидент в журналах. В этих случаях текст — не подробность, а обещание, и тест должен падать при любом его изменении. Внутри пакета, где формулировка — просто подсказка для человека, EqualError начинает мешать: каждое перефразирование сообщения красит тесты без изменения поведения. Там надёжнее проверять факт ошибки и её тип — об этом дальше.
EqualError на валидации
GOfunc TestValidateAge(t *testing.T) {
err := ValidateAge(-1)
assert.EqualError(t, err, "age must be positive, got -1")
}Сообщение валидации содержит значение аргумента — это часть обещания функции: человек в отчёте должен увидеть, какой возраст отклонён. Если кто-то уберёт значение из текста, тест упадёт: контракт нарушили, поведение изменилось, и EqualError фиксирует именно это.
ErrorIs и ErrorContains: sentinel и обёртки#
Go-код обычно не придумывает текст на месте, а отдаёт известную ошибку: sentinel объявляют переменной var ErrNotFound = errors.New("record not found") и возвращают из всех подходящих веток. Тест хочет сверить не текст, а факт: вернулась ли именно ErrNotFound. assert.ErrorIs(t, err, target) делает это через errors.Is — он пройдёт по цепочке обёрток и найдёт sentinel, даже если функция вернула fmt.Errorf("user %d: %w", id, ErrNotFound). Аргумент target — именно sentinel, а не строка. Для подстроки есть assert.ErrorContains(t, err, "substring"): он проверяет, что err не nil и текст ошибки содержит фрагмент. Это середина между строгим ErrorIs и дословным EqualError: подстрока устойчива к переформулировкам, но всё же проверяет содержимое. Типичный дуэт в тесте — ErrorIs по sentinel плюс ErrorContains по деталям вроде идентификатора записи.
Sentinel, обёртка и две проверки
GO// package store
var ErrNotFound = errors.New("record not found")
func (s *Store) User(id int) (*User, error) {
u, ok := s.users[id]
if !ok {
return nil, fmt.Errorf("user %d: %w", id, ErrNotFound)
}
return u, nil
}
func TestUserNotFound(t *testing.T) {
s := NewStore()
_, err := s.User(42)
assert.ErrorIs(t, err, ErrNotFound)
assert.ErrorContains(t, err, "user 42")
}User оборачивает sentinel через %w, и в err лежит не сама ErrNotFound, а обёртка с контекстом. ErrorIs находит sentinel сквозь обёртку, ErrorContains сверяет подстроку с итоговым текстом — вместе они фиксируют и тип ошибки, и попадание id в сообщение. Прогнать этот тест можно только локальным go test: тестовые пакеты в браузерной песочнице не исполняются.
Реальная история из ревью: тесты проверяли ошибку через assert.Equal(t, ErrNotFound, err) и годами были зелёными, пока в функцию не добавили контекст fmt.Errorf("load config: %w", err). Поведение не изменилось — sentinel возвращается тот же, но тесты покраснели: Equal сравнивает значения напрямую, и обёртка с ними не совпадает. Чинить код ради зелёных тестов никто не собирался — правились тесты: Equal заменили на ErrorIs, и набор снова стал надёжным. Вывод: Equal на ошибках работает ровно до первой обёртки; если в функции возможен fmt.Errorf с %w, пишите ErrorIs сразу.
assert.ErrorIs не сверяет указатели и не сравнивает текст — он вызывает errors.Is и полагается на протокол обёрток. Ошибка может реализовать метод Unwrap() error и вернуть внутреннюю ошибку; errors.Is идёт по цепочке шаг за шагом, на каждом уровне сравнивая цель через == или метод Is(error) bool. Отсюда свойства, важные для тестов. Обёрток может быть сколько угодно: load config: read file: not found — три уровня, и sentinel на дне всё равно найдётся. С Go 1.20 протокол пополнился Unwrap() []error для ошибок, склеенных через errors.Join: ErrorIs находит sentinel в любой из ветвей. Работают и sentinel стандартной библиотеки: assert.ErrorIs(t, err, fs.ErrNotExist) — обычная проверка в тестах на файлы, sql.ErrNoRows — в тестах на хранилища. А assert.Equal про этот протокол не знает ничего: он сравнивает пару значений как есть, поэтому и ломается на первом же слое обёртки.
Как выбрать проверку ошибки#
- Факт без деталей: assert.NoError на счастливом пути, assert.Error на сценарии с ошибкой.
- Известный sentinel или ошибка стандартной библиотеки: assert.ErrorIs — он переживёт любую обёртку fmt.Errorf с %w.
- Часть текста, которая обязана дойти до человека: assert.ErrorContains по подстроке.
- Формулировка — контракт: assert.EqualError с дословной строкой.
- Nil и NotNil — для значений, не для ошибок: пары NoError и Error дают внятный отчёт и честное намерение.
Пять проверок на одном экране#
| Проверка | Что сверяет | Типичное место |
|---|---|---|
| assert.NoError | err == nil | счастливый путь: функция отработала без ошибок |
| assert.Error | err != nil | плохой вход обязан вернуть ошибку |
| assert.EqualError | err.Error() и строка дословно | валидация и ошибки-контракты с текстом |
| assert.ErrorContains | подстрока в тексте ошибки | детали вроде id в сообщении |
| assert.ErrorIs | errors.Is по цепочке обёрток | sentinel-ошибки пакета и stdlib |
Проверка: сообщение при провале
Функция вернула ошибку connection refused. Какая проверка покажет её текст самым читаемым образом?
Проверка: обёрнутая ошибка
Функция вернула fmt.Errorf("load config: %w", ErrNotFound), где ErrNotFound = errors.New("not found"). Какая проверка пройдёт?
Что покажет прогон?
Прочитайте код и выберите, что он выведет:
var ErrNotFound = errors.New("not found")
func load() error {
return fmt.Errorf("read config: %w", ErrNotFound)
}
func TestLoad(t *testing.T) {
err := load()
assert.Equal(t, ErrNotFound, err)
assert.ErrorIs(t, err, ErrNotFound)
}Соберите проверки ошибок
Допишите проверки: счастливый путь, факт ошибки, сверка sentinel и подстрока с id.
Прежде чем писать EqualError, задайте вопрос: что случится, если формулировку перефразируют. Если текст попадает в ответ API, в алерт или называет значения — это контракт, и дословная проверка оправдана. Если ошибка нужна только журналу — проверяйте ErrorIs по sentinel и ErrorContains по деталям: рефакторинг сообщений не должен красить тесты. Граница простая: чем ближе текст к пользователю, тем строже проверка.
Соберите тест на sentinel
Расставьте строки файла: объявление sentinel, тест, вызов, две проверки и закрывающая скобка.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: проверки ошибок
Что дальше#
Ошибки проверены — следующий вопрос: что сравнивать, когда функция вернула не ошибку, а структуру или слайс. Тема урока про сравнение структур и слайсов: там внутри ObjectsAreEqual видно, когда assert.Equal обходит поля через reflect.DeepEqual, а когда сравнивает байты. Для ошибок в подготовке действует правило прошлого урока про assert и require: require.NoError на ошибке запроса — предусловие, а проверки ошибок из этого урока — уже содержательные проверки результата. Примеры урока собираются в пакет с go test и запускаются локально: браузерная песочница не исполняет тестовые пакеты, зато go test на папке со store прогонит весь набор за секунды. Карта всего курса — на странице testify-курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- assert.NoError проверяет отсутствие ошибки, assert.Error — её наличие; при провале оба печатают текст ошибки.
- assert.Nil тоже проходит на nil-ошибке, но при провале показывает внутреннее представление — NoError даёт внятный отчёт.
- assert.EqualError сверяет err.Error() с точной строкой: уместно, когда формулировка — контракт.
- assert.ErrorIs проверяет ошибку через errors.Is: sentinel находится сквозь обёртки fmt.Errorf с %w.
- assert.ErrorContains ищет подстроку в тексте ошибки — середина между хрупким EqualError и строгим ErrorIs.
- assert.Equal на ошибках ломается при любой обёртке: сравнение по значению не проходит по цепочке Unwrap.
- Выбор проверки: факт — Error и NoError, sentinel — ErrorIs, подстрока — ErrorContains, точный текст — EqualError.
Домашнее задание
Добавьте в свой пакет sentinel-ошибку var ErrNotFound и оберните её при возврате через fmt.Errorf с %w. Перепишите проверки тестов на ErrorIs и ErrorContains, одну ошибку с фиксированным текстом проверьте EqualError. Прогоните go test локально и убедитесь, что assert.Equal на обёрнутой ошибке действительно красный.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем assert.NoError лучше assert.Nil для ошибок?
Обе проверки пройдут одинаково: nil-ошибка — это nil-интерфейс. Разница в отчёте при провале: Nil напечатает внутреннее представление значения, а NoError выведет текст самой ошибки. NoError ещё и честнее заявляет намерение: проверяется именно отсутствие ошибки.
Когда EqualError оправдан, если текст хрупкий?
Когда формулировка — часть контракта: текст попадает в ответ API, в алерт или содержит значение аргумента, которое должно дойти до человека. Внутри пакета, где текст — просто подробности, надёжнее ErrorIs по sentinel или ErrorContains по подстроке: рефакторинг формулировок не красит тесты.
ErrorIs работает с ошибками стандартной библиотеки?
Да: подходит любая sentinel-ошибка, которую цепочка обёрток сохранила. Типичные случаи — fs.ErrNotExist и sql.ErrNoRows после fmt.Errorf с %w: errors.Is их находит, значит и assert.ErrorIs зелёный. А Equal сравнивает значения напрямую и на обёртке провалится.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Сравнение структур и слайсов в тестах
- ElementsMatch, Subset и Len: коллекции под контролем
- InDelta и InEpsilon: числа с плавающей точкой
- True, False, Nil, NotNil и Empty: простые проверки
- 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