Моки, стабы и фейки: как изолировать код в тестах
Практика3 минРедакция Innovialabs
Коротко
Test double — подставной объект вместо реальной зависимости: stub возвращает заготовленные данные, mock проверяет вызовы, fake — упрощённая рабочая реализация. Подменяйте только медленное и внешнее.
Зачем подменять зависимости
Юнит-тест должен быть быстрым, стабильным и проверять одну вещь. Реальная сеть, база данных или платёжный шлюз мешают этому: они медленные, могут быть недоступны и возвращают разные данные. Поэтому в тестах их заменяют test doubles — «дублёрами», как в кино.
Пять видов дублёров
| Тип | Что делает | Когда нужен |
|---|---|---|
| Dummy | Просто занимает место аргумента, не используется | Параметр обязателен, но в тесте не важен |
| Stub | Возвращает заготовленный ответ | Нужно задать входные данные от зависимости |
| Spy | Работает как stub и запоминает вызовы | Нужно потом посмотреть, как вызывали |
| Mock | Заранее знает ожидаемые вызовы и проверяет их | Важен сам факт взаимодействия: письмо отправлено, платёж создан |
| Fake | Упрощённая, но рабочая реализация | Нужна логика: in-memory база, локальное хранилище файлов |
В повседневной речи всё это часто называют «моками», а библиотеки вроде unittest.mock и Jest создают объекты, которые могут играть любую из этих ролей. Важно понимать роль в конкретном тесте: вы задаёте данные (stub) или проверяете поведение (mock).
Подмена HTTP-запроса в pytest
Функция получает температуру из внешнего API:
# weather.py
import requests
def get_temp(city):
r = requests.get("https://api.example.com/weather",
params={"q": city}, timeout=5)
r.raise_for_status()
return r.json()["temp"]
Тест подменяет requests.get там, где он используется — в модуле weather:
from unittest.mock import Mock, patch
from weather import get_temp
def test_get_temp_reads_temp_field():
response = Mock()
response.json.return_value = {"temp": 21}
with patch("weather.requests.get", return_value=response) as get:
assert get_temp("Tashkent") == 21
get.assert_called_once()
Частая ошибка — патчить requests.get в другом месте, а не по пути, через который его импортирует тестируемый модуль.
Подмена fetch в Jest
// user.js
export async function getUserName(id) {
const res = await fetch(`/api/users/${id}`);
const data = await res.json();
return data.name;
}
// user.test.js
import { getUserName } from './user';
test('returns user name', async () => {
global.fetch = jest.fn().mockResolvedValue({
json: async () => ({ name: 'Aziz' }),
});
await expect(getUserName(1)).resolves.toBe('Aziz');
expect(fetch).toHaveBeenCalledWith('/api/users/1');
});
Для целых модулей в Jest есть jest.mock('./db'): все экспорты становятся управляемыми функциями-заглушками.
База данных: fake вместо мока
Мокать каждый SQL-запрос — путь к хрупким тестам. Удобнее спрятать базу за интерфейсом репозитория и подставить в тестах fake:
class FakeUserRepo:
def __init__(self):
self.users = {}
def save(self, user):
self.users[user["email"]] = user
def find_by_email(self, email):
return self.users.get(email)
Логика регистрации тестируется на FakeUserRepo, а работа с настоящей базой проверяется отдельными интеграционными тестами — например, с тестовой БД в Docker.
Опасность избыточного мокирования
Признаки, что моков слишком много:
- тест повторяет реализацию строка за строкой: «вызвать A, потом B с такими аргументами»;
- любой рефакторинг без изменения поведения ломает тесты;
- все тесты зелёные, а в продакшене ошибка, потому что мок вёл себя не как реальный сервис;
- для одного теста настраивается пять и больше подмен.
Как избежать:
- Подменяйте только границы системы: сеть, БД, время, файловую систему, сторонние SDK.
- Свою чистую логику не мокайте — вызывайте её по-настоящему.
- Проверяйте результат, а не последовательность внутренних вызовов, если взаимодействие само по себе не является требованием.
- Держите рядом интеграционные тесты, которые проверяют реальную связку.