Содержание урока · 11 разделов
- stub.On и Return: связка ожидание-ответ
- Несколько On на один метод: сценарии по аргументам
- Return повторяет сигнатуру метода
- Once, Twice, Times: счётчик вызовов
- Модификаторы ожидания на одном экране
- Run: побочный эффект на вызове
- Правила матчинга, которые помнит testify
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Прошлый урок закончился на том, что mock.Mock принимает вызовы и запоминает их. Осталась вторая половина: научить заглушку отвечать. Связка stub.On и Return в пакете mock — способ сказать: когда у тебя спросят Get с id 42, верни Anna и nil, а с id 7 — ErrNotFound. Именно так настраивается поведение заглушек: ожидание и ответ склеены в одну цепочку, читающуюся как сценарий теста. Подмена возможна потому, что зависимость спрятана за интерфейсом — приём из урока про интерфейсы: сервис знает метод, а кто за ним стоит — решает тест. Прогоняет всё go test, как разобрано в тестировании продвинутого курса. Дальше по порядку: ответы для разных аргументов, сигнатура Return, счётчики Once и Times, эффекты через Run и правила, по которым testify выбирает ожидание, когда подходят несколько.
stub.On и Return: связка ожидание-ответ#
Любая настройка начинается с вызова stub.On(метод, аргументы...). Метод — строка с именем, аргументы — то, с чем вызов обязан случиться: On("Get", 42) означает «ждать Get с сорока двумя». Цепочка возвращает строитель Call, у которого и берётся Return: .Return(user, nil). Пока вызова нет, ожидание просто лежит в моке; как только мок-метод делает m.Called(42), testify ищет подходящее On и отдаёт заготовленный ответ. Из этого устройства следуют три правила. Аргументы матчатся точно: 43 не подойдёт к On("Get", 42), а вызов вовсе без подходящего ожидания паникует с сообщением I don't know what to return. Return без On не бывает: сначала объявляете, что ждёте, потом — что вернёте. И настройка идёт до вызова тестируемого кода: после svc.Profile(42) менять сценарий поздно, поздние On повлияют только на следующие вызовы.
Первый сценарий: Get возвращает пользователя
GOtype User struct {
ID int
Name string
}
type UserRepo struct{ mock.Mock }
func (m *UserRepo) Get(id int) (*User, error) {
args := m.Called(id)
user, _ := args.Get(0).(*User) // comma-ok: nil от заглушки не паникует
return user, args.Error(1)
}
func TestProfileFound(t *testing.T) {
repo := new(UserRepo)
anna := &User{ID: 42, Name: "Anna"}
repo.On("Get", 42).Return(anna, nil)
svc := NewProfileService(repo)
got, err := svc.Profile(42)
require.NoError(t, err)
assert.Equal(t, anna, got)
}Мок встраивает mock.Mock, метод распаковывает ответ по позициям: args.Get(0) — первый параметр, args.Error(1) — второй. Запятая-ok при приведении типа оставляет user равным nil, когда заглушка вернула nil. Сценарий объявлен до вызова svc.Profile(42): On ждёт ровно 42 и отдаёт anna. Прогон — локальный go test: пакет mock не исполняется в браузерной песочнице.
Несколько On на один метод: сценарии по аргументам#
Один метод — сколько угодно ожиданий. On("Get", 42) и On("Get", 7) живут параллельно: на каждом вызове testify перебирает ожидания и берёт то, чьи аргументы совпали. Так записываются сценарии: счастливый путь, отсутствующая запись, отказ доступа — по ожиданию на ветку. Аргументы — единственный способ развести сценарии внутри одного метода, поэтому в них закладывают вход теста: id, который должен найтись, и id, который обязан уйти в ошибку. Если подходят несколько ожиданий, testify выбирает одно по своим правилам приоритета: в актуальной версии побеждает первое неисчерпанное в порядке объявления. Отсюда рабочий порядок «частные раньше общего»: сначала On("Get", 42) с конкретным ответом, затем On("Get", mock.Anything) — базовый ответ для всех остальных значений. Частный сценарий со счётчиком отвечает, пока счётчик жив, исчерпанный — уступает общий; а правило выбора между подходящими On менялось между версиями testify, так что порядок объявлений держите осознанным и одинаковым во всём наборе.
Два сценария: найден и не найден
GOvar ErrNotFound = errors.New("user not found")
func TestProfileScenarios(t *testing.T) {
repo := new(UserRepo)
anna := &User{ID: 42, Name: "Anna"}
repo.On("Get", 42).Return(anna, nil)
repo.On("Get", 7).Return(nil, ErrNotFound)
svc := NewProfileService(repo)
got, err := svc.Profile(42)
require.NoError(t, err)
assert.Equal(t, "Anna", got.Name)
_, err = svc.Profile(7)
assert.ErrorIs(t, err, ErrNotFound)
}Два On на один Get различаются только аргументом — и этого достаточно: 42 уходит в счастливый путь, 7 — в ErrNotFound. Проверку ошибки делаем ErrorIs: sentinel устойчив к обёрткам fmt.Errorf, которые сервис может добавить по дороге. Добавьте третий сценарий On("Get", 99) с другой ошибкой — набор расширится без правки старых ожиданий.
Return повторяет сигнатуру метода#
Return — не «что-то подходящее», а точная копия хвоста сигнатуры: столько же значений, в том же порядке и с теми же типами. Метод Get объявлен как (*User, error) — значит, Return ждёт два значения: указатель и ошибку. Внутри мок-метод распаковывает их по позициям: args.Get(0) — первый параметр, args.Error(1) — второй. Написать .Return(anna) без ошибки — оставить мок-метод без второго значения: при распаковке args.Error(1) тест упадёт паникой о нехватке аргументов. Перепутать порядок хуже: nil и ошибка меняются местами, счастливый путь внезапно «падает», а отчёт указывает не на Return, а на ветку сервиса — диагностика растягивается. Правило простое: скопируйте сигнатуру метода в Return слово в слово, включая nil-ы. Отдельный случай — методы без возвращаемых значений: для void-методов Return не нужен вовсе, одного On достаточно, но факт вызова такая заглушка всё равно запомнит и отдаст его при проверке вызовов.
Проверка: сколько значений в Return
Метод мока объявлен как func (m *Repo) Find(id int) (Item, error). Какой Return ему нужен?
Реальная история с ревью: сервис обязан отдавать профиль строго его владельцу, тест мокал репозиторий через On("Get", mock.Anything).Return(anna, nil) и проверял Profile(999) — зелёный: мок вернул Anna и на несуществующий id. В бою репозиторий отдал запись чужого пользователя, и тест не мог этого поймать в принципе: заглушка подменяет репозиторий, а не проверяет его. Если сценарий про то, что вернёт зависимость, различайте аргументы: точный On на несуществующий id с ответом nil, ErrNotFound. Заглушка честна ровно настолько, насколько точно повторяет контракт — включая ответы на плохие входы.
Once, Twice, Times: счётчик вызовов#
У каждого ожидания есть счётчик: .Once() — ответить один раз, .Twice() — два, .Times(n) — ровно n. По умолчанию счётчика нет, и заглушка отвечает бессрочно. Зачем ограничивать? Первое — расходуемость: частный сценарий с Once отвечает ровно один заход, исчерпанный — уступает дорогу другому подходящему ожиданию; если запасного нет, testify роняет тест сообщением о превышении числа вызовов. Второе — читаемость: Once в тесте говорит «этот сценарий — ровно один заход», и следующий читающий понимает намерение без комментариев. Третье — строгость: Times(3) читается как «метод обязан вызваться трижды», и это требование проверяется в конце теста через AssertExpectations — тема отдельного урока, там же разберём, что случится, если счётчик не израсходован. В этом уроке берём из счётчиков расходуемость и намерение: полный контроль над числом вызовов приходит позже, вместе с явной проверкой ожиданий.
Модификаторы ожидания на одном экране#
| Метод | Что делает | Когда берём |
|---|---|---|
| .Return(v, err) | Отдаёт значения при вызове | Всегда, когда метод возвращает значения |
| .Once() | Разрешает один вызов | Разовый сценарий внутри теста |
| .Twice() | Разрешает два вызова | Двойное срабатывание: ретрай, два запроса |
| .Times(n) | Разрешает n вызовов | Фиксированное число заходов |
| .Run(fn) | Побочный эффект при вызове | Записать вызов, закрыть канал, дернуть коллбэк |
| .Unset() | Снимает ожидание | Перенастроить заглушку между шагами теста |
Run: побочный эффект на вызове#
Return отвечает значением, Run — действием. Цепочка On("Delete", 42).Return(nil).Run(func(args mock.Arguments){ ... }) исполняет функцию всякий раз, когда срабатывает ожидание: аргументы приходят в args и читаются по позиции — args.Int(0), args.String(1), args.Error(2). Классика — собрать, что именно удалили: Run складывает id в слайс, тест в конце сверяет порядок. Так же пишут эффекты, которые нельзя выразить значением: закрыть канал, дернуть коллбэк, инкрементировать счётчик, отправить сообщение второй горутине. Честности ради: Mock и сам записывает каждый вызов в поле Calls — часто аргументы можно достать и без Run, просто прочитав m.Calls[0].Arguments после действия. Run берут, когда эффект нужен в момент вызова: заполнять слайс тестируемого кода, эмулировать запись в файл, срабатывать синхронно с обращением. Порядок в цепочке свободный: Return и Run можно менять местами, testify учтёт оба.
Run пишет удаления в слайс
GOfunc (m *Cache) Evict(id int) error {
args := m.Called(id)
return args.Error(0)
}
func TestCacheEvicts(t *testing.T) {
cache := new(Cache)
var evicted []int
cache.On("Evict", mock.Anything).
Return(nil).
Run(func(args mock.Arguments) {
evicted = append(evicted, args.Int(0))
})
svc := NewCleanupService(cache)
svc.Purge([]int{10, 20, 30})
assert.Equal(t, []int{10, 20, 30}, evicted)
}Evict отвечает nil через nil-безопасный args.Error(0), а Run в момент каждого вызова складывает id в evicted. Матчер mock.Anything пропускает любой ключ — содержимое мы всё равно фиксируем в слайсе и сверяем через assert.Equal. Для многоразовой заглушки со сбором вызовов это короче, чем On на каждый id.
Проверка: два подходящих ожидания
Ожидания настроены в этом порядке: сначала On("Get", mock.Anything).Return(guest, nil), затем On("Get", 42).Return(anna, nil). Что вернёт repo.Get(42) в актуальной testify?
Моё правило, в котором я далеко не одинок: до двух-трёх сценариев на метод берите On, дальше пишите фиктивную реализацию. Пять On с ветками превращают тест в интерпретатор условий: читающий восстанавливает, какое ожидание победит и почему. Фиктивный репозиторий с картой внутри отвечает по данным: data[42] — и сценарий виден сразу, плюс он переиспользуется тремя сервисами сразу. Условие, при котором я прав: интерфейс маленький и стабильный. Для одного теста с одним ответом мок короче — фиктивная реализация ради единственного возврата — церемония ради церемонии.
Правила матчинга, которые помнит testify#
- Аргументы матчатся точно: тип и значение по порядку — 43 не подойдёт к On с 42.
- Вызов без подходящего On паникует: mock: I don't know what to return because the method call was unexpected.
- Подходят несколько ожиданий — в актуальной версии побеждает первое неисчерпанное в порядке объявления.
- Исчерпанный счётчик Once или Times уступает дорогу другому подходящему On, а без запасного тест падает с сообщением о превышении вызовов.
- Частные сценарии объявляйте раньше общего Anything-ответа — иначе общий перехватит их вызовы.
- On объявляют до вызова тестируемого кода: после запуска менять сценарий поздно.
Что решит прогон?
Прочитайте код и выберите, что он выведет:
func TestProfileMissing(t *testing.T) {
repo := new(UserRepo)
repo.On("Get", 42).Return(&User{ID: 42, Name: "Anna"}, nil)
svc := NewProfileService(repo)
_, err := svc.Profile(43)
assert.Error(t, err)
}Соберите сценарий заглушки
Допишите настройку заглушки и проверку результата: объявление ожидания, ответ и сверку имени.
Соберите тест с двумя сценариями
Расставьте строки: мок, два ожидания, вызов и проверка ошибки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: On и Return
Что дальше#
Заглушка отвечает по сценариям — остался вопрос: с чем её вызывали? Этим займётся следующий урок про аргументы вызова: точные значения, mock.Anything, AnythingOfType и свои условия через MatchedBy. А объявленные здесь Times и Once обретут строгую проверку в уроке про AssertExpectations: факт вызова и его число станут требованием, а не намерением. Примеры урока — тестовые пакеты: они запускаются локальным go test, браузерная песочница их не исполняет, зато весь набор прогоняется за секунды. Возьмите за привычку добавлять к своим мокам по одному сценарию после каждого урока — к концу курса у вас будет живой справочник. Карта всех уроков — на странице testify-курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- stub.On(метод, аргументы).Return(...) — связка ожидание и ответ: заглушка отвечает только на заявленный вызов.
- Несколько On на один метод — сценарии: различаются аргументами, побеждает первое неисчерпанное ожидание в порядке объявления.
- Return копирует сигнатуру метода: столько же значений, в том же порядке; ошибка идёт последним значением.
- Once, Twice и Times задают счётчик: исчерпанное ожидание уступает общий On или роняет тест при превышении.
- Run исполняет побочный эффект в момент вызова: собрать вызовы в слайс, закрыть канал.
- Вызов без подходящего On паникует: mock: I don't know what to return.
- До двух-трёх сценариев — On, дальше фиктивная реализация интерфейса читается проще.
Домашнее задание
Возьмите сервис с зависимостью-интерфейсом и напишите три теста на одном моке: счастливый путь с точным id, отсутствующая запись с ErrNotFound и Times(2) с Run, собирающим вызовы в слайс. Затем добавьте четвёртый тест с вызовом необъявленного id и посмотрите на панику I don't know what to return в отчёте.
Личные заметкиПусто
Вопросы и ответы по уроку
Что вернёт заглушка, если On объявлен без Return?
Зависит от распаковки в методе мока: сгенерированные стабы достают значения comma-ok формой и отдают нулевые — nil, ноль, пустую ошибку. Но полагаться на умолчание не советую: явный Return убирает вопросы у читающего и защищает от паники при строгой распаковке.
Чем Run отличается от записи в m.Calls?
Mock и так запоминает каждый вызов в поле Calls — аргументы можно достать после действия. Run нужен, когда эффект должен случиться в момент вызова: заполнить слайс тестируемого кода, отправить в канал, эмулировать запись. Для простой инспекции аргументов Calls обычно достаточно.
Почему подошло несколько On и сработало не то ожидание?
В актуальной testify побеждает первое неисчерпанное подходящее ожидание в порядке объявления, в старых версиях — последнее. Переставьте объявления или дайте частному сценарию Once или Times: общий On с Anything объявляйте после частных, и поведение станет предсказуемым.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Аргументы вызова: Anything и свои matcher'ы
- AssertExpectations: проверяем, что мок вызван
- mockery: генерация моков из интерфейсов
- Тестирование HTTP-хендлеров с testify
- Знакомство с mock.Mock: первый мок
- Табличные тесты с testify
- Хуки сьюта: SetupSuite, TearDownTest и компания
- Test suite: suite.Suite и его методы
- Все курсы по Go
Следующая ступень учебного пути
Viper: конфигурация приложений на Go
Интерактивный курс Viper для Go: YAML и env-переменные, приоритеты источников, Unmarshal, WatchConfig, профили окружений и секреты. 25 уроков с примерами.
- Собирать конфиг из файлов, окружения и флагов в один реестр
- Читать значения типизированно: Get, GetString, Unmarshal в структуры
- Задавать приоритеты источников: флаг > env > файл > default
- Перечитывать конфиг на лету через WatchConfig и OnConfigChange