@application-kit/testing
v0.9.0
Published
Тестовая инфраструктура NestJS-приложений: шины CQRS, логгер, фейковый microservice, сборка web-приложения, Postgres в testcontainers, запуск e2e-приложения, изоляция окружения прогона, дешёвый bcrypt для unit-прогона, раскладка ячеек CI по проектам vites
Maintainers
Readme
@application-kit/testing
Тестовая инфраструктура NestJS-приложений: стабы шин CQRS, логгер-накопитель, фейковый microservice-транспорт,
сборка и запуск web-приложения для e2e, Postgres в testcontainers, изоляция окружения прогона, конфиги vitest приложения,
дешёвый bcrypt для unit-прогона и раскладка ячеек CI по проектам vitest (входы /vitest, /vitest/fast-bcrypt).
Для devDependencies приложения, в прод не попадает.
Установка
yarn add --dev @application-kit/testingPeer-зависимости: @nestjs/common, @nestjs/core, @nestjs/cqrs,
@nestjs/microservices, @nestjs/platform-fastify, @nestjs/testing, nestjs-pino, oxide.ts, rxjs,
testcontainers, typeorm — обязательные для корневого входа. Вход /vitest несёт свои optional peers vitest и
vite (mergeConfig фабрик конфигов), /vitest/fast-bcrypt — bcrypt.
Состав
TestLogger— наследуетPinoLogger, складывает вызовы вdebugs/errors/infos/warns.TestCommandBus/asCommandBus,TestQueryBus/asQueryBus— стабы шин: фиксируютexecuted, отдают заданный результат.TestEventBus—EventBus, который копит события вevents.FakeMicroserviceStrategy— транспорт microservice без сети.buildWebApplication(imports, overrides, options)— Fastify-приложение из реальных модулей с подменой провайдеров;testQueryRunnerзакрепляетQueryRunner(отключаетсяpinQueryRunner: false).startPostgresContainer(image),testDataSource({ image, migrations, entities, … })— Postgres в контейнере и инициализированныйDataSource. Образ и стратегию имён передаёт приложение: версия БД и именование — его решение, у пакета их нет. Опции подключения к контейнеру —postgresContainerDataSourceOptions(container, namingStrategy).startPostgresWebApplication({ databaseConfigToken, image, imports, namingStrategy, …buildOptions })— настоящий web-граф на временном Postgres: конфиг БД по токену подменяется объектом сcreateDataSourceOptions()поверх контейнера, схема —synchronizeиз entities,stop()закрывает приложение, затем контейнер. Звать изbeforeAllс таймаутом.replaceProcessEnvironment(environment)— окружение прогона целиком: изprocess.envснимается всё, кроме переменных инструментария (node, yarn, vitest, Docker, прокси, локаль), и выставляется набор приложения. Звать изsetupFilese2e-профиля: конфиг-модули снимают окружение на импорте.
// src/testing/e2e-environment.ts — setupFiles e2e-профиля
replaceProcessEnvironment({ DB_HOST: 'localhost', JWT_SECRET: randomUUID() })
const { dataSource } = await testDataSource({
entities: [UserOrmEntity],
image: 'postgres:18.3-alpine3.23',
migrations: [UserMigration],
namingStrategy: new TypeormPluralNamingStrategy(),
})Вход /vitest: конфиги vitest приложения
defineApplicationUnitConfig(options) и defineApplicationEndToEndConfig(options) собирают vitest.config и
vitest.config.e2e поверх экспорта vite.config приложения (viteConfig — объект, mergeConfig из vite):
import { defineApplicationEndToEndConfig, defineApplicationUnitConfig } from '@application-kit/testing/vitest'
import viteConfig from './vite.config.mjs'
const E2E_SPECS = ['src/modules/*/tests/*.spec.ts']
// vitest.config.mts
export default defineApplicationUnitConfig({
clientSources: ['src/modules/*/user-interface/client/**'],
clientSpecs: ['src/modules/*/user-interface/client/**/*.spec.ts'],
coverage: { exclude: ['**/*.module.ts'], include: ['src'], thresholds: { lines: 95 } },
excludeSpecs: E2E_SPECS,
root: import.meta.dirname,
serverSetupFiles: ['@application-kit/testing/vitest/fast-bcrypt'],
setupFiles: [],
specs: ['src/**/*.spec.ts'],
viteConfig,
})
// vitest.config.e2e.mts
export default defineApplicationEndToEndConfig({
coverage: { include: E2E_SPECS, thresholds: { 100: true } },
root: import.meta.dirname,
setupFiles: ['./src/testing/e2e-environment.ts'],
specs: E2E_SPECS,
viteConfig,
})Спеки, исходники клиента, покрытие (глобы, исключения, пороги), setupFiles (serverSetupFiles — только проекту
server) и дополнительные пакеты для
server.deps.inline (inlineDependencies) — у приложения. Фабрики решают сами:
- оба профиля —
globals,isolate: false,testTimeout20 с (таймаут ловит зависший промис, а не медленный раннер), кеш трансформов.vitest-module-cache(путь — контракт кеша джобts:test:*шаблона CI; ключ —fsModuleCacheверхнего уровня на vitest 5 иexperimental.fsModuleCacheна vitest 4), покрытие v8 с отчётомtextиskipFull;@application-kit/graphqlидёт через Vite, а не нативно через Node — иначе ошибка из пакета и код приложения видят разные копииgraphql, иinstanceof GraphQLErrorложен; - unit — проекты
server(node, спекиspecsбезexcludeSpecsиclientSpecs) иclient(jsdom,clientSpecs), ячейки шарда CI иProjectShardSequencer(ниже); - e2e — окружение
nodeи алиасgraphqlна CJS-входnode_modules/graphql/index.jsподroot: Vite для SSR берёт ESM-сборкуgraphql, внешнийmercurius— CJS, и без алиаса схема и валидация запроса живут в разных realm; - мутации — отдельного конфига не нужно:
vitestConfigFileStryker указывает на обычныйvitest.config, а фабрики, увидевSTRYKER_MUTATOR_WORKER, выключают кеш модулей (под Stryker он отдаёт тестам трансформ без активного мутанта) и на vitest 5 ставятisolate: true: без изоляции vitest-runner Stryker собирает покрытие по тестам только с первого файла спек воркера.
Вход /vitest/fast-bcrypt: дешёвый bcrypt
Setup-файл unit-профиля (serverSetupFiles: ['@application-kit/testing/vitest/fast-bcrypt']): vi.mock('bcrypt') считает хеш
с cost 4 (≈ 1 мс против ≈ 300 мс у cost 12), а поле стоимости в строке переписывает на запрошенную — getRounds и
решение о перехешировании видят настоящую стоимость; compare перед сверкой возвращает поле на 4. Сверяются только хеши,
выпущенные подменой: литерал хеша с настоящим cost 12 не сойдётся. Подмена видна коду, прошедшему трансформ vitest
(исходники приложения); внешний пакет, импортирующий bcrypt, ускоряется только через inlineDependencies. E2e-профилю
файл не нужен.
Файл идёт в serverSetupFiles, а не в setupFiles: клиентскому коду bcrypt не нужен, а проект client грузит setup-файлы
через vite-сервер, который отдаёт только файлы из server.fs.allow (по умолчанию — корень проекта). При node_modules вне
корня (образ CI, git worktree со ссылкой на чужой node_modules) файл пакета в setupFiles роняет все спеки client на
Cannot find module '/@fs/…'.
Ячейки CI по проектам
vitest --shard=i/n сам режет спеки хешем, и при двух проектах разных трансформов (server в node, client в
jsdom) объединение blob-отчётов покрытия неточное: непокрытые файлы одна ячейка синтезирует ssr-картой, а другая даёт
web-карту того же файла, и statements/functions задваиваются. ProjectShardSequencer отдаёт первой ячейке проект
client целиком, остальным — проект server по размеру спек (LPT, одна и та же раскладка в каждой ячейке без обмена
данными). parseShard(process.argv) читает --shard=i/n и --shard i/n; без флага, с одной ячейкой или с мусором в
значении — undefined, то есть обычный прогон (и --merge-reports, и Stryker).
defineApplicationUnitConfig выбирает ячейку по process.argv в момент вызова: 1 — только проект client и покрытие
по clientSources, 2..n — только проект server и покрытие без clientSources, без флага — оба проекта. Свой конфиг
без фабрики собирается из parseShard и ProjectShardSequencer напрямую.
Вход /stryker: мутации по группам каталогов
Вход не тянет vitest: конфиг Stryker — обычный объект. stryker.config.mjs приложения собирается из трёх частей.
CODE_WITHOUT_LOGIC — глобы файлов без логики (команды, DTO, модули, мапперы, манифесты, сущности и миграции TypeORM,
навигация, виджет-плагины). Список один на оба инструмента: defineStrykerConfig добавляет к нему ! для mutate, а
приложение кладёт его в coverage.exclude vitest. Расхождение допустимо только в явных добавках приложения:
*.spec.ts и tests/** для мутаций, *.html для покрытия.
import { CODE_WITHOUT_LOGIC, defineStrykerConfig } from '@application-kit/testing/stryker'
// stryker.conf.mjs — база
export default defineStrykerConfig({
ignorePatterns: ['docs', 'tasks'],
mutate: { exclude: ['src/**/*.spec.ts', 'src/**/*.vue', 'src/**/tests/**'], include: ['src/modules/**/*.ts', 'src/libs/**/*.ts'] },
thresholds: { break: 94, high: 100, low: 95 },
vitestConfigFile: 'vitest.config.mts',
})
// vitest.config.mts
coverage: { exclude: [...CODE_WITHOUT_LOGIC, '**/*.html'], include: ['src'], thresholds: { lines: 95 } }defineStrykerLayers режет прогон на группы каталогов: ячейка CI мутирует одну группу со своим порогом break (общий
порог разбавлял бы требование к сильной группе слабой). Группу называет TYPESCRIPT_MUTATION_LAYER (имя меняется
layerVariable); без переменной берётся defaultLayer. Фабрика сама проверяет при загрузке: каждый корень (scope)
лежит ровно в одной группе — новый модуль без группы падает с его именем, а не выпадает молча из мутаций; список ячеек
parallel: matrix в файле CI совпадает с таблицей групп; из incremental-отчёта перед прогоном Stryker убираются файлы
вне области группы. Каталог внутри чужого (слой модуля) принадлежит своей группе и у объемлющей снимается сам.
// stryker.layer.conf.mjs
export const layers = defineStrykerLayers({
base,
ciFile: path.join(import.meta.dirname, '.gitlab-ci.yml'),
defaultLayer: 'modules',
layers: {
libs: { break: 80, paths: ['src/libs'] },
modules: { break: 94, paths: ['src/modules/client', 'src/modules/common'] },
},
root: import.meta.dirname,
scope: { directories: ['src/libs'], parents: ['src/modules'] },
skip: { configFiles: /^(\.gitlab-ci\.yml|vitest\.config[^/]*\.mts)$/, sharedDirectories: ['src/libs/testing-module'], sourceRoot: 'src' },
})
export default layers.configBin application-mutation-layer читает экспорт layers (--config=, по умолчанию stryker.layer.conf.mjs):
names— группы через пробел (for layer in $(application-mutation-layer names));paths— каталоги группы из переменной окружения, вырезанные каталоги других групп — с!;run [base]— в MR (baseилиCI_MERGE_REQUEST_DIFF_BASE_SHA) пропускает ячейку, если дифф не трогает её группу, иначе запускаетyarn stryker run <config> --logLevel info --concurrency 2(MUTATION_LAYER_COMMANDподменяет команду). Причину решения печатает всегда: зелёная ячейка без прогона и с прогоном в логе различимы. Прогон идёт при любом сомнении — нет базы, дифф не читается или пуст, тронут файл изskip.configFiles, файл подsourceRootвне групп, каталог изskip.sharedDirectories. Образу безgitего ставит приложение до вызова.
