How the suite is organised, where each kind of test belongs, and — the part that costs contributors the most time to rediscover — exactly what the in-memory PostgreSQL emulator can and cannot do.
npm test # both projects, serially — the full suite
npm run test:unit # pure unit specs only, parallel workers
npm run test:integration # Nest bootstrap / pg-mem / real PostgreSQL, serially
npm run test:cov # full suite with coverage thresholds enforcednpm test runs --runInBand on purpose: several suites point at the same throwaway
database through TEST_DATABASE_URL and would otherwise race each other's schema resets.
The file name decides which Jest project a spec lands in. There is no other switch.
| Pattern | Project | May use |
|---|---|---|
*.spec.ts |
unit |
Nothing external. No pg-mem, no Nest container, no database. |
*.integration-spec.ts |
integration |
pg-mem, a Nest TestingModule, or a real PostgreSQL. |
jest.unit.config.ts matches .*(?<!integration-)spec\.ts$ — the negative lookbehind is
what keeps the two disjoint, so every spec runs exactly once across the two projects.
HEAVY_SPEC_PATHS in jest.unit.config.ts lists four files that carry a .spec.ts name
but bootstrap a container or a pg-mem database. They are routed to the integration project
until they are renamed. Do not add entries to that list — name a new heavy spec
*.integration-spec.ts instead.
Both projects transpile with ts-jest in isolatedModules mode and diagnostics: false.
That is what takes the unit run from roughly twelve seconds to under four, and it is only
safe because type errors are still caught by npm run typecheck in the same CI job. The two
must stay together.
Everything shared lives in test/support/ and is excluded from both projects' test
matching.
| File | Purpose |
|---|---|
pg-mem.ts |
The only place allowed to instantiate pg-mem. Creates the engine, registers function shims, returns a pg-compatible pool. |
schema.ts |
The single source of truth for the pg-mem schema, reproducing the end state of db/migrations/0001..0021, plus KNOWN_SCHEMA_DIVERGENCES. |
builders.ts |
Fixture builders for domain objects. |
fakes.ts |
In-memory doubles for the queue and fetcher ports. |
http.ts |
Typed readers for supertest bodies — dataOf, itemsOf, paginatedBody, errorBody — that funnel supertest's any through unknown in one audited place. |
expect-defined.ts |
expectDefined(value, what) — narrows the T | undefined that noUncheckedIndexedAccess gives every indexed read. Used by 25 spec files. |
Before schema.ts existed, four suites each hand-wrote their own subset of the schema and
they had already drifted: one still declared a constraint migration 0013 drops, another kept
an index migration 0018 drops, and none of them had feeds.claimed_at from migration 0015.
A repository could therefore pass its tests against a schema production had not had for
months. test/schema-parity.integration-spec.ts now diffs the emulator schema against the
real migrations and fails on any undeclared difference.
tsconfig.json sets noUncheckedIndexedAccess, so rows[0] is typed T | undefined in
tests as well as in src/. Reach for expectDefined(rows[0], 'first row') rather than a
! assertion: it fails with Expected first row to be defined, got undefined instead of a
TypeError pointing at whichever property you happened to read next.
Suites that need a real PostgreSQL guard themselves with
const describeWithDatabase = process.env.TEST_DATABASE_URL ? describe : describe.skip;and skip silently when it is unset. A green npm test on a machine without a database
is therefore a weaker signal than it looks: it leaves the migration runner, schema parity,
tenant isolation, the canonical-link dedupe and the feed claim/sweep unexercised.
docker run -d --rm --name fp-test --network host \
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=feedpulse_test -e PGPORT=55999 \
postgres:16-alpine
TEST_DATABASE_URL=postgres://postgres:postgres@127.0.0.1:55999/feedpulse_test npm test
docker rm -f fp-testTwo constraints on that URL:
- The target schema is wiped (
DROP SCHEMA public CASCADE) on every run. Never point it at anything real. The suites deliberately refuse to fall back toDATABASE_URLfor this reason. - The role needs
CREATE DATABASEandDROP DATABASE.test/migrations.integration-spec.tsprovisions a throwaway database per run so it can applydb/migrationsfrom empty without touching the schema the other suites share. The defaultpostgressuperuser satisfies this.
CI provisions a postgres:16-alpine service and exports TEST_DATABASE_URL, so these
suites do run on every pull request.
Version: pg-mem 3.0.14 (package.json declares ^3.0.5; the resolved version in
package-lock.json is what these results were measured against). The table below was
produced by executing each statement against a fresh newDb({ autoCreateForeignKeyIndices: true, noAstCoverageCheck: true }) engine — the same options test/support/pg-mem.ts uses.
Re-probe this table whenever pg-mem is upgraded. A capability list that is not re-measured becomes confidently wrong, which is worse than having none.
| Feature | Notes |
|---|---|
| Partial unique indexes | Actually enforced, not just parsed. |
CHECK constraints |
Enforced. |
ON CONFLICT (col) DO NOTHING |
|
ON CONFLICT (col) DO UPDATE SET … EXCLUDED.… |
|
ON CONFLICT inferred from an expression index |
e.g. ON CONFLICT (COALESCE(a, 'x:' || b::text)). |
TEXT[] and INTEGER[] columns, ARRAY[…] literals |
|
Array subscripting (a[1]) |
|
GENERATED ALWAYS AS (…) STORED |
How entries.normalized_search_document is emulated. |
GREATEST, LIKE, ILIKE |
|
ORDER BY … NULLS LAST, CASE in ORDER BY |
|
COUNT(*)::text |
|
NOW() on TIMESTAMPTZ, and NOW() - INTERVAL '…' |
|
information_schema.tables |
Used by test/schema-parity.integration-spec.ts and the pg-mem harness. |
WITH … SELECT (CTE in a query) |
|
RETURNING |
|
jsonb columns |
|
CREATE INDEX … USING btree (col DESC) |
|
FOR UPDATE SKIP LOCKED |
Parses and returns rows, but there is no row locking behind it. It proves the statement is syntactically valid and nothing about concurrency. |
| Feature | Consequence |
|---|---|
unnest() |
AlertsRepository.createForEntryWithRules needs it; that suite needs real Postgres. |
The xmax system column |
Same statement. (xmax = 0) AS inserted cannot be emulated at all. |
Window functions (… OVER (…)) |
Used by several migrations to de-duplicate before adding a unique index. |
DELETE … USING … |
Both with and without a CTE. |
| plpgsql function bodies | LANGUAGE sql bodies are supported; LANGUAGE plpgsql is not. |
translate() |
Shimmed in test/support/pg-mem.ts. |
regexp_replace(), regexp_match() |
Shimmed. |
split_part() |
Shimmed. |
to_tsvector() and GIN full-text indexes |
Shimmed to the identity function purely so the DDL parses; it indexes nothing. |
normalize(x, NFD) |
Why feedpulse_normalize_search_text is bound to the TypeScript normalizer instead. |
sha256(), convert_to(), encode() |
Why feedpulse_entry_content_hash has no emulator equivalent. |
pg_try_advisory_lock() |
The migration runner's lock cannot be exercised in the emulator. |
gen_random_uuid(), random() |
Shimmed where a suite needs them. |
"Shimmed" means test/support/pg-mem.ts registers a minimal JavaScript reimplementation —
correct for the inputs this schema and these suites produce, and nothing more. It is not a
general PostgreSQL implementation and must not be treated as one.
db/migrations is never applied to pg-mem. These files use SQL the emulator does not
implement:
| Migration | Unsupported SQL it uses |
|---|---|
| 0004 | to_tsvector, translate |
| 0007 | translate |
| 0012 | LANGUAGE plpgsql, window functions, regexp_match, regexp_replace, split_part |
| 0013 | window functions |
| 0016 | normalize(…, NFD), regexp_replace, to_tsvector |
| 0017 | sha256, convert_to |
| 0018 | LANGUAGE plpgsql, window functions, unnest, regexp_match, regexp_replace, split_part |
| 0020 | window functions |
| 0021 | sha256 |
That is why test/support/schema.ts reproduces the end state of the migrations as
emulator-compatible DDL rather than replaying the files, and why schema-parity exists to
keep the two honest.
- BIGINT type.
pgreturnsbigintas a string (which is why ids are typedstringacross the domain); pg-mem returns a JavaScript number. Compare withString(id) === '42'in pg-mem suites. information_schema.columns.is_nullablereportsNOfor every column, including nullable ones, so the parity spec compares names and types only.- Column order differs: the migrations append columns with
ALTER TABLE ADD COLUMN, the emulator declares them inline. Not observable through any query the application makes.
| You are testing… | Write… |
|---|---|
| A pure function, a domain rule, a DTO transform | *.spec.ts with no I/O at all. |
| A use case with a port | *.spec.ts using the fakes in test/support/fakes.ts. |
| A repository's SQL against emulator-supported features | *.integration-spec.ts with createPgMemPool(). |
| SQL that touches anything in the "not supported" table above | *.integration-spec.ts gated on TEST_DATABASE_URL. |
| An HTTP contract end to end | *.integration-spec.ts with a Nest TestingModule and supertest, using the readers in test/support/http.ts. |
Thresholds live in the root jest.config.ts and are a ratchet, never a target. The
floor is set just under the run without TEST_DATABASE_URL, because that is the weakest
run anyone can perform. Both runs were re-measured on 2026-07-30:
| statements | branches | functions | lines | |
|---|---|---|---|---|
| Without database | 79.28 | 67.05 | 70.28 | 78.70 |
| With database | 85.70 | 71.74 | 81.15 | 85.26 |
Raise the thresholds when coverage rises. Never lower them to make a build pass.
| Command | What it proves |
|---|---|
npm run smoke:ci |
The full Docker stack boots, migrates, ingests and delivers. See local-smoke.md. |
npm run benchmark:stage:100:safe |
The ingestion path under load, one command, self-cleaning. See local-benchmark.md. |