@starci/jest-preset
v2.6.0
Published
The StarCi jest config for Nest repositories (unit, integration, e2e and contract projects, ts-jest with diagnostics off, per-file 100 coverage of *.service.ts only) and the one shared unit-test double kit: typed mock<T>(), mockEntityManager(), fakeTransa
Readme
@starci/jest-preset
The jest config of a Nest repository and the one shared unit-test double kit (mock<T>(), mockEntityManager, fakeTransaction, fakeQueryBuilder, queueQueryBuilders, fakeCache, fakeLock, recordingEventBus, recordingQueueOutbox, fakeInbox, builder, FakeClock, fakeIds and the Outcome matchers). Install it from the npm registry at the exact version in the StarCi runtime's knowledge/stack/pins.yaml (see packages/README.md).
// jest.config.js (a managed file: `starci app sync` renders it, `starci app check` compares it; do not edit)
module.exports = require("@starci/jest-preset").starciJestConfig({
coverage: { roots, roles, files, excludes },
})The only option is coverage: the measured scope starci app sync derives once from the slot manifest
(scripts/hfs/coverage-scope.mjs, R204 HFS_COVERAGE_SCOPE_DRIFT) — the measured roots, the roles a measured file carries,
the logic files a slot declares (logic) and the exclusions. A repository has no other jest setting to tune.
What it sets
- Four projects, one per test kind:
unit(**/*.spec.ts, which by the unit standard means one<name>.<role>.spec.tsbeside each spec-required subject — a*.service.ts, a*.cli.tsofsrc/features/cli— and beside any other measured logic file that chooses one;clearMocks; the Outcome matchers are installed throughsetupFilesAfterEnv; ignoressrc/tests/{world,integration,e2e,contract}/),integration(src/tests/integration/**/*.integration-spec.ts),e2e(src/tests/e2e/**/*.e2e-spec.ts) andcontract(src/tests/contract/**/*.contract-spec.ts). The last three compile againstsrc/tests/tsconfig.json, start the one test world throughglobalSetup/globalTeardown(src/tests/world/global-setup.ts,global-teardown.ts) and run on the world runner (world-runner.js,WORLD_RUNNER): every spec file in a worker process of its own and never in band, so nothing the globalSetup or another file left on a process global (a framework registry such as@nestjs/graphql's type metadata) reaches the file; and up tomin(--maxWorkers, slots)files at once, each bound to its own data slot of@starci/test-world1.1.0 (STARCI_TEST_WORLD_SLOT), never two files on one slot, because the test world resets a slot's data when a file boots. The runner reads test-world state protocol 2 only: pin the two packages together (knowledge/stack/pins.yaml); another protocol fails withJEST_PRESET_WORLD_PAIR_MISMATCH. Only the unit project has asetupFilesAfterEnv. The managed scripts select one project each:test,test:integration,test:e2e,test:contract; a contract spec skips itself without sandbox config. - The three path aliases
@features/*,@modules/*,@tests/*(MODULE_NAME_MAPPER), the same ones the managedtsconfig.jsondeclares. - ts-jest with
diagnostics: falsein every project.isolatedModules: truecomes from@starci/tsconfig(ts-jest 29.4 reads it there; its own option is deprecated). Types are checked once, bytypecheck(unit specs included) andtypecheck:tests(src/tests/tsconfig.json), andtest:integration/test:e2e/test:contractruntypecheck:testsfirst, so no world spec runs on code that does not type-check. - Coverage of the measured logic, per-file 100.
collectCoverageFromis one glob percoverage.rootsentry covering<root>/**/*.{<roles>}.ts(theruleParams.be.logicRolesof the rendered scope) plus eachcoverage.filesentry (a logic file its slot declares), minus itsexcludes,distandcoverage, andcoverageThresholdholds{ lines: 100, branches: 100, functions: 100, statements: 100 }on one union glob of those roots and files. Jest applies a glob threshold to each file on its own, so every file of aruleParams.be.logicRolesrole inside a measured slot, and every declared logic file, must reach 100 on every metric (an average never passes). The managedtestscript runs the unit project with--coverageand fails below it. Coverage is measured by v8, which counts the real source. The unit project overlaysisolatedModules: true,importHelpers: trueandsourceMap: trueon the repository tsconfig, so ts-jest transpiles each file without building a type program.unit-metadata.js, a ts-jest wrapper and after-transformer, maps only synthetictypeof value === "function" ? value : Objectguards inside TypeScript's generated__metadata("design:paramtypes", ...)calls to the dedicatedunit-metadata.generated.jscoverage anchor. Their runtime guards remain unchanged, including the fallback toObjectfor an undefined or non-class dependency; generated branches are not attributed to source parameters. The wrapper restores that second source after ts-jest emits its map. The anchor ignores generated guards only and is omitted from LCOV. Application source calls and guards remain measured. Source maps are enabled even when the build config disables them. Source conditionals, including branches on the same constructor line, still count toward per-file 100 coverage. Decorator helpers come fromtslib, which the repository lists in its devDependencies (UNIT_COMPILER_OPTIONS). Reporters aretext-summary,textand the bundled LCOV reporter (which gives same-named callbacks unique function identities): the run writescoverage/lcov.infounder the repository (be/) root. - Sonar and Codecov import that lcov, scoped to the same measured files.
starci app syncrenderssonar.coverage.exclusions(the complement of the measured files: SonarQube has no coverage inclusions) and thecodecov.ymlstatus paths and components from the one derivation (scripts/hfs/coverage-scope.mjs), so jest, Sonar and Codecov measure the same files; the Sonar gate holds coverage at 100 overall, on new code and per file.sonarExclusions()still renderssonar.exclusions(**/*.spec.ts,**/*.e2e-spec.ts,**/dist/**,**/coverage/**). There is no coverage exclusion list.
The unit standard in one page
Every spec-required file is unit-tested beside its subject — a *.service.ts, a cli command of src/features/cli — through Test.createTestingModule, with the doubles of this package and nothing else, and any other measured logic file may carry the same spec beside it:
import { Test } from "@nestjs/testing"
import { FakeClock, mockEntityManager } from "@starci/jest-preset"
const clock = new FakeClock("2026-01-01T00:00:00.000Z")
const entityManager = mockEntityManager({ findOne: [Accrual, null] })
const moduleRef = await Test.createTestingModule({
providers: [
CommissionService,
{ provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager }, // exactly the constructor dependencies
{ provide: CLOCK, useValue: clock },
],
}).compile()
const service = moduleRef.get(CommissionService)No new of the service, no imports, no overrideProvider, no jest.mock, no casts, no Date.now() and no process.env. The
full rules and the dependency-to-double-to-cases table are contexts/code/be/test.md (the BE-TEST rules). Every
export below is on the package root (@starci/jest-preset); the sub-paths ./mock, ./clock, ./entity-manager, ./ids,
./query-builder, ./matchers, ./cache, ./lock, ./event-bus, ./queue, ./inbox and ./builders exist as well.
mockEntityManager and fakeTransaction
A strict, typed EntityManager double. Describe what the subject should see; every method is a jest.fn, and any method the
subject calls that the spec did not stub throws an error naming the method, so the absence of a stub proves the absence of that
database call. A wrong entity or SQL text throws too. create(Entity, plain) works without a stub.
import { mockEntityManager, fakeTransaction } from "@starci/jest-preset"
// [Entity, result], typed against the entity: `findOne: [Order, 5]` is a compile error
const em = mockEntityManager({ findOne: [Order, order], save: [Order, saved] })
expect(em.save).toHaveBeenCalledWith(expect.objectContaining({ status: "paid" }))
// a raw query: [sql, rows]
const reader = mockEntityManager({ query: ["select 1", [{ n: 1 }]] })
// lists: a list of pairs answers several calls in order
const two = mockEntityManager({ find: [[Order, [orderA]], [Order, [orderB]]] })
// a transaction: work runs with a scoped view; commits, rollbacks and writes are recorded
const tx = fakeTransaction(mockEntityManager({ save: [Order, saved] }))
await service.place(params) // service calls em.transaction(async (manager) => ...)
expect(tx.commits).toBe(1)
expect(tx.committedWrites).toHaveLength(1) // after a throwing body: tx.rollbacks, tx.rolledBackWrites, tx.outcomesThe stubbable methods are findOne, findOneBy, findOneOrFail, findOneByOrFail, find, findBy, findAndCount,
findAndCountBy, count, countBy, exists, existsBy, save, remove, softRemove, recover, insert, update, upsert,
delete, softDelete, restore, increment, decrement and query. fakeTransaction(em) returns { em, outcomes, commits,
rollbacks, committedWrites, rolledBackWrites }. A write is a write method, a query of a DML statement, or a query that calls a
migration's database function the way callDbFunction does (SELECT * FROM "public"."inbox_claim_event"($1, $2), any schema but
pg_catalog); any other SELECT is a read.
fakeQueryBuilder and queueQueryBuilders
The double of createQueryBuilder(Entity, "alias"), the builder a lite service reads with. Every chained method records
{ method, args } and returns the builder; a terminal (execute, getMany, getOne, getRawMany, getRawOne, getCount,
getManyAndCount) resolves the answer the spec gave it, and a function answer is called when the terminal runs.
import { mockEntityManager, queueQueryBuilders } from "@starci/jest-preset"
const em = mockEntityManager()
const [open] = queueQueryBuilders(em, [{ getMany: [order] }]) // one fake per createQueryBuilder call, in order
await service.listOpen(owner)
expect(open.callsOf("where")).toEqual([["o.ownerId = :owner", { owner }]])fakeCache(clock), fakeLock(clock), recordingEventBus() and recordingQueueOutbox()
const clock = new FakeClock("2026-01-01T00:00:00.000Z")
const cache = fakeCache(clock) // JSON round trip; TTL from the clock
await cache.set({ key, args: ["u1"], value: profile }) // the typed-key port: get/set/del({ key, args, value })
await cache.get({ key, args: ["u1"] })
await cache.set("raw-key", value, 60_000) // the cache-manager shape: get(text), set(text, value, ttlMs)
cache.has({ key, args: ["u1"] }) // a live entry exists
cache.ttlOf({ key, args: ["u1"] }) // seconds left (rounded up), null when absent or expired
cache.keys() // the texts of the live entries
clock.advance(301_000) // entries with a 300 s TTL are now expired
const lock = fakeLock(clock) // acquire, contention, release, expiry, growing fence
const grant = await lock.acquire({ name: "writer", holder: "a", ttlMs: 30_000 }) // null when held by another
lock.isHeld("writer"); lock.holderOf("writer"); lock.fenceOf("writer")
await lock.release({ grant })
const bus = recordingEventBus() // publish(event, tx): the EventBus port
bus.events // what the outbox would hold: the first publication of each (event name, event id)
bus.writes // every event published, duplicates included
bus.entries // every publication with its tx and `inTransaction`
bus.allInTransaction; bus.eventsOf(OrderPlacedEvent); bus.clear()
bus.setPendingRetries(2) // what pendingRetries(eventClass) answers
bus.queueDeadLetters(letter) // what deadLetters(eventClass) hands out; bus.requeued keeps requeue(id) calls
bus.failNext("publish", new Error("down")) // the next call of that operation rejects once
const queueOutbox = recordingQueueOutbox() // write(tx, queue, payload): the QueueOutbox port
queueOutbox.jobs; queueOutbox.jobsOf("mail"); queueOutbox.entries; queueOutbox.allInTransaction
const inbox = fakeInbox() // the delivery side, the twin of the recording outboxes
await inbox.claim("sepay", "e-1") // true the first time, false for the same pair until it is released
await inbox.release("sepay", "e-1") // gives the claim back: the next claim answers true
inbox.seen("sepay", "e-2") // a redelivery: the next claim of the pair answers false
inbox.claims; inbox.claimed; inbox.released; inbox.failNext("claim", new Error("db down")); inbox.clear()
// claimRow: the EntityInbox claim that inserts the receipt row only for the winning claim and answers its id
await inbox.claimRow("webhook.sepay", "e-3", tx, { entity: SepayInboxEntity, row: (deliveryId) => ({ deliveryId }) }) // "receipt-1"
inbox.receipts // every inserted receipt: id, source, eventId, entity, row, tx, inTransaction
inbox.receiptExists("webhook.sepay", "e-4") // the receipt row is already there: the winning claim inserts nothing, answers undefined
const clientInbox = fakeInbox(clock) // the driven clock stamps client claims for retention tests
await clientInbox.runClient({ principalId: "p-1", operation: "checkout.place", idempotencyKey: "key-1", request: { quantity: 2 } }, tx, async () => ({ kind: "ok", value: { orderId: "o-1" } }))
clientInbox.clientEntries // every client claim with its tx and inTransaction flag
await clientInbox.sweepClients(cutoff, tx)
clientInbox.sweeps // every sweep attempt's cutoff, tx and inTransaction flag, failures includedClient repeats replay the stored JSON outcome, including a refusal; a different request for the same namespace and key
rejects with IDEMPOTENCY_KEY_REUSED. Concurrent repeats wait for the first effect. A rejected effect gives its claim back.
Delivery release preserves client receipts and their outcomes.
The double records the transaction passed to it; a later outer transaction rollback needs real database integration proof.
Without a clock, client claims use the Unix epoch. The sweep removes one bounded batch of completed expired client claims
and preserves delivery claims.
builder, fakeIds and the Outcome matchers
import { builder, fakeIds } from "@starci/jest-preset"
const options = builder<CommissionOptions>({ bps: 3000, enabled: true }) // a function returning the defaults with overrides applied
const disabled = options({ enabled: false })
const ids = fakeIds() // ids.next() === "00000000-0000-4000-8000-000000000001", then ...0002; ids.issued, ids.reset()
expect(outcome).toBeRefused("COMMISSION_SELF_REFERRAL") // { kind: "refused" } with this code
expect(outcome).toBeRefused({ code: "PLAN_LIMIT", params: { max: 3 } }) // and these params
expect(outcome).toSucceedWith(accrual) // { kind: "ok" } whose value equals `accrual`The matchers are installed in the unit project only (setupFilesAfterEnv); their types are declared globally by importing the
package.
mock<T>()
Replaces { ... } as unknown as T.
import { mock } from "@starci/jest-preset"
const repo = mock<UserRepository>({ name: "users" }) // overrides may be partial
repo.find.mockResolvedValue({ id: "a" }) // typed against UserRepository["find"]
const service = new UserService(repo) // assignable wherever a UserRepository is required
expect(repo.find).toHaveBeenCalledWith("a")Every property read yields one stable jest.fn(), created on first read. The double is not thenable, so returning it from
an async function does not hang; then, toJSON and Symbol keys read as absent. A value you pass in overrides wins. Wrong
stubs (repo.find.mockResolvedValue(1)) and members the type does not have are compile errors. Fixtures use it instead of
as never and as unknown as.
FakeClock
The test double for the injected Clock port (platform/clock). Business code asks clock.now() instead of reading
Date.now() or new Date() ambiently (no-ambient-clock, @starci/eslint-canon-be R79 BE_AMBIENT_CLOCK); a spec
drives time explicitly instead of sleeping for real or racing the wall clock.
import { FakeClock } from "@starci/jest-preset/clock"
const clock = new FakeClock("2026-01-01T00:00:00.000Z") // starts at a chosen instant; no argument starts at real now
const service = new PlanService(clock)
clock.advance(60_000) // move the clock forward (or back, with a negative value) without a real sleep
clock.set("2026-06-15T00:00:00.000Z") // jump to a chosen instant, e.g. to drive a scheduled job
expect(clock.now().toISOString()).toBe("2026-06-15T00:00:00.000Z")FakeClock satisfies the Clock port structurally ({ now(): Date }); no cast is needed to inject it in place of the
real clock.
