Олег Марков
Мокирование данных - базовые и продвинутые техники для надежных тестов
Введение
Мокирование данных (mocking) — это техника, которая позволяет вам подменять реальные зависимости в коде на «фальшивые» версии во время тестирования. Смотрите, идея очень простая: вместо того чтобы ваш тест ходил в реальную базу данных, вы подсовываете ему объект, который ведет себя «как база», но ничего не сохраняет и не читает по‑настоящему.
Давайте сразу зафиксируем, зачем это нужно:
- изолировать тестируемый код от внешних систем (БД, сетевые сервисы, файловая система);
- сделать тесты быстрыми и стабильными;
- проверять не только результат, но и то, как ваш код взаимодействует с зависимостями (какие методы вызываются, с какими аргументами).
В этой статье мы разберем основные виды моков, отличия от других тестовых двойников (stub, fake, spy), общие принципы проектирования кода под мокирование и посмотрим на конкретные примеры, как это делается на практике (на примере Go и немного псевдокода, чтобы было понятно независимо от языка).
Что такое мокирование данных
Тестовые двойники и их роль
Чтобы лучше понять mocking, важно различать несколько типов тестовых объектов. В литературе их называют «тестовыми двойниками» (test doubles). Давайте кратко их перечислим:
- Dummy — заглушка, которая существует просто чтобы занять аргумент метода (но не используется).
- Stub — объект, который возвращает заранее подготовленные данные, но не проверяет, как его вызвали.
- Fake — упрощенная реализация, обычно с «мини‑логикой» (например, in‑memory база вместо реальной).
- Spy — объект, который запоминает, как его вызывали (аргументы, количество вызовов).
- Mock — объект, который не только имитирует поведение, но и проверяет ожидания: какие методы должны быть вызваны и сколько раз.
Смотрите, я покажу вам простое разделение: mocks — это про «проверку взаимодействия», stubs — про «подставить данные». На практике инструменты mocking‑фреймворков часто позволяют делать и то, и другое, поэтому границы немного размыты. Но концептуально разница именно такая.
Когда использовать мокирование
Мокирование особенно полезно, когда:
- есть внешние зависимости:
- база данных;
- REST/GraphQL API;
- message queue;
- файловая система;
- кэш (Redis, Memcached);
- зависимости нестабильны или недоступны в тестовой среде;
- вы хотите протестировать сложную бизнес‑логику, а не интеграцию с внешними системами;
- важно проверить сам факт вызова методов (например, что при ошибке отправляется уведомление).
Но при этом чрезмерное мокирование вредит:
- тесты становятся «хрупкими» — любое изменение внутренней реализации ломает ожидаемые вызовы;
- тесты начинают проверять детали реализации, а не поведение;
- код переполнен интерфейсами «ради тестов».
Чуть позже мы обсудим, как избежать таких проблем.
Базовые принципы мокирования
Принцип инверсии зависимостей
Чтобы мокировать что‑то в тестах, вам сначала нужно научиться отделять «что делает код» от «с чем он работает». Обычно это делается через интерфейсы или абстракции.
Представьте сервис:
- он читает пользователя из базы;
- отправляет ему письмо;
- логирует результат.
Если вы сделаете эти зависимости жестко зашитыми внутрь (newDatabaseClient, newMailer и т.п.), вы не сможете подменить их в тесте. Поэтому вам нужен либо конструктор, либо dependency injection.
Давайте разберемся на примере на Go. В других языках идея такая же.
Пример без мокирования (плотные зависимости)
Сначала пример, который мокировать неудобно:
type UserService struct{}
func (s *UserService) Register(email string) error {
// Здесь мы напрямую создаем клиента БД
db := NewRealDBClient() // Тесная связь с реальной БД
// Здесь создаем реальный отправитель почты
mailer := NewMailer()
user := &User{Email: email}
// Сохраняем пользователя
if err := db.SaveUser(user); err != nil {
return err
}
// Отправляем письмо
if err := mailer.SendWelcomeEmail(user); err != nil {
return err
}
return nil
}
Комментарии:
// В этом коде невозможно просто так подменить БД и мейлер // Все зависимости создаются прямо внутри метода // Для теста это плохо - он будет зависеть от реальных внешних ресурсов
Такой код сложно тестировать юнит‑тестами: вам придется поднимать настоящую базу и почтовый сервис или городить сложную конфигурацию.
Пример с интерфейсами и внедрением зависимостей
Теперь давайте посмотрим, как изменить дизайн, чтобы его было удобно мокировать:
// Интерфейс работы с пользователями в хранилище
type UserRepository interface {
SaveUser(user *User) error
}
// Интерфейс для отправки писем
type Mailer interface {
SendWelcomeEmail(user *User) error
}
type UserService struct {
repo UserRepository
mailer Mailer
}
// Конструктор сервиса - зависимости передаются снаружи
func NewUserService(repo UserRepository, mailer Mailer) *UserService {
return &UserService{
repo: repo,
mailer: mailer,
}
}
func (s *UserService) Register(email string) error {
user := &User{Email: email}
// Здесь используется абстракция - нам не важно, реальная это БД или мок
if err := s.repo.SaveUser(user); err != nil {
return err
}
if err := s.mailer.SendWelcomeEmail(user); err != nil {
return err
}
return nil
}
Комментарии:
// Теперь UserService не создает зависимости сам // Вместо этого они приходят через конструктор // В продакшене вы передадите реальные реализации // В тестах - мок-объекты, реализующие те же интерфейсы
Теперь вы легко можете подменить repo и mailer на моки, не затрагивая бизнес‑логику.
Виды моков и подходы к их созданию
Ручные (hand-written) моки
Самый прозрачный способ — написать мок руками. Вы просто создаете структуру (или класс), которая реализует нужный интерфейс, и добавляете в нее поля для фиксации вызовов и возвращаемых значений.
Давайте я покажу вам, как это выглядит:
// Мок репозитория пользователей
type UserRepositoryMock struct {
SavedUsers []*User // Здесь мы запоминаем, какие пользователи были сохранены
SaveErr error // Ошибка, которую нужно вернуть (если нужна)
}
func (m *UserRepositoryMock) SaveUser(user *User) error {
// Сохраняем пользователя в список вызовов
m.SavedUsers = append(m.SavedUsers, user)
// Возвращаем заранее настроенную ошибку
return m.SaveErr
}
// Мок отправителя писем
type MailerMock struct {
SentToUsers []*User
SendErr error
}
func (m *MailerMock) SendWelcomeEmail(user *User) error {
m.SentToUsers = append(m.SentToUsers, user)
return m.SendErr
}
Комментарии:
// Эти моки ничего не делают снаружи - не ходят в БД и не отправляют письма // Они просто записывают, как их вызывали, и возвращают заранее заданные значения
Теперь вы увидите, как это выглядит в тесте:
func TestUserService_Register_Success(t *testing.T) {
// Создаем моки
repoMock := &UserRepositoryMock{}
mailerMock := &MailerMock{}
// Собираем сервис с моками
service := NewUserService(repoMock, mailerMock)
// Вызываем тестируемый метод
err := service.Register("test@example.com")
if err != nil {
t.Fatalf("ожидали nil ошибку, получили %v", err)
}
// Проверяем, что пользователь был сохранен
if len(repoMock.SavedUsers) != 1 {
t.Fatalf("ожидали 1 сохраненного пользователя, получили %d", len(repoMock.SavedUsers))
}
// Проверяем, что было отправлено письмо
if len(mailerMock.SentToUsers) != 1 {
t.Fatalf("ожидали 1 отправленное письмо, получили %d", len(mailerMock.SentToUsers))
}
}
Комментарии:
// Здесь мы используем моки, чтобы проверить взаимодействие // Мы не проверяем напрямую базу или почтовый сервис // Мы проверяем, что бизнес-логика вызвала нужные методы
Преимущества ручных моков:
- очень прозрачны и понятны;
- легко контролировать логику;
- меньше магии и зависимостей.
Недостатки:
- много шаблонного кода;
- для большого числа интерфейсов писать моки руками утомительно.
Генерируемые моки (mocking frameworks)
Во многих языках есть фреймворки, которые позволяют:
- автоматически генерировать моки по интерфейсу (Go: gomock, testify/mock; Java: Mockito; JS: Jest и т.д.);
- на лету конфигурировать ожидаемые вызовы и возвращаемые значения;
- проверять, что ожидания были выполнены.
Покажу вам упрощенный пример с популярным стилем «ожидание → вызов → проверка» (псевдокод, но он похож на Go/Java‑стиль):
// pseudo-code, стиль похож на testify/mock
// В тесте мы создаем мок-объект
repoMock := NewUserRepositoryMock(t)
mailerMock := NewMailerMock(t)
// Настраиваем ожидания
repoMock.
On("SaveUser", mock.AnythingOfType("*User")). // Ждем вызов с аргументом типа *User
Return(nil). // Вернем nil-ошибку
mailerMock.
On("SendWelcomeEmail", mock.Anything). // Любой пользователь
Return(nil)
// Собираем сервис
service := NewUserService(repoMock, mailerMock)
// Вызываем метод
err := service.Register("test@example.com")
require.NoError(t, err)
// Проверяем, что все ожидания по мокам выполнены
repoMock.AssertExpectations(t)
mailerMock.AssertExpectations(t)
Комментарии:
// Мы заранее задаем "контракт" - какие методы должны быть вызваны // После выполнения теста фреймворк сам проверит, что ожидания выполнены // Если метод не был вызван или был вызван с другими аргументами - тест упадет
Плюсы:
- меньше ручного кода;
- удобно конфигурировать разные сценарии;
- часто есть «умные» матчеры аргументов (any, regexp, custom‑matcher).
Минусы:
- тесты могут становиться слишком привязанными к деталям реализации;
- высокая магия — сложнее понять, почему упал тест;
- дополнительная зависимость от фреймворка.
Fake‑объекты как альтернатива мокам
Иногда вместо моков удобнее использовать fake — упрощенную реализацию.
Например, вы пишете интерфейс UserRepository:
type UserRepository interface {
SaveUser(user *User) error
FindByEmail(email string) (*User, error)
}
Для интеграционных тестов можно сделать in‑memory реализацию:
type InMemoryUserRepository struct {
users map[string]*User
}
func NewInMemoryUserRepository() *InMemoryUserRepository {
return &InMemoryUserRepository{
users: make(map[string]*User),
}
}
func (r *InMemoryUserRepository) SaveUser(user *User) error {
// Сохраняем пользователя в карту по email
r.users[user.Email] = user
return nil
}
func (r *InMemoryUserRepository) FindByEmail(email string) (*User, error) {
user, ok := r.users[email]
if !ok {
return nil, ErrNotFound
}
return user, nil
}
Комментарии:
// Это рабочая реализация, но она использует память вместо реальной БД // В тестах такая реализация ведет себя "как база", но не требует окружения // Вы тестируете больше логики сразу, чем с моками
Такие fake‑объекты часто удобнее, чем моки, когда:
- у вас сложный сценарий работы с хранилищем;
- хочется протестировать больше реальной логики;
- не требуется проверять точные вызовы методов.
Как проектировать код, который легко мокировать
Используйте интерфейсы для внешних зависимостей
Ключевая идея: все, что выходит «наружу» — в сеть, в файловую систему, в БД, во время — хорошо выносить за интерфейсы.
Примеры зависимостей, которые обычно оборачивают в интерфейсы:
- HTTP‑клиент;
- клиент БД;
- провайдер текущего времени;
- генератор UUID;
- сервис отправки писем/смс.
Например, с текущим временем:
// Интерфейс поставщика времени
type Clock interface {
Now() time.Time
}
// Реальная реализация, использующая time.Now
type RealClock struct{}
func (RealClock) Now() time.Time {
return time.Now()
}
Комментарии:
// В продакшене вы будете использовать RealClock // В тестах вы создадите мок Clock, который возвращает фиксированное время // Так ваши тесты станут детерминированными
Разделяйте бизнес‑логику и инфраструктуру
Старайтесь, чтобы ваши доменные сервисы (бизнес‑логика) были как можно более «чистыми»:
- минимум знаний о конкретной базе;
- минимум зависимостей от HTTP;
- без привязки к фреймворкам.
Инфраструктура (настройка HTTP‑роутов, соединения с базой, конфигурация) пусть живет отдельно, например в main‑пакете или отдельных сборочных модулях.
Это делает тесты проще: вы можете протестировать доменную логику отдельно, подменив все инфраструктурные зависимости на моки.
Не мокируйте то, что можно протестировать напрямую
Обратите внимание на такой момент: многие новички начинают мокировать вообще все, включая простые структуры данных или «чистые» функции.
Если функция:
- не обращается наружу;
- детерминирована (результат зависит только от аргументов);
то мокировать ее не нужно. Ее можно (и нужно) тестировать напрямую, обычными unit‑тестами.
Мокирование — это инструмент для изоляции от внешнего мира. Чем меньше внешних зависимостей, тем меньше моков.
Практические сценарии мокирования
1. Мокирование HTTP‑клиента
Представьте сервис, который обращается к внешнему API:
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
type ExternalAPI struct {
client HTTPClient
}
func NewExternalAPI(client HTTPClient) *ExternalAPI {
return &ExternalAPI{client: client}
}
func (api *ExternalAPI) GetUserScore(userID string) (int, error) {
// Собираем запрос
req, err := http.NewRequest("GET", "https://example.com/score?user="+userID, nil)
if err != nil {
return 0, err
}
// Отправляем запрос через абстрактный клиент
resp, err := api.client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("unexpected status %d", resp.StatusCode)
}
// Здесь мы читаем тело и парсим его, опустим это для краткости
// ...
return 42, nil // Допустим, распарсили такой результат
}
В тесте вы можете замокировать HTTPClient:
type HTTPClientMock struct {
LastRequest *http.Request
Response *http.Response
Err error
}
func (m *HTTPClientMock) Do(req *http.Request) (*http.Response, error) {
// Сохраняем последний запрос для проверки
m.LastRequest = req
// Возвращаем заранее подготовленный ответ и ошибку
return m.Response, m.Err
}
Теперь давайте проверим позитивный сценарий:
func TestExternalAPI_GetUserScore_Success(t *testing.T) {
// Готовим фейковое тело ответа
body := io.NopCloser(strings.NewReader(`{"score": 42}`))
// Готовим ответ
resp := &http.Response{
StatusCode: http.StatusOK,
Body: body,
}
clientMock := &HTTPClientMock{
Response: resp,
}
api := NewExternalAPI(clientMock)
score, err := api.GetUserScore("123")
if err != nil {
t.Fatalf("ожидали nil ошибку, получили %v", err)
}
if score != 42 {
t.Fatalf("ожидали score 42, получили %d", score)
}
// Проверим, что запрос собирался с нужным URL
if clientMock.LastRequest == nil {
t.Fatalf("ожидали, что запрос был отправлен")
}
if clientMock.LastRequest.URL.Query().Get("user") != "123" {
t.Fatalf("ожидали user=123, получили %s",
clientMock.LastRequest.URL.Query().Get("user"))
}
}
Комментарии:
// Мы полностью контролируем ответ от внешнего API // Тест ничего не знает о сети и не требует интернета // При этом мы проверяем и логику, и правильность сформированного запроса
2. Мокирование ошибок и нестабильных сценариев
Моки особенно удобны, чтобы симулировать ошибки: падение БД, таймауты, исключения. Реализовать такие ситуации с реальной системой часто сложно.
Представим, что метод Register должен откатить сохранение пользователя, если отправка письма не удалась. В тесте мы можем сделать так:
func TestUserService_Register_FailOnEmail(t *testing.T) {
repoMock := &UserRepositoryMock{}
mailerMock := &MailerMock{
// Заставляем отправку письма падать
SendErr: errors.New("smtp error"),
}
service := NewUserService(repoMock, mailerMock)
err := service.Register("test@example.com")
if err == nil {
t.Fatalf("ожидали ошибку, получили nil")
}
// Проверим, что пользователь был сохранен
if len(repoMock.SavedUsers) != 1 {
t.Fatalf("ожидали 1 сохраненного пользователя, получили %d", len(repoMock.SavedUsers))
}
// В реальной логике можно добавить откат, логирование и т.д.
// Моки позволят проверить каждую ветку поведения
}
Комментарии:
// Мы легко моделируем ошибку отправки письма // Для настоящего SMTP сервера это сделать сложнее // Так вы можете покрыть логикой обработки ошибок почти все кейсы
3. Частичное мокирование и spies
Иногда вам нужно не полностью подменить реализацию, а только «подсмотреть», как объект используется. В таких случаях применяют spies или частичные моки.
Например, вы хотите убедиться, что при определенном сценарии вызывается метод LogError.
В ручном стиле это может выглядеть так:
type Logger interface {
Info(msg string)
Error(msg string)
}
type LoggerSpy struct {
InfoMessages []string
ErrorMessages []string
}
func (l *LoggerSpy) Info(msg string) {
l.InfoMessages = append(l.InfoMessages, msg)
}
func (l *LoggerSpy) Error(msg string) {
l.ErrorMessages = append(l.ErrorMessages, msg)
}
Комментарии:
// LoggerSpy ничего не пишет в stdout или файл // Он просто запоминает сообщения // В тесте вы проверите, что Error был вызван с нужным текстом
В тесте:
func TestService_LogsErrorOnFailure(t *testing.T) {
logger := &LoggerSpy{}
// Передаем logger в сервис вместе с другими зависимостями
// ...
// Вызовем метод, который должен логировать ошибку
// ...
if len(logger.ErrorMessages) == 0 {
t.Fatalf("ожидали хотя бы одно сообщение об ошибке")
}
}
Типичные ошибки при использовании mocking
1. Мокирование деталей реализации вместо поведения
Чрезмерное количество ожиданий в моках делает тесты хрупкими.
Например, вы пишете такой тест:
- ожидаете, что метод SaveUser вызывается первым;
- потом ожидаете, что SendWelcomeEmail вызывается вторым;
- затем проверяете конкретный текст логов.
Если в будущем вы:
- решите отправлять письмо до сохранения;
- добавите промежуточные проверки;
тест сломается, хотя внешнее поведение сервиса может оставаться корректным.
Рекомендации:
- фиксируйте только то, что действительно важно для контракта;
- старайтесь проверять результат работы, а не порядок внутренних вызовов;
- избегайте проверки «лишних» логов, если они не часть контракта.
2. Слишком много интерфейсов ради мокирования
Бывает, что разработчик начинает выносить в интерфейсы почти все, включая простые структуры, чтобы только можно было замокировать. Это усложняет код, а пользы не добавляет.
Хороший критерий: выносите в интерфейсы только те зависимости, которые:
- внешние по отношению к домену;
- могут иметь разные реализации;
- логично представляет собой «контракт».
Не нужно делать интерфейс для структуры с 1 методом, если она и так чисто вычисляет значение без внешних эффектов.
3. Смешивание unit‑тестов и интеграционных тестов
Иногда моки используются там, где логичнее написать интеграционный тест с реальной системой:
- например, проверить, что SQL‑запросы к базе действительно работают;
- или что REST‑endpoint корректно интегрирован с бекендом.
Моки позволяют писать быстрые и изолированные тесты, но они не гарантируют, что ваша интеграция с реальной системой будет работать. Поэтому:
- для логики — используйте моки;
- для интеграции — пишите отдельные тесты с реальными зависимостями (по возможности в отдельном тестовом окружении).
4. Сложные моки с логикой
Еще один нюанс: если ваш мок начинает содержать сложную логику, условия, циклы и т.д., это сигнал, что вы, возможно, реализуете второй «мини‑продакшен» внутри моков. Это:
- усложняет поддержку тестов;
- создает риск расхождения между моками и реальной реализацией.
В такой ситуации лучше:
- либо упростить моки;
- либо создать fake‑реализацию, близкую к настоящей, и использовать ее как тестовую инфраструктуру.
Рекомендации по организации тестов с моками
Где хранить моки
Практично выносить моки:
- в отдельные файлы с суффиксом
_mockилиmock_в имени; - в отдельный пакет, если моки используются в нескольких модулях.
Это помогает:
- не засорять файлы с продакшен‑кодом;
- переиспользовать моки между тестами.
Например:
- user_service.go — код сервиса;
- userservicetest.go — тесты сервиса;
- usermockstest.go — моки для тестов.
Именование
Старайтесь давать мокам понятные имена:
- UserRepositoryMock;
- HTTPClientMock;
- LoggerSpy;
- InMemoryUserRepository (для fake).
Так по имени сразу понятно, какую роль играет объект.
Повторное использование и настройка
Хорошая практика — делать моки настраиваемыми:
- можно задать возвращаемую ошибку;
- можно подготовить список ответов по очереди;
- можно указывать поведение (успех/ошибка) для разных аргументов.
Например:
type UserRepositoryMock struct {
// Отображение email -> ошибка
SaveErrors map[string]error
}
func (m *UserRepositoryMock) SaveUser(user *User) error {
if m.SaveErrors != nil {
if err, ok := m.SaveErrors[user.Email]; ok {
return err
}
}
return nil
}
Комментарии:
// В разных тестах вы сможете задавать разные сценарии // Для одного пользователя SaveUser будет падать, для другого - проходить успешно
Заключение
Мокирование данных — это ключевая техника для построения быстрых и надежных юнит‑тестов. Суть подхода в том, чтобы временно подменять реальные зависимости вашего кода на тестовые двойники:
- моки — для проверки взаимодействий;
- стабы — для подстановки данных;
- fake‑объекты — для упрощенной, но рабочей логики;
- spies — для наблюдения за вызовами.
Чтобы мокирование было удобным, код нужно проектировать с учетом:
- инверсии зависимостей;
- разделения бизнес‑логики и инфраструктуры;
- четких интерфейсов для внешних систем.
Моки позволяют:
- изолировать тестируемый модуль;
- моделировать ошибки и редкие сценарии;
- ускорить тесты за счет отказа от реальных внешних ресурсов.
При этом важно не перегибать:
- не мокировать все подряд;
- не завязывать тесты на мелкие детали реализации;
- не превращать моки во вторую сложную систему.
Если вы будете использовать mocking осознанно, то получите тесты, которые:
- быстро выполняются;
- легко читаются;
- хорошо документируют ожидаемое поведение системы.
Частозадаваемые технические вопросы по теме статьи и ответы на них
Как замокировать время чтобы тесты не зависели от текущей даты
Сделайте интерфейс Clock с методом Now и используйте его вместо прямого вызова текущего времени. В проде передавайте реальную реализацию, в тестах — мок, который возвращает фиксированное время. Например, в тесте создайте структуру FixedClock со свойством Time и реализуйте метод Now, возвращающий это значение. Так вы сможете воспроизводимо проверять сценарии с дедлайнами и просрочками.
Как протестировать код который создает зависимости внутри себя и не принимает их снаружи
Первый вариант — рефакторинг под dependency injection и введение интерфейсов. Если это временно невозможно, можно использовать паттерн «переопределяемые фабрики» — вынести создание зависимости в функцию‑переменную и в тестах переопределять ее на мок‑функцию. Например, var newDB = NewRealDBClient, а в тесте подменить newDB на функцию, возвращающую мок.
Как мокировать функции без интерфейса например глобальные утилиты
Если язык поддерживает подмену функций на время теста (как в JavaScript), можно временно заменить реализацию. В строгих языках чаще всего: оборачивают функцию в интерфейс и прокидывают зависимость или используют шаблон «фасад» — структура с методами обертками над функциями. Тогда в тестах вы подменяете фасад на мок, а не трогаете глобальную функцию напрямую.
Когда лучше использовать fake вместо моков
Fake удобен, когда нужно больше реальной логики и последовательных операций. Например, in‑memory реализация репозитория для сложных сценариев сохранения и выборки. Если вы видите, что моки становятся слишком сложными и начинают хранить состояние и условия, лучше вынести это в отдельную fake‑реализацию, которая будет вести себя максимально похоже на настоящую, но без внешних зависимостей.
Как не запутаться в большом количестве моков в проекте
Разделите моки по уровням и областям: храните интерфейсные моки в отдельных пакетах (например, internal/mocks или pkg/testsupport), давайте им однозначные имена и не смешивайте их с продакшен‑кодом. Используйте базовые фабрики для типовых конфигураций моков (например, NewSuccessfulUserRepoMock). Если моки начинают дублировать друг друга, вынесите общую часть в один общий мок и добавляйте поверх него небольшие настройки в конкретных тестах.