@jterrazz/test 3.4.0 → 4.0.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
@@ -1,23 +1,17 @@
1
1
  # @jterrazz/test
2
2
 
3
- Testing framework for the @jterrazz ecosystem declarative Docker infrastructure, specification runners, and conventions that all projects follow.
4
-
5
- ## Installation
3
+ Declarative testing framework for APIs and CLIs. Same fluent builder API, three execution modes.
6
4
 
7
5
  ```bash
8
6
  npm install -D @jterrazz/test vitest
9
7
  ```
10
8
 
11
- Requires Docker.
12
-
13
- ## Specification runners
14
-
15
- Declare services, provide an app factory, the framework starts containers and wires everything.
9
+ ## Quick start
16
10
 
17
- ### Integration (testcontainers, in-process app)
11
+ ### API testing (HTTP)
18
12
 
19
13
  ```typescript
20
- // tests/integration/integration.specification.ts
14
+ // tests/setup/integration.specification.ts
21
15
  import { afterAll } from "vitest";
22
16
  import { integration, postgres } from "@jterrazz/test";
23
17
  import { createApp } from "../../src/app.js";
@@ -33,37 +27,204 @@ export const spec = await integration({
33
27
  afterAll(() => spec.cleanup());
34
28
  ```
35
29
 
36
- ### E2E (docker compose up, real HTTP)
30
+ ```typescript
31
+ // tests/e2e/users/users.e2e.test.ts
32
+ import { spec } from "../../setup/integration.specification.js";
33
+
34
+ test("creates a user", async () => {
35
+ // Given — one existing user
36
+ const result = await spec("creates user")
37
+ .seed("initial-users.sql")
38
+ .post("/users", "new-user.json")
39
+ .run();
40
+
41
+ // Then — user created
42
+ result.status.toBe(201);
43
+ await result.table("users").toMatch({
44
+ columns: ["name"],
45
+ rows: [["Alice"], ["Bob"]],
46
+ });
47
+ });
48
+ ```
49
+
50
+ ### CLI testing
51
+
52
+ ```typescript
53
+ // tests/setup/cli.specification.ts
54
+ import { resolve } from "node:path";
55
+ import { cli } from "@jterrazz/test";
56
+
57
+ export const spec = await cli({
58
+ command: resolve(import.meta.dirname, "../../bin/my-cli.sh"),
59
+ root: "../fixtures",
60
+ });
61
+ ```
62
+
63
+ ```typescript
64
+ // tests/e2e/build/build.e2e.test.ts
65
+ import { spec } from "../../setup/cli.specification.js";
66
+
67
+ test("builds the project", async () => {
68
+ // Given — sample app project
69
+ const result = await spec("build").project("sample-app").exec("build").run();
70
+
71
+ // Then — ESM output with source maps
72
+ result.exitCode.toBe(0);
73
+ result.stdout.toContain("Build completed");
74
+ result.file("dist/index.js").toExist();
75
+ result.file("dist/index.cjs").not.toExist();
76
+ result.file("dist/index.js").toContain("Hello");
77
+ });
78
+ ```
79
+
80
+ ## Specification runners
81
+
82
+ Three modes, same builder API. Each handles infrastructure and cleanup automatically.
83
+
84
+ ### `integration()` — testcontainers + in-process app
85
+
86
+ Starts real containers via testcontainers. App runs in-process (Hono). Fastest feedback loop.
87
+
88
+ ```typescript
89
+ import { integration, postgres, redis } from "@jterrazz/test";
90
+
91
+ const db = postgres({ compose: "db" });
92
+ const cache = redis({ compose: "cache" });
93
+
94
+ export const spec = await integration({
95
+ services: [db, cache],
96
+ app: () => createApp({ databaseUrl: db.connectionString }),
97
+ root: "../../",
98
+ });
99
+ ```
100
+
101
+ ### `e2e()` — docker compose up + real HTTP
102
+
103
+ Starts the full `docker/compose.test.yaml` stack. App URL and databases auto-detected.
37
104
 
38
105
  ```typescript
39
- // tests/e2e/e2e.specification.ts
40
- import { afterAll } from "vitest";
41
106
  import { e2e } from "@jterrazz/test";
42
107
 
43
108
  export const spec = await e2e({
44
109
  root: "../../",
45
110
  });
111
+ ```
46
112
 
47
- afterAll(() => spec.cleanup());
113
+ ### `cli()` local command execution
114
+
115
+ Runs CLI commands against fixture projects in temp directories. Optionally starts infrastructure.
116
+
117
+ ```typescript
118
+ import { cli } from "@jterrazz/test";
119
+
120
+ export const spec = await cli({
121
+ command: resolve(import.meta.dirname, "../../bin/my-cli.sh"),
122
+ root: "../fixtures",
123
+ });
124
+
125
+ // With infrastructure (CLI that needs a database)
126
+ export const spec = await cli({
127
+ command: "my-migrate-tool",
128
+ root: "../fixtures",
129
+ services: [db],
130
+ });
48
131
  ```
49
132
 
50
- ### Usage
133
+ ## Builder API
134
+
135
+ Every test follows the same pattern: `spec("label") → setup → action → assertions`.
136
+
137
+ ### Setup (cross-mode)
138
+
139
+ | Method | Description |
140
+ | ---------------------------------------- | -------------------------------------------------------- |
141
+ | `.seed("file.sql")` | Load SQL from `seeds/file.sql` into the default database |
142
+ | `.seed("file.sql", { service: "name" })` | Load SQL into a specific database |
143
+ | `.fixture("file")` | Copy `fixtures/file` into the CLI working directory |
144
+ | `.project("name")` | Use `fixtures/name/` as the CLI working directory |
145
+ | `.mock("file.json")` | Register mocked external API response (MSW, planned) |
146
+
147
+ ### Actions (one per spec, mutually exclusive)
148
+
149
+ **HTTP:**
150
+
151
+ | Method | Description |
152
+ | -------------------------- | --------------------------------------------- |
153
+ | `.get(path)` | HTTP GET request |
154
+ | `.post(path, "body.json")` | HTTP POST with body from `requests/body.json` |
155
+ | `.put(path, "body.json")` | HTTP PUT with body from `requests/body.json` |
156
+ | `.delete(path)` | HTTP DELETE request |
157
+
158
+ **CLI:**
159
+
160
+ | Method | Description |
161
+ | -------------------------------------- | ----------------------------------------------------------- |
162
+ | `.exec("args")` | Run command (blocking) |
163
+ | `.exec(["build", "start"])` | Run commands sequentially in same directory |
164
+ | `.spawn("args", { waitFor, timeout })` | Run long-lived process, resolve on pattern match or timeout |
165
+
166
+ ### Assertions
167
+
168
+ Assertions use a scoped API: `result.{scope}.{assertion}`. Database assertions (`result.table()`) are async.
169
+
170
+ **HTTP-specific:**
171
+
172
+ | Method | Description |
173
+ | ------------------------------------------ | -------------------------------------------------- |
174
+ | `result.status.toBe(code)` | Assert HTTP status code |
175
+ | `result.response.toMatchFile("file.json")` | Assert response body matches `responses/file.json` |
176
+
177
+ **CLI-specific:**
178
+
179
+ | Method | Description |
180
+ | --------------------------------------------------- | -------------------------------------------------- |
181
+ | `result.exitCode.toBe(code)` | Assert process exit code |
182
+ | `result.stdout.toContain(str)` | Assert stdout contains string |
183
+ | `result.stdout.not.toContain(str)` | Assert stdout does not contain string |
184
+ | `result.stdout.toContain(str, { near: "ctx" })` | Assert stdout contains string near context |
185
+ | `result.stderr.toContain(str)` | Assert stderr contains string |
186
+ | `result.stderr.not.toContain(str)` | Assert stderr does not contain string |
187
+ | `result.stderr.not.toContain(str, { near: "ctx" })` | Assert stderr does not contain string near context |
188
+ | `result.stdout.toMatch(/regex/)` | Assert stdout matches regex |
189
+ | `result.stdout.toMatchFile("file.txt")` | Assert stdout matches `expected/file.txt` |
190
+ | `result.stderr.toMatchFile("file.txt")` | Assert stderr matches `expected/file.txt` |
191
+ | `result.stdout.toBeEmpty()` | Assert stdout is empty |
192
+
193
+ **Cross-mode:**
194
+
195
+ | Method | Description |
196
+ | ------------------------------------------------------------------ | --------------------------------------- |
197
+ | `await result.table(name).toMatch({ columns, rows })` | Assert database table contents |
198
+ | `await result.table(name, { service }).toMatch({ columns, rows })` | Assert on a specific database |
199
+ | `await result.table(name).toBeEmpty()` | Assert database table is empty |
200
+ | `result.file(path).toExist()` | Assert file exists in working directory |
201
+ | `result.file(path).not.toExist()` | Assert file does not exist |
202
+ | `result.file(path).toContain(content)` | Assert file contains string |
203
+ | `result.file(path).toMatch(/regex/)` | Assert file content matches regex |
204
+
205
+ ## Multi-database support
206
+
207
+ When multiple databases are declared, `seed()` and `result.table()` accept `{ service: "name" }` to target a specific database by its compose name. Without `service`, both default to the first postgres.
51
208
 
52
209
  ```typescript
53
- import { spec } from "../integration.specification.js";
210
+ const db = postgres({ compose: "db" });
211
+ const analyticsDb = postgres({ compose: "analytics-db" });
54
212
 
55
- test("creates company", async () => {
56
- const result = await spec("creates company")
57
- .seed("transactions.sql")
58
- .post("/api/analyze", "request.json")
59
- .run();
213
+ const spec = await integration({
214
+ services: [db, analyticsDb],
215
+ app: () => createApp({ ... }),
216
+ });
60
217
 
61
- result.expectStatus(201);
62
- result.expectResponse("created.response.json");
63
- await result.expectTable("company_profile", {
64
- columns: ["name"],
65
- rows: [["TEST COMPANY"]],
66
- });
218
+ const result = await spec("cross-db")
219
+ .seed("users.sql")
220
+ .seed("events.sql", { service: "analytics-db" })
221
+ .post("/users", "request.json")
222
+ .run();
223
+
224
+ await result.table("users").toMatch({ columns: ["name"], rows: [["Alice"]] });
225
+ await result.table("events", { service: "analytics-db" }).toMatch({
226
+ columns: ["type"],
227
+ rows: [["user_created"]],
67
228
  });
68
229
  ```
69
230
 
@@ -72,60 +233,59 @@ test("creates company", async () => {
72
233
  ```typescript
73
234
  import { postgres, redis } from "@jterrazz/test";
74
235
 
75
- const db = postgres({ compose: "db" }); // Reads config from docker/compose.test.yaml
236
+ const db = postgres({ compose: "db" });
76
237
  const cache = redis({ compose: "cache" });
77
238
  ```
78
239
 
79
- After `await integration()`, service handles have `.connectionString` populated from running containers.
240
+ Service handles read image and environment from `docker/compose.test.yaml`. After the runner starts, `.connectionString` is populated from the running container.
80
241
 
81
- ## Docker convention
242
+ | Factory | Options | Connection string |
243
+ | ------------ | ------------------------- | ------------------------------------- |
244
+ | `postgres()` | `compose`, `image`, `env` | `postgresql://user:pass@host:port/db` |
245
+ | `redis()` | `compose`, `image` | `redis://host:port` |
82
246
 
83
- ```
84
- docker/
85
- ├── compose.test.yaml # Source of truth for test infrastructure
86
- ├── postgres/
87
- │ └── init.sql # Auto-run on container start
247
+ ## Mocking utilities
248
+
249
+ ```typescript
250
+ import { mockOf, mockOfDate } from "@jterrazz/test";
88
251
  ```
89
252
 
90
- ## Builder API
253
+ | Export | Description |
254
+ | ------------- | -------------------------------------------- |
255
+ | `mockOf<T>()` | Deep mock of any interface |
256
+ | `mockOfDate` | Date mocking via `.set(date)` and `.reset()` |
91
257
 
92
- **Setup:** `.seed("file.sql")`, `.mock("file.json")`
258
+ ## Conventions
93
259
 
94
- **Action:** `.get(path)`, `.post(path, "body.json")`, `.put(path, "body.json")`, `.delete(path)`
260
+ ### Docker
95
261
 
96
- **Assertions:** `.expectStatus(code)`, `.expectResponse("file.json")`, `.expectTable(table, { columns, rows })`
262
+ ```
263
+ docker/
264
+ ├── compose.test.yaml # Source of truth for test infrastructure
265
+ ├── postgres/
266
+ │ └── init.sql # Auto-run on container start
267
+ ```
97
268
 
98
- ## Test structure
269
+ ### Test structure
99
270
 
100
271
  ```
101
272
  tests/
102
- ├── setup/ # Infrastructure (DB init, Docker config)
103
- ├── fixtures/ # Shared fake things to test against
104
- ├── helpers/ # Shared test utilities
105
- ├── integration/
106
- ├── integration.specification.ts
107
- └── api/
108
- └── {feature}/
109
- ├── {feature}.integration.test.ts
110
- ├── seeds/
111
- │ ├── requests/
112
- └── responses/
113
- └── e2e/
114
- ├── e2e.specification.ts
115
- └── api/...
116
- ```
117
-
118
- ## Test data (colocated per test)
119
-
120
- | Folder | Purpose |
121
- | ------------ | ---------------------------------- |
122
- | `seeds/` | Database state setup |
123
- | `mock/` | Mocked external API responses |
124
- | `requests/` | Request bodies |
125
- | `responses/` | Expected API responses |
126
- | `expected/` | Expected output to compare against |
127
-
128
- ## File naming
273
+ ├── e2e/ # Full-stack specification tests
274
+ │ └── {feature}/
275
+ ├── {feature}.e2e.test.ts
276
+ ├── seeds/ # Database state setup
277
+ ├── fixtures/ # Files copied into CLI working dir
278
+ ├── requests/ # HTTP request bodies
279
+ ├── responses/ # Expected HTTP responses
280
+ └── expected/ # Expected CLI output
281
+ ├── integration/ # Infrastructure tests (containers)
282
+ └── setup/ # Specification runners, fixtures, helpers
283
+ ├── fixtures/ # Shared fixture projects
284
+ ├── helpers/ # Shared test utilities
285
+ └── *.specification.ts # Runner setup files
286
+ ```
287
+
288
+ ### File naming
129
289
 
130
290
  | Type | Suffix | Location |
131
291
  | ----------- | ---------------------- | --------------------- |
@@ -133,13 +293,31 @@ tests/
133
293
  | Integration | `.integration.test.ts` | `tests/integration/` |
134
294
  | E2E | `.e2e.test.ts` | `tests/e2e/` |
135
295
 
136
- ## Mocking utilities
296
+ ### Test writing
297
+
298
+ Every test uses `// Given` and `// Then` comments. Always both, never one without the other.
137
299
 
138
300
  ```typescript
139
- import { mockOf, mockOfDate } from "@jterrazz/test";
301
+ test("creates a user and returns 201", async () => {
302
+ // Given — two existing users
303
+ const result = await spec("creates user")
304
+ .seed("initial-users.sql")
305
+ .post("/users", "new-user.json")
306
+ .run();
307
+
308
+ // Then — user created with all three in table
309
+ result.status.toBe(201);
310
+ await result.table("users").toMatch({
311
+ columns: ["name"],
312
+ rows: [["Alice"], ["Bob"], ["Charlie"]],
313
+ });
314
+ });
140
315
  ```
141
316
 
142
- | Export | Description |
143
- | ------------- | ---------------------------------------- |
144
- | `mockOfDate` | Date mocking — `set(date)` and `reset()` |
145
- | `mockOf<T>()` | Deep mock of any interface |
317
+ `// When` is only used if the action isn't obvious. The spec builder chain (`.seed().post().run()` / `.project().exec().run()`) IS the when.
318
+
319
+ ## Requirements
320
+
321
+ - **Docker** — testcontainers for `integration()`, docker compose for `e2e()`
322
+ - **vitest** — peer dependency
323
+ - **hono** — optional peer, only needed for `integration()` mode with in-process apps