@ultimat3/testing 1.0.0 → 1.1.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 +65 -3
- package/package.json +6 -6
- package/src/errors.ts +39 -0
- package/src/factories.ts +157 -62
- package/src/factory-persist.ts +41 -0
- package/src/factory-registry.ts +50 -0
- package/src/index.ts +17 -2
- package/src/shared-examples.ts +41 -0
- package/src/template-db.ts +10 -5
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` |
|
|
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.
|
|
3
|
+
"version": "1.1.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.
|
|
35
|
-
"@ultimat3/db": "1.
|
|
36
|
-
"@ultimat3/jobs": "1.
|
|
37
|
-
"@ultimat3/mail": "1.
|
|
38
|
-
"@ultimat3/time": "1.
|
|
34
|
+
"@ultimat3/core": "1.1.0",
|
|
35
|
+
"@ultimat3/db": "1.1.0",
|
|
36
|
+
"@ultimat3/jobs": "1.1.0",
|
|
37
|
+
"@ultimat3/mail": "1.1.0",
|
|
38
|
+
"@ultimat3/time": "1.1.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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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 {
|
|
52
|
-
|
|
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
|
+
}
|
package/src/template-db.ts
CHANGED
|
@@ -32,14 +32,19 @@ export interface WorkerDatabase {
|
|
|
32
32
|
export const DEFAULT_TEMPLATE = 'ultimate_test_template';
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
41
|
-
//
|
|
42
|
-
//
|
|
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;
|