@ultimat3/testing 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,10 @@ frozen clock. Never let a test reach the network unmocked — it fails by design
11
11
  | `template-db.ts` | N workers, N databases, one migrated template, `CREATE DATABASE ... TEMPLATE` |
12
12
  | `determinism.ts` | frozen clock, seeded RNG, seeded uuids, `assertDeterministic` |
13
13
  | `sealed-network.ts` | any unmocked egress fails, with the URL and the mock line |
14
- | `factories.ts` | typed factories from the entity registry, seeded |
14
+ | `factories.ts` | `defineFactory` — seeded rows, traits, associations, `build` vs `create` |
15
+ | `factory-registry.ts` | `factoriesFor(registry)` — one factory per entity, defaults read off column names |
16
+ | `factory-persist.ts` | `usePersister` — the one seam `create()` writes through |
17
+ | `shared-examples.ts` | `sharedExamples` / `behavesLike` — one rule, many subjects |
15
18
  | `test-types.ts` | the six test types and their helpers |
16
19
  | `matchers.ts` | `toBeUltimateError` `toDenyPolicy` `toEmitSteps` `toMatchOpenApi` `toBeWithinBudget` `toRejectInput` |
17
20
  | `fixtures.ts` | the registry + `test('…', ({ clock }) => …)` injection |
@@ -100,18 +103,76 @@ Each helper prefixes the test name with its type (`job · onboards an org`), whi
100
103
  `bun test --test-name-pattern "job · "` selects — the six lines of `x verify` come from the tests
101
104
  themselves, not from a directory convention.
102
105
 
106
+ ## Factories
107
+
108
+ ```ts
109
+ const orgs = defineFactory(orgEntity, {
110
+ defaults: (n, ids): Org => ({ id: ids.uuid(), name: `org-${n}` }),
111
+ });
112
+
113
+ const posts = defineFactory(postEntity, {
114
+ defaults: (n, ids): Post => ({ id: ids.uuid(), title: `post-${n}`, orgId: '', published: false }),
115
+ traits: { published: { published: true }, popular: (n) => ({ views: n * 100 }) },
116
+ associations: { orgId: associate(orgs, (org) => org.id) },
117
+ });
118
+
119
+ posts.with('published').build(); // in memory, org built alongside it, no database
120
+ await posts.with('published').create(); // org written first, then the post
121
+ ```
122
+
123
+ | | |
124
+ |---|---|
125
+ | **trait** | a named partial. `with('a', 'b')` composes left to right; an explicit override still wins |
126
+ | **association** | a column whose value comes from another factory, built with the **same strategy** — `build` leaves the parent in memory, `create` writes it |
127
+ | **overrides suppress associations** | a column the caller (or a trait) supplied never creates a parent row nobody asked for |
128
+ | **`build` vs `create`** | `build` never touches a database; `create` writes through `usePersister` and fails as `X_TEST_FACTORY_NOT_PERSISTED` when nothing installed one |
129
+ | **seeded per table** | the default seed is derived from the table name, so a post and an org never draw the same uuid. Pass `seed` only to replay an older recording |
130
+ | **`with()` validates** | an undeclared trait fails at the line that named it, listing the declared ones (`X_TEST_FACTORY_TRAIT_UNKNOWN`) |
131
+
132
+ `factoriesFor(registry)` builds one factory per registered entity, with values inferred from column
133
+ names (`…Id` → uuid, `…At` → date, `…Minor` → integer, `is…`/`has…` → false). Enough for the rows a
134
+ test does not care about; `defineFactory` is for the rows it does.
135
+
136
+ ## Shared examples
137
+
138
+ ```ts
139
+ const anAuthenticatedAction = sharedExamples<Action>('an authenticated action', (subject) => {
140
+ test('denies an anonymous actor', async () => {
141
+ await expect(subject().call(input, { actor: anonymous })).toDenyPolicy();
142
+ });
143
+ });
144
+
145
+ describe('publishPost', () => behavesLike(anAuthenticatedAction, () => publishPost));
146
+ ```
147
+
148
+ The subject is a function, not a value, for the reason `describeApp`'s accessor is: the block is
149
+ declared at module scope and the subject often does not exist until `beforeAll` has run. The
150
+ failure line reads `publishPost > behaves like an authenticated action > denies an anonymous actor`
151
+ — which subject, and which shared rule. `behavesLike` calls `describe`, so it goes at declaration
152
+ scope, never inside a test body.
153
+
103
154
  ## Parallel databases
104
155
 
105
156
  ```ts
106
157
  const db = await acquireWorkerDatabase({ adminUrl, migrate });
107
- // worker 0 -> ultimate_test_template_w0
108
- // worker 1 -> ultimate_test_template_w1
109
158
  ```
110
159
 
111
160
  The first worker creates the template under a Postgres advisory lock and migrates it once; every
112
161
  worker then clones it copy-on-write. With no Postgres configured it falls back to PGlite, so
113
162
  `bun test` works on a laptop with nothing installed.
114
163
 
164
+ **Parallelism is opt-in, not the default.** `As of 2026-08`:
165
+
166
+ | Command | Processes | Worker ids | Databases |
167
+ |---|---|---|---|
168
+ | `bun test` (what a scaffolded app's `test` script runs, and what every `x verify` test step runs) | 1 | `0` | one |
169
+ | `bun test --parallel[=N]` | N (default: CPU count) | `1..N`, from Bun's own `BUN_TEST_WORKER_ID` | N |
170
+ | `x test --workers N` | N | `0..N-1`, from `ULTIMATE_TEST_WORKER` | N |
171
+
172
+ `ULTIMATE_TEST_WORKER` is read first so a runner-assigned shard always beats the index Bun assigns
173
+ its own `--parallel` worker — measured on Bun 1.3.14, `--parallel` populates `BUN_TEST_WORKER_ID`
174
+ and `JEST_WORKER_ID` itself, so that precedence is load-bearing rather than defensive.
175
+
115
176
  ## Sealed network
116
177
 
117
178
  ```
@@ -128,3 +189,4 @@ integration — never for a socket test.
128
189
  ## Errors
129
190
 
130
191
  `X_TEST_NETWORK_SEALED` `X_TEST_DB_UNAVAILABLE` `X_TEST_NONDETERMINISTIC` `X_TEST_FIXTURE_UNKNOWN`
192
+ `X_TEST_FACTORY_TRAIT_UNKNOWN` `X_TEST_FACTORY_NOT_PERSISTED`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Test harness: cloned template DBs per worker, frozen clock, sealed network, 6 test types",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,10 +31,10 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "1.0.0",
35
- "@ultimat3/db": "1.0.0",
36
- "@ultimat3/jobs": "1.0.0",
37
- "@ultimat3/mail": "1.0.0",
38
- "@ultimat3/time": "1.0.0"
34
+ "@ultimat3/core": "1.2.0",
35
+ "@ultimat3/db": "1.2.0",
36
+ "@ultimat3/jobs": "1.2.0",
37
+ "@ultimat3/mail": "1.2.0",
38
+ "@ultimat3/time": "1.2.0"
39
39
  }
40
40
  }
package/src/errors.ts CHANGED
@@ -13,6 +13,8 @@ export const TESTING_ERROR_CODES = [
13
13
  'X_TEST_SCHEMA_EXPECTED',
14
14
  'X_TEST_JOB_EXPECTED',
15
15
  'X_TEST_NETWORK_RACE',
16
+ 'X_TEST_FACTORY_TRAIT_UNKNOWN',
17
+ 'X_TEST_FACTORY_NOT_PERSISTED',
16
18
  ] as const;
17
19
 
18
20
  export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number];
@@ -28,6 +30,8 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
28
30
  X_TEST_SCHEMA_EXPECTED: 'a matcher expected a Standard Schema and got something else',
29
31
  X_TEST_JOB_EXPECTED: 'a matcher expected a job declaration and got something else',
30
32
  X_TEST_NETWORK_RACE: 'a request raced unsealNetwork() and lost the patched fetch',
33
+ X_TEST_FACTORY_TRAIT_UNKNOWN: 'a factory was asked for a trait it does not declare',
34
+ X_TEST_FACTORY_NOT_PERSISTED: 'a factory create() had nowhere to write the row',
31
35
  };
32
36
 
33
37
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -185,6 +189,41 @@ export class TestJobExpectedError extends UltimateError {
185
189
  }
186
190
  }
187
191
 
192
+ /**
193
+ * A factory was asked for a trait it does not declare. Listing the declared ones is the whole
194
+ * value: a typo (`:pubished`) and a trait that was never written are the same symptom and
195
+ * different fixes, and only the list tells them apart without opening the factory.
196
+ */
197
+ export class FactoryTraitUnknownError extends UltimateError {
198
+ constructor(input: { table: string; trait: string; declared: readonly string[] }) {
199
+ super({
200
+ code: 'X_TEST_FACTORY_TRAIT_UNKNOWN',
201
+ cause:
202
+ input.declared.length === 0
203
+ ? `factory "${input.table}" was asked for trait "${input.trait}" but declares none`
204
+ : `factory "${input.table}" has no trait "${input.trait}"; declared: ${input.declared.join(', ')}`,
205
+ fix: `declare it: defineFactory(${input.table}, { traits: { ${input.trait}: { /* columns */ } } })`,
206
+ docs: docsFor('X_TEST_FACTORY_TRAIT_UNKNOWN'),
207
+ });
208
+ }
209
+ }
210
+
211
+ /**
212
+ * `create()` with no persister installed. Distinct from "the insert failed": nothing was attempted,
213
+ * so the instruction is to install the seam once in the preload — or to stop asking for a row that
214
+ * outlives the test, which `build()` already gives without a database at all.
215
+ */
216
+ export class FactoryNotPersistedError extends UltimateError {
217
+ constructor(input: { table: string }) {
218
+ super({
219
+ code: 'X_TEST_FACTORY_NOT_PERSISTED',
220
+ cause: `factory "${input.table}".create() ran with no persister in this process`,
221
+ fix: 'usePersister({ insert: (table, row) => repoFor(table).insert(row) }) in the test preload — or build() for an in-memory row',
222
+ docs: docsFor('X_TEST_FACTORY_NOT_PERSISTED'),
223
+ });
224
+ }
225
+ }
226
+
188
227
  /**
189
228
  * `sealNetwork()` always sets the original `fetch` before installing its patch, so this can only
190
229
  * fire if `unsealNetwork()` ran concurrently with a request from the same seal — a race, not a
package/src/factories.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  // Typed factories derived from the entity registry. Rows come from the entity's own columns, so a
2
2
  // new NOT NULL column breaks the factory at compile time instead of at the first insert — and the
3
3
  // values are seeded, so two runs of the same suite produce byte-identical rows.
4
+ //
5
+ // Traits and associations are FactoryBot's two, and only its two: a trait is a named partial the
6
+ // caller composes, an association is a column whose value comes from another factory built with
7
+ // the SAME strategy — `build()` leaves the parent in memory, `create()` writes it.
4
8
 
5
9
  import { seededRandom, seededUuid } from './determinism';
10
+ import { FactoryTraitUnknownError } from './errors';
11
+ import { persistRow } from './factory-persist';
6
12
 
7
13
  export interface EntityLike {
8
14
  readonly kind: 'entity';
@@ -10,90 +16,179 @@ export interface EntityLike {
10
16
  readonly columns: Readonly<Record<string, unknown>>;
11
17
  }
12
18
 
13
- export interface Factory<TRow> {
19
+ /** The seeded generators a `defaults` or trait body draws from. Never `Math.random` directly. */
20
+ export interface FactoryIds {
21
+ uuid(): string;
22
+ number(): number;
23
+ }
24
+
25
+ /** A named partial. A function form gets the row's index and the same seeded generators. */
26
+ export type Trait<TRow> = Partial<TRow> | ((index: number, ids: FactoryIds) => Partial<TRow>);
27
+
28
+ export type TraitMap<TRow> = Readonly<Record<string, Trait<TRow>>>;
29
+
30
+ /**
31
+ * A column filled from another factory. Opaque on purpose: `associate()` captures the parent
32
+ * factory and the column to lift off it, so the map below stays exactly typed per column with no
33
+ * variance escape hatch.
34
+ */
35
+ export interface Association<TValue> {
36
+ build(): TValue;
37
+ create(): Promise<TValue>;
38
+ /** Cascades from the child's `reset()`: a half-reset row is not the row the first block saw. */
39
+ reset(): void;
40
+ }
41
+
42
+ export type AssociationMap<TRow> = { readonly [K in keyof TRow]?: Association<TRow[K]> };
43
+
44
+ /**
45
+ * `associate(orgs, (org) => org.id)` — the parent is built with the strategy the child is built
46
+ * with, which is the whole reason associations are not just another default: `create()` on a post
47
+ * has to leave an org row behind it, and `build()` must not touch a database at all.
48
+ */
49
+ export function associate<TParent extends object, TValue>(
50
+ parent: Factory<TParent>,
51
+ pick: (row: TParent) => TValue,
52
+ ): Association<TValue> {
53
+ return {
54
+ build: () => pick(parent.build()),
55
+ create: async () => pick(await parent.create()),
56
+ reset: () => parent.reset(),
57
+ };
58
+ }
59
+
60
+ export interface FactoryOptions<TRow, TTraits extends TraitMap<TRow> = TraitMap<TRow>> {
61
+ /** Omitted means "derived from the table name" — see `seedFor`. */
62
+ readonly seed?: number;
63
+ /** Values for every column the entity requires; called once per built row. */
64
+ defaults(index: number, ids: FactoryIds): TRow;
65
+ readonly traits?: TTraits;
66
+ readonly associations?: AssociationMap<TRow>;
67
+ }
68
+
69
+ export interface Factory<TRow, TTrait extends string = string> {
14
70
  readonly table: string;
71
+ /** The declared trait names, sorted — what a failure lists and what a test can assert on. */
72
+ readonly traits: readonly TTrait[];
15
73
  build(over?: Partial<TRow>): TRow;
16
74
  buildMany(count: number, over?: Partial<TRow>): readonly TRow[];
75
+ /** Build, resolve associations by creating their parents, and write through the persister. */
76
+ create(over?: Partial<TRow>): Promise<TRow>;
77
+ createMany(count: number, over?: Partial<TRow>): Promise<readonly TRow[]>;
78
+ /** A view with those traits applied, sharing this factory's sequence so ids never repeat. */
79
+ with(...traits: readonly TTrait[]): Factory<TRow, TTrait>;
17
80
  /** Restart the sequence so a second describe block sees the same ids as the first. */
18
81
  reset(): void;
19
82
  }
20
83
 
21
- export interface FactoryOptions<TRow> {
22
- readonly seed?: number;
23
- /** Values for every column the entity requires; called once per built row. */
24
- defaults(index: number, ids: { uuid(): string; number(): number }): TRow;
84
+ /**
85
+ * Two factories with the same seed emit the same uuids, so a default of `1` everywhere gave a user
86
+ * and a post the same id — rows that only look related. FNV-1a over the table name keeps every
87
+ * table on its own stream while staying a pure function of the schema, so the ids are still
88
+ * identical run to run and machine to machine.
89
+ */
90
+ export function seedFor(table: string): number {
91
+ let hash = 0x811c9dc5;
92
+ for (let i = 0; i < table.length; i += 1) {
93
+ hash = Math.imul(hash ^ table.charCodeAt(i), 0x01000193) >>> 0;
94
+ }
95
+ return hash === 0 ? 1 : hash;
25
96
  }
26
97
 
27
- export function defineFactory<TRow extends object>(
98
+ export function defineFactory<TRow extends object, TTraits extends TraitMap<TRow> = TraitMap<TRow>>(
28
99
  entity: EntityLike,
29
- options: FactoryOptions<TRow>,
30
- ): Factory<TRow> {
31
- const seed = options.seed ?? 1;
100
+ options: FactoryOptions<TRow, TTraits>,
101
+ ): Factory<TRow, Extract<keyof TTraits, string>> {
102
+ type Name = Extract<keyof TTraits, string>;
103
+ const seed = options.seed ?? seedFor(entity.table);
104
+ const traits: TraitMap<TRow> = options.traits ?? {};
105
+ const declared = Object.keys(traits).sort() as Name[];
106
+ // Erased to one shape for iteration; the public `AssociationMap` already held each entry to its
107
+ // own column's type, so the value at key K is an `Association<TRow[K]>` by construction.
108
+ const links = Object.entries(
109
+ (options.associations ?? {}) as Readonly<Record<string, Association<unknown>>>,
110
+ );
111
+
32
112
  let uuid = seededUuid(seed);
33
113
  let random = seededRandom(seed);
34
114
  let index = 0;
35
- const ids = {
115
+ const ids: FactoryIds = {
36
116
  uuid: () => uuid(),
37
117
  number: () => Math.floor(random() * 1_000_000),
38
118
  };
39
- return {
119
+
120
+ const assertTrait = (name: string): string => {
121
+ if (traits[name] === undefined) {
122
+ throw new FactoryTraitUnknownError({ table: entity.table, trait: name, declared });
123
+ }
124
+ return name;
125
+ };
126
+
127
+ /**
128
+ * Traits then the call's own overrides, resolved before any association runs: a column the
129
+ * caller supplied must not also create a parent row nobody asked for.
130
+ */
131
+ const overridesOf = (
132
+ applied: readonly string[],
133
+ over: Partial<TRow>,
134
+ i: number,
135
+ ): Partial<TRow> => {
136
+ let patch: Partial<TRow> = {};
137
+ for (const name of applied) {
138
+ const trait = traits[name];
139
+ patch = { ...patch, ...(typeof trait === 'function' ? trait(i, ids) : trait) };
140
+ }
141
+ return { ...patch, ...over };
142
+ };
143
+
144
+ const buildRow = (applied: readonly string[], over: Partial<TRow>): TRow => {
145
+ index += 1;
146
+ const base = options.defaults(index, ids);
147
+ const overrides = overridesOf(applied, over, index);
148
+ const linked: Record<string, unknown> = {};
149
+ for (const [column, link] of links) if (!(column in overrides)) linked[column] = link.build();
150
+ return { ...base, ...(linked as Partial<TRow>), ...overrides };
151
+ };
152
+
153
+ const createRow = async (applied: readonly string[], over: Partial<TRow>): Promise<TRow> => {
154
+ index += 1;
155
+ const base = options.defaults(index, ids);
156
+ const overrides = overridesOf(applied, over, index);
157
+ const linked: Record<string, unknown> = {};
158
+ // Sequential, not `Promise.all`: two parents built concurrently would interleave their draws
159
+ // from the shared seeded generators and the run would stop being reproducible.
160
+ for (const [column, link] of links) {
161
+ if (!(column in overrides)) linked[column] = await link.create();
162
+ }
163
+ const row = { ...base, ...(linked as Partial<TRow>), ...overrides };
164
+ await persistRow(entity.table, row);
165
+ return row;
166
+ };
167
+
168
+ const view = (applied: readonly string[]): Factory<TRow, Name> => ({
40
169
  table: entity.table,
41
- build: (over = {}) => {
42
- index += 1;
43
- return { ...options.defaults(index, ids), ...over };
170
+ traits: declared,
171
+ build: (over = {}) => buildRow(applied, over),
172
+ buildMany: (count, over = {}) => Array.from({ length: count }, () => buildRow(applied, over)),
173
+ create: (over = {}) => createRow(applied, over),
174
+ createMany: async (count, over = {}) => {
175
+ const rows: TRow[] = [];
176
+ for (let i = 0; i < count; i += 1) rows.push(await createRow(applied, over));
177
+ return rows;
44
178
  },
45
- buildMany: (count, over = {}) =>
46
- Array.from({ length: count }, () => {
47
- index += 1;
48
- return { ...options.defaults(index, ids), ...over };
49
- }),
179
+ // Checked here rather than at build time: `with()` is the line that named the trait, and a
180
+ // failure three calls later would point at a row instead of at the typo.
181
+ with: (...names) => view([...applied, ...names.map(assertTrait)]),
50
182
  reset: () => {
51
183
  uuid = seededUuid(seed);
52
184
  random = seededRandom(seed);
53
185
  index = 0;
186
+ // Without this a reset factory rebuilt its own columns identically and drew a fresh org id
187
+ // for the association — one row that is half the row the first block saw, which is worse
188
+ // than not resetting at all because only one column moves.
189
+ for (const [, link] of links) link.reset();
54
190
  },
55
- };
56
- }
57
-
58
- export type EntityRegistry = Readonly<Record<string, EntityLike>>;
59
-
60
- export type FactoryRegistry<TRegistry extends EntityRegistry> = {
61
- readonly [K in keyof TRegistry]: Factory<Record<string, unknown>>;
62
- };
63
-
64
- /**
65
- * Build one factory per registered entity, with column-name-driven defaults. Enough for the rows a
66
- * test does not care about; pass `defineFactory` explicitly for the rows it does.
67
- */
68
- export function factoriesFor<TRegistry extends EntityRegistry>(
69
- registry: TRegistry,
70
- seed = 1,
71
- ): FactoryRegistry<TRegistry> {
72
- const out: Record<string, Factory<Record<string, unknown>>> = {};
73
- for (const [name, entity] of Object.entries(registry)) {
74
- out[name] = defineFactory<Record<string, unknown>>(entity, {
75
- seed,
76
- defaults: (index, ids) => {
77
- const row: Record<string, unknown> = {};
78
- for (const column of Object.keys(entity.columns)) {
79
- row[column] = defaultFor(column, index, ids);
80
- }
81
- return row;
82
- },
83
- });
84
- }
85
- return out as FactoryRegistry<TRegistry>;
86
- }
191
+ });
87
192
 
88
- function defaultFor(
89
- column: string,
90
- index: number,
91
- ids: { uuid(): string; number(): number },
92
- ): unknown {
93
- if (column === 'id' || column.endsWith('Id')) return ids.uuid();
94
- if (column.endsWith('At')) return new Date(0);
95
- if (column.endsWith('Minor')) return ids.number();
96
- if (column.endsWith('Currency')) return 'USD';
97
- if (column.startsWith('is') || column.startsWith('has')) return false;
98
- return `${column}-${index}`;
193
+ return view([]);
99
194
  }
@@ -0,0 +1,41 @@
1
+ // The one seam a factory's `create()` writes through. Structural rather than an import of
2
+ // `@ultimat3/entity`'s `Repo`: testing must not take a package dependency for one type — the same
3
+ // reason `LiveTarget` is declared structurally in fixture-drivers.ts.
4
+
5
+ import { FactoryNotPersistedError } from './errors';
6
+
7
+ /**
8
+ * Writes a built row and answers nothing. Returning the stored row would make `create()` and
9
+ * `build()` able to disagree about what the row is, and a test could then only find out which one
10
+ * it holds by looking — the ambiguity axiom 1 forbids. The factory owns every column; the
11
+ * persister owns only whether the row exists.
12
+ */
13
+ export interface Persister {
14
+ insert<TRow extends object>(table: string, row: TRow): Promise<void>;
15
+ }
16
+
17
+ let current: Persister | undefined;
18
+
19
+ /**
20
+ * Install the writer, once, in the test preload. Process-global for the same reason the fixture
21
+ * registry is: a factory is imported by the file under test, not handed to it.
22
+ */
23
+ export function usePersister(persister: Persister): void {
24
+ current = persister;
25
+ }
26
+
27
+ /** Hand the process back. An `afterAll` in any file that installed one of its own. */
28
+ export function clearPersister(): void {
29
+ current = undefined;
30
+ }
31
+
32
+ export const persisterInstalled = (): boolean => current !== undefined;
33
+
34
+ /**
35
+ * Throws where the row would have been written, so the failure names the table it was for rather
36
+ * than surfacing as a missing method on `undefined` inside the factory.
37
+ */
38
+ export async function persistRow<TRow extends object>(table: string, row: TRow): Promise<void> {
39
+ if (current === undefined) throw new FactoryNotPersistedError({ table });
40
+ await current.insert(table, row);
41
+ }
@@ -0,0 +1,50 @@
1
+ // One factory per registered entity, with defaults inferred from column names. Enough for the rows
2
+ // a test does not care about; `defineFactory` is what a test reaches for when it cares.
3
+
4
+ import type { EntityLike, Factory, FactoryIds } from './factories';
5
+ import { defineFactory } from './factories';
6
+
7
+ export type EntityRegistry = Readonly<Record<string, EntityLike>>;
8
+
9
+ export type FactoryRegistry<TRegistry extends EntityRegistry> = {
10
+ readonly [K in keyof TRegistry]: Factory<Record<string, unknown>>;
11
+ };
12
+
13
+ /**
14
+ * `seed` is deliberately optional and deliberately not shared: with one seed for the whole
15
+ * registry every table drew the same uuid stream, so a user and a post came out with the same id.
16
+ * Omitted, each factory derives its own from its table name — still reproducible, no longer equal.
17
+ */
18
+ export function factoriesFor<TRegistry extends EntityRegistry>(
19
+ registry: TRegistry,
20
+ seed?: number,
21
+ ): FactoryRegistry<TRegistry> {
22
+ const out: Record<string, Factory<Record<string, unknown>>> = {};
23
+ for (const [name, entity] of Object.entries(registry)) {
24
+ out[name] = defineFactory(entity, {
25
+ ...(seed === undefined ? {} : { seed }),
26
+ defaults: (index, ids): Record<string, unknown> => {
27
+ const row: Record<string, unknown> = {};
28
+ for (const column of Object.keys(entity.columns)) {
29
+ row[column] = defaultFor(column, index, ids);
30
+ }
31
+ return row;
32
+ },
33
+ });
34
+ }
35
+ return out as FactoryRegistry<TRegistry>;
36
+ }
37
+
38
+ /**
39
+ * Column names carry the type in this framework's conventions — `…At` is a timestamp, `…Minor` is
40
+ * the integer half of a Money, `is…`/`has…` is a flag — so the name is the only input that can
41
+ * produce a value the schema will accept without the test saying anything.
42
+ */
43
+ export function defaultFor(column: string, index: number, ids: FactoryIds): unknown {
44
+ if (column === 'id' || column.endsWith('Id')) return ids.uuid();
45
+ if (column.endsWith('At')) return new Date(0);
46
+ if (column.endsWith('Minor')) return ids.number();
47
+ if (column.endsWith('Currency')) return 'USD';
48
+ if (column.startsWith('is') || column.startsWith('has')) return false;
49
+ return `${column}-${index}`;
50
+ }
package/src/index.ts CHANGED
@@ -48,8 +48,21 @@ export {
48
48
  TESTING_ERROR_TITLES,
49
49
  TestDatabaseUnavailableError,
50
50
  } from './errors';
51
- export type { EntityLike, EntityRegistry, Factory, FactoryOptions } from './factories';
52
- export { defineFactory, factoriesFor } from './factories';
51
+ export type {
52
+ Association,
53
+ AssociationMap,
54
+ EntityLike,
55
+ Factory,
56
+ FactoryIds,
57
+ FactoryOptions,
58
+ Trait,
59
+ TraitMap,
60
+ } from './factories';
61
+ export { associate, defineFactory, seedFor } from './factories';
62
+ export type { Persister } from './factory-persist';
63
+ export { clearPersister, persisterInstalled, usePersister } from './factory-persist';
64
+ export type { EntityRegistry, FactoryRegistry } from './factory-registry';
65
+ export { factoriesFor } from './factory-registry';
53
66
  export type { TestClock, TestDuration } from './fixture-clock';
54
67
  export { createTestClock } from './fixture-clock';
55
68
  export type {
@@ -96,6 +109,8 @@ export {
96
109
  sealNetwork,
97
110
  unsealNetwork,
98
111
  } from './sealed-network';
112
+ export type { SharedExamples } from './shared-examples';
113
+ export { behavesLike, sharedExamples } from './shared-examples';
99
114
  export type { SqlRunner, TemplateDbConfig, WorkerDatabase } from './template-db';
100
115
  export {
101
116
  acquireWorkerDatabase,
@@ -0,0 +1,41 @@
1
+ // One behaviour, asserted against many subjects — RSpec's `shared_examples` / `it_behaves_like`.
2
+ // The framework's own rules are the reason it exists: "every action denies an anonymous actor" is
3
+ // a sentence about forty actions, and forty copies of it is forty places for one of them to be
4
+ // quietly missing.
5
+
6
+ import { describe } from 'bun:test';
7
+
8
+ /**
9
+ * A named block of tests waiting for a subject. Opaque — the only thing that may run it is
10
+ * `behavesLike`, so the `describe` nesting below is the one way these ever appear in output.
11
+ */
12
+ export interface SharedExamples<TSubject> {
13
+ readonly name: string;
14
+ readonly body: (subject: () => TSubject) => void;
15
+ }
16
+
17
+ /**
18
+ * The subject arrives as a function, not a value, for the reason `describeApp`'s accessor does:
19
+ * the block is declared at module scope and the subject often does not exist until `beforeAll`
20
+ * has run. Called inside each test, it is always the current one.
21
+ */
22
+ export function sharedExamples<TSubject>(
23
+ name: string,
24
+ body: (subject: () => TSubject) => void,
25
+ ): SharedExamples<TSubject> {
26
+ return { name, body };
27
+ }
28
+
29
+ /**
30
+ * Wrapped in its own `describe` so the failure line reads
31
+ * `publishPost > behaves like an authenticated action > denies an anonymous actor` — which subject
32
+ * failed, and which shared rule it failed. Two uses in one file therefore never collide.
33
+ */
34
+ export function behavesLike<TSubject>(
35
+ examples: SharedExamples<TSubject>,
36
+ subject: () => TSubject,
37
+ ): void {
38
+ describe(`behaves like ${examples.name}`, () => {
39
+ examples.body(subject);
40
+ });
41
+ }
@@ -32,14 +32,19 @@ export interface WorkerDatabase {
32
32
  export const DEFAULT_TEMPLATE = 'ultimate_test_template';
33
33
 
34
34
  /**
35
- * Bun exposes the test worker index in the environment; a plain `bun test` run is worker 0. The
36
- * pid fallback keeps two hand-run processes from colliding on the same database.
35
+ * Which database this process owns. A plain `bun test` run is one process and worker 0; a
36
+ * `bun test --parallel=N` run is N processes and workers 1..N; an `x test --workers N` run is N
37
+ * processes and workers 0..N-1.
38
+ *
39
+ * The pid fallback keeps two hand-run processes from colliding on the same database.
37
40
  */
38
41
  export function workerId(env: Readonly<Record<string, string | undefined>>, pid = 0): number {
39
42
  // ULTIMATE_TEST_WORKER is checked FIRST because `x test` assigns it deliberately, one index per
40
- // shard. A runner-set value must beat anything the runtime invents: if Bun ever populates
41
- // BUN_TEST_WORKER_ID itself, two shards could resolve to the same index and then race on the
42
- // same cloned database — a data-dependent failure that would look like a flaky test.
43
+ // shard, and a runner-set value must beat anything the runtime invents. That precedence is no
44
+ // longer hypothetical: measured on Bun 1.3.14, `bun test --parallel` populates BOTH
45
+ // BUN_TEST_WORKER_ID and JEST_WORKER_ID (identically, 1-based). So an `x test` shard that ever
46
+ // ran its child with --parallel would otherwise resolve to Bun's index instead of its own, two
47
+ // shards would land on one cloned database, and the failure would read as a flaky test.
43
48
  for (const key of ['ULTIMATE_TEST_WORKER', 'BUN_TEST_WORKER_ID', 'JEST_WORKER_ID']) {
44
49
  const raw = env[key];
45
50
  if (raw === undefined) continue;