В современной разработке программного обеспечения терминологическая точность является важной составляющей построения надежных и поддерживаемых систем. В области тестирования зависимостей наблюдается значительная путаница. Многие разработчики используют термин "мок" (mock) как общее обозначение для любого объекта-заменителя в тестах.
Эта неточность усугубляется в документации популярных фреймворков: Jest называет свои mock-функции "шпионами" (spies), что вносит дополнительную двусмысленность
Термин "тестовый двойник" (Test Double) введен Gerard Meszaros в его работе xUnit Test Patterns (2007). Основная идея заключается в замене реального зависимого компонента (DOC) на его специфичный для теста эквивалент. Такая замена необходима, когда реальный компонент недоступен, работает слишком медленно, ведет себя недетерминированно или вызывает нежелательные побочные эффекты (например, отправка реальных электронных писем или списание средств со счета).
"мок" — это не обобщенный термин, а лишь один из конкретных видов тестовых двойников, каждый из которых служит своей уникальной цели
Gerard Meszaros (а впоследствие и Martin Fowler), выделяют пять основных типов тестовых двойников:

Тестирование результата действия путем проверки состояния тестируемой системы (System Under Test, SUT) и ее зависимостей после выполнения кода.
Отвечает на вопрос: "Пришла ли система в корректное конечное состояние?"
Используемые двойники: реальные объекты, фейки и стабы. Шпионы могут использоваться для захвата состояния с целью его последующей проверки.
Пример: росле вызова order.fill(warehouse) проверить, что warehouse.getInventory(product) уменьшилось, а order.isFilled() вернуло true.
Тестирование процесса путем проверки того, что SUT выполнила корректную последовательность вызовов к своим зависимостям.
Отвечает на вопрос: "Следовала ли система правильным шагам для достижения результата?"
Используемые двойники: преимущественно моки.
Пример: установить ожидание на мок-объекте склада, что его метод remove(product, amount) будет вызван ровно один раз с корректными аргументами. Тест провалится, если это взаимодействие не произойдет.
Выбор между подходами — не просто техническое решение, а философский выбор, который напрямую влияет на архитектуру системы и хрупкость тестов. Верификация поведения (мокистский подход) проверяет, как достигается результат, и, следовательно, тесно связывает тест с деталями реализации взаимодействия SUT и его зависимости.
Если разработчик вызовет два разных вспомогательных метода у зависимости вместо одного, не меняя конечного состояния, "мокистский" тест сломается. В то же время "классический" тест, проверяющий состояние, продолжит проходить успешно. Это явление известно как хрупкость тестов (test brittleness).
Такая связь с реализацией имеет и архитектурные последствия. Мокистский подход поощряет проектирование систем на основе интерфейсов и ролей, поскольку именно эти контракты и подвергаются мокированию. Классический подход, в свою очередь, больше ориентирован на наблюдаемые изменения состояния и менее предписывает внутренние паттерны коммуникации между объектами.
Запрос (Query): метод, который возвращает результат и не меняет состояние системы (не имеет побочных эффектов).
Команда (Command): метод, который меняет состояние системы (имеет побочные эффекты), но ничего не возвращает.
Моккистский подход (верификация через поведение) наиболее ценен при тестировании команд, а не запросов
Когда метод должен вызвать другой сервис для сохранения данных, отправки email или записи лога, мы не можем легко проверить конечное состояние (например, заглянуть в почтовый ящик). Вместо этого мы проверяем, что SUT правильно попросил зависимость выполнить это действие.
Интенсивное и некритическое использование моков порождает целый класс проблем, которые могут уничтожить преимущества модульного тестирования
В 2014 году серия публичных дискуссий между David Heinemeier Hansson, Kent Beck и Martin Fowler вывела на передний план критику TDD, особенно его мокистского варианта.
Ущерб проектированию от тестов (Test-Induced Design Damage): слепое следование TDD, особенно в его мокистской форме, заставляет разработчиков создавать излишне сложные архитектуры; стремление к быстрой и полной изоляции каждого класса приводит к "чрезмерно сложной паутине промежуточных объектов и косвенности", таких как обилие сервисных объектов, паттернов "Команда" и ненужных интерфейсов; эта сложность не диктуется бизнес-требованиями, а навязывается исключительно потребностями тестирования
Приоритет системных тестов: в качестве альтернативы предложено сместить акцент с тысяч изолированных модульных тестов, где все зависимости заменены моками, на более высокоуровневые системные тесты, которые взаимодействуют с реальной инфраструктурой (например, с базой данных); по мнению David Heinemeier Hansson,такие тесты дают больше уверенности в работоспособности системы в целом
TDD — инструмент, а не догма: конечный результат зависит от решений в области проектирования, принимаемых разработчиком. Плохая архитектура — следствие плохих решений, а не самого инструмента
Цель не равно средства: Martin Fowler разделил понятия "самотестируемый код" (общая цель) и TDD (один из способов ее достижения); цель — иметь набор тестов, дающий уверенность, а TDD — это дисциплина, которая помогает этого достичь, но не является единственным путем
Критика David Heinemeier Hansson и других экспертов может быть формализована в виде набора конкретных антипаттернов
The Mockery (Насмешка)
Тест, который содержит такое большое количество моков, что SUT практически не проверяется. Основная сложность теста заключается в настройке и связывании моков, а утверждения в итоге проверяют не логику SUT, а правильность конфигурации моков. Часто подготовка к тесту занимает больше строк кода, чем сам тест и проверяемый код вместе взятые.
The Inspector (Инспектор)
Тест, который нарушает инкапсуляцию SUT для проверки его внутреннего состояния. Это может проявляться в использовании рефлексии для доступа к приватным полям или в добавлении публичных геттеров, единственная цель которых — сделать состояние видимым для теста. Такие тесты тесно связываются с деталями реализации и ломаются при любом рефакторинге, даже если внешнее поведение класса не изменилось.
Testing the Happy Path Only (Тестирование только "счастливого пути")
Моки настраиваются на имитацию только идеальных сценариев, когда все зависимости работают без сбоев. Тест проверяет, что система работает, когда database.save() всегда возвращает true, а network.request() всегда успешен. Это создает ложное чувство безопасности, поскольку код остается не протестированным на устойчивость к реальным сбоям, таким как ошибки сети, тайм-ауты базы данных или неожиданные форматы ответов.
Testing the Mock Itself (Тестирование самого мока)
Тривиальный тест, который по сути проверяет работоспособность фреймворка для мокинга. Классический пример: тестирование функции, которая просто вызывает метод зависимости и возвращает его результат. Тест настраивает мок на возврат определенного значения, а затем утверждает, что SUT вернул то же самое значение. Такой тест не несет никакой ценности, так как не проверяет никакой бизнес-логики, но при этом увеличивает метрику покрытия кода.
Leaky Mocks (Протекающие моки)
Состояние или конфигурация мока из одного теста не сбрасывается должным образом после его завершения и "протекает" в последующие тесты. Это приводит к тому, что результаты тестов становятся зависимыми от порядка их выполнения, что является одним из самых серьезных нарушений принципов модульного тестирования и приводит к трудноуловимым, недетерминированным сбоям.
Распространенность антипаттернов мокинга часто является сильным индикатором глубинных архитектурных проблем в коде, в частности, нарушений SRP и Закона Деметры.
"The Mockery" часто указывает на "Божественный объект" (God Object) — класс со слишком большим количеством обязанностей и, следовательно, зависимостей. Тесты, требующие цепочек моков (mock.getA().getB().getC()), являются признаком кода в стиле "крушение поезда" (train wreck), нарушающего Закон Деметры.
Закон Деметры — принцип проектирования ПО, который предписывает объекту иметь минимальные знания о структуре других объектов. Его главная идея: "Не разговаривай с незнакомцами".
Метод объекта может вызывать методы только:
string price = account.GetPlan().GetPrice().GetAmount();
Для решения всех этих проблем инженерная практика предлагает архитектурные подходы, которые позволяют изначально проектировать систему так, чтобы минимизировать потребность в моккинге.
Концепция: идентифицировать код, который трудно тестировать (взаимодействие с GUI, прямые вызовы к базе данных, работа с файловой системой, обращение к DateTime.Now), вынести его в отдельный, "скромный" класс-обертку. Этот класс должен содержать минимум логики. Вся сложная, тестируемая логика перемещается в другой класс, который можно легко протестировать в изоляции от трудной зависимости.
Паттерн разделяет компонент на две части:
The Humble Object (Скромный Объект)
Тонкая обертка вокруг труднотестируемой границы. Единственная задача — делегировать вызовы реальной инфраструктуре (например, фреймворку GUI или драйверу БД). Он настолько прост ("скромен"), что не требует модульного тестирования.
The Testable Component (Тестируемый компонент)
Часто это Presenter, Controller или ViewModel. Он содержит всю извлеченную бизнес-логику. Этот компонент взаимодействует со "скромным объектом" через интерфейс, который легко подменить в тестах с помощью стаба или мока.
# 1. "Скромный объект" (Humble Object)
# Он просто оборачивает сложную зависимость (сетевой клиент, БД и т.д.) и не содержит никакой логики.
class DataFetcher:
def __init__(self, data_source):
# data_source — сложная, труднотестируемая зависимость.
self._source = data_source
def fetch(self):
# Единственная задача — вызвать метод зависимости. Логики здесь нет.
return self._source.get_data()
# 2. "Нескромный" объект с бизнес-логикой
class DataProcessor:
def __init__(self, fetcher):
# зависит от "скромного" объекта, который легко подменить в тестах.
self._fetcher = fetcher
def process_and_uppercase(self):
# бизнес-логика.
raw_data = self._fetcher.fetch()
if raw_data:
return raw_data.upper()
return "NO DATA"
# 3. Внешняя, сложная для тестирования зависимость
class NetworkClient:
def get_data(self):
return "some network data"
network_source = NetworkClient()
humble_fetcher = DataFetcher(network_source)
processor = DataProcessor(humble_fetcher)
result = processor.process_and_uppercase()
print(result) # Вывод: SOME NETWORK DATA
class MockDataFetcher:
def fetch(self):
return "test data"
def test_data_processor():
# Arrange (Подготовка)
mock_fetcher = MockDataFetcher()
processor_under_test = DataProcessor(mock_fetcher)
# Act (Действие)
result = processor_under_test.process_and_uppercase()
# Assert (Проверка)
assert result == "TEST DATA"
Весь код, который напрямую взаимодействует со сложными, нестабильными или медленными зависимостями (UI, сеть, база данных), собирается в один класс — "скромный объект"
Вся система делится на две части:
Функциональное ядро (Functional Core)
Содержит всю бизнес-логику, реализованную в виде чистых функций (pure functions) без побочных эффектов. Ядро принимает структуры данных на вход и возвращает новые структуры данных на выход. Оно не знает о существовании баз данных, сетей или пользовательских интерфейсов. Такое ядро по своей природе полностью тестируемо без каких-либо моков.
Императивная Оболочка (Imperative Shell)
Внешний слой приложения, который обрабатывает все побочные эффекты: выполняет HTTP-запросы, делает запросы к базе данных, ведет логирование, осуществляет ввод-вывод. Оболочка вызывает функции ядра для выполнения логики, а затем исполняет необходимые "грязные" операции, основываясь на результате, полученном от ядра.
# Антипаттерн: бизнес-логика смешана с I/O
class OrderService:
def __init__(self, db_connection):
self.db = db_connection
def apply_discount(self, order_id, user_id):
order = self.db.get_order(order_id)
user = self.db.get_user(user_id)
# Бизнес-логика
if user.is_premium and order.total > 100:
discount = order.total * 0.1
order.total -= discount
order.discount_applied = True
self.db.save_order(order)
return order
# Функциональное ядро: чистая функция
def calculate_discounted_order(order, user):
new_order = order.copy()
if user.is_premium and new_order.total > 100:
discount = new_order.total * 0.1
new_order.total -= discount
new_order.discount_applied = True
return new_order
# Императивная оболочка: сервисный класс
class OrderService:
def __init__(self, db_connection):
self.db = db_connection
def apply_discount(self, order_id, user_id):
order = self.db.get_order(order_id)
user = self.db.get_user(user_id)
# Вызов функционального ядра
updated_order = calculate_discounted_order(order, user)
# Выполнение побочного эффекта
self.db.save_order(updated_order)
return updated_order
Humble Object — тактический паттерн, применяемый для решения локальной проблемы тестируемости в конкретном классе или на границе системы.
Functional Core, Imperative Shell — стратегический, общесистемный архитектурный паттерн, который применяет тот же принцип разделения логики и побочных эффектов ко всему приложению.
Многократное применение паттерна Humble Object для исправления проблем с тестируемостью в разных частях системы естественным образом подталкивает архитектуру к состоянию, которое описывается как "Функциональное ядро, императивная оболочка"
Spies
Для отслеживания вызовов метода без замены его оригинальной реализации используется jest.spyOn(object, 'methodName').
test('should log a message to the console', () => {
const logSpy = jest.spyOn(console, 'log');
function logMessage(message) {
console.log(`LOG: ${message}`);
}
logMessage('Hello, World!');
expect(logSpy).toHaveBeenCalledWith('LOG: Hello, World!');
logSpy.mockRestore();
});
Stubs
Для создания функции-заглушки и управления ее выводом используются jest.fn() в сочетании с методами .mockReturnValue(value) или .mockImplementation(fn).
export class UserService {
getUser(id) {
throw new Error('Database not available in tests');
}
}
import { UserService } from './userService';
test('processUserData should return formatted user name', () => {
const userService = new UserService();
const userStub = { id: 1, name: 'Mikhail' };
jest.spyOn(userService, 'getUser').mockReturnValue(userStub);
function processUserData(userId) {
const user = userService.getUser(userId);
return `User: ${user.name.toUpperCase()}`;
}
const result = processUserData(1);
expect(result).toBe('User: MIKHAIL');
});
Mocks
Для верификации поведения используются матчеры Jest, такие как expect(mockFn).toHaveBeenCalled(), toHaveBeenCalledWith(...) и toHaveBeenCalledTimes(...).
export const notificationService = {
sendConfirmationEmail(user) { /*... */ }
};
import { notificationService } from './notificationService';
export function processPayment(user, amount) {
// Логика обработки платежа...
const isSuccess = true;
if (isSuccess) {
notificationService.sendConfirmationEmail(user);
}
}
import { processPayment } from './paymentService';
import { notificationService } from './notificationService';
test('should send a confirmation email on successful payment', () => {
const user = { email: 'test@pmifi.ru' };
const emailSpy = jest.spyOn(notificationService, 'sendConfirmationEmail').mockImplementation(() => {});
processPayment(user, 100);
expect(emailSpy).toHaveBeenCalledTimes(1);
expect(emailSpy).toHaveBeenCalledWith(user);
});
Spies
Используется mocker.spy(object, 'method_name'), что является прямым аналогом jest.spyOn.
class MyService:
def process(self, data):
print(f"Processing {data}")
return data.upper()
def test_spy_on_service(mocker):
service = MyService()
spy = mocker.spy(service, 'process')
result = service.process('test')
assert result == 'TEST'
spy.assert_called_once_with('test')
Stubs
Для замены метода и контроля его вывода используется mocker.patch('module.ClassName.method', return_value=...).
# user_repository.py
def get_user(user_id):
raise ConnectionError("DB not available")
# user_service.py
from. import user_repository
def get_formatted_username(user_id):
user = user_repository.get_user(user_id)
return f"User: {user['name']}"
def test_get_formatted_username(mocker):
user_stub = {'id': 1, 'name': 'Mikhail'}
mocker.patch('user_service.user_repository.get_user', return_value=user_stub)
from user_service import get_formatted_username
result = get_formatted_username(1)
assert result == "User: Mikhail"
Mocks
Используются методы утверждения на мок-объекте, возвращаемом mocker.patch, такие как mock_method.assert_called_once_with(...).
# notification_service.py
def send_confirmation_email(user):
pass
# payment_service.py
from. import notification_service
def process_payment(user, amount):
#...
notification_service.send_confirmation_email(user)
def test_sends_email_on_success(mocker):
user = {'email': 'test@pmifi.ru'}
mock_send_email = mocker.patch('payment_service.notification_service.send_confirmation_email')
from payment_service import process_payment
process_payment(user, 100)
mock_send_email.assert_called_once_with(user)
Цель в том, чтобы предоставить предопределенный ответ (успех, ошибка, пустые данные) от API, а не в проверке деталей реализации HTTP-клиента.
# data_fetcher.py
import requests
def fetch_user(user_id):
response = requests.get(f'https://api.pmifi.ru/users/{user_id}')
response.raise_for_status()
return response.json()
# test_data_fetcher.py
def test_fetch_user_success(mocker):
user_data = {'id': 1, 'name': 'Leanne Graham'}
mock_response = mocker.Mock()
mock_response.status_code = 200
mock_response.json.return_value = user_data
mock_get = mocker.patch('requests.get', return_value=mock_response)
result = fetch_user(1)
assert result == user_data
mock_get.assert_called_once_with('https://api.pmifi.ru/users/1')
In-Memory База данных
Использование реального движка СУБД, который может работать полностью в оперативной памяти, например, SQLite в режиме :memory:.
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models import Base
@pytest.fixture(scope="function")
def db_session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
Base.metadata.drop_all(engine)
def test_user_creation(db_session):
from user_repository import create_user
user = create_user(db_session, name="testuser")
assert user.id is not None
assert user.name == "testuser"
Fake Repository
Создание реализации интерфейса репозитория, которая использует простые структуры данных (например, списки или словари) вместо подключения к БД.
Использование фейковой in-memory файловой системы.
import fs from 'fs';
import mock from 'mock-fs';
describe('File Processor', () => {
beforeEach(() => {
mock({
'/data': {
'input.txt': 'hello world',
'empty-dir': {},
},
});
});
afterEach(() => {
mock.restore();
});
test('should read and process the file', () => {
const content = fs.readFileSync('/data/input.txt', 'utf-8');
expect(content).toBe('hello world');
});
});
import os
def process_file(path):
with open(path, 'r') as f:
content = f.read()
return content.upper()
def test_process_file(fs): # fs - фикстура, обнаруживается pytest автоматически
fs.create_file('/test/data.txt', contents='hello')
result = process_file('/test/data.txt')
assert result == 'HELLO'
assert os.path.exists('/test/data.txt')
Следует по умолчанию использовать классическое тестирование (на основе состояния) с использованием фейков и стабов. Это ведет к более надежным и менее хрупким тестам. Используйте мокистский подход (на основе поведения) только тогда, когда само взаимодействие является основным результатом работы SUT (при тестировании команд, результат выполнения которых сложно проверить).
Если одному тесту требуется более одного мока, это может быть признаком того, что SUT имеет слишком много обязанностей и нуждается в рефакторинге