Бэкенд-тесты чаще всего портит одна привычка - мокать всё подряд. Заглушают ORM, заглушают HTTP-клиент, заглушают файловую систему, и в итоге тест проверяет не приложение, а собственные моки: он остаётся зелёным, даже когда реальный запрос к базе давно сломан. Правило простое: чем ближе к настоящей границе идёт проверка, тем больше она стоит. Мок - для дешёвой изоляции правил, реальная граница - для того, что без неё нельзя проверить честно.
Основную ценность на сервере даёт интеграционный тест HTTP-слоя. Приложение собирают фабрикой buildApp и посылают запрос в память - через app.inject у Fastify или supertest у Express, - не поднимая настоящий порт. Тест шлёт POST /users с телом, получает 201 и проверяет ответ. Это покрывает маршрут, валидацию, сериализацию и обработчик разом, ближе к тому, что видит реальный клиент, чем любой изолированный юнит.
Отдельного внимания заслуживает публичный контракт ответа. Проверяют не только то, что нужное поле есть, но и то, что лишнего нет: id соответствует expect.any(String), а passwordHash отсутствует - not.toHaveProperty('passwordHash'). Утечка внутреннего поля в DTO - это уже дефект безопасности, и интеграционный тест ловит его на границе, там, где данные покидают систему.
Границу с базой держат по её природе. Бизнес-правила - лимиты, переходы статусов, расчёты - проверяют быстро на fake-репозитории в памяти: они не про SQL, а про логику, и настоящая база тут только замедлит. Но SQL, транзакции, каскады и миграции ничем не заменить - их гоняют против реальной тестовой PostgreSQL, потому что именно там живут ошибки, которых мок по определению не видит.
Реальную базу для тестов поднимают в контейнере. testcontainers стартует одноразовую PostgreSQL на время прогона, накатывает миграции и даёт чистую схему - ту же, что в продакшене, а не её приближение. Это дороже мока по времени, но это единственный способ проверить, что запрос действительно возвращает то, что нужно, что уникальный индекс срабатывает и что транзакция откатывается целиком.
Между этими уровнями проходит граница ответственности. Fake-репозиторий отвечает на вопрос 'правильно ли работает правило', интеграционный тест с настоящей базой - на вопрос 'правильно ли работает хранилище'. Смешивать их - ошибка: правило, проверенное только через реальную базу, тащит с собой её медлительность в каждый тест, а хранилище, проверенное только моком, не проверено вовсе.
Асинхронность на бэкенде подчиняется тем же законам, что и везде: тест обязан дождаться работы. Запрос к базе, вызов внешнего сервиса, запись в очередь - всё это await, а незамоканные исходящие вызовы в интеграционном тесте лучше ловить явно, чтобы случайный реальный запрос не ушёл наружу. Ошибку проверяют не только по коду ответа, но и по телу с понятным клиенту сообщением.
Типичный провал - утверждать поведение базы через мок ORM: тест уверяет, что пользователь сохранён, хотя настоящий запрос никогда не исполнялся и мог бы упасть на констрейнте. Второй провал - гонять каждое бизнес-правило через реальную базу и утопить suite в секундах ожидания. Держите пропорцию: много быстрых юнитов на правила, несколько честных интеграционных на настоящие границы.
test('POST /users создаёт пользователя и не отдаёт хеш', async () => {
const app = buildApp({ users: inMemoryUsers() })
const res = await app.inject({
method: 'POST',
url: '/users',
payload: { email: 'a@shop.io', password: 'secret123' },
})
expect(res.statusCode).toBe(201)
const body = res.json()
expect(body).toMatchObject({ id: expect.any(String), email: 'a@shop.io' })
expect(body).not.toHaveProperty('passwordHash')
})// Реальная PostgreSQL в контейнере - для SQL, транзакций, миграций
const container = await new PostgreSqlContainer('postgres:16').start()
const db = drizzle(container.getConnectionUri())
await migrate(db, { migrationsFolder: './drizzle' })
afterAll(() => container.stop())