Тесты бывают трудными не потому, что инструмент слаб, а потому, что код к ним не приспособлен. Функция, намертво сросшаяся с базой, часами и сетью, требует горы моков, и набор превращается в хрупкую копию реализации. Профессиональные паттерны решают это с другого конца: они меняют структуру кода так, чтобы его было легко проверять честно. Хороший тест часто начинается не в тесте, а в дизайне того, что тестируешь.
Базовый приём - ports and adapters, отделение домена от внешнего мира через интерфейсы. Домен не зовёт напрямую системные часы, репозиторий или платёжный шлюз - он принимает Clock, Repository, Gateway как зависимости за интерфейсами. В продакшене подставляют реальные реализации, в тесте - простые фейки. Тогда бизнес-правило проверяется без всякого мока модулей: нужную границу передают через конструктор, а не перехватывают на уровне импорта.
Собирать сложные объекты для теста помогает Test Data Builder. Вместо того чтобы в каждом тесте вручную набивать заказ с двумя десятками полей, заводят билдер с разумными значениями по умолчанию, а в тесте задают только те поля, что важны для проверяемого поведения. Это убирает шум - глядя на тест, сразу видно, какое именно поле определяет исход, - и не рассыпается, когда в объект добавляют новое обязательное поле.
Ещё один паттерн - humble object: свести к минимуму логику в частях, которые трудно тестировать. Компонент, контроллер, обработчик очереди делают тонкими - они только принимают вход и делегируют, - а всю содержательную логику выносят в чистые функции и доменные объекты, которые проверяются юнит-тестом без среды. Тонкую оболочку тогда достаточно покрыть немногими интеграционными или E2E-проверками, а не пытаться протестировать всё через неё.
Особую роль играют контрактные тесты. Когда у интерфейса несколько реализаций - репозиторий в памяти для скорости и на PostgreSQL для продакшена, - один и тот же набор проверок прогоняют против обеих. Функция repositoryContract(name, createRepo) описывает ожидания от интерфейса один раз, а вызывают её и для памяти, и для базы. Так фейк гарантированно ведёт себя как настоящий, и тесты на нём не лгут о поведении продакшен-хранилища.
Контрактные тесты закрывают главную опасность фейков - расхождение с реальностью. Быстрый фейк-репозиторий ценен ровно до тех пор, пока он ведёт себя как настоящий; стоит им разойтись - и зелёные юнит-тесты начинают врать. Общий контракт держит их синхронными: если фейк перестал соответствовать интерфейсу, красным становится тест контракта, а не баг в проде. Это и есть цена доверия к быстрым тестам поверх фейка.
Все эти паттерны служат одному - убрать соблазн мокать всё подряд. Когда зависимости за интерфейсами, объекты собираются билдером, логика вынесена в чистые функции, а фейки скреплены контрактом, честный тест пишется естественно и без груды подмен. Антипаттерн, от которого они уводят, один и тот же: mock everything, набор, который проверяет собственные моки и разваливается при любом рефакторинге.
Типичный провал - лечить трудный тест новыми моками вместо того, чтобы поправить дизайн: инъекция зависимости убрала бы мок совсем. Второй - фейк без контрактного теста: он тихо расходится с реальной реализацией, и однажды прод падает там, где юнит был зелёным. Начинайте с формы кода: границы за интерфейсами, тонкие оболочки, билдеры для данных, контракт для фейков - и моков понадобится в разы меньше.
// Порт: домен зависит от интерфейса, не от реализации
interface Clock { now(): Date }
class Subscription {
constructor(private clock: Clock, private repo: Repository) {}
isActive(id: string) { /* использует this.clock.now() */ }
}
// В тесте - простой фейк вместо мока модуля
const clock: Clock = { now: () => new Date('2026-07-26T10:00:00Z') }// Контракт: один набор проверок против обеих реализаций
function repositoryContract(name: string, createRepo: () => UserRepo) {
describe(name, () => {
test('находит сохранённого пользователя', async () => {
const repo = createRepo()
const saved = await repo.save({ email: 'a@shop.io' })
expect(await repo.byId(saved.id)).toEqual(saved)
})
})
}
repositoryContract('in-memory', () => inMemoryUsers())
repositoryContract('postgres', () => postgresUsers(testDb))