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