Олег Марков
Мокирование данных mocking в тестировании
Введение
Мокирование данных (mocking) — это техника, которая позволяет вам заменить реальные зависимости в коде на контролируемые тестовые объекты. С помощью моков вы подменяете, например, реальные обращения к базе данных, HTTP-запросы или интеграцию с внешними сервисами на предсказуемое поведение, которое полностью под вашим контролем.
Смотрите, я покажу вам, как эта идея помогает:
- изолировать тестируемый код;
- ускорить тесты;
- воспроизводить сложные или редкие сценарии (ошибки сети, таймауты, нестабильные сервисы);
- не зависеть от окружения (настроенной БД, доступного API и т.п.).
Давайте шаг за шагом разберемся, что такое моки, чем они отличаются от стабов, фейков и шпионов, когда и как их правильно использовать, и посмотрим на практические примеры на популярных технологиях (языки и фреймворки будем брать как иллюстрации, принципы остаются одинаковыми).
Что такое мокирование и зачем оно нужно
Основная идея мокирования
Представьте, что у вас есть функция, которая:
- читает данные из базы данных;
- отправляет эти данные во внешний HTTP‑сервис;
- возвращает результат в виде ответа пользователю.
В юнит‑тесте вы, как правило, не хотите:
- поднимать реальную базу данных;
- ждать реальный HTTP‑запрос;
- зависеть от сети и работы стороннего сервиса.
Вместо этого вы подменяете настоящий объект доступа к БД и настоящий HTTP‑клиент на моки — тестовые объекты, которые:
- возвращают заранее определённые данные;
- фиксируют, какие методы были вызваны, с какими аргументами;
- могут эмулировать ошибки, таймауты и другие нестандартные ситуации.
Мокирование — это как создание «театральных декораций» для вашего кода. Внешний мир выглядит «как настоящий», но всё под контролем и не выходит за границы теста.
Задачи, которые решает мокирование
Основные задачи, которые вы закрываете с помощью моков:
- Изоляция кода в юнит‑тестах.
- Управляемость поведения зависимостей (успех/ошибка, задержки, нестабильность).
- Детерминированность тестов (одни и те же входные данные — один и тот же результат).
- Повышение скорости тестов (нет реальных сетевых/дисковых операций).
- Проверка взаимодействия между компонентами (вызывается ли нужный метод, с нужными параметрами, нужное количество раз).
Виды тестовых двойников: моки, стабы, фейки, шпионы
Часто под словом «мок» подразумевают любой тестовый двойник, но давайте аккуратно разделим основные типы — так вам будет легче выбирать правильный инструмент под задачу.
Stub (стаб)
Стаб — это простой объект, который подставляет готовые ответы. Он не «знает», вызывали его или нет, его задача — только вернуть нужные данные.
Применение:
- заменить источник данных на предопределенный;
- не проверять, вызывался ли метод, а лишь использовать его результат.
Пример использования стаба (на псевдокоде):
// Интерфейс источника данных
type UserRepository interface {
FindByID(id int) (User, error)
}
// Стаб репозитория - всегда возвращает одного и того же пользователя
type StubUserRepository struct{}
func (s StubUserRepository) FindByID(id int) (User, error) {
// Здесь мы не обращаемся к базе данных
return User{ID: id, Name: "Test User"}, nil
}
Здесь стаб просто возвращает фиксированный результат. Мы не проверяем, сколько раз и с какими ID его вызывали.
Fake (фейк)
Фейк — это реализация интерфейса, которая ведет себя «почти как настоящая», но проще и «в памяти». Например:
- in‑memory база данных;
- локальный кэш вместо распределенного;
- упрощенный файловый сторедж.
Фейк часто имеет какую‑то простую, но рабочую внутреннюю логику.
// Фейковая in-memory реализация
type FakeUserRepository struct {
users map[int]User
}
func NewFakeUserRepository() *FakeUserRepository {
return &FakeUserRepository{
users: map[int]User{},
}
}
func (f *FakeUserRepository) Save(u User) error {
// Здесь мы не обращаемся к реальной БД - просто сохраняем в map
f.users[u.ID] = u
return nil
}
func (f *FakeUserRepository) FindByID(id int) (User, error) {
// Эмулируем простое чтение из "БД" в памяти
if u, ok := f.users[id]; ok {
return u, nil
}
return User{}, errors.New("not found")
}
Фейк хорош, если вы хотите немного логики (сохранение, поиск), но не хотите реальной инфраструктуры.
Mock (мок)
Мок — это тестовый объект, который:
- умеет возвращать заданные значения;
- фиксирует информацию о вызовах;
- часто проверяет ожидания (какие вызовы должны были произойти).
Мок позволяет сказать: «Ожидаю, что метод SendEmail будет вызван один раз с аргументом user@example.com».
// Интерфейс почтового сервиса
type Mailer interface {
SendEmail(to string, subject string, body string) error
}
// Ручной мок для проверки вызова
type MockMailer struct {
SentTo []string
ShouldError bool
}
func (m *MockMailer) SendEmail(to string, subject string, body string) error {
// Сохраняем, кому отправляли - это поможет нам потом проверить вызовы
m.SentTo = append(m.SentTo, to)
if m.ShouldError {
// Эмулируем ошибку, если включен флаг
return errors.New("send failed")
}
return nil
}
Здесь вы можете в тесте:
- задать
ShouldError = true, чтобы симулировать сбой отправки; - проверить, какой список адресов оказался в
SentTo.
Spy (шпион)
Шпион — это объект, который главным образом служит для записи информации о вызовах:
- какие методы вызывались;
- с какими параметрами;
- сколько раз.
Фактически в ручной реализации мок и шпион очень похожи. Разница в акцентах: шпион — про «наблюдение», мок — про «ожидания и поведение».
Когда стоит и когда не стоит использовать моки
Когда моки особенно полезны
Используйте моки, если:
- Вы пишете юнит‑тесты для бизнес‑логики, которая сильно зависит от внешних сервисов.
- Ваша логика должна правильно реагировать на разные ошибки внешних зависимостей (HTTP 500, таймауты, недоступность БД).
- Реальная зависимость медленная или нестабильная.
- Важно проверить, что ваш код «правильно взаимодействует» с зависимостью, а не только результат.
Например, вы хотите проверить, что:
- при неудачной оплате не отправляется e‑mail;
- при успешной оплате отправляется два разных письма.
Это удобно делать через моки и проверки их состояния после выполнения кода.
Когда лучше обойтись без моков
Есть ситуации, когда моки только усложняют код:
- Простая функция, которая почти не имеет внешних зависимостей (логика чистая, без IO).
- Интеграционные тесты, где вы хотите проверить работу с реальной БД или реальным API (на тестовом стенде).
- Тесты, которые должны гарантировать совместимость со сторонним сервисом по реальному протоколу.
Иногда проще поднять тестовую БД (например, через Docker) и писать интеграционные тесты без моков — это уменьшит расхождение с реальностью. Хорошая стратегия — комбинировать:
- юнит‑тесты с моками;
- интеграционные тесты с реальными зависимостями.
Основные подходы к мокированию
Ручное мокирование через интерфейсы
Самый понятный и контролируемый подход — вы определяете интерфейсы для зависимостей и создаете их ручные реализации для тестов.
Давайте разберемся на примере на Go. В других языках идею можно повторить 1‑в‑1.
Боевая реализация и интерфейс
// Интерфейс клиента внешнего сервиса
type PaymentGateway interface {
Charge(amount int, cardToken string) (string, error)
}
// Реальная реализация - ходит во внешний API
type HttpPaymentGateway struct {
// Здесь может быть http.Client и настройки
}
func (g *HttpPaymentGateway) Charge(amount int, cardToken string) (string, error) {
// Здесь мы бы сформировали HTTP-запрос
// отправили его и обработали ответ
// В тестах это нам не нужно - поэтому будем подменять этот объект
return "", nil
}
Код, который зависит от интерфейса
// Сервис платежей использует интерфейс PaymentGateway
type PaymentService struct {
gateway PaymentGateway
}
func NewPaymentService(g PaymentGateway) *PaymentService {
// Здесь мы внедряем зависимость через конструктор - это удобно мокировать
return &PaymentService{gateway: g}
}
func (s *PaymentService) Pay(amount int, token string) (string, error) {
// Здесь мы просто делегируем работу гейтвею и обрабатываем результат
txID, err := s.gateway.Charge(amount, token)
if err != nil {
return "", fmt.Errorf("payment failed: %w", err)
}
return txID, nil
}
Ручной мок для PaymentGateway
// MockPaymentGateway - мок для эмуляции поведения внешнего платежного сервиса
type MockPaymentGateway struct {
// Поля для контроля поведения и фиксации вызовов
ShouldError bool // Эмулировать ли ошибку
LastAmount int // Последняя сумма, которую "списали"
LastToken string // Последний токен карты
TxIDToReturn string // Какой ID транзакции вернуть
CallCount int // Сколько раз вызывался метод Charge
}
func (m *MockPaymentGateway) Charge(amount int, cardToken string) (string, error) {
// Сохраняем параметры вызова
m.LastAmount = amount
m.LastToken = cardToken
m.CallCount++
if m.ShouldError {
// Эмулируем ошибочный ответ внешнего сервиса
return "", errors.New("gateway unavailable")
}
// Возвращаем заранее заданный идентификатор транзакции
if m.TxIDToReturn == "" {
// Если ID не задан, поставим значение по умолчанию
m.TxIDToReturn = "tx_default"
}
return m.TxIDToReturn, nil
}
Тест с использованием ручного мока
func TestPaymentService_Pay_Success(t *testing.T) {
// Здесь мы создаем мок платежного гейтвея
mockGateway := &MockPaymentGateway{
ShouldError: false, // Ошибку не эмулируем
TxIDToReturn: "tx_12345", // Задаем ожидаемый ID транзакции
}
// Внедряем мок в сервис вместо реальной реализации
service := NewPaymentService(mockGateway)
// Вызываем боевой код
txID, err := service.Pay(100, "token_abc")
if err != nil {
// Если сюда попали, значит бизнес-логика отработала неверно
t.Fatalf("unexpected error: %v", err)
}
if txID != "tx_12345" {
// Проверяем, что мы получили ожидаемый ID транзакции
t.Errorf("expected txID tx_12345, got %s", txID)
}
if mockGateway.CallCount != 1 {
// Убеждаемся, что шлюз вызывался ровно один раз
t.Errorf("expected gateway to be called once, got %d", mockGateway.CallCount)
}
if mockGateway.LastAmount != 100 || mockGateway.LastToken != "token_abc" {
// Проверяем, что метод вызывался с правильными аргументами
t.Errorf("unexpected args amount=%d token=%s", mockGateway.LastAmount, mockGateway.LastToken)
}
}
Обратите внимание, что:
- мы полностью контролируем поведение внешнего сервиса;
- можем проверять, как именно наш код с ним взаимодействует;
- не делаем ни одного реального HTTP‑запроса.
Мокирование через библиотеки (пример на Jest / Java / GoMock)
Иногда ручные моки становятся громоздкими. Специальные библиотеки упрощают создание моков и проверку ожиданий.
Пример на Jest (JavaScript / TypeScript)
Давайте посмотрим, как это реализовано в тесте для Node.js:
// service.js
// Здесь мы описываем модуль, который хотим протестировать
const mailer = require("./mailer")
async function registerUser(user) {
// Здесь мы вызываем внешнюю зависимость - почтовый сервис
await mailer.sendWelcome(user.email)
return { status: "ok" }
}
module.exports = { registerUser }
// mailer.js
// Здесь определен реальный почтовый модуль
async function sendWelcome(email) {
// Здесь могли бы быть реальные сетевые вызовы к почтовому сервису
console.log("Send email to " + email)
}
module.exports = { sendWelcome }
// service.test.js
// Здесь мы пишем тест и мокируем модуль mailer
jest.mock("./mailer") // Здесь мы говорим Jest подменить настоящий mailer на мок
const mailer = require("./mailer")
const { registerUser } = require("./service")
test("registerUser calls mailer.sendWelcome", async () => {
// Здесь мы задаем поведение мока - функция возвращает успешно завершенный промис
mailer.sendWelcome.mockResolvedValueOnce()
const user = { email: "user@example.com" }
await registerUser(user)
// Здесь мы проверяем, что sendWelcome был вызван ровно один раз
expect(mailer.sendWelcome).toHaveBeenCalledTimes(1)
// И что его вызывали с нужным аргументом
expect(mailer.sendWelcome).toHaveBeenCalledWith("user@example.com")
})
Здесь библиотека:
- автоматически создает мок вместо настоящего модуля;
- предоставляет удобные функции
toHaveBeenCalledWith,toHaveBeenCalledTimes; - избавляет вас от необходимости писать вспомогательный код вручную.
Пример на Java с Mockito
Еще один распространенный пример — Java + Mockito. Покажу вам, как это выглядит в тесте сервиса:
// UserService.java
// Здесь мы описываем сервис с зависимостью от репозитория
public class UserService {
private final UserRepository repository;
private final Mailer mailer;
public UserService(UserRepository repository, Mailer mailer) {
// Внедряем зависимости через конструктор
this.repository = repository;
this.mailer = mailer;
}
public void register(String email) {
// Сохраняем пользователя
User user = new User(email);
repository.save(user);
// Отправляем письмо
mailer.sendWelcome(email);
}
}
// UserServiceTest.java
// Здесь мы тестируем UserService с использованием Mockito
public class UserServiceTest {
@Test
public void register_sendsWelcomeEmail() {
// Создаем моки для зависимостей
UserRepository repository = Mockito.mock(UserRepository.class);
Mailer mailer = Mockito.mock(Mailer.class);
// Создаем тестируемый объект, передав ему моки
UserService service = new UserService(repository, mailer);
// Вызываем метод, который хотим протестировать
service.register("user@example.com");
// Проверяем, был ли вызван метод save у репозитория
Mockito.verify(repository).save(Mockito.any(User.class));
// Проверяем, что почтовый сервис вызывался с нужным email
Mockito.verify(mailer).sendWelcome("user@example.com");
}
}
Здесь библиотека Mockito:
- создаёт динамические прокси‑объекты;
- записывает информацию о вызовах;
- позволяет удобно проверять ожидания с помощью
verify.
Мокирование HTTP‑клиентов и внешних API
Очень частая задача — замокировать HTTP‑запросы. Здесь подходы зависят от языка:
- В Go часто создают
http.Clientс кастомнымRoundTripper. - В JS используют библиотеки вроде
nock. - В Python —
responses,requests-mock.
Покажу пример на Go с кастомным транспортом.
Мокирование HTTP‑клиента через RoundTripper
// MockTransport - реализует интерфейс http.RoundTripper
type MockTransport struct {
// Здесь мы можем описать, какой ответ вернуть
ResponseStatus int
ResponseBody string
// И какую ошибку эмулировать, если нужно
Err error
// А также зафиксировать входящий запрос
LastRequest *http.Request
}
func (m *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Сохраняем запрос для последующей проверки
m.LastRequest = req
if m.Err != nil {
// Если задана ошибка, возвращаем ее сразу
return nil, m.Err
}
// Создаем фейковый HTTP-ответ
body := io.NopCloser(strings.NewReader(m.ResponseBody)) // Здесь создаем тело ответа из строки
resp := &http.Response{
StatusCode: m.ResponseStatus,
Body: body,
Header: make(http.Header),
Request: req,
}
return resp, nil
}
Использование в тесте:
func TestClient_GetUser(t *testing.T) {
// Здесь мы создаем транспорт, который будет подменять реальные HTTP-запросы
mockTransport := &MockTransport{
ResponseStatus: 200, // Эмулируем успешный статус
ResponseBody: `{"id":1,"name":"John"}`, // Эмулируем JSON-ответ
}
// Создаем HTTP-клиент, который использует наш мок-транспорт
httpClient := &http.Client{
Transport: mockTransport,
}
// Передаем его в наш клиент, который мы хотим протестировать
client := NewUserAPIClient(httpClient, "https://api.example.com")
// Вызываем боевой код
user, err := client.GetUser(1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.ID != 1 || user.Name != "John" {
t.Errorf("unexpected user: %+v", user)
}
// Дополнительно проверяем, какой URL был вызван
if mockTransport.LastRequest.URL.Path != "/users/1" {
t.Errorf("unexpected path: %s", mockTransport.LastRequest.URL.Path)
}
}
Как видите, этот код:
- не делает ни одного реального HTTP‑запроса;
- полностью контролирует статус и тело ответа;
- позволяет проверить, какой именно запрос был сформирован.
Типичные ошибки при использовании моков
Избыточное мокирование
Одна из самых частых ошибок — мокировать всё подряд. Тогда ваши тесты:
- становятся привязанными к внутренней структуре кода;
- плохо отражают реальную работу системы;
- ломаются при малейших рефакторах.
Рекомендация: мокируйте только реальные внешние зависимости и тяжёлые компоненты, а не каждый метод подряд.
Проверка лишних деталей реализации
Если вы в тестах:
- проверяете каждый внутренний вызов;
- завязываетесь на конкретную последовательность вызовов;
то вы получаете «хрупкие» тесты. Любой рефакторинг, пусть даже без изменения поведения, будет ломать тесты.
Лучше фокусироваться на:
- публичном контракте метода (входные данные → выходные данные);
- действительно важных взаимодействиях (вызвали ли внешнюю систему в принципиально необходимый момент).
Моки без интерфейсов и без инъекции зависимостей
Когда зависимости создаются прямо внутри кода, их сложно мокировать. Например:
func NewUserService() *UserService {
// Здесь мы создаем http.Client прямо внутри - замокировать его будет тяжело
client := &http.Client{}
return &UserService{client: client}
}
Гораздо удобнее для тестирования сделать так:
func NewUserService(client *http.Client) *UserService {
// Теперь мы можем в тесте передать сюда мок-клиент
return &UserService{client: client}
}
То же касается других языков: используйте внедрение зависимостей (через конструктор, параметры или DI‑контейнер), чтобы замена на мок была естественной.
Практические паттерны мокирования
Паттерн «Порт и адаптер» (Hexagonal Architecture)
Если вы разделяете:
- порты (интерфейсы вашей доменной логики —
UserRepository,PaymentGateway); - адаптеры (конкретные реализации — PostgreSQL, Stripe, AWS SES);
то мокирование становится естественным: тесты работают с портами, а вы подставляете вместо реальных адаптеров моки или фейки.
Такой подход:
- уменьшает связанность;
- делает тесты проще и понятнее;
- облегчает замену инфраструктуры.
Паттерн «Test Doubles per Interface»
Хорошая практика — для каждого важного интерфейса иметь:
- фейковую in‑memory реализацию (для интеграционных/сложных тестов без реальной инфраструктуры);
- один‑два специализированных мока (для проверки конкретных сценариев взаимодействия).
Не стоит плодить десятки разных моков для каждого теста — лучше иметь несколько хорошо продуманных и многократно используемых.
Моки для негативных сценариев
Особое внимание стоит уделять тестам, где:
- внешний сервис недоступен;
- база данных возвращает ошибку;
- запрос занимает слишком много времени.
Моки позволяют вам легко воспроизвести такие сценарии. Вы можете, например:
- возвращать специальные ошибки;
- считать количество повторных попыток;
- проверять, корректно ли срабатывает fallback‑логика.
Заключение
Мокирование данных — один из ключевых инструментов при написании автоматических тестов. Оно помогает изолировать код, убрать внешние зависимости и сделать тесты быстрыми и предсказуемыми. При этом важно:
- понимать разницу между моками, стабами, фейками и шпионами;
- использовать моки там, где нужно контролировать взаимодействие с внешним миром;
- не перегибать и не мокировать всё подряд;
- проектировать код через интерфейсы и внедрение зависимостей, чтобы мокирование было естественным.
Если вы будете осторожно относиться к границам между бизнес‑логикой и инфраструктурой, моки станут для вас удобным и мощным инструментом, а не источником хрупких тестов.
Частозадаваемые технические вопросы по теме статьи и ответы на них
Как замокировать время (time.Now) в тестах
Вместо прямого вызова глобальной функции оберните время в интерфейс.
Пример на Go:
// Интерфейс для получения текущего времени
type Clock interface {
Now() time.Time
}
// Реальная реализация - использует time.Now
type RealClock struct{}
func (RealClock) Now() time.Time {
return time.Now()
}
// Мок-часы для теста
type FixedClock struct {
Fixed time.Time
}
func (c FixedClock) Now() time.Time {
// Всегда возвращаем одно и то же время
return c.Fixed
}
В коде вместо time.Now используйте Clock, а в тесте передавайте FixedClock с нужной датой.
Как замокировать глобальные или статические функции
Глобальные зависимости сложно мокировать. Типичные варианты:
- Вынести вызов глобальной функции в обертку‑интерфейс (как с временем выше).
- Использовать DI и передавать зависимость как параметр/поле.
- В некоторых языках (JS, Python) использовать возможности рантайма — подмену/монкипатчинг (jest.mock, monkeypatch и т.п.), но делать это аккуратно.
Общий подход — не обращаться к глобальной функции напрямую из доменной логики, а спрятать ее за абстракцией.
Как мокировать асинхронный код и события
При мокировании асинхронного кода важно:
- управлять моментом завершения операции;
- уметь проверять, что колбэки/обработчики были вызваны.
Подходы:
- В JS — использовать async/await, промисы и Jest fake timers для контроля таймеров.
- В других языках — возвращать из моков заранее завершенные или контролируемые future/promise/каналы.
Например, в тесте можно заранее подготовить promise, который вы завершите вручную, а затем проверите реакции кода.
Как замокировать базу данных если используется ORM
Здесь есть три основных пути:
- Вынести операции БД в интерфейсы репозиториев и мокировать репозитории (рекомендуется).
- Использовать in‑memory режим БД (SQLite in‑memory, H2 в Java) — это скорее фейк, чем мок.
- Использовать специальные библиотеки‑моки для конкретного ORM, которые перехватывают запросы (подход зависит от фреймворка).
Хорошая стратегия — доменная логика не должна знать о конкретном ORM, она должна работать с интерфейсами репозиториев.
Как тестировать код с ретраями и экспоненциальной задержкой
Основная проблема — реальные задержки делают тесты медленными. Решения:
- Вынести стратегию ожидания (sleep/таймеры) в отдельный интерфейс и замокировать его — в тесте задержки можно делать нулевыми.
- Использовать фейковые таймеры (fake timers) в тех фреймворках, где это поддерживается (Jest, некоторые библиотеки для Java и др.).
- Проверять количество попыток через мок, фиксирующий число вызовов, а не реальное время.