Когда я вижу очередную статью или видеоурок про тестирование кода, я почти уверен, что мне опять расскажут про моки.

Создаётся впечатление, что это самый лучший и правильный способ писать тесты, и вообще, невозможно обойтись без моков. Это не так! Можно писать тестируемый код без моков. Более того, использование моков следует избегать и использовать их только в специфичных случаях.

Концепция мок-объектов

Концепция мок-объектов была впервые представлена в статье Endo-Testing: Unit Testing with Mock Objects на конференции eXtreme Programming and Agile Processes в 1999 году. И уже в этой статье написано, что моки — это не просто прокаченные заглушки, это целая парадигма, которая предлагает новый способ тестирования через проверку поведения вместо проверки состояния (Behavior vs State verification).

С тех пор появилось две стратегии написания тестов и несколько терминов, которые описывают одно и то же: Behavior vs State verification, Mockist vs Classical testing strategy, White-box vs Black-box testing, London vs Detroit Schools of Test-Driven Development.

Если гуглить эти термины, то можно найти¹ множество² статей³ с критикой⁴ мокисткого подхода, при этом мокисткий подход сейчас предлагается как тестирование по умолчанию, а классический подход даже не упоминается. Хочется напомнить о проблемах, которые несёт с собой тестирование через проверку поведения, и что существует альтернатива.

Для примера возьмём веб-приложение, написанное с использованием архитектурного паттерна Controller-Service-Repository.

CSR устроен как цепочка слоёв, где контроллер принимает http-запрос, передаёт его в сервис, который реализует бизнес-логику, а сервис обращается к репозиторию для работы с базой данных.

В нашем примере изображён http-метод /UpdateItem, который принимает данные запроса в контроллере, парсит и валидирует их. Далее в сервисе эти данные преобразуются в формат, понятный репозиторию, и передаются ему в методе repo.UpdateItem. Далее репозиторий формирует sql-запрос и отправляет его в базу данных.

Чтобы протестировать такую цепочку, кажется логичным написать изолированные (solitary) юнит-тесты для каждого компонента, тогда упавший тест сразу покажет, в каком компоненте ошибка. Так как у нас связанные классы и методы одного вызывают методы другого, нам нужно подменить реальные компоненты моками, поэтому для каждого компонента создаются интерфейсы.

Казалось бы, дело сделано, теперь мы можем написать юнит-тесты, подменив вложенные компоненты моками. Но насколько хороши такие тесты?

Какие тесты мы хотим видеть

Прежде чем говорить о том, что не так с мокистким подходом и конкретно с этими тестами, давайте сформулируем, что мы вообще хотим от тестирования кода, и к каким тестам мы должны стремиться.

Хорошие тесты позволяют:

  • Ловить реальные ошибки до того, как они попадут в прод.

  • Поддерживать уверенность, при рефакторинге менять код без страха всё сломать.

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

  • Снижать стоимость изменений. Чем раньше найдена ошибка, тем дешевле её исправить.

Какими должны быть хорошие тесты:

  • Надёжными — не ломаться без причины и не флакать.

  • Читаемыми — легко понять, что именно проверяется и почему.

  • Быстрыми — чтобы их удобно было запускать локально и в CI.

  • Значимыми — проверяют поведение, а не реализацию.

  • Устойчивыми — не требуют переписывания при каждом рефакторинге.

В чём вообще проблема с моками

1. Лишние интерфейсы

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

У этой проблемы даже есть специальный термин Interface Pollution, а также проблема подробно описана в книгах 100 ошибок в Go и как их избежать и Learn Go with tests.

Разработчики, пришедшие из языков вроде C# или Java, склонны создавать избыточное количество интерфейсов, и не считают это проблемой. Но в Go так не делается.

Rob Pike (создатель Go) в своём выступлении на GopherCon подчёркивает:

Don't design with interfaces, discover them.

То есть не придумывай интерфейсы заранее, пусть они естественным образом возникнут из кода.

В этом же выступлении он говорит о том, что чем больше методов описывает интерфейс, тем менее он полезен:

The bigger the interface, the weaker the abstraction.

Это записано как один из постулатов Go.

Часто разработчики создают интерфейсы заранее, чтобы подготовить код для тестов, которые они напишут когда-нибудь потом, когда будет время. Я уверен, что в коммерческой разработке такое время не настанет никогда. Юнит-тесты пишутся либо одновременно с фичей, либо вообще не пишутся! Такие интерфейсы ничто иное как Premature Abstraction. Преждевременные абстракции такое же зло, как и преждевременная оптимизация. Когда вы заранее создаёте интерфейс для использования его в тесте, помните, что вы прямо сейчас усложняете код для того, чтобы в будущем, возможно, получить пользу от его использования.

Мы настраиваем gocognit, который вычисляет когнитивную сложность функций, и не разрешаем мержить код, если она слишком большая. Но почему-то мы не учитываем сложность, которые привносят ненужные абстракции и усложнение архитектуры.

2. Тавтологичность

Тавтологичный тест — это тест, который проверяет сам себя. То есть он не добавляет новой ценности, а лишь подтверждает то, что уже и так явно написано в коде. Он может успешно проходить, даже если вся система в реальности сломается, потому что моки не отражают поведение настоящих зависимостей.

В данном примере у нас есть метод репозитория GetByID который возвращает пользователя по ID.

type userRepository struct {
	db pgxpool.Pool
}

func (r *userRepository) GetByID(ctx context.Context, id int) (User, error) {
	row := r.db.QueryRow(ctx, "SELECT id, name FROM users WHERE id = $1", id)
	var user User
	err := row.Scan(&user.ID, &user.Name)
	return user, err
}

Тест для него мог бы выглядеть так:

func TestUserRepository_GetByID_Tautology(t *testing.T) {
	mock, err := pgxmock.NewPool()
	require.NoError(t, err)
	defer mock.Close()

	expectedID := 1
	expectedName := "Alice"

	rows := pgxmock.NewRows([]string{"id", "name"}).
		AddRow(expectedID, expectedName)

	mock.ExpectQuery("SELECT id, name FROM users WHERE id = $1").
		WithArgs(expectedID).
		WillReturnRows(rows)

	repo := &userRepository{db: mock}
	user, err := repo.GetByID(context.Background(), expectedID)
	require.NoError(t, err)
	require.Equal(t, expectedID, user.ID)
	require.Equal(t, expectedName, user.Name)
}

Этот тест тавтологичен, потому что мок всегда возвращает "Alice" с ID 1, независимо от входного значения. Тест проверяет то, что сам и подстроил, никакой логики не проверяется.

Тавтологичные тесты не просто бесполезны и зря отнимают время разработчика, они ещё и вредны, потому что создают ложное ощущение, что код протестирован и надёжен.

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

Больше информации на эту тему:

3. Хрупкость

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

Для примера возьмём функцию, которая удаляет профили, что не заходили более 30 дней и не защищены от удаления.

package cleanup

import (
	"time"
)

type ProfileAPI interface {
	GetInactiveProfiles() ([]int64, error)
}

type ProfileDB interface {
	GetProfileData(id int64) (lastLogin time.Time, canDelete bool, err error)
	DeleteProfile(id int64) error
}

func CleanupInactiveProfiles(api ProfileAPI, db ProfileDB) error {
	ids, err := api.GetInactiveProfiles()
	if err != nil {
		return err
	}
	for _, id := range ids {
		lastLogin, protected, err := db.GetProfileData(id)
		if err != nil {
			return err
		}
		if !protected && time.Since(lastLogin) > 30*24*time.Hour {
			if err := db.DeleteProfile(id); err != nil {
				return err
			}
		}
	}
	return nil
}

Тест для неё мог бы выглядеть так:

package cleanup

import (
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
)

type MockAPI struct{ mock.Mock }
func (m *MockAPI) GetInactiveProfiles() ([]int64, error) {
	args := m.Called()
	return args.Get(0).([]int64), args.Error(1)
}

type MockDB struct{ mock.Mock }
func (m *MockDB) GetProfileData(id int64) (time.Time, bool, error) {
	args := m.Called(id)
	return args.Get(0).(time.Time), args.Get(1).(bool), args.Error(2)
}
func (m *MockDB) DeleteProfile(id int64) error {
	args := m.Called(id)
	return args.Error(0)
}

func TestCleanupInactiveProfiles_Brittle(t *testing.T) {
	api := new(MockAPI)
	db := new(MockDB)
	now := time.Now()
	oldTime := now.Add(-31 * 24 * time.Hour)
	api.On("GetInactiveProfiles").Return([]int64{1, 2, 3}, nil)
	db.On("GetProfileData", int64(1)).Return(oldTime, false, nil)
	db.On("DeleteProfile", int64(1)).Return(nil).Once()
	db.On("GetProfileData", int64(2)).Return(oldTime, true, nil)
	db.On("GetProfileData", int64(3)).Return(now.Add(-1*time.Hour), false, nil)
	err := CleanupInactiveProfiles(api, db)
	assert.NoError(t, err)
	db.AssertNumberOfCalls(t, "DeleteProfile", 1)
	db.AssertCalled(t, "DeleteProfile", int64(1))
}

Этот тест хрупкий потому что:

  • Зависит от внутренней реализации. Если мы решим оптимизировать код и перепишем методы db.GetProfileData и db.DeleteProfile так, чтобы они могли принимать несколько id, то нам придётся переписать и тест. Хотя логика не поменялась.

  • Жёстко проверяет количество вызовов. Если изменить условие удаления (например, с 30 на 60 дней), тест сломается, хотя логика останется корректной.

  • Знает конкретные id для удаления. Тест ожидает вызов db.DeleteProfile только для id=1. При изменении тестовых данных потребуется переписывать проверки.

Как бы мы поступили, если бы моков вообще не существовало?

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

...
		if ShouldDeleteProfile(lastLogin, protected, 30*24*time.Hour) {
			if err := deleter.DeleteProfile(id); err != nil {
				return err
			}
		}
...

// ShouldDeleteProfile определяет, нужно ли удалять профиль
func ShouldDeleteProfile(lastLogin time.Time, protected bool, cutoffDuration time.Duration) bool {
	return !protected && time.Since(lastLogin) > cutoffDuration
}

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

package cleanup

import (
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
)

func TestShouldDeleteProfile(t *testing.T) {
	now := time.Now()
	testCases := []struct {
		name           string
		lastLogin      time.Time
		protected      bool
		cutoffDuration time.Duration
		expected       bool
	}{
		{
			name:           "should delete - old and not protected",
			lastLogin:      now.Add(-31 * 24 * time.Hour),
			protected:      false,
			cutoffDuration: 30 * 24 * time.Hour,
			expected:       true,
		},
		{
			name:           "should not delete - too new",
			lastLogin:      now.Add(-29 * 24 * time.Hour),
			protected:      false,
			cutoffDuration: 30 * 24 * time.Hour,
			expected:       false,
		},
		{
			name:           "should not delete - not allowed",
			lastLogin:      now.Add(-31 * 24 * time.Hour),
			protected:      true,
			cutoffDuration: 30 * 24 * time.Hour,
			expected:       false,
		},
		{
			name:           "zero time - never logged in",
			lastLogin:      time.Time{}, // нулевое время
			protected:      false,
			cutoffDuration: 30 * 24 * time.Hour,
			expected:       true, // удаляем "мертвые" профили
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			result := ShouldDeleteProfile(tc.lastLogin, tc.protected, tc.cutoffDuration)
			assert.Equal(t, tc.expected, result)
		})
	}
}

Что мы получили:

  • Устойчивость к изменениям. Этот тест не сломается при следующем рефакторинге.

  • Простота тестирования. Нет сложных моков, только проверка входных и выходных параметров.

  • Чёткое разделение ответственности. Бизнес-логика теперь располагается отдельно от работы с внешними системами.

  • Лёгкость поддержки. Добавить новое условие удаления профиля очень просто.

  • Интерфейсы, специально созданные для моков, теперь можно удалить и упростить код.

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

Может показаться, что у нас уменьшился coverage и что теперь вместо большой функции мы тестируем функцию всего с одним условием. Но на самом деле coverage стал честным!

Вызовы api.GetInactiveProfiles, db.GetProfilesData и db.DeleteProfile, которые раньше прогонялись в тесте, это всё вызовы моков, а не реального кода. На самом деле, и до, и после рефакторинга то, что мы реально тестировали, — это условие в 27 строке.

Больше примеров с рефакторингом кода, чтобы сделать его более тестируемым, можно посмотреть в докладе Victor Rentea Test Driven Design Insights : 10 Hints You Were Missing.

4. Сложность для понимания

Тесты с моками сложны для понимания. Они требуют настройки поведения, ожиданий и проверок вызовов. Вместо простого набора входных и выходных данных приходится вникать в дополнительный код, о том как ведут себя моки, названия и логику подменных функций. Все эти детали отвлекают от сути теста и того, что на самом деле должно быть протестировано. Часто бывает трудно понять смысл теста, если вы не знакомы с реализацией тестируемой функции. Это делает код менее доступным для новых разработчиков.

Если вам приходится читать тест с моками, и вы замечаете, что мысленно прокручиваете код, который тестируется, чтобы понять суть теста, скорее всего, вы чрезмерно используете моки. Testing on the Toilet: Don't Overuse Mocks

Как обойтись без моков

1. Разделить обработку и ввод-вывод данных

Архитектурный подход Functional Core Imperative Shell предлагает выносить бизнес-логику в функциональное ядро, состоящее из чистых функций, а ввод-вывод, побочные эффекты и инфраструктурный код в императивную оболочку.

Чистая функция — это функция, которая при одних и тех же входных данных всегда возвращает один и тот же результат.

Например math.Sin, math.Sqrt, strings.TrimSpace, strings.Replace являются чистыми, их очень просто тестировать. И если мы полностью поменяем реализацию этих функции, старые тесты всё равно будут работать.

Этот подход можно совмещать с DDD, Гексагональной архитектурой или Controller-Service-Repository.

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

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

2. Понять, какие тесты вам действительно нужны

Конечно, мы не сможем перенести весь код в чистые функции, а следовательно, и покрыть его юнит-тестами. Кажется, что в этом большое преимущество тестов с моками. Но высокое покрытие кода не значит высокую полноту тестирования.

Нужно помнить, что сопровождение тестов небесплатное, их тоже надо поддерживать и тратить на это время программистов, поэтому не нужно стремиться к 100% покрытию кода. Большое покрытие тестами только замедляет разработку и не несёт практической пользы.

Test coverage — полезный инструмент для поиска непротестированных частей кодовой базы. Test coverage малопригоден в качестве числового показателя того, насколько хороши ваши тесты. Martin Fowler

В наше время писать плохие тесты как никогда просто: mockery сам генерирует код, а ChatGPT напишет любой тест для любой функции за секунду. Хотите 100% покрытие? Нет проблем! Но будут ли такие тесты полезны?

Общеизвестный факт, что цифры покрытия кода полезны только для менеджмента. Увеличение цифр не должно быть целью вашего набора тестов. Beyond Code Coverage

Половина работы большинства программ состоит в том, чтобы принять данные по http, обработать их и положить в базу данных. Вторая половина — это взять данные из БД, обработать их и отправить обратно.

Единственная часть, которую действительно стоит покрывать юнит-тестами — это обработка данных. I Mock Your Mocks

Jim Coplien предлагает периодически проводить ревизию тестов и безжалостно удалять тесты, которые не падали больше года.

Нужны только те тесты, которые проверяют ключевую логику и несут бизнес-ценность. Why Most Unit Testing is Waste (перевод)

3. Использовать интеграционные тесты

Если нужно протестировать код, который не получается вынести в чистые функции, возможно, что это — повод написать интеграционный тест.

Все мы знаем пирамиду тестирования, фундаментом которой являются юнит-тесты. Возможно, из-за этого мы в первую очередь стремимся писать юнит-тесты. Но, возможно, эта пирамида тестирования устарела, потому что она была предложена во времена монолитов. А в наше время повсеместного использования микросервисов большая часть сложности находится в их взаимодействии, и теперь правильнее ориентироваться на модель тестирования в виде соты, предложенную инженерами из Spotify.

Моки позволяют ускорить тестирование, эмулируя поведение зависимостей. Но это ускорение за счёт того, что большая часть кода просто не выполняется и не проверяется. Нужно помнить, что интеграционные тесты лучше тестов с моками, так как они тестируют не только код компонентов, но и их связанность, а также взаимодействие с реальными объектами такими как база данных, внешний api-сервер и т.д., тем самым позволяют находить значительно больше ошибок.

Eric Hartill в своём блог-посте о пирамиде тестирования рассуждает о том, что для компании дешевле иметь несколько серверов, которые будут запускать интеграционные тесты параллельно, чем иметь команду разработчиков, исправляющую и поддерживающую юнит-тесты каждый раз, когда они меняют метод, вместо того, чтобы писать новые фичи.

Также нужно вспомнить, что со времён 2000-х годов, когда были представлены моки и пирамида тестирования, прошло много времени, и сегодня у нас есть отличный инструментарий вроде Testcontainers, пакета httptest, CI/CD, облаков и быстрые компьютеры. Писать и прогонять интеграционные тесты уже не так дорого, как раньше. Почти любой проект можно поднять на компьютере разработчика за секунды. Вероятно, вы даже не заметите разницу между прогоном юнит-тестов и интеграционных тестов вашего микросервиса.

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

В нашем примере один интеграционный тест покрывает код всех компонентов, тестирует их взаимодействие, а также код middlewares, ORM и реальные sql-запросы в реальной базе данных. Такую глубину тестирования невозможно было бы достичь, если бы мы использовали тесты с моками.

Когда использовать моки

Конечно же, тестирование через поведение расширяет наши возможности, и нельзя просто отказываться от этого способа. Некоторые вещи невозможно было бы протестировать классическим способом, или это было бы значительно сложнее. Возможно, тесты с моками будут хорошим решением:

  • Когда нужно подготовить определённое состояние.

  • Когда нужно тестировать отказы или специфическое поведение.

  • В некоторых случаях, когда нужно тестировать асинхронные операции, например очереди.

Подробнее о таких кейсах можно послушать в этом докладе.

Кратко

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

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

  • Ключевую логику следует выносить в чистые функции и писать юнит-тесты только для них.

  • Моки позволяют сделать процент покрытия кода выше, но это метрика, которая ничего не говорит о качестве тестирования и качестве кода.

  • Интеграционные тесты лучше тестов с моками, потому что они проверяют всю цепочку вызова, а в наше время писать и прогонять их просто, как никогда.

Ссылки

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


  1. cupraer
    24.06.2025 06:49

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

    Этот тест хрупкий потому что:

    • Зависит от внутренней реализации. Если мы решим оптимизировать код и перепишем методы db.GetProfileData и db.DeleteProfile так, чтобы они могли принимать несколько id, то нам придётся переписать и тест. Хотя логика не поменялась.

    Интерфейсы плохо, потому что они мешают нам ломать обратную совместимость. Ясно. Может быть, надо просто научиться определять интерфейсы? Потому что ваша чистая функция — это и есть правильный интерфейс.

    Тестирование через поведение нужно применять тогда, когда нужно протестировать поведение. 

    Мне всегда нужно протестировать именно поведение. Интеграционные тесты, которые вы восхваляете, тестируют именно поведение.

    Интеграционные тесты лучше тестов с моками, потому что они проверяют всю цепочку вызова […]

    Ога, зато пока соседняя команда не допилит свой эндпоинт, я никаких тестов прогнать не смогу. Удобно!

    ———

    И, наконец, вишенка на торте:

    [Когда нужны моки?] — В некоторых случаях, когда нужно тестировать асинхронные операции […]

    В общем, вы в 2025 году считаете асинхронные операции «некоторыми случаями» (что неудивительно в свете выбора языка). Вся остальная индустрия в то же самое время старается буквально всё сделать асинхронным.

    Поэтому имеет смысл научиться правильно работать с моками, а не призывать использовать инструментарий XIX века. Так-то можно просто в прод всё выкатить, и пусть пользователи тестируют — вот уж точно лучший вариант тестов. Правда, дорогой.


    1. AlexAkulov Автор
      24.06.2025 06:49

      У вас столько статей про функциональное программирование, кажется вы должны понимать красоту чистых функций и преимущества классического тестирования.

      Интерфейсы плохо, потому что они мешают нам ломать обратную совместимость.

      Нет смысла сохранять обратную совместимость между сервисом и репозиторием одного компонента в одном микросервисе. В нашем примере они просто мешают рефакторингу.

      Мне всегда нужно протестировать именно поведение. Интеграционные тесты, которые вы восхваляете, тестируют именно поведение.

      Не совсем понимаю как вы тестируете поведение в интеграционных тестах. Вы отправляете какие-то данные на эндпоинт и проверяете, что вам вернулось именно те данные которые вы ожидаете. Это тестирование состояния.

      Ога, зато пока соседняя команда не допилит свой эндпоинт, я никаких тестов прогнать не смогу. Удобно!

      Как часто такое случается на практике? Мне кажется это достаточно редкий кейс. Но даже в таком случае вы сможете прогнать самые главные тесты, тесты вашей бизнес-логики. Либо использовать WireMock или что-то подобное для эмулирования API которого ещё не существует, если хотите написать интеграционные тесты заранее.

      В общем, вы в 2025 году считаете асинхронные операции «некоторыми случаями»

      Имеется ввиду работа с очередями (кафка, рэббит). В приведённом докладе более подробно рассказан этот кейс.


  1. qeeveex
    24.06.2025 06:49

    В golang нет проблем с интерфейсами, благодаря duck-typing.

    Статья высосана из пальца.