Тесты против настоящей базы и смоук по живому HTTP
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Раннер у вас свой: describe кладёт тесты в список, expect бросает, run ждёт каждый. Осталось решить, что подставить сервису вместо базы, и решить честно. Подделка в памяти отвечает мгновенно и проверяет ровно то, что вы в неё написали. Настоящий Postgres проверяет типы, ограничения и значения по умолчанию, но требует чистоты между тестами — и чистота эта устроена хитрее, чем кажется. В статье три приёма доводятся до рабочего вида: подмена хранилища через ваш контейнер, очистка базы на каждый тест и смоук, который проходит весь путь запроса без единого сокета.
Сервис заметок делает две разные работы. Он считает правило — кому что видно, что вернуть, когда заметки нет, какой номер получит новая, — и он разговаривает с базой: составляет запрос, разбирает отказ, приводит типы. Тест правила базу спрашивать не обязан: ему нужен объект, который помнит строки и отвечает сразу. Подставляют такой объект не правкой сервиса, а другим провайдером под тем же токеном — сервис знает токен и вызовы, а что под токеном лежит, решает тот, кто собирает контейнер.
Ниже сервис заметок, подделка MemoryStore и четыре теста парами. В первой паре сервис берётся из контейнера, собранного один раз на весь файл; во второй контейнер собирается внутри каждого теста. Прогноз: какой из четырёх тестов упадёт и с каким сообщением?
Упал второй, «ожидалось 0, получено 1», и ни одна строка NotesService в этом не виновата. Контейнер кеширует объект по токену: рецепт useValue: new MemoryStore() выполнился один раз, и подделка под NOTES одна на весь файл. Первый тест положил в неё заметку, второй получил её же — а вместе с ней и номер 1 в счётчике строк.
Это та же ловушка, что с корзиной, объявленной в describe мимо it, только цена выше: там общее состояние жило внутри одной группы, здесь — столько же, сколько контейнер, то есть весь файл. Порядок тестов становится частью проверки. Переставьте первую пару местами — оба теста пройдут, и файл будет зелёным до того дня, когда кто-нибудь вставит третий тест посередине.
Вторая пара лечится одной строкой: build() создаёт и контейнер, и новую подделку внутри него. Свежий объект в тесте даёт не new внутри сервиса, а новый контейнер — у него своя Map экземпляров, и провайдер выполняется заново. В настоящем раннере эту строку выносят в beforeEach своей группы; у вашего раннера beforeEach общий на файл, поэтому в примере контейнер собирается прямо в тесте.
Подмена работает, пока сервис получает хранилище извне — из контейнера или прямо в конструкторе. Класс, который создаёт хранилище сам, подменить нечем.
Подделка отвечает быстро, потому что почти ничего не делает. Ровно поэтому тест на ней бывает зелёным там, где работа сломается. Сравним две подделки одного хранилища: список в памяти и вашу таблицу с уникальным индексом из статьи про свою таблицу. Обе получают один и тот же заголовок дважды. Прогноз: что каждая ответит на второй «купить хлеб» и что обе скажут про status новой строки?
Список принял дубль молча: у него нет ни индексов, ни ограничений — есть push. Ваша таблица отказала кодом 23505 и назвала индекс, так что правило «ветвиться по коду и имени ограничения» на ней действительно проверяется. Про status не знает ни одна: значение по умолчанию — свойство колонки, а не кода, и подделке взять его негде.
Список того, чего нет ни у одной модели, длиннее: типы колонок и приведение до записи, NOT NULL и CHECK, count(*) строкой, порядок сортировки, выбор индекса и версии строк. Отсюда правило выбора, и оно простое. Правило вашего кода — на подделке: она быстрая, её видно целиком, и падение в ней указывает прямо на сервис. Разговор с базой — на базе: запрос, ограничение, значение по умолчанию, приведение типа.
Тест на подделке отвечает на вопрос «правильно ли написан сервис», а не «сработает ли это в базе».
База для тестов нужна отдельная, не рабочая: тесты пишут, удаляют и чистят таблицы целиком, и промах по адресу означает потерянные данные. Схему в неё накатывают теми же миграциями, что в работе, и до первого теста. База, собранная руками «под тесты», через месяц отличается от боевой, и тесты начинают проверять не то, что выкатывается.
Дальше нужна чистота между тестами, и самый дешёвый способ — транзакция: beforeEach открывает BEGIN, afterEach делает ROLLBACK, и всё, что тест записал, исчезает вместе с транзакцией. Ниже репозиторий, который принимает клиента снаружи, и четыре теста на одном соединении. Прогноз: какой из четырёх упадёт?
import { after, afterEach, before, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import pg from 'pg'; import { NOTES_TABLE, NotesRepo } from '../_shared/p6-tests/notes-repo.mjs'; const url = new URL(process.env.DATABASE_URL); url.password = ''; console.log('база:', url.href); const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); const repo = new NotesRepo(client); describe('NotesRepo на настоящей базе', () => { before(async () => { await client.connect(); await client.query(NOTES_TABLE); }); // соединение закрывается руками: с открытым клиентом pg раннер не закончится after(() => client.end()); beforeEach(() => client.query('BEGIN')); afterEach(() => client.query('ROLLBACK')); it('создаёт заметку и считает её', async () => { await repo.create('ann', 'купить хлеб'); assert.equal(await repo.countFor('ann'), 1); }); it('у автора нет заметок', async () => { assert.equal(await repo.countFor('ann'), 0); }); it('status новой заметки задаёт база, а не код', async () => { const note = await repo.create('ann', 'позвонить маме'); assert.equal(note.status, 'draft'); }); it('то же название у того же автора не принимается', async () => { await repo.create('ann', 'купить хлеб'); await assert.rejects( () => repo.create('ann', 'купить хлеб'), (err) => { assert.equal(err.code, '23505'); assert.equal(err.constraint, 'notes_author_title_key'); return true; }, ); }); });
база: postgres://postgres@127.0.0.1:<порт-базы>/app ▶ NotesRepo на настоящей базе ✔ создаёт заметку и считает её (<мс>) ✔ у автора нет заметок (<мс>) ✔ status новой заметки задаёт база, а не код (<мс>) ✔ то же название у того же автора не принимается (<мс>) ✔ NotesRepo на настоящей базе (<мс>) ℹ tests 4 ℹ suites 1 ℹ pass 4 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms <мс>
Ни один. Второй тест и есть доказательство: он ждёт ноль заметок сразу после того, как первый вставил одну, — и получает ноль, потому что ROLLBACK вернул таблицу к прежнему виду. Третий показывает то, чего не было ни у одной подделки: status новой строки — draft, и подставила его база. Четвёртый — отказ 23505 с именем notes_author_title_key, которое придумали не вы, а Postgres по имени таблицы и колонок.
Две строки примера стоят особняком: обе про то, что тест против базы умеет не заканчиваться, но причины у них разные. after(() => client.end()) — не вежливость: открытый клиент pg держит сокет, сокет держит цикл событий, и node --test ждёт вечно. Проверено на node 26.5: файл с одним зелёным тестом и незакрытым клиентом печатает галочку и не выходит вовсе — ни итога, ни кода выхода, конвейер стоит до своего таймаута.
--test-timeout=20000 в команде лечит другую беду, и путать их не надо. У node --test срока по умолчанию нет — это вы уже видели в сравнении раннеров, — и внутренний процесс раннера так и запускается, с --test-timeout=0. Поэтому тест, повисший на запросе к недоступной базе, повис бы вместе со всей проверкой. Со сроком он получает крестик, а раннер идёт дальше. От незакрытого соединения этот флаг не спасает: сам тест-то закончился.
Открытое соединение не даёт прогону закончиться, а тест без срока не краснеет сам. Это две разные поломки и два разных лекарства.
Адрес базы прогон печатает первой строкой и без пароля — чтобы видно было, куда ушли запросы, а не только что «всё сошлось». Здесь его выдал прогонщик учебника: он поднимает временный кластер PostgreSQL и отдаёт примеру DATABASE_URL. В проекте на этом месте локальный Postgres и отдельная база из переменной окружения.
У отката есть условие, которое легко не заметить: он отменяет то, что записано в его соединении. А боевой репозиторий получает не клиента, а пул — соединение под каждый запрос он берёт сам. Ниже те же два теста дважды: сначала с транзакцией на своём клиенте, потом с TRUNCATE. Прогноз: сколько тестов покраснеет и в какой группе?
import { after, afterEach, before, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import pg from 'pg'; import { NOTES_TABLE, NotesRepo } from '../_shared/p6-tests/notes-repo.mjs'; // как в боевом коде: репозиторию достаётся пул, и соединение под каждый запрос // он берёт сам const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 5 }); const repo = new NotesRepo(pool); const own = new pg.Client({ connectionString: process.env.DATABASE_URL }); before(async () => { await own.connect(); await own.query(NOTES_TABLE); }); after(async () => { await pool.end(); await own.end(); }); describe('откат транзакции теста', () => { beforeEach(() => own.query('BEGIN')); afterEach(() => own.query('ROLLBACK')); it('создаёт заметку и считает её', async () => { await repo.create('ann', 'купить хлеб'); assert.equal(await repo.countFor('ann'), 1); }); it('у автора нет заметок', async () => { assert.equal(await repo.countFor('ann'), 0); }); }); describe('очистка таблиц перед тестом', () => { beforeEach(() => pool.query('TRUNCATE TABLE notes')); it('создаёт заметку и считает её', async () => { await repo.create('ann', 'купить хлеб'); assert.equal(await repo.countFor('ann'), 1); }); it('у автора нет заметок', async () => { assert.equal(await repo.countFor('ann'), 0); }); });
▶ откат транзакции теста
✔ создаёт заметку и считает её (<мс>)
✖ у автора нет заметок (<мс>)
✖ откат транзакции теста (<мс>)
▶ очистка таблиц перед тестом
✔ создаёт заметку и считает её (<мс>)
✔ у автора нет заметок (<мс>)
✔ очистка таблиц перед тестом (<мс>)
ℹ tests 4
ℹ suites 2
ℹ pass 3
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms <мс>
✖ failing tests:
test at notes.test.mjs:31:3
✖ у автора нет заметок (<мс>)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
1 !== 0
at TestContext.<anonymous> (file://<пример>/notes.test.mjs:32:12)
at <внутренние кадры node> {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 1,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}В первой группе упал второй тест: строка ушла через пул по другому соединению, и ROLLBACK на своём клиенте о ней не знает — для него транзакция была пустой. Получилось хуже, чем без всякой очистки: тест, который якобы давал чистоту, её не давал, а первая группа ещё и оставила строку в таблице после себя. Вторая группа зелёная целиком: TRUNCATE удаляет строки независимо от того, чьё соединение их записало.
Цена у TRUNCATE своя. Таблицы перечисляются руками или сносятся CASCADE по внешним ключам; он дороже отката; и он общий на базу. Два тестовых процесса, которые чистят одну базу одновременно, затирают данные друг друга — поэтому рабочий процесс у раннера остаётся один. Так это и настроено в API учебника:
Тот же файл называет и остальное: отдельную базу app_db_test и globalSetup, который её создаёт и мигрирует один раз на весь прогон. Имя базы выводится из адреса, а не пишется рядом вторым местом, — и перед CREATE DATABASE проверяется отдельно: имя в этой команде параметром не подставить, поэтому адрес из окружения иначе стал бы дырой.
Здесь же видна и цена честности: truncateAll перечисляет таблицы явно, хотя notes ушли бы и по CASCADE от users. Читающий тест не должен держать в голове схему внешних ключей, чтобы понять, что именно очистилось.
Хуки те же, что в прогоне, только в словаре jest: beforeAll заводит пул и сервис, afterAll закрывает пул, beforeEach чистит таблицы. Данные каждый тест создаёт себе сам — в том числе пользователя, потому что у заметки есть автор.
Обратите внимание на сборку: new NotesService(new NotesRepository(db)) — руками, без контейнера. У сервиса одна зависимость, и подставить её напрямую короче, чем описывать модуль. Контейнер и подмена провайдеров нужны там, где собирать руками дороже: когда зависимостей пять, когда часть из них создаётся фабрикой, когда проверяется не сервис, а путь запроса целиком.
Смоук проверяет не функцию, а то, что приложение вообще поднимается и отвечает: модули собрались, провайдеры создались, трубы и фильтры встали на места, маршрут нашёлся. У Nest для этого есть своя сборка модуля — та же, что в работе, только с правом подменить в ней провайдера. Test.createTestingModule({ imports: [AppModule] }) собирает тот же модуль, что в работе, а overrideProvider(Токен).useValue(подделка) меняет под токеном рецепт — то же самое, что вы делали у себя методом provide.
Запрос при этом идёт без сокета: app.inject подаёт приложению разобранный запрос и возвращает ответ, пройдя весь конвейер Nest. Порт не занимается, сеть не участвует — значит, тест не падает от занятого порта и не ждёт таймаутов.
В примере один и тот же модуль собран дважды, а спрошен тремя способами. В первой сборке репозиторий подменён: у неё сначала спрашивают сервис прямо из контейнера, потом те же данные через inject. Вторая собрана без подмены. Прогноз: появится ли в выводе строка «NotesRepository: соединяюсь с базой» и что ответит inject на /notes/abc?
import 'reflect-metadata'; import type { NotFoundException } from '@nestjs/common'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import { Test } from '@nestjs/testing'; import { AppModule } from './app.module.js'; import { NotesRepository, type Note } from './notes.repository.js'; import { NotesService } from './notes.service.js'; const asked: number[] = []; const fake = { async byId(id: number): Promise<Note | null> { asked.push(id); return id === 1 ? { id: 1, title: 'купить хлеб' } : null; }, }; // тот же модуль, что в работе, но под токеном репозитория лежит подделка const testing = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(NotesRepository) .useValue(fake) .compile(); const service = testing.get(NotesService); console.log('сервис из контейнера:', (await service.byId(1)).title); try { await service.byId(99); } catch (error) { const http = error as NotFoundException; console.log('сервис из контейнера:', http.constructor.name, '→', http.getStatus()); } // то же приложение целиком: запрос идёт через трубы и контроллер, но без сокета const app = testing.createNestApplication<NestFastifyApplication>(new FastifyAdapter(), { logger: false }); await app.init(); for (const url of ['/notes/1', '/notes/99', '/notes/abc']) { const res = await app.inject({ method: 'GET', url }); console.log(`inject GET ${url} → ${res.statusCode} ${res.body}`); } console.log('подделку спросили про:', asked.join(', ')); await app.close(); // тот же модуль, но провайдеры все настоящие const asIs = await Test.createTestingModule({ imports: [AppModule] }).compile(); const asIsApp = asIs.createNestApplication<NestFastifyApplication>(new FastifyAdapter(), { logger: false }); await asIsApp.init(); const res = await asIsApp.inject({ method: 'GET', url: '/notes/1' }); console.log(`без подмены: inject GET /notes/1 → ${res.statusCode} ${res.body}`); await asIsApp.close();
сервис из контейнера: купить хлеб
сервис из контейнера: NotFoundException → 404
inject GET /notes/1 → 200 {"id":1,"title":"купить хлеб"}
inject GET /notes/99 → 404 {"message":"заметки 99 нет","error":"Not Found","statusCode":404}
inject GET /notes/abc → 400 {"message":"Validation failed (numeric string is expected)","error":"Bad Request","statusCode":400}
подделку спросили про: 1, 99, 1, 99
NotesRepository: соединяюсь с базой
без подмены: inject GET /notes/1 → 500 {"statusCode":500,"message":"Internal server error"}Строка про соединение появилась ровно один раз — у второй сборки. overrideProvider заменил не объект, а рецепт: класс NotesRepository не создавался вовсе, поэтому и его конструктор не выполнялся. Это ответ на частый вопрос «а не полезет ли смоук в базу»: пока под токеном лежит useValue, класс с соединением не вызывается ни разу.
Остальные строки — про то, что смоук проверяет, а тест сервиса нет. 400 на /notes/abc сложила труба ParseIntPipe у параметра: сервис такого входа не увидел бы вовсе, до него бы не дошло. 404 на /notes/99 — исключение сервиса, превращённое в ответ фильтром; напрямую сервис отдаёт то же самое исключением, и первые две строки вывода это показывают. Последняя строка — то же приложение без подмены: 500, потому что настоящий репозиторий без соединения с базой бросил обычную ошибку, а такие ошибки фильтр отдаёт наружу одинаково — «Internal server error», без подробностей. Логгер в примере выключен, иначе к этой строке прибавился бы стек от фильтра; в работе он там и стоит.
Последняя мелочь объясняет, почему подделку спросили четыре раза, хотя запросов было три: два раза её позвал сервис, взятый из контейнера, и два раза — контроллер приложения (на /notes/abc сервис не звали вовсе). Объект один и тот же. testing.get(NotesService) и контроллер получили один экземпляр, потому что прошли через один контейнер, — и это значит, что состояние, накопленное первой половиной смоука, видно второй.
inject — это запрос приложению, а не серверу. Всё, что делает сокет, остаётся непроверенным.
Три приёма статьи и четвёртый, который бывает только на живом сервисе, складываются в таблицу. Читать её стоит по последнему столбцу: он и объясняет, почему уровни не заменяют друг друга.
| Что проверяем | Чем | Что подменяем | Что осталось непроверенным |
|---|---|---|---|
| Правило сервиса: кому видно, что вернуть при отсутствии, какой номер выдать | свой раннер, node --test, Vitest или jest; подделка под токеном | хранилище | запрос, ограничение, приведение типа |
| Разговор с базой: запрос, уникальность, значение по умолчанию | тот же раннер и настоящая база с очисткой между тестами | ничего | маршрут, трубы, фильтры, формат ответа |
| Путь запроса целиком: маршрут, трубы, фильтры, коды ответа | Test.createTestingModule и app.inject | внешние службы: база, почта, хранилище файлов | сокет: прокси, заголовки, keep-alive, TLS |
| Живой сервис после выката | запрос по боевому адресу | ничего | ничего — но и починить уже дороже |
Разница между третьей и четвёртой строкой не формальная. inject не проходит через прокси, не видит заголовков, которые тот дописывает, и ничего не знает ни про keep-alive, ни про сроки соединения. Правило здесь то же, что у пробы здоровья в стеке: спрашивать надо тем же путём, каким придёт работа, — поэтому после выката сервис ещё раз спрашивают запросом по настоящему адресу.
Проект на Nest, собранный стартером 12-й версии, приходит с Vitest — и здесь ждёт тихая поломка, о которой стоит знать заранее. Сверено на vitest 4.1.11 и tsconfig стартера, без единого дополнительного плагина: с vite 8.3.0 Test.createTestingModule собирает модуль и внедряет зависимости по типу, всё работает. Та же папка с vite 7.3.6 — design:paramtypes у класса undefined, и первое же обращение к сервису падает TypeError.
Ломается не сборка, а чтение типов конструктора: список зависимостей Nest берёт из метаданных, которые пишет преобразователь TypeScript внутри Vite, и в 7-й версии их в выводе нет. При этом vitest 4.1.x разрешает vite ^6, ^7 или ^8: свежая установка возьмёт 8, а lock или соседний пакет вполне может оставить 7 — и внедрение сломается молча, без единой диагностики.
Лекарство одно из двух: закрепить vite ^8 или поставить unplugin-swc там, где остался 7. Совет «нужен unplugin-swc» в общем виде неверен — на vite 8 он лишний. И проверяется это только запуском: «собралось» здесь не значит ничего. В API учебника раннер другой, jest 29 с ts-jest, и метаданные ему пишет сам tsc.
- Подмена хранилища в тесте — это другой провайдер под тем же токеном, и сервис при этом не меняется. Но контейнер кеширует объект по токену: подделка живёт столько же, сколько контейнер, поэтому свежий объект в тесте даёт новый контейнер, а не
newвнутри сервиса. - Подделка проверяет ровно то, что в неё написано. Список принимает дубль молча, ваша таблица отказывает кодом
23505и именем индекса, а значение по умолчанию не подставляет ни одна — его знает только колонка. Правило кода проверяют на подделке, разговор с базой — на базе. - Чистота между тестами дешевле всего достаётся транзакцией:
BEGINвbeforeEach,ROLLBACKвafterEach. Условие — одно соединение: репозиторий на пуле уходит из этой транзакции, и тест остаётся зелёным при грязной таблице. TRUNCATEчистит независимо от соединения, но он общий на базу: тесты гоняются одним рабочим процессом и против отдельной базы, схему в которую накатывают те же миграции, что в работе.node --testне заканчивается, пока открыт клиент базы, и по умолчанию не даёт тесту срока. Закрытие вafterи--test-timeout— часть теста против базы, а не украшение, и лечат они две разные поломки.- Смоук —
Test.createTestingModuleсoverrideProviderиapp.inject: тот же модуль, что в работе, подделка под токеном (класс с соединением при этом не создаётся вовсе) и весь путь запроса без сокета. Чегоinjectне видит — прокси, заголовков, keep-alive, — спрашивают запросом по боевому адресу.
1/4В тесте под токеном хранилища стоит useValue: new MemoryStore(), а контейнер собран один на файл. Сколько разных подделок увидят четыре теста?
2/4Репозиторий получает пул. Тест открыл BEGIN на своём клиенте, записал строку через репозиторий и сделал ROLLBACK в afterEach. Что останется в таблице?
3/4В тестовом модуле сделано overrideProvider(NotesRepository).useValue(fake). Что случится с конструктором класса NotesRepository?
4/4Тест против базы прошёл, но соединение pg в конце не закрыли. Что увидит конвейер выкладки?
Тест, который зеленеет от соседа
Все три теста проходят, но второй проходит только потому, что перед ним выполнился первый: номер 2 берётся из подделки, в которую уже положили заметку. Убедитесь сами — переставьте первые два it местами, и второй покраснеет. Сделайте каждый тест независимым от порядка, не меняя ни строки в NotesService и не трогая MemoryStore.
Независимость даёт не порядок тестов, а свежий контейнер: в новом контейнере своя Map экземпляров, и провайдер useValue выполняется заново, отдавая новую подделку.
Второй тест стал на строку длиннее, и это не потеря, а приобретение: раньше его условие («уже есть одна заметка») было спрятано в соседнем тесте, теперь оно написано в нём самом. Проверка, которой нужна предыстория, обязана создавать её себе — иначе читающий тест не может сказать, что именно проверяется.
В настоящем раннере эти три одинаковые строки уносят в beforeEach группы, а в тестах против базы на то же место встаёт очистка таблиц.
Подделка, которая пропускает дубль
Сервис не проверяет уникальность сам: он полагается на ограничение в базе и переводит её отказ в доменный код TITLE_TAKEN. Тест на эту ветку есть, но подделка дубль принимает, и ветка не выполняется ни разу. Допишите add так, чтобы подделка отказывала ровно как база — исключением с полями code и constraint.
Отказ подделки должен нести те же поля, по которым ветвится сервис, — код SQLSTATE и имя ограничения:
Теперь оба теста зелёные, и второй наконец проверяет ветку, а не проходит мимо неё.
Но заметьте, на чём этот тест держится. Имя notes_author_title_key в подделке придумали вы. В базе его придумывает Postgres — по имени таблицы и колонок, — и стоит переименовать колонку, как боевая ветка перестанет срабатывать, а тест останется зелёным. Поэтому одну и ту же ветку проверяют дважды: быстрым тестом на подделке и одним прогоном против базы, где имя приходит настоящее.