Всем привет! Меня зовут Семён Эйгин, я бэкендер в Авито, люблю опенсорс и периодически что-то туда контрибьючу. В этой статье разбираемся с моками и выбираем самый удобный инструмент (не обязательно лучший!). Это достаточно холиварная тема, хотя при подготовки статьи я не ожидал, что она окажется настолько спорной — у каждого разработчика своё мнение на этот счёт.

Почему вообще возникла идея сделать такую статью? Я — один из мейнтейнеров Minimoсk. Может быть, кто-то из вас его использовал, может, просто слышал. Это такой мокер для генерации моков. И я подумал: а почему бы не сравнить три популярных инструмента и не выявить хотя бы самый удобный?

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

Запись доклада по статье можно посмотреть здесь.

Содержание:

Задача

Собрались как-то три стажёра: Саша, Миша и Сёма (все совпадения случайны).

Миша говорит: «Я знаю инструмент для моков — mockery».

Саша отвечает: «А я про gomock читал».

А Сёма им возражает: «А я слышал про minimoсk».

Пока ребята обсуждали, приходит продакт и говорит:

«Ребята, надо сделать логистическую систему. Нам нужен сервис создания посылок».

Какие инструменты будем сравнивать:

Я ранжировал их по звёздочкам. Не очень корректное сравнение, потому что, например, gomock был форкнут и передан в поддержку другой компании, но тем не менее.

Тут еще больше контента

Глава 1. Несбывшиеся надежды

Итак, вернёмся к истории.

Продакт ставит задачу: написать сервис создания посылок. Задача ребят — написать бизнес-логику и протестировать её.

Миша, Сёма и Саша договорились провести мини-эксперимент: покрыть код тремя разными мокерами. То есть напишем тесты в трех вариациях, сгенерим моки тремя разными инструментами и выясним, кому было наиболее удобно работать. 

Первая часть задачи — написать сервис и протестировать логику. 

type UserStore interface {
    GetUser(id int64) (User, error)
}

func (u *ShipmentCreator) getUserAddress(userID int64) (string, error) {
    user, err = u.userStore.GetUser(userID)
    // ...
    return user.Address, nil
}

func (u *ShipmentCreator) CreateShipment(userID int64) error {
    // ...
    addr, err = u.getUserAddress(userID)

Вот элементарная бизнес-логика. В ней, естественно, много упрощений. Самое главное — у нас есть функция CreateShipment, которая принимает на вход ID юзера. Чтобы достать адрес пользователя, мы вызываем метод getUserAddress у интерфейса, который абстрагирует работу с базой данных UserStore.

Вроде бы всё просто и понятно. Давайте напишем к этому тест.

Первым за дело берётся Миша с mockery. Он пишет простейший тест:

func TestCreateShipment(t *testing.T) {
    // ...
    userStore := NewMockUserStore(t)
    userStore.EXPECT().GetUser(id: 1).Return(user, _a1: nil)
    // ...
    err = creator.CreateShipment(useID: 1)
  • задаёт ожидание, что GetUser будет передан ID = 1;

  • в функции создания посылок userID передаёт 1.

  • ожидает, что дальше 1 прокинется и всё сработает.

Запускаем тесты — и получаем фейл!

Diff: 0: FAIL: (int64=1) != (int=1)
mock UserStore.go:85: FAIL: GetUser(int)
    at: [/Users/slevgin/60landProjects/shipper/internal/mock_UserStore.go:56 /Users/slevgin/60landProjects/shipper/internal/
mock_UserStore.go:85: FAIL: 0 out of 1 expectation(s) were met.
    The code you are testing needs to make 1 more call(s).
    at: [/Users/slevgin/60landProjects/shipper/internal/mock_UserStore.go:85 /opt/homebrew/Cellar/go/1.22.3/libexec/src/testing/
FAIL: TestCreateShipment (0.00s)

type UserStore interface {
    GetUser(id int64) (User, error)
}

Удивительно: как можно было ошибиться в трёх строчках? Но тут важно вспомнить, что у интерфейса UserStore, у функции GetUser входной параметр ID имеет тип int64. 

При этом mockery генерирует не строго типизированные ожидания. Ожидание GetUser принимает в себя пустой интерфейс. 1 при этом приведется к типу int, а не int64. Далее в ожиданиях сравнивается 1 типа int с 1 типа int64, который принимает CreateShipment и пробрасывает в GetUser.

Вот что мы видим в логах:

(int64=1) != (int=1)

Это простая, но неприятная ошибка. Особенно в продуктовом коде, где функции могут принимать множество параметров и становится сложно отследить в каком из параметров возникла ошибка.

Исправляем: просто явно приводим 1 к int64, и всё проходит.

func TestCreateShipment(t *testing.T) {
    // ...
    userStore := NewMockUserStore(t)
    userStore.EXPECT().GetUser(int64(1)).Return(user, _a:: nil)
    // ...
    err = creator.CreateShipment(userID: 1)

Теперь за дело берётся Саша с gomock

func TestCreateShipment(t *testing.T) {
    // ...
    userStore := NewMockUserStore(ctl)
    userStore.EXPECT().GetUser(id: 1).Return(user, _a1: nil)
    // ...
    err = creator.CreateShipment(userID: 1)

Здесь почти всё то же самое: сигнатуры методов идентичны. У GetUser от gomock также не строго типизированные ожидания, тот же самый пустой интерфейс. 

TestCreateShipment
shipment_creator.go:44: Unexpected call to *internal.MockUserStoreGomock.GetUser([1]) at /Users/stevgin/GoLandProjects/shipper/internal/
    expected call at /Users/stevgin/GoLandProjects/shipper/internal/shipment_creator_test.go:52 doesn't match the argument at index 0.
    Got: 1 (int64)
    Want: is equal to 1 (int)

type UserStore interface {
    GetUser(id int64) (User, error)
}

И, как вы уже догадались, ошибка ровно та же: хотели int64, а получили почему-то int.

Исправляется аналогично — явно приводим тип.

func TestCreateShipment(t *testing.T) {
    // ...
    userStore := NewMockUserStore(ctl)
    userStore.EXPECT().GetUser(int64(1)).Return(user, _a1: nil)
    // ...
    err = creator.CreateShipment(userId: 1)

    === RUN    TestCreateShipment
    --- PASS: TestCreateShipment (0.00s)

PASS

Теперь работает Сёма с minimock

func TestCreateShipment(t *testing.T) {
    // ...
    userStore = NewUserStoreMock(t).GetUserMock.Expect(id: 1).Return(user, err: nil)
    // ...
    err = creator.CreateShipment(userID: 1)
}

=== RUN   TestCreateShipment
--- PASS: TestCreateShipment (0.00s)

PASS

// Expect sets up expected params for UserStore.GetUser
func (mmGetUser *mUserStoreMockGetUser) Expect(id int64) *mUserStoreMockGetUser {

У minimock немного другая генерация сигнатур: в Expect, который задает ожидания для параметров функции, строго типизированные ожидания. То есть если мы подставляем 1 — то она уже означает int64.

Запускаем тесты — всё работает, потому что мы сравниваем строго типизированные int.

Выводы:

  • у mockery и gomock не строго типизированные ожидания;

  • у minimock строго типизированные ожидания, которые позволяют избавиться от мелких раздражающих ошибок.

Cтрого типизированные ожидания особенно важны, когда у вас сложные параметры. Компилятор вместе с вашей IDE сами подсказывают какой тип принимается на вход, что позволяет сэкономить много времени на фиксы ошибок.

Глава 2. Двойная подстава

Продакт приходит снова:

«Теперь нам нужно, чтобы посылки создавались только для пользователей из России».

func (u *ShipmentCreator) validate(userID int64) error {
    user, err = u.userStore.GetUser(userID)
    // ...
    if user.Country != "Russia" {
        return fmt.Errorf("address is not available for delivery")
    }
    return nil
}

func (u *ShipmentCreator) CreateShipment(userID int64) error {
    if err := u.validate(userID); err != nil {
        return err
    }
    // ...
    addr, err = u.getUserAddress(userID)

Добавляем функцию валидации, где проверяем, что user.Country == «RU». Всё просто.

Но стажёры забыли: GetUser уже был вызван ранее. Зачем снова вызывать его при валидации? Не проще ли один раз получить данные и валидировать, используя адрес полученный ранее.

Проверим, помогут ли тесты поймать нежелательный повторный вызов GetUser:

  • Миша с mockery:

  === RUN TestCreateShipment
  --- PASS: TestCreateShipment (0.00s)
  PASS
  • Саша с gomock:

  === RUN TestCreateShipment
    shipment_creator.go:35: Unexpected call to *internal.MockUserStoreGomock.GetUser([1]) at /Users/stevgin/GolandProjects/shipper/internal/shipment
    expected call at /Users/stevgin/GolandProjects/shipper/internal/shipment_creator_test.go:67 has already been called the max number of times
  --- FAIL: TestCreateShipment (0.00s)
  • Сёма с minimock:

  === RUN TestCreateShipment
  --- PASS: TestCreateShipment (0.00s)
  PASS

У Миши всё «прекрасно».

У Сёмы тоже.

У Саши — тест падает!

Почему? Потому что gomock по умолчанию предполагает, что ожидание будет вызвано ровно один раз. Если вызов ожидания повторяется — тест падает. А вот mockery и minimock позволяют вызывать ожидания сколько угодно раз, если явно не указано иное.

Выводы:

  1. У gomock строгое ограничение на количество вызовов по умолчанию, которое спасает от непредвиденных вызовов.

  2. У mockery и minimock по умолчанию — бесконечное количество вызовов, поэтому не нужно править тесты, если добавил еще один вызов.

  3. У всех трех мокеров есть метод Times, задающий явно количество вызовов. Использовать его можно и нужно! • Писать везде AnyTimes в gomock — плохой паттерн (кроме кейсов, когда количество вызовов заранее не определено).

gomock в этом плане более строгий и может спасти от лишнего дублирования, правда тесты при этом падают. У minimock и mockery — больше гибкости, но и выше шанс упустить лишний вызов.

Отдельная рекомендация: используйте метод Times везде, где это возможно. И не злоупотребляйте AnyTimes — это потом может аукнуться.

Жми сюда!

Глава 3. Асинхронный тест-драйв

Приходит тот же самый продакт и говорит:

«Теперь нам нужно реализовать асинхронный лог записи о создании посылки. Что-то вроде аналитического события».

type historyLogger interface {
    Log(s *Shipment) error
}

func (u *ShipmentCreator) CreateShipment() {
    // ...
    go func() {
        err = u.historyLogger.Log(s)
        // ...
    }()
}

Ребята пишут бизнес-логику и хотят протестировать: вызывался ли Log? Тут начинаются проблемы — надо задать ожидание, что исторический логгер запишет лог. Но функция CreateShipment может быстрее исполниться, чем запустится горутина, отправляющая лог, что приведет к провалу тестов.

func TestShipmentCreator_CreateShipment(t *testing.T) {
    // ...
    resultCh := make(chan struct{})
    logger.EXPECT().Log(s).RunAndReturn(func(*Shipment) error {
        resultCh <- struct{}{}
        return nil
    })
    // ...
    creator.CreateShipment()
}

select {
    case <-resultCh:
    case <-time.After(time.Second):
}

Миша с mockery нашел такой выход: он встраивает канал в мок, используя хелпер RunAndReturn, который вызывается вместе с вызовом мока и явно ожидает, что лог вызовется. Это плохо масштабируется, особенно если асинхронных вызовов несколько.

func wait(ctl xgomock.Controller) {
    timer := time.NewTimer(time.Second)
    for !ctl.Satisfied() {
        select {
        case <-time.After(time.Millisecond):
        case <-timer.C:
            return
        }
    }
}

func TestShipmentCreator_CreateShipment(t *testing.T) {
    ctl := gomock.NewController(t)
    defer wait(ctl)
    // ...
    logger.EXPECT().Log(s).Return(_a0: nil)
    // ...
    creator.CreateShipment()
}

Саша с gomock делает по-другому. Использует хелпер Satisfied(), который проверяет, чтобы все ожидания были вызваны. Написал простенький цикл, задал разумный тайм-аут, чтобы дождаться выполнения всех ожиданий. В этом цикле каждую миллисекунду проверяет, вызвались ли все ожидания. 

func TestShipmentCreator_CreateShipment(t *testing.T) {
    mc := minimock.NewController(t)
    defer mc.Wait(time.Second)
    // ...
    logger = NewHistoryLoggerMock(mc).LogMock.Expect(s).Return(err: nil)
    // ...
    creator.CreateShipment()

Сёма с minimock делает ещё проще: использует mc.Wait(time.Second), встроенный в мокер из коробки. Просто в defer вызвать функцию Wait, которая будет ждать в течение тайм-аута, чтобы были вызваны все ожидания. Никаких костылей. 

Выводы:

  1. Для тестирования асинхронного кода с mockery и gomock приходится писать свою реализацию ожидания асинхронных вызовов.

  2. Реализация с mockery плохо масштабируется на несколько асинхронных операций.

  3. В minimock есть нативная поддержка для тестирования асинхронного кода.

По итогам эксперимента у Minimock — лучшая нативная поддержка для тестов асинхронного кода.

Глава 4. Плохие тесты

Теперь поговорим про плохие практики в тестировании. В этой главе мы не будем сравнивать конкретные мокеры — плохие практики одинаково распространяются на все.

Помним, что у нас была функция CreateShipment, и представим, что мы теперь хотим написать более умные табличные тесты.

func TestShipmentCreator_CreateShipment(t *testing.T) {
    testCases := []struct {
        name      string
        userStore UserStore
        userID    int64
        wantErr   assert.ErrorAssertionFunc
    }{
        {
            name:    "first case",
            userID:  1,
            wantErr: assert.NoError,
            userStore: NewUserStoreMock(t).
                GetUserMock.
                Expect(id: 1).
                Return(User{ID: 1}, err: nil),
        },
    }
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            u := &ShipmentCreator{
                userStore: tc.userStore,
            }
            err := u.CreateShipment(tc.userID)
            tc.wantErr(t, err)
        })
    }
}

Теперь ребята все вместе накинулись и написали тесты. Здесь уже ничего не зависит от выбора инструментов. Я заведомо не выделяю никакие части кода — просто посмотрите на него пару секунд и подумайте что плохого вы видите в данных кейсах. 

=== RUN   TestShipmentCreator_CreateShipment
=== RUN   TestShipmentCreator_CreateShipment/first_case
--- PASS: TestShipmentCreator_CreateShipment (0.00s)
    --- PASS: TestShipmentCreator_CreateShipment/first_case (0.00s)

PASS

Всё работает, все тесты прошли. Какая-то не очень интересная история получается, тогда зачем я про это рассказываю?

name: "first case",
userID: 1,
wantErr: assert.NoError,
userStore: NewUserStoreMock(t).
    GetUserMock.
    Expect(id: 1).
    Return(User{ID: 1}, err: nil),
},
{
    name: "second case",
    userID: 2,
    wantErr: assert.NoError,
    userStore: NewUserStoreMock(t).
    GetUserMock.
    Expect(id: 2).
    Return(User{ID: 2}, err: nil),
},

Добавляем второй кейс, который абсолютно идентичен первому (для упрощения демонстрации). 

=== RUN   TestShipmentCreator_CreateShipment
=== RUN   TestShipmentCreator_CreateShipment/first_case
=== RUN   TestShipmentCreator_CreateShipment/second_case
--- PASS: TestShipmentCreator_CreateShipment (0.00s)
    --- PASS: TestShipmentCreator_CreateShipment/first_case (0.00s)
    --- PASS: TestShipmentCreator_CreateShipment/second_case (0.00s)

PASS

И снова все тесты успешно пройдены. Здесь уже совсем непонятно, зачем мы затронули эту тему. 

internal go test -v -test.run "TestShipmentCreator_CreateShipment/first_case" ./...
=== RUN  TestShipmentCreator_CreateShipment
=== RUN  TestShipmentCreator_CreateShipment/first_case
=== NAME  TestShipmentCreator_CreateShipment
    user_store_mock_test.go:342: Expected call to UserStoreMock.GetUser at
    /Users/slevgin/GolandProjects/mock_conf/internal/table_minimock_test.go:31 with params: internal.UserStoreMockGetUserParams{id:2}
--- FAIL: TestShipmentCreator_CreateShipment (0.00s)
    --- PASS: TestShipmentCreator_CreateShipment/first_case (0.00s)

FAIL
FAIL  github.com/zcollect/shipper/internal    0.269s
FAIL

Запускаем только один кейс из тестовой таблицы — и внезапно он падает. Как такое может быть? 

name: "first case",
userID: 1,
wantErr: assert.NoError,
userStore: NewUserStoreMock(t).
    GetUserMock.
    Expect(id: 1).
    Return(User{ID: 1}, err: nil),

Посмотрим ближе на инициализацию моков — неважно, в каком именно инструменте, так как они работают примерно одинаково. Мы сразу создаем объект мока, когда инициализируется вся таблица кейсов. 

Заглянем под капот. 

func NewUserStoreMock(t minimock.Tester) *UserStoreMock {
    // ...
    t.Cleanup(m.MinimockFinish)
    // ...
    return m
}

В NewUserStoreMock вызывается Cleanup, который проверяет, когда все тесты пройдут, что все ожидания были вызваны. В случае запуска одного кейса все равно инициализируется сразу вся таблица и все объекты моков. В таком случае мы предполагаем, что все ожидания кейсов будут вызваны — но мы запускаем только один. У одного ожидания выполнились, второй не был запущен из-за чего мокер фейлит тест.

Решение

func TestShipmentCreator_CreateShipment(t *testing.T) {
    tests := []struct {
        // ...
        userStoreFn func(t *testing.T) UserStore
    }{
        {
            // ...
            userStoreFn: func(t *testing.T) UserStore {
                return NewUserStoreMock(t).
                    GetUserMock.
                    Expect(id: 1).
                    Return(User{ID: 1}, err: nil)
            },
            // ...
        },
    }

    for _, tt := range tests {
        t.Run(name, func(t *testing.T) {
            u := &ShipmentCreator{
                userStore: tt.userStoreFn(t),
            }
            err := u.CreateShipment(userID)
        })
    }
}

Легко решить проблему можно перейдя на паттерн билдер. Тогда инициализация мока происходит в функции, которая вызывается в теле кейса.

internal go test -v -test.run "TestShipmentCreator_CreateShipment/first_case" ./...
=== RUN  TestShipmentCreator_CreateShipment
=== RUN  TestShipmentCreator_CreateShipment/first_case
--- PASS: TestShipmentCreator_CreateShipment (0.00s)
    --- PASS: TestShipmentCreator_CreateShipment/first_case (0.00s)

PASS
ok    github.com/zoolleen/shipper/internal    0.691s

Таким образом, если вызван один кейс, то только его объекты моков инициализируются и ожидаются к выполнению в тесте.

Выводы:

  1. Писать тесты можно с учетом запуска сабтестов по отдельности вне зависимости от выбора инструмента для моков.

  2. Гибкие тесты дают удобство запуска каждого сабтеста в отдельности


Зачем вообще была эта часть, если мы не разбирали отдельные инструменты? Очень часто в коде встречаются тесты, которые написаны по нашему примеру. При изменении бизнес-логики зачастую приходится править тесты. Если написать тесты по нашему «плохому» примеру, то, чтобы исправить один или несколько кейсов, приходится запускать каждый раз весь полноценный тест со всеми кейсами, что очень неудобно. В случае тестов с билдером есть возможность отдельно запускать каждый кейс и исправлять при необходимости. 

Плохие тесты можно написать с любым инструментом. Но если вы делаете их гибкими и изолированными — потом это сэкономит массу времени.

Кликни здесь и узнаешь

Финальные итоги

  1. Mockery — сильный инструмент, но не очень удобный в некоторых случаях.

  2. Gomock — сильный инструмент, покрывает множество кейсов, но также не очень удобный в некоторых случаях.

  3. Minimock — самый простой, удобный и топорный инструмент, покрывающий 99 процентов случаев.

  4. Тесты с моками надо уметь писать более гибко.

Приз зрительских симпатий наши герои отдали minimock. Хотя, казалось бы, как можно по трем кейсам оценивать такие инструменты.

Самое главное не забывайте: у каждого разработчика — своё мнение. Экспериментируйте, сравнивайте, ищите то, что удобно вам.

Какому из трех инструментов вы бы отдали предпочтение? Какие риски видите в возможных кейсах? Делитесь впечатлениями в комментариях!

А если хотите вместе с нами помогать людям и бизнесу через технологии — присоединяйтесь к командам. Свежие вакансии есть на нашем карьерном сайте.

Комментарии (3)


  1. RodionGork
    22.08.2025 10:26

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

    Задача ребят – написать бизнес-логику и протестировать её.

    вот в данных примерах честно говоря не очень много той "логики" для тестирования которой нужны моки. Что в тесте создания посылки делают например expect-ы для GetUser? Всё это код который в общем-то к задаче тестирования не относится. И такого кода в тесте получается 95%.

    Вообще поскольку нынче модно писать "микросервисную архитектуру" (и вероятно вы так и делаете) - надо как-то перестраивать мозг и рассматривать свой микросервис как единицу для тестирования, а на более низкий уровень лазить только в исключительных случаях.


  1. evgeniy_kudinov
    22.08.2025 10:26

    Спасибо за статью.
    Не указано, какие тесты используем? Unit или какие-то другие?
    Mockery позволяет использовать тип Anything и это реально помогает некоторые параметры не принимать в тесте, если они не нужны для конкретного кейса, например context.
    Кстати, если бы в методах был бы сквозной ctx (context.Context) как аргумент, вызов асинхронного метода был бы простой в тесте с отменой.


  1. krus210
    22.08.2025 10:26

    сам использую минмок на проекте, вот он простой в использовании как автомат калашникова. никаких проблем с ним нет. любой джун разберется. трогал уберовский - вообще не зашло