Программирование GoТестированиеРазработчик Go в backend-команде

После рефакторинга функция начала добавлять контекст к sentinel ошибке. Какую проверку следует использовать...

После рефакторинга функция начала добавлять контекст к sentinel-ошибке. Какую проверку следует использовать здесь, чтобы сохранить смысл теста?

package service

import (
    "errors"
    "fmt"
    "testing"
)

var ErrNotFound = errors.New("not found")

func load() error {
    return fmt.Errorf("load config: %w", ErrNotFound)
}

func TestLoad(t *testing.T) {
    if err := load(); err != ErrNotFound {
        t.Fatalf("got %v", err)
    }
}
Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Вместо сравнения через == нужно использовать errors.Is(err, ErrNotFound). Обёрнутая ошибка содержит исходную ошибку внутри цепочки, но сама не является тем же значением, поэтому прямое сравнение обычно завершается неудачей.

func TestLoad(t *testing.T) { if err := load(); !errors.Is(err, ErrNotFound) { t.Fatalf("expected %v, got %v", ErrNotFound, err) } }

Исторический контекст

До появления стандартного механизма оборачивания ошибок код часто сравнивал sentinel-ошибки напрямую или вручную извлекал причины. Такой подход плохо переносил добавление контекста: функция хотела сообщить дополнительную информацию, но изменение текста или структуры ошибки могло сломать тесты и обработку ошибок.

В Go 1.13 появились форматирование %w, функция errors.Is и функция errors.As. Они отделяют внешний контекст ошибки от проверки её семантической причины.

Постановка проблемы

ErrNotFound создаётся один раз, но fmt.Errorf("...: %w", ErrNotFound) возвращает новое значение ошибки. Его текст содержит исходное сообщение, однако сама внешняя ошибка не равна sentinel-значению через ==.

Проверка текста вроде err.Error() == "not found" тоже неверна: она ломается при добавлении полезного контекста и проверяет формат сообщения, а не причину ошибки.

Подробное решение

errors.Is проверяет саму ошибку и последовательно проходит по цепочке, получаемой через Unwrap(). Поэтому вызов успешно распознаёт ErrNotFound, даже если между внешней ошибкой и sentinel-ошибкой есть несколько уровней оборачивания.

Оператор == проверяет равенство значений ошибок, а не наличие одной ошибки внутри другой. Он уместен только тогда, когда контракт специально требует сравнить конкретные значения и оборачивание не допускается; для проверки причины ошибки предпочтителен errors.Is.

Оборачивание должно выполняться через %w:

func load() error { return fmt.Errorf("load config: %w", ErrNotFound) }

Форматирование через %v добавляет только текст и не сохраняет машинно доступную связь с исходной ошибкой. После %v errors.Is уже не сможет найти ErrNotFound внутри результата.

Ситуация из практики

Сервис загружал конфигурацию и возвращал ErrNotFound. После добавления имени файла команда заменила возврат на fmt.Errorf("%s: %v", path, ErrNotFound). Тесты, проверяющие текст, продолжили работать только для старого формата, а обработчик, который должен был выбрать значение по умолчанию, перестал распознавать причину.

Рассматривались три варианта. Сравнение текста было простым, но зависело от формулировки сообщения. Ручной вызов errors.Unwrap проверял только один уровень и становился хрупким при изменении цепочки. Переход на %w вместе с errors.Is сохранил контекст для человека и стабильный контракт для кода.

Выбранное решение использовало %w при формировании ошибки и errors.Is в тестах и обработчиках. В результате добавление новых уровней контекста больше не меняло поведение проверки причины.

Что кандидаты часто упускают

  1. Чем errors.Is отличается от errors.As?

    errors.Is отвечает на вопрос, присутствует ли в цепочке конкретная ошибка или совместимая с ней причина. errors.As извлекает из цепочки ошибку определённого типа и записывает её в переданную переменную.

    Например, errors.Is подходит для sentinel-ошибки ErrNotFound, а errors.As — для получения структурированной ошибки с полями Code или Path. Эти функции не взаимозаменяемы: первая проверяет идентичность или соответствие причины, вторая предоставляет доступ к типизированным данным.

  2. Всегда ли errors.Is сравнивает только исходное значение ошибки?

    Нет. Помимо стандартного прохождения через Unwrap, механизм может учитывать метод Is(error) bool, определённый пользовательским типом ошибки. Такой метод позволяет объявить две разные ошибки семантически совместимыми.

    Поэтому реализация Is должна быть узкой и предсказуемой. Слишком широкое совпадение скроет реальные различия между причинами и приведёт к неверной обработке ошибок.

  3. Что произойдёт, если заменить %w на %v в load?

    Пользователь увидит тот же текстовый контекст, но цепочка ошибок будет потеряна. Проверка errors.Is(load(), ErrNotFound) вернёт false, поскольку результат больше не сообщает стандартной библиотеке, какая ошибка была причиной.

    Это особенно важно на границах пакетов: вызывающий код обычно не должен разбирать строки ошибок. Если причина является частью контракта, её нужно сохранять через %w или возвращать структурированным способом.