Начнем с терминов, потому что здесь их легко перепутать. Тест-дабл - это подмена настоящей зависимости на время теста. Мок - разновидность дабла, которая запоминает, как ее вызывали, чтобы потом это проверить. Фейк - тоже дабл, но рабочий: упрощенная, но настоящая реализация, например репозиторий, который держит записи в памяти вместо базы. Граница - это точка, где ваш код встречает внешний мир: базу данных, сеть, файловую систему. Весь спор о бэкенд-тестах - про то, какие границы подменять, а какие оставлять настоящими.
Наивный ход соблазнителен своей скоростью: замокать все, что за границей. Мокается ORM, мокается драйвер базы, мокается любой вызов наружу. Тест исполняется за миллисекунды, база не нужна, CI не тащит Docker. Кажется, что это и есть идеальный юнит-тест - изолированный и быстрый. И для части логики это правда.
Ломается это там, где мок начинает изображать саму базу. Мок ORM утверждает не поведение PostgreSQL, а ваши представления о нем. Уникальный constraint, откат транзакции, каскадное удаление, поведение индекса, применение миграции к реальной схеме - ничего этого в моке нет. Когда вы пишете mockRepo.save.mockResolvedValue(...), вы проверяете, что ваш код правильно обращается с тем, что вернул мок. Вернет ли настоящая база именно это - вопрос, на который мок ответить не может по определению. Утверждать поведение реальной БД по mock-объекту ORM значит тестировать свою веру в базу, а не базу.
Отсюда разделение, которое снимает спор. Логика бывает двух родов. Бизнес-правило - форма публичного DTO, запрет утечки пароля наружу, валидация входа - не зависит от SQL. Для него достаточно фейка: in-memory репозитория, который ведет себя как хранилище, но живет в памяти. Тест поднимает приложение с таким репозиторием, шлет запрос и проверяет ответ - быстро, честно, без базы. Здесь моков как раз мало, и это правильно.
А вот SQL, транзакции, индексы, constraints и миграции проверяются только настоящей тестовой PostgreSQL. Инструмент вроде testcontainers поднимает одноразовый экземпляр Postgres в Docker на время набора тестов; альтернатива - выделенная тестовая база. Это уже интеграционный тест: он идет через настоящий драйвер к настоящей схеме, с примененными миграциями. Только так вы узнаете, что уникальный индекс действительно ловит дубликат, а транзакция действительно откатывается целиком.
Механизм работает по простой причине: единственный авторитет по поведению базы - сама база. Уровни изоляции, срабатывание constraints, порядок применения миграций, тип данных колонки - это свойства СУБД, а не вашего кода. Их нельзя воспроизвести в моке, потому что мок и есть отсутствие СУБД. Настоящая база в тесте возвращает разговор о ее поведении из области предположений в область фактов.
Цена честна и понятна. Реальная база медленнее, требует Docker и аккуратной изоляции между тестами - отката транзакции или очистки таблиц, иначе тесты пачкают друг друга. Поэтому распределяйте: чистые бизнес-правила - на фейках, слой запросов и миграции - на настоящей базе. Классический провал - suite со стопроцентно зелеными мок-тестами, который падает в проде на нарушении уникального constraint, потому что этот constraint никто и никогда не проверял против настоящей базы. Моков было много, границы не было ни одной.
test('POST /users возвращает публичный DTO', async () => {
const app = buildApp({ users: new InMemoryUserRepository() })
const response = await app.inject({
method: 'POST',
url: '/users',
payload: { email: 'anna@example.com', password: 'secret123' },
})
expect(response.statusCode).toBe(201)
expect(response.json()).toEqual({
id: expect.any(String),
email: 'anna@example.com',
})
expect(response.json()).not.toHaveProperty('passwordHash')
})