@acmekit/acmekit 2.13.83 → 2.13.85
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/dist/templates/app/.claude/agents/test-writer.md +348 -368
- package/dist/templates/app/.claude/commands/test.md +7 -1
- package/dist/templates/app/.claude/rules/testing.md +711 -844
- package/dist/templates/app/.claude/skills/write-test/SKILL.md +216 -112
- package/dist/templates/plugin/.claude/agents/test-writer.md +405 -265
- package/dist/templates/plugin/.claude/commands/test.md +7 -1
- package/dist/templates/plugin/.claude/rules/testing.md +608 -597
- package/dist/templates/plugin/.claude/skills/write-test/SKILL.md +275 -147
- package/package.json +39 -39
|
@@ -10,874 +10,885 @@ paths:
|
|
|
10
10
|
|
|
11
11
|
## Critical — Read Before Writing Any Test
|
|
12
12
|
|
|
13
|
+
**Single unified runner.** Use `integrationTestRunner` from `@acmekit/test-utils` with a `mode` parameter. The old names (`pluginIntegrationTestRunner`, `moduleIntegrationTestRunner`) are deprecated aliases — NEVER use them.
|
|
14
|
+
|
|
13
15
|
**Service resolution key = module constant.** `container.resolve(MY_MODULE)` where `MY_MODULE = "my-module"` (the string passed to `Module()`). NEVER guess `"myModuleService"` — it won't resolve.
|
|
14
16
|
|
|
15
17
|
**Workflow errors are plain objects, not Error instances.** The distributed transaction engine serializes them. NEVER use `.rejects.toThrow()` on workflows — it always fails with "Received function did not throw". Use `throwOnError: false` + `errors` array, or `.rejects.toEqual(expect.objectContaining({ message }))`.
|
|
16
18
|
|
|
17
19
|
**Error path is `errors[0].error.message`** — NOT `errors[0].message`. Each item in the `errors` array wraps the actual error under `.error`.
|
|
18
20
|
|
|
19
|
-
**Schema sync vs migrations.** `
|
|
21
|
+
**Schema sync vs migrations.** `mode: "plugin"` (without `http: true`) and `mode: "module"` sync DB schema from entities — no migration files needed. `mode: "plugin"` with `http: true` runs full migrations like app mode.
|
|
20
22
|
|
|
21
23
|
**`.rejects.toThrow()` DOES work for service errors** (e.g., `service.retrievePost("bad-id")`). Services throw real `Error` instances. Only workflow errors are serialized.
|
|
22
24
|
|
|
25
|
+
**jest.mock factories are hoisted.** `jest.mock()` runs BEFORE `const`/`let` declarations. Never reference file-level variables inside a `jest.mock()` factory — see "Unit Tests" section below.
|
|
26
|
+
|
|
27
|
+
**Always add `jest.clearAllMocks()` in `beforeEach`.** Mock state leaks between test blocks. Without explicit cleanup, assertions on mock call counts fail from prior-test contamination.
|
|
28
|
+
|
|
23
29
|
---
|
|
24
30
|
|
|
25
31
|
## Test Runner Selection
|
|
26
32
|
|
|
27
|
-
| What to test |
|
|
28
|
-
|
|
29
|
-
| Full plugin (modules + workflows + subscribers + jobs) | `
|
|
30
|
-
|
|
|
31
|
-
|
|
|
33
|
+
| What to test | Mode | HTTP? | Fixtures | DB setup |
|
|
34
|
+
|---|---|---|---|---|
|
|
35
|
+
| Full plugin (modules + workflows + subscribers + jobs) | `mode: "plugin"` | no | `container`, `acmekitApp`, `MikroOrmWrapper`, `dbConfig` | Schema sync |
|
|
36
|
+
| Plugin HTTP routes end-to-end | `mode: "plugin"` + `http: true` | yes | `api`, `container`, `dbConfig` | Runs migrations |
|
|
37
|
+
| Module service CRUD in isolation | `mode: "module"` | no | `service`, `MikroOrmWrapper`, `acmekitApp`, `dbConfig` | Schema sync |
|
|
38
|
+
| Pure functions (no DB) | Plain Jest `describe/it` | — | none | N/A |
|
|
32
39
|
|
|
33
|
-
|
|
40
|
+
---
|
|
34
41
|
|
|
35
42
|
## File Locations (must match `jest.config.js` buckets)
|
|
36
43
|
|
|
37
44
|
```
|
|
38
|
-
integration-tests/plugin/<feature>.spec.ts
|
|
39
|
-
|
|
40
|
-
src
|
|
45
|
+
integration-tests/plugin/<feature>.spec.ts → TEST_TYPE=integration:plugin
|
|
46
|
+
integration-tests/http/<feature>.spec.ts → TEST_TYPE=integration:http
|
|
47
|
+
src/modules/<mod>/__tests__/<name>.spec.ts → TEST_TYPE=integration:modules
|
|
48
|
+
src/**/__tests__/<name>.unit.spec.ts → TEST_TYPE=unit
|
|
41
49
|
```
|
|
42
50
|
|
|
51
|
+
**`integration-tests/plugin/`** — container-only tests (module CRUD, workflows, subscribers, jobs, event spying). No HTTP server.
|
|
52
|
+
|
|
53
|
+
**`integration-tests/http/`** — full HTTP tests with Express. Boots the entire framework with the plugin installed. Requires auth setup (JWT + client API key).
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
43
57
|
## Commands
|
|
44
58
|
|
|
45
59
|
```bash
|
|
46
60
|
pnpm test:unit # Unit tests
|
|
47
61
|
pnpm test:integration:modules # Module integration tests
|
|
48
|
-
pnpm test:integration:plugin #
|
|
62
|
+
pnpm test:integration:plugin # Plugin integration tests (container-only)
|
|
63
|
+
pnpm test:integration:http # Plugin HTTP integration tests
|
|
49
64
|
```
|
|
50
65
|
|
|
51
66
|
All integration tests require `NODE_OPTIONS=--experimental-vm-modules` (set in package.json scripts).
|
|
52
67
|
|
|
53
68
|
---
|
|
54
69
|
|
|
55
|
-
## Plugin Integration Tests
|
|
70
|
+
## Plugin Integration Tests (`integration-tests/plugin/`)
|
|
71
|
+
|
|
72
|
+
No HTTP server — test services, workflows, subscribers, and jobs directly through the container.
|
|
56
73
|
|
|
57
74
|
```typescript
|
|
58
|
-
import {
|
|
59
|
-
import {
|
|
60
|
-
import {
|
|
75
|
+
import { integrationTestRunner, MockEventBusService } from "@acmekit/test-utils"
|
|
76
|
+
import { Modules } from "@acmekit/framework/utils"
|
|
77
|
+
import { GREETING_MODULE } from "../../src/modules/greeting"
|
|
61
78
|
|
|
62
79
|
jest.setTimeout(60 * 1000)
|
|
63
80
|
|
|
64
|
-
|
|
81
|
+
integrationTestRunner({
|
|
82
|
+
mode: "plugin",
|
|
65
83
|
pluginPath: process.cwd(),
|
|
66
84
|
pluginOptions: {
|
|
67
85
|
apiKey: "test-api-key",
|
|
68
86
|
},
|
|
69
|
-
|
|
70
|
-
// injectedDependencies: { externalApi: mockExternalApi },
|
|
71
|
-
testSuite: ({ container, acmekitApp, MikroOrmWrapper }) => {
|
|
87
|
+
testSuite: ({ container, acmekitApp }) => {
|
|
72
88
|
describe("Plugin loading", () => {
|
|
73
|
-
it("should load plugin
|
|
89
|
+
it("should load plugin resources", () => {
|
|
74
90
|
expect(acmekitApp.modules).toBeDefined()
|
|
75
91
|
})
|
|
76
92
|
|
|
77
|
-
it("should resolve
|
|
78
|
-
const
|
|
79
|
-
expect(
|
|
93
|
+
it("should resolve EVENT_BUS as MockEventBusService", () => {
|
|
94
|
+
const eventBus = container.resolve(Modules.EVENT_BUS)
|
|
95
|
+
expect(eventBus).toBeInstanceOf(MockEventBusService)
|
|
80
96
|
})
|
|
81
97
|
})
|
|
82
98
|
|
|
83
|
-
describe("
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it("should create a blog post", async () => {
|
|
91
|
-
const result = await service.createBlogPosts([
|
|
92
|
-
{ title: "Launch Announcement" },
|
|
99
|
+
describe("GreetingModule CRUD", () => {
|
|
100
|
+
it("should create a greeting", async () => {
|
|
101
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
102
|
+
const result = await service.createGreetings([
|
|
103
|
+
{ message: "Hello World" },
|
|
93
104
|
])
|
|
94
105
|
expect(result).toHaveLength(1)
|
|
95
106
|
expect(result[0]).toEqual(
|
|
96
107
|
expect.objectContaining({
|
|
97
108
|
id: expect.any(String),
|
|
98
|
-
|
|
109
|
+
message: "Hello World",
|
|
99
110
|
})
|
|
100
111
|
)
|
|
101
112
|
})
|
|
102
113
|
|
|
103
|
-
it("should list and count", async () => {
|
|
104
|
-
await service.createBlogPosts([
|
|
105
|
-
{ title: "Post A" },
|
|
106
|
-
{ title: "Post B" },
|
|
107
|
-
])
|
|
108
|
-
const [posts, count] = await service.listAndCountBlogPosts()
|
|
109
|
-
expect(count).toBe(2)
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it("should soft delete and restore", async () => {
|
|
113
|
-
const [created] = await service.createBlogPosts([
|
|
114
|
-
{ title: "To Delete" },
|
|
115
|
-
])
|
|
116
|
-
await service.softDeleteBlogPosts([created.id])
|
|
117
|
-
await expect(
|
|
118
|
-
service.retrieveBlogPost(created.id)
|
|
119
|
-
).rejects.toThrow()
|
|
120
|
-
|
|
121
|
-
await service.restoreBlogPosts([created.id])
|
|
122
|
-
const restored = await service.retrieveBlogPost(created.id)
|
|
123
|
-
expect(restored.title).toBe("To Delete")
|
|
124
|
-
})
|
|
125
|
-
|
|
126
114
|
it("should throw on missing required field", async () => {
|
|
127
|
-
|
|
115
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
116
|
+
await expect(service.createGreetings([{}])).rejects.toThrow()
|
|
128
117
|
})
|
|
129
118
|
})
|
|
130
119
|
},
|
|
131
120
|
})
|
|
132
121
|
```
|
|
133
122
|
|
|
134
|
-
###
|
|
123
|
+
### Plugin mode options
|
|
135
124
|
|
|
136
125
|
| Option | Type | Default | Description |
|
|
137
126
|
|---|---|---|---|
|
|
138
|
-
| `
|
|
139
|
-
| `
|
|
127
|
+
| `mode` | `"plugin"` | **(required)** | Selects plugin mode |
|
|
128
|
+
| `pluginPath` | `string` | **(required)** | Path to plugin root — always use `process.cwd()` |
|
|
129
|
+
| `pluginOptions` | `Record<string, unknown>` | `{}` | Simulates host app plugin config |
|
|
130
|
+
| `http` | `boolean` | `false` | Set `true` to boot full Express server for HTTP tests |
|
|
140
131
|
| `additionalModules` | `Record<string, any>` | `{}` | Extra modules to load alongside the plugin |
|
|
141
132
|
| `injectedDependencies` | `Record<string, any>` | `{}` | Mock services to register in the container |
|
|
142
|
-
| `
|
|
133
|
+
| `skipDependencyValidation` | `boolean` | `false` | Skip `definePlugin({ dependencies })` validation — use when peer plugins aren't installed |
|
|
134
|
+
| `pluginModuleOptions` | `Record<string, Record<string, any>>` | `{}` | Per-module options keyed by module name (e.g., provider config) |
|
|
143
135
|
| `dbName` | `string` | auto-generated | Override the computed DB name |
|
|
136
|
+
| `schema` | `string` | `"public"` | Postgres schema |
|
|
144
137
|
| `debug` | `boolean` | `false` | Enables DB query logging |
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
147
|
-
| `testSuite` | `(options: PluginSuiteOptions) => void` | **(required)** | Test callback |
|
|
138
|
+
| `hooks` | `RunnerHooks` | `{}` | Lifecycle hooks |
|
|
139
|
+
| `testSuite` | `(options) => void` | **(required)** | Test callback |
|
|
148
140
|
|
|
149
|
-
###
|
|
141
|
+
### Plugin mode fixtures (container-only)
|
|
150
142
|
|
|
151
143
|
- `container` — proxy to the shared `AcmeKitContainer` (auto-refreshed each `beforeEach`)
|
|
152
144
|
- `acmekitApp` — proxy to the `AcmeKitApp` instance (has `.modules`, `.sharedContainer`)
|
|
153
145
|
- `MikroOrmWrapper` — raw DB access: `.getManager()`, `.forkManager()`, `.getOrm()`
|
|
154
|
-
- `dbConfig` — `{ schema, clientUrl }`
|
|
146
|
+
- `dbConfig` — `{ schema, clientUrl, dbName }`
|
|
147
|
+
|
|
148
|
+
### Plugin test lifecycle (container-only)
|
|
149
|
+
|
|
150
|
+
Each `it` block gets: schema drop + recreate → fresh plugin boot (all modules, subscribers, workflows, jobs loaded) → test runs → schema clear → module shutdown. No manual cleanup needed.
|
|
155
151
|
|
|
156
|
-
|
|
152
|
+
---
|
|
157
153
|
|
|
158
|
-
|
|
154
|
+
## Plugin HTTP Integration Tests (`integration-tests/http/`)
|
|
159
155
|
|
|
160
|
-
|
|
156
|
+
Full Express server with the plugin installed. Boots the entire framework including all core modules (user, auth, api-key, etc.), runs migrations, and serves plugin routes.
|
|
161
157
|
|
|
162
158
|
```typescript
|
|
163
|
-
import {
|
|
159
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
160
|
+
import {
|
|
161
|
+
ApiKeyType,
|
|
162
|
+
CLIENT_API_KEY_HEADER,
|
|
163
|
+
ContainerRegistrationKeys,
|
|
164
|
+
generateJwtToken,
|
|
165
|
+
Modules,
|
|
166
|
+
} from "@acmekit/framework/utils"
|
|
167
|
+
import { GREETING_MODULE } from "../../src/modules/greeting"
|
|
164
168
|
|
|
165
|
-
|
|
169
|
+
jest.setTimeout(120 * 1000)
|
|
170
|
+
|
|
171
|
+
integrationTestRunner({
|
|
172
|
+
mode: "plugin",
|
|
173
|
+
http: true,
|
|
166
174
|
pluginPath: process.cwd(),
|
|
167
|
-
pluginOptions: { apiKey: "test-key"
|
|
168
|
-
testSuite: ({ container }) => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
175
|
+
pluginOptions: { apiKey: "test-api-key" },
|
|
176
|
+
testSuite: ({ api, container }) => {
|
|
177
|
+
let adminHeaders: Record<string, any>
|
|
178
|
+
let clientHeaders: Record<string, any>
|
|
179
|
+
|
|
180
|
+
beforeEach(async () => {
|
|
181
|
+
const userModule = container.resolve(Modules.USER)
|
|
182
|
+
const authModule = container.resolve(Modules.AUTH)
|
|
183
|
+
const apiKeyModule = container.resolve(Modules.API_KEY)
|
|
184
|
+
|
|
185
|
+
const user = await userModule.createUsers({
|
|
186
|
+
email: "admin@test.js",
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const authIdentity = await authModule.createAuthIdentities({
|
|
190
|
+
provider_identities: [
|
|
191
|
+
{ provider: "emailpass", entity_id: "admin@test.js" },
|
|
192
|
+
],
|
|
193
|
+
app_metadata: { user_id: user.id },
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const config = container.resolve(
|
|
197
|
+
ContainerRegistrationKeys.CONFIG_MODULE
|
|
198
|
+
)
|
|
199
|
+
const { jwtSecret, jwtOptions } = config.projectConfig.http
|
|
200
|
+
|
|
201
|
+
const token = generateJwtToken(
|
|
202
|
+
{
|
|
203
|
+
actor_id: user.id,
|
|
204
|
+
actor_type: "user",
|
|
205
|
+
auth_identity_id: authIdentity.id,
|
|
206
|
+
app_metadata: { user_id: user.id },
|
|
207
|
+
},
|
|
208
|
+
{ secret: jwtSecret, expiresIn: "1d", jwtOptions }
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
adminHeaders = {
|
|
212
|
+
headers: { authorization: `Bearer ${token}` },
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const apiKey = await apiKeyModule.createApiKeys({
|
|
216
|
+
title: "Test Client Key",
|
|
217
|
+
type: ApiKeyType.CLIENT,
|
|
218
|
+
created_by: "test",
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
clientHeaders = {
|
|
222
|
+
headers: { [CLIENT_API_KEY_HEADER]: apiKey.token },
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe("Admin plugin routes", () => {
|
|
227
|
+
it("POST /admin/plugin/greetings creates a greeting", async () => {
|
|
228
|
+
const response = await api.post(
|
|
229
|
+
"/admin/plugin/greetings",
|
|
230
|
+
{ message: "Hello from HTTP" },
|
|
231
|
+
adminHeaders
|
|
232
|
+
)
|
|
233
|
+
expect(response.status).toEqual(200)
|
|
234
|
+
expect(response.data.greeting).toEqual(
|
|
235
|
+
expect.objectContaining({
|
|
236
|
+
id: expect.any(String),
|
|
237
|
+
message: "Hello from HTTP",
|
|
238
|
+
})
|
|
239
|
+
)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it("DELETE /admin/plugin/greetings/:id soft-deletes", async () => {
|
|
243
|
+
const created = await api.post(
|
|
244
|
+
"/admin/plugin/greetings",
|
|
245
|
+
{ message: "Delete me" },
|
|
246
|
+
adminHeaders
|
|
247
|
+
)
|
|
248
|
+
const response = await api.delete(
|
|
249
|
+
`/admin/plugin/greetings/${created.data.greeting.id}`,
|
|
250
|
+
adminHeaders
|
|
251
|
+
)
|
|
252
|
+
expect(response.data).toEqual({
|
|
253
|
+
id: created.data.greeting.id,
|
|
254
|
+
object: "greeting",
|
|
255
|
+
deleted: true,
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
describe("Client plugin routes", () => {
|
|
261
|
+
it("GET /client/plugin/greetings with API key", async () => {
|
|
262
|
+
await api.post(
|
|
263
|
+
"/admin/plugin/greetings",
|
|
264
|
+
{ message: "Public" },
|
|
265
|
+
adminHeaders
|
|
266
|
+
)
|
|
267
|
+
const response = await api.get(
|
|
268
|
+
"/client/plugin/greetings",
|
|
269
|
+
clientHeaders
|
|
270
|
+
)
|
|
271
|
+
expect(response.status).toEqual(200)
|
|
272
|
+
expect(response.data.greetings).toHaveLength(1)
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
describe("Auth enforcement", () => {
|
|
277
|
+
it("admin route rejects without JWT", async () => {
|
|
278
|
+
const error = await api
|
|
279
|
+
.get("/admin/plugin")
|
|
280
|
+
.catch((e: any) => e)
|
|
281
|
+
expect(error.response.status).toEqual(401)
|
|
282
|
+
})
|
|
173
283
|
})
|
|
174
284
|
},
|
|
175
285
|
})
|
|
176
286
|
```
|
|
177
287
|
|
|
288
|
+
### Plugin HTTP fixtures
|
|
289
|
+
|
|
290
|
+
- `api` — axios instance pointed at `http://localhost:<port>`
|
|
291
|
+
- `container` — proxy to the live container (includes all framework modules)
|
|
292
|
+
- `dbConfig` — `{ schema, clientUrl, dbName }`
|
|
293
|
+
|
|
178
294
|
---
|
|
179
295
|
|
|
180
296
|
## Module Integration Tests
|
|
181
297
|
|
|
182
298
|
```typescript
|
|
183
|
-
import {
|
|
184
|
-
import { MY_MODULE } from "../../src/modules/my-module" // MY_MODULE = "my-module" — must match Module() key
|
|
299
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
185
300
|
|
|
186
301
|
jest.setTimeout(30000)
|
|
187
302
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
// moduleOptions: { jwt_secret: "test" },
|
|
303
|
+
integrationTestRunner<IGreetingModuleService>({
|
|
304
|
+
mode: "module",
|
|
305
|
+
moduleName: "greeting",
|
|
306
|
+
resolve: process.cwd() + "/src/modules/greeting",
|
|
193
307
|
testSuite: ({ service }) => {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
describe("createPosts", () => {
|
|
199
|
-
it("should create a post", async () => {
|
|
200
|
-
const result = await service.createPosts([
|
|
201
|
-
{ title: "Quarterly Report" },
|
|
308
|
+
describe("createGreetings", () => {
|
|
309
|
+
it("should create a greeting", async () => {
|
|
310
|
+
const result = await service.createGreetings([
|
|
311
|
+
{ message: "Hello" },
|
|
202
312
|
])
|
|
203
313
|
expect(result).toHaveLength(1)
|
|
204
314
|
expect(result[0]).toEqual(
|
|
205
315
|
expect.objectContaining({
|
|
206
316
|
id: expect.any(String),
|
|
207
|
-
|
|
317
|
+
message: "Hello",
|
|
208
318
|
})
|
|
209
319
|
)
|
|
210
320
|
})
|
|
211
|
-
|
|
212
|
-
it("should throw on missing required field", async () => {
|
|
213
|
-
await expect(service.createPosts([{}])).rejects.toThrow()
|
|
214
|
-
})
|
|
215
321
|
})
|
|
216
322
|
},
|
|
217
323
|
})
|
|
218
324
|
```
|
|
219
325
|
|
|
220
|
-
###
|
|
326
|
+
### Module mode fixtures
|
|
221
327
|
|
|
222
328
|
- `service` — proxy to the module service (auto-refreshed each `beforeEach`)
|
|
223
|
-
- `MikroOrmWrapper` — raw DB access
|
|
329
|
+
- `MikroOrmWrapper` — raw DB access
|
|
224
330
|
- `acmekitApp` — proxy to the `AcmeKitApp` instance
|
|
225
331
|
- `dbConfig` — `{ schema, clientUrl }`
|
|
226
332
|
|
|
227
|
-
###
|
|
228
|
-
|
|
229
|
-
```typescript
|
|
230
|
-
// --- Create ---
|
|
231
|
-
const [post] = await service.createPosts([{ title: "Test" }])
|
|
232
|
-
|
|
233
|
-
// --- List with filters ---
|
|
234
|
-
const filtered = await service.listPosts({ status: "published" })
|
|
235
|
-
const withRelations = await service.listPosts({}, { relations: ["comments"] })
|
|
236
|
-
|
|
237
|
-
// --- List and count ---
|
|
238
|
-
const [posts, count] = await service.listAndCountPosts()
|
|
239
|
-
expect(count).toEqual(2)
|
|
240
|
-
|
|
241
|
-
// --- Retrieve ---
|
|
242
|
-
const post = await service.retrievePost(id)
|
|
243
|
-
|
|
244
|
-
// --- Retrieve with select (verify no extra fields) ---
|
|
245
|
-
const withSelect = await service.retrievePost(id, { select: ["id"] })
|
|
246
|
-
const serialized = JSON.parse(JSON.stringify(withSelect))
|
|
247
|
-
expect(serialized).toEqual({ id })
|
|
248
|
-
|
|
249
|
-
// --- Update ---
|
|
250
|
-
const updated = await service.updatePosts(id, { title: "New Title" })
|
|
251
|
-
|
|
252
|
-
// --- Soft delete / restore ---
|
|
253
|
-
await service.softDeletePosts([id])
|
|
254
|
-
const listed = await service.listPosts({ id })
|
|
255
|
-
expect(listed).toHaveLength(0)
|
|
256
|
-
await service.restorePosts([id])
|
|
257
|
-
const restored = await service.listPosts({ id })
|
|
258
|
-
expect(restored).toHaveLength(1)
|
|
259
|
-
|
|
260
|
-
// --- Hard delete ---
|
|
261
|
-
await service.deletePosts([id])
|
|
262
|
-
```
|
|
263
|
-
|
|
264
|
-
### Error handling in module tests
|
|
265
|
-
|
|
266
|
-
```typescript
|
|
267
|
-
// Style 1: .catch((e) => e) — preferred when checking message
|
|
268
|
-
const error = await service.retrievePost("nonexistent").catch((e) => e)
|
|
269
|
-
expect(error.message).toEqual("Post with id: nonexistent was not found")
|
|
270
|
-
|
|
271
|
-
// Style 2: rejects.toThrow() — when only checking it throws
|
|
272
|
-
await expect(service.createPosts([{}])).rejects.toThrow()
|
|
273
|
-
|
|
274
|
-
// Style 3: check error.type
|
|
275
|
-
let error
|
|
276
|
-
try {
|
|
277
|
-
await service.deleteApiKeys([unrevokedKey.id])
|
|
278
|
-
} catch (e) {
|
|
279
|
-
error = e
|
|
280
|
-
}
|
|
281
|
-
expect(error.type).toEqual("not_allowed")
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
### Related entity testing
|
|
333
|
+
### Module test lifecycle
|
|
285
334
|
|
|
286
|
-
|
|
287
|
-
const [category] = await service.createCategories([{ name: "Tech" }])
|
|
288
|
-
const [post] = await service.createPosts([
|
|
289
|
-
{ title: "Test", category_id: category.id },
|
|
290
|
-
])
|
|
291
|
-
|
|
292
|
-
const withRelation = await service.retrievePost(post.id, {
|
|
293
|
-
relations: ["category"],
|
|
294
|
-
})
|
|
295
|
-
expect(withRelation.category.name).toBe("Tech")
|
|
296
|
-
```
|
|
335
|
+
Each `it` block gets: schema drop + recreate → fresh module boot → test runs → schema clear → module shutdown. No manual cleanup needed.
|
|
297
336
|
|
|
298
337
|
---
|
|
299
338
|
|
|
300
|
-
## Testing Workflows
|
|
339
|
+
## Testing Workflows
|
|
301
340
|
|
|
302
|
-
Workflows are
|
|
341
|
+
Workflows are called via `workflowFn(container).run({ input })`:
|
|
303
342
|
|
|
304
343
|
```typescript
|
|
305
|
-
import {
|
|
344
|
+
import { createGreetingsWorkflow } from "../../src/workflows"
|
|
306
345
|
|
|
307
|
-
|
|
346
|
+
integrationTestRunner({
|
|
347
|
+
mode: "plugin",
|
|
308
348
|
pluginPath: process.cwd(),
|
|
309
349
|
testSuite: ({ container }) => {
|
|
310
|
-
it("should
|
|
311
|
-
const { result } = await
|
|
312
|
-
input: {
|
|
313
|
-
customerId: "cus_123",
|
|
314
|
-
items: [{ sku: "SKU-001", qty: 2 }],
|
|
315
|
-
},
|
|
350
|
+
it("should create a greeting via workflow", async () => {
|
|
351
|
+
const { result } = await createGreetingsWorkflow(container).run({
|
|
352
|
+
input: { greetings: [{ message: "Workflow Hello" }] },
|
|
316
353
|
})
|
|
317
|
-
expect(result
|
|
318
|
-
|
|
319
|
-
)
|
|
354
|
+
expect(result).toHaveLength(1)
|
|
355
|
+
expect(result[0].message).toBe("Workflow Hello")
|
|
320
356
|
})
|
|
321
357
|
|
|
322
|
-
it("should reject invalid
|
|
323
|
-
const { errors } = await
|
|
324
|
-
input: {},
|
|
358
|
+
it("should reject invalid input", async () => {
|
|
359
|
+
const { errors } = await createGreetingsWorkflow(container).run({
|
|
360
|
+
input: { greetings: [{ message: "", status: "invalid" }] },
|
|
325
361
|
throwOnError: false,
|
|
326
362
|
})
|
|
327
363
|
expect(errors).toHaveLength(1)
|
|
364
|
+
expect(errors[0].error.message).toContain("Invalid")
|
|
328
365
|
})
|
|
329
366
|
},
|
|
330
367
|
})
|
|
331
368
|
```
|
|
332
369
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
## Asserting Domain Events
|
|
336
|
-
|
|
337
|
-
Both runners inject `MockEventBusService` under `Modules.EVENT_BUS`. Spy on the **prototype**, not an instance.
|
|
370
|
+
### Step compensation
|
|
338
371
|
|
|
339
372
|
```typescript
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
let eventBusSpy: jest.SpyInstance
|
|
343
|
-
|
|
344
|
-
beforeEach(() => {
|
|
345
|
-
eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
|
|
346
|
-
})
|
|
347
|
-
|
|
348
|
-
afterEach(() => {
|
|
349
|
-
eventBusSpy.mockClear()
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
it("should emit blog-post.created event", async () => {
|
|
353
|
-
const service = container.resolve(BLOG_MODULE) // "blog" — from Module() key
|
|
354
|
-
await service.createBlogPosts([{ title: "Event Test" }])
|
|
373
|
+
it("should compensate on failure", async () => {
|
|
374
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
355
375
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
expect.objectContaining({
|
|
362
|
-
name: "blog-post.created",
|
|
363
|
-
data: expect.objectContaining({ id: expect.any(String) }),
|
|
364
|
-
}),
|
|
365
|
-
])
|
|
366
|
-
)
|
|
376
|
+
const { errors } = await createGreetingsWorkflow(container).run({
|
|
377
|
+
input: { greetings: [{ message: "Will Fail", trigger_error: true }] },
|
|
378
|
+
throwOnError: false,
|
|
379
|
+
})
|
|
380
|
+
expect(errors).toHaveLength(1)
|
|
367
381
|
|
|
368
|
-
//
|
|
369
|
-
|
|
382
|
+
// Verify compensation ran — greeting rolled back
|
|
383
|
+
const greetings = await service.listGreetings()
|
|
384
|
+
expect(greetings).toHaveLength(0)
|
|
370
385
|
})
|
|
371
386
|
```
|
|
372
387
|
|
|
373
388
|
---
|
|
374
389
|
|
|
375
|
-
##
|
|
390
|
+
## Testing Subscribers
|
|
391
|
+
|
|
392
|
+
### Direct handler invocation (plugin mode — container-only)
|
|
376
393
|
|
|
377
|
-
|
|
394
|
+
Import the handler and call it manually with `{ event, container }`:
|
|
378
395
|
|
|
379
396
|
```typescript
|
|
380
|
-
import {
|
|
381
|
-
import
|
|
397
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
398
|
+
import greetingCreatedHandler from "../../src/subscribers/greeting-created"
|
|
399
|
+
import { GREETING_MODULE } from "../../src/modules/greeting"
|
|
382
400
|
|
|
383
|
-
|
|
384
|
-
const eventBus = container.resolve(Modules.EVENT_BUS)
|
|
401
|
+
jest.setTimeout(60 * 1000)
|
|
385
402
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
403
|
+
integrationTestRunner({
|
|
404
|
+
mode: "plugin",
|
|
405
|
+
pluginPath: process.cwd(),
|
|
406
|
+
testSuite: ({ container }) => {
|
|
407
|
+
it("should append ' [notified]' to greeting message", async () => {
|
|
408
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
409
|
+
const [greeting] = await service.createGreetings([
|
|
410
|
+
{ message: "Hello World" },
|
|
411
|
+
])
|
|
412
|
+
|
|
413
|
+
await greetingCreatedHandler({
|
|
414
|
+
event: {
|
|
415
|
+
data: { id: greeting.id },
|
|
416
|
+
name: "greeting.created",
|
|
417
|
+
},
|
|
418
|
+
container,
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
const updated = await service.retrieveGreeting(greeting.id)
|
|
422
|
+
expect(updated.message).toBe("Hello World [notified]")
|
|
423
|
+
})
|
|
424
|
+
},
|
|
395
425
|
})
|
|
396
426
|
```
|
|
397
427
|
|
|
398
|
-
|
|
428
|
+
### Event bus driven (plugin HTTP mode or app mode)
|
|
399
429
|
|
|
400
|
-
|
|
430
|
+
Use `TestEventUtils.waitSubscribersExecution` with the real event bus:
|
|
401
431
|
|
|
402
432
|
```typescript
|
|
403
|
-
|
|
404
|
-
import { BLOG_MODULE } from "../../src/modules/blog" // BLOG_MODULE = "blog"
|
|
405
|
-
const service = container.resolve(BLOG_MODULE)
|
|
406
|
-
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
|
433
|
+
import { integrationTestRunner, TestEventUtils } from "@acmekit/test-utils"
|
|
407
434
|
|
|
408
|
-
//
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
435
|
+
// CRITICAL: create the promise BEFORE emitting the event
|
|
436
|
+
const subscriberDone = TestEventUtils.waitSubscribersExecution(
|
|
437
|
+
"greeting.created",
|
|
438
|
+
eventBus
|
|
439
|
+
)
|
|
440
|
+
await eventBus.emit({ name: "greeting.created", data: { id } })
|
|
441
|
+
await subscriberDone
|
|
413
442
|
```
|
|
414
443
|
|
|
415
444
|
---
|
|
416
445
|
|
|
417
|
-
##
|
|
446
|
+
## Testing Jobs
|
|
418
447
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
This applies when testing your plugin mounted in a host application with `acmekitIntegrationTestRunner`.
|
|
448
|
+
Import the job function directly and call with container:
|
|
422
449
|
|
|
423
450
|
```typescript
|
|
424
|
-
import {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
Modules,
|
|
428
|
-
} from "@acmekit/framework/utils"
|
|
451
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
452
|
+
import cleanupGreetingsJob from "../../src/jobs/cleanup-greetings"
|
|
453
|
+
import { GREETING_MODULE } from "../../src/modules/greeting"
|
|
429
454
|
|
|
430
|
-
|
|
431
|
-
const config = container.resolve(ContainerRegistrationKeys.CONFIG_MODULE)
|
|
432
|
-
const { jwtSecret, jwtOptions } = config.projectConfig.http
|
|
455
|
+
jest.setTimeout(60 * 1000)
|
|
433
456
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
},
|
|
441
|
-
{ secret: jwtSecret, expiresIn: "1d", jwtOptions }
|
|
442
|
-
)
|
|
443
|
-
```
|
|
457
|
+
integrationTestRunner({
|
|
458
|
+
mode: "plugin",
|
|
459
|
+
pluginPath: process.cwd(),
|
|
460
|
+
testSuite: ({ container }) => {
|
|
461
|
+
it("should soft-delete greetings with lang='old'", async () => {
|
|
462
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
444
463
|
|
|
445
|
-
|
|
464
|
+
await service.createGreetings([
|
|
465
|
+
{ message: "Old 1", lang: "old" },
|
|
466
|
+
{ message: "Old 2", lang: "old" },
|
|
467
|
+
{ message: "Current", lang: "en" },
|
|
468
|
+
])
|
|
446
469
|
|
|
447
|
-
|
|
470
|
+
await cleanupGreetingsJob(container)
|
|
448
471
|
|
|
449
|
-
|
|
472
|
+
const remaining = await service.listGreetings()
|
|
473
|
+
expect(remaining).toHaveLength(1)
|
|
474
|
+
expect(remaining[0].lang).toBe("en")
|
|
475
|
+
})
|
|
450
476
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
CLIENT_API_KEY_HEADER,
|
|
455
|
-
Modules,
|
|
456
|
-
} from "@acmekit/framework/utils"
|
|
477
|
+
it("should do nothing when no old greetings exist", async () => {
|
|
478
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
479
|
+
await service.createGreetings([{ message: "Hello", lang: "en" }])
|
|
457
480
|
|
|
458
|
-
|
|
459
|
-
const apiKey = await apiKeyModule.createApiKeys({
|
|
460
|
-
title: "Test Client Key",
|
|
461
|
-
type: ApiKeyType.CLIENT,
|
|
462
|
-
created_by: "system",
|
|
463
|
-
})
|
|
481
|
+
await cleanupGreetingsJob(container)
|
|
464
482
|
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
}
|
|
483
|
+
const remaining = await service.listGreetings()
|
|
484
|
+
expect(remaining).toHaveLength(1)
|
|
485
|
+
})
|
|
486
|
+
},
|
|
487
|
+
})
|
|
468
488
|
```
|
|
469
489
|
|
|
470
490
|
---
|
|
471
491
|
|
|
472
|
-
##
|
|
492
|
+
## Asserting Domain Events
|
|
473
493
|
|
|
474
|
-
|
|
494
|
+
Plugin mode (without HTTP) injects `MockEventBusService`. Spy on the **prototype**, not an instance.
|
|
475
495
|
|
|
476
496
|
```typescript
|
|
477
|
-
|
|
478
|
-
const authModule = container.resolve(Modules.AUTH)
|
|
479
|
-
const userModule = container.resolve(Modules.USER)
|
|
480
|
-
const apiKeyModule = container.resolve(Modules.API_KEY)
|
|
497
|
+
import { MockEventBusService } from "@acmekit/test-utils"
|
|
481
498
|
|
|
482
|
-
|
|
483
|
-
const
|
|
484
|
-
const
|
|
485
|
-
```
|
|
499
|
+
it("should capture events", async () => {
|
|
500
|
+
const spy = jest.spyOn(MockEventBusService.prototype, "emit")
|
|
501
|
+
const eventBus = container.resolve(Modules.EVENT_BUS)
|
|
486
502
|
|
|
487
|
-
|
|
503
|
+
await eventBus.emit([
|
|
504
|
+
{ name: "greeting.created", data: { id: "g1", message: "Hello" } },
|
|
505
|
+
])
|
|
506
|
+
|
|
507
|
+
expect(spy).toHaveBeenCalledTimes(1)
|
|
508
|
+
expect(spy.mock.calls[0][0]).toEqual(
|
|
509
|
+
expect.arrayContaining([
|
|
510
|
+
expect.objectContaining({
|
|
511
|
+
name: "greeting.created",
|
|
512
|
+
data: expect.objectContaining({ id: "g1" }),
|
|
513
|
+
}),
|
|
514
|
+
])
|
|
515
|
+
)
|
|
488
516
|
|
|
489
|
-
|
|
517
|
+
spy.mockRestore()
|
|
518
|
+
})
|
|
519
|
+
```
|
|
490
520
|
|
|
491
|
-
|
|
492
|
-
- `integration-tests/setup.js` clears `MetadataStorage` between test files (prevents MikroORM entity bleed)
|
|
493
|
-
- DB defaults: `DB_HOST=localhost`, `DB_USERNAME=postgres`, `DB_PASSWORD=""`, `DB_PORT=5432`
|
|
494
|
-
- Each test run creates a unique DB: `acmekit-<module>-integration-<JEST_WORKER_ID>`
|
|
521
|
+
**Note:** MockEventBusService `emit` takes an **array** of events. Real event bus `emit` takes a single `{ name, data }` object.
|
|
495
522
|
|
|
496
523
|
---
|
|
497
524
|
|
|
498
|
-
##
|
|
525
|
+
## Service Resolution
|
|
499
526
|
|
|
500
527
|
```typescript
|
|
501
|
-
//
|
|
502
|
-
import {
|
|
503
|
-
|
|
504
|
-
import { pluginIntegrationTestRunner } from "@acmekit/test-utils"
|
|
505
|
-
|
|
506
|
-
// WRONG — trying to use `api` in plugin tests (no HTTP server!)
|
|
507
|
-
testSuite: ({ api }) => { ... } // ❌ api is not available
|
|
508
|
-
// RIGHT — resolve services from container and call methods directly
|
|
509
|
-
testSuite: ({ container }) => {
|
|
510
|
-
const service = container.resolve(BLOG_MODULE)
|
|
511
|
-
await service.createBlogPosts([{ title: "Test" }])
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// WRONG — silently passes without asserting
|
|
515
|
-
const result = await createWorkflow(container).run({ input: body })
|
|
516
|
-
if (!result.result) return // ❌ test passes with zero assertions!
|
|
517
|
-
// RIGHT
|
|
518
|
-
const { result } = await createWorkflow(container).run({ input: body })
|
|
519
|
-
expect(result).toBeDefined()
|
|
528
|
+
// Custom modules — use module constant
|
|
529
|
+
import { GREETING_MODULE } from "../../src/modules/greeting"
|
|
530
|
+
const service = container.resolve(GREETING_MODULE)
|
|
520
531
|
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
// RIGHT
|
|
525
|
-
expect(value === null || typeof value === "string").toBe(true)
|
|
532
|
+
// Core modules — use Modules.* constants
|
|
533
|
+
container.resolve(Modules.AUTH)
|
|
534
|
+
container.resolve(Modules.USER)
|
|
526
535
|
|
|
527
|
-
// WRONG
|
|
528
|
-
|
|
529
|
-
//
|
|
530
|
-
|
|
536
|
+
// WRONG
|
|
537
|
+
container.resolve("greetingModuleService") // ❌
|
|
538
|
+
container.resolve("auth") // ❌
|
|
539
|
+
```
|
|
531
540
|
|
|
532
|
-
|
|
533
|
-
/** BlogModule — tests CRUD, validation, events */ // ❌
|
|
541
|
+
---
|
|
534
542
|
|
|
535
|
-
|
|
536
|
-
const filtered = (operations as Array<{ status: string }>).filter(...) // ❌
|
|
543
|
+
## Unit Tests (No Framework Bootstrap)
|
|
537
544
|
|
|
538
|
-
|
|
539
|
-
expect(someStatus).toBeGreaterThanOrEqual(400) // ❌
|
|
540
|
-
// RIGHT
|
|
541
|
-
expect(someStatus).toEqual("voided")
|
|
545
|
+
For providers, utility functions, and standalone classes that don't need the database or AcmeKit container. Uses plain Jest — no `integrationTestRunner`.
|
|
542
546
|
|
|
543
|
-
|
|
544
|
-
expect(result).toEqual({ id: "123", title: "Test", created_at: "..." }) // ❌
|
|
545
|
-
// RIGHT
|
|
546
|
-
expect(result).toEqual(expect.objectContaining({
|
|
547
|
-
id: expect.any(String),
|
|
548
|
-
title: "Test",
|
|
549
|
-
}))
|
|
547
|
+
**File naming:** `src/**/__tests__/<name>.unit.spec.ts` — matches `TEST_TYPE=unit` in `jest.config.js`.
|
|
550
548
|
|
|
551
|
-
|
|
552
|
-
await service.createBlogPosts([{ title: "Test" }])
|
|
553
|
-
await TestEventUtils.waitSubscribersExecution("blog-post.created", eventBus) // ❌ may miss it
|
|
554
|
-
// RIGHT — capture promise BEFORE triggering
|
|
555
|
-
const execution = TestEventUtils.waitSubscribersExecution("blog-post.created", eventBus)
|
|
556
|
-
await service.createBlogPosts([{ title: "Test" }])
|
|
557
|
-
await execution
|
|
558
|
-
|
|
559
|
-
// WRONG — putting test files in non-standard locations
|
|
560
|
-
// integration-tests/tests/plugin.test.ts ← won't be picked up
|
|
561
|
-
// RIGHT
|
|
562
|
-
// integration-tests/plugin/plugin.spec.ts
|
|
549
|
+
### jest.mock Hoisting (Temporal Dead Zone)
|
|
563
550
|
|
|
564
|
-
|
|
565
|
-
pluginIntegrationTestRunner({ pluginPath: "/absolute/path/to/plugin" }) // ❌
|
|
566
|
-
// RIGHT
|
|
567
|
-
pluginIntegrationTestRunner({ pluginPath: process.cwd() })
|
|
551
|
+
`jest.mock()` factories are **hoisted above all `const`/`let` declarations** by SWC/Babel. Referencing a file-level `const` inside a `jest.mock()` factory causes `ReferenceError: Cannot access before initialization`.
|
|
568
552
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
553
|
+
```typescript
|
|
554
|
+
// WRONG — TDZ error: mockSign is not yet initialized when factory runs
|
|
555
|
+
const mockSign = jest.fn()
|
|
556
|
+
jest.mock("tronweb", () => ({
|
|
557
|
+
TronWeb: jest.fn().mockImplementation(() => ({ trx: { sign: mockSign } })),
|
|
558
|
+
}))
|
|
572
559
|
|
|
573
|
-
//
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
560
|
+
// RIGHT — create mocks INSIDE the factory, expose via module return
|
|
561
|
+
jest.mock("tronweb", () => {
|
|
562
|
+
const mocks = {
|
|
563
|
+
sign: jest.fn(),
|
|
564
|
+
isAddress: jest.fn().mockReturnValue(true),
|
|
565
|
+
}
|
|
566
|
+
const MockTronWeb = jest.fn().mockImplementation(() => ({
|
|
567
|
+
trx: { sign: mocks.sign },
|
|
568
|
+
}))
|
|
569
|
+
MockTronWeb.isAddress = mocks.isAddress
|
|
570
|
+
return { TronWeb: MockTronWeb, __mocks: mocks }
|
|
571
|
+
})
|
|
577
572
|
|
|
578
|
-
//
|
|
579
|
-
|
|
580
|
-
|
|
573
|
+
// Access mocks after jest.mock via require()
|
|
574
|
+
const { __mocks: tronMocks } = require("tronweb")
|
|
575
|
+
```
|
|
581
576
|
|
|
582
|
-
|
|
583
|
-
// Module container is scoped, not root — resolveOptions needs root container
|
|
584
|
-
// RIGHT — only use plugin.resolveOptions(container) in tests, subscribers, and workflows
|
|
577
|
+
**Rule:** All mock state must live INSIDE the `jest.mock()` factory or be accessed via `require()` after the mock is set up. Never reference file-level `const`/`let` from inside a `jest.mock()` factory.
|
|
585
578
|
|
|
586
|
-
|
|
587
|
-
await createOrderWorkflow.run({ input: { ... } }) // ❌
|
|
588
|
-
// RIGHT
|
|
589
|
-
await createOrderWorkflow(container).run({ input: { ... } })
|
|
590
|
-
|
|
591
|
-
// WRONG — using jsonwebtoken directly or hardcoding JWT secrets
|
|
592
|
-
import jwt from "jsonwebtoken"
|
|
593
|
-
const token = jwt.sign({ user_id: user.id }, "supersecret") // ❌
|
|
594
|
-
// RIGHT — use generateJwtToken with config-resolved secret
|
|
595
|
-
import { generateJwtToken, ContainerRegistrationKeys } from "@acmekit/framework/utils"
|
|
596
|
-
const config = container.resolve(ContainerRegistrationKeys.CONFIG_MODULE)
|
|
597
|
-
const token = generateJwtToken(payload, { secret: config.projectConfig.http.jwtSecret })
|
|
598
|
-
|
|
599
|
-
// WRONG — using string "publishable" for API key type
|
|
600
|
-
await apiKeyModule.createApiKeys({ type: "publishable" }) // ❌
|
|
601
|
-
// RIGHT — use ApiKeyType.CLIENT enum
|
|
602
|
-
import { ApiKeyType } from "@acmekit/framework/utils"
|
|
603
|
-
await apiKeyModule.createApiKeys({ type: ApiKeyType.CLIENT })
|
|
604
|
-
|
|
605
|
-
// WRONG — using old publishable header name
|
|
606
|
-
headers: { "x-publishable-api-key": token } // ❌
|
|
607
|
-
// RIGHT — use CLIENT_API_KEY_HEADER constant
|
|
608
|
-
import { CLIENT_API_KEY_HEADER } from "@acmekit/framework/utils"
|
|
609
|
-
headers: { [CLIENT_API_KEY_HEADER]: token }
|
|
610
|
-
|
|
611
|
-
// WRONG — resolving core services with string literals
|
|
612
|
-
container.resolve("auth") // ❌
|
|
613
|
-
container.resolve("user") // ❌
|
|
614
|
-
// RIGHT — use Modules.* constants
|
|
615
|
-
container.resolve(Modules.AUTH)
|
|
616
|
-
container.resolve(Modules.USER)
|
|
579
|
+
### Provider Unit Test Pattern
|
|
617
580
|
|
|
618
|
-
|
|
619
|
-
// The distributed transaction engine serializes errors into plain objects
|
|
620
|
-
// (not Error instances), so .toThrow() never matches.
|
|
621
|
-
await expect(
|
|
622
|
-
createOrderWorkflow(container).run({ input: {} })
|
|
623
|
-
).rejects.toThrow() // ❌ always fails — "Received function did not throw"
|
|
581
|
+
Providers have a specific structure: constructor receives `(container, options)`, static `identifier`, optional static `validateOptions`. Test each part:
|
|
624
582
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
583
|
+
```typescript
|
|
584
|
+
jest.mock("external-sdk", () => {
|
|
585
|
+
const mocks = {
|
|
586
|
+
doThing: jest.fn(),
|
|
587
|
+
}
|
|
588
|
+
const MockClient = jest.fn().mockImplementation(() => ({
|
|
589
|
+
doThing: mocks.doThing,
|
|
590
|
+
}))
|
|
591
|
+
return { Client: MockClient, __mocks: mocks }
|
|
629
592
|
})
|
|
630
|
-
expect(errors).toHaveLength(1)
|
|
631
|
-
expect(errors[0].error.message).toContain("customerId")
|
|
632
593
|
|
|
633
|
-
|
|
634
|
-
await expect(
|
|
635
|
-
createOrderWorkflow(container).run({ input: {} })
|
|
636
|
-
).rejects.toEqual(
|
|
637
|
-
expect.objectContaining({
|
|
638
|
-
message: expect.stringContaining("customerId"),
|
|
639
|
-
})
|
|
640
|
-
)
|
|
594
|
+
const { __mocks: sdkMocks } = require("external-sdk")
|
|
641
595
|
|
|
642
|
-
|
|
643
|
-
// service.retrievePost("bad-id")) because services throw real Error
|
|
644
|
-
// instances. The serialization only happens in the workflow engine.
|
|
645
|
-
```
|
|
596
|
+
import MyProvider from "../my-provider"
|
|
646
597
|
|
|
647
|
-
|
|
598
|
+
describe("MyProvider", () => {
|
|
599
|
+
let provider: MyProvider
|
|
648
600
|
|
|
649
|
-
|
|
601
|
+
const mockContainer = {} as any
|
|
602
|
+
const defaultOptions = { apiKey: "test-key" }
|
|
650
603
|
|
|
651
|
-
|
|
604
|
+
beforeEach(() => {
|
|
605
|
+
jest.clearAllMocks()
|
|
606
|
+
provider = new MyProvider(mockContainer, defaultOptions)
|
|
607
|
+
})
|
|
652
608
|
|
|
653
|
-
|
|
654
|
-
|
|
609
|
+
describe("static identifier", () => {
|
|
610
|
+
it("should have correct identifier", () => {
|
|
611
|
+
expect(MyProvider.identifier).toBe("my-provider")
|
|
612
|
+
})
|
|
613
|
+
})
|
|
655
614
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
input: {
|
|
662
|
-
customerId: "cus_123",
|
|
663
|
-
items: [{ sku: "SKU-001", qty: 2 }],
|
|
664
|
-
},
|
|
665
|
-
})
|
|
666
|
-
expect(result.order).toEqual(
|
|
667
|
-
expect.objectContaining({ customerId: "cus_123" })
|
|
668
|
-
)
|
|
615
|
+
describe("validateOptions", () => {
|
|
616
|
+
it("should accept valid options", () => {
|
|
617
|
+
expect(() =>
|
|
618
|
+
MyProvider.validateOptions({ apiKey: "key" })
|
|
619
|
+
).not.toThrow()
|
|
669
620
|
})
|
|
670
621
|
|
|
671
|
-
it("should reject
|
|
672
|
-
|
|
673
|
-
input: {},
|
|
674
|
-
throwOnError: false,
|
|
675
|
-
})
|
|
676
|
-
expect(errors).toHaveLength(1)
|
|
677
|
-
expect(errors[0].error.message).toContain("customerId")
|
|
622
|
+
it("should reject missing required option", () => {
|
|
623
|
+
expect(() => MyProvider.validateOptions({})).toThrow()
|
|
678
624
|
})
|
|
679
|
-
}
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
describe("doSomething", () => {
|
|
628
|
+
it("should delegate to SDK", async () => {
|
|
629
|
+
sdkMocks.doThing.mockResolvedValue({ success: true })
|
|
630
|
+
const result = await provider.doSomething({ input: "test" })
|
|
631
|
+
expect(result.success).toBe(true)
|
|
632
|
+
expect(sdkMocks.doThing).toHaveBeenCalledWith(
|
|
633
|
+
expect.objectContaining({ input: "test" })
|
|
634
|
+
)
|
|
635
|
+
})
|
|
636
|
+
})
|
|
680
637
|
})
|
|
681
638
|
```
|
|
682
639
|
|
|
683
|
-
###
|
|
640
|
+
### Mock Cleanup Between Tests
|
|
684
641
|
|
|
685
|
-
|
|
642
|
+
Mock state leaks between `describe` and `it` blocks. **Always add cleanup:**
|
|
686
643
|
|
|
687
644
|
```typescript
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
throwOnError: false,
|
|
645
|
+
// Recommended: file-level cleanup
|
|
646
|
+
beforeEach(() => {
|
|
647
|
+
jest.clearAllMocks()
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
// Alternative: per-describe when different describes need different setups
|
|
651
|
+
describe("feature A", () => {
|
|
652
|
+
beforeEach(() => {
|
|
653
|
+
jest.clearAllMocks()
|
|
654
|
+
mockFn.mockResolvedValue("A result")
|
|
699
655
|
})
|
|
656
|
+
})
|
|
657
|
+
```
|
|
700
658
|
|
|
701
|
-
|
|
659
|
+
Without `jest.clearAllMocks()`, a mock called in one test still shows those calls in the next test:
|
|
702
660
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
expect(orders).toHaveLength(0)
|
|
706
|
-
})
|
|
661
|
+
```typescript
|
|
662
|
+
expect(mockSign).not.toHaveBeenCalled() // FAILS — called by prior test
|
|
707
663
|
```
|
|
708
664
|
|
|
709
|
-
###
|
|
665
|
+
### Testing Code with Timers
|
|
666
|
+
|
|
667
|
+
When code under test calls `setTimeout`, `setInterval`, or a `sleep()` function, tests time out or run slowly.
|
|
710
668
|
|
|
711
669
|
```typescript
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
670
|
+
// Option 1: Fake timers (for setTimeout/setInterval)
|
|
671
|
+
beforeEach(() => {
|
|
672
|
+
jest.useFakeTimers()
|
|
673
|
+
})
|
|
674
|
+
afterEach(() => {
|
|
675
|
+
jest.useRealTimers()
|
|
676
|
+
})
|
|
677
|
+
it("should retry after delay", async () => {
|
|
678
|
+
const promise = provider.retryOperation()
|
|
679
|
+
await jest.advanceTimersByTimeAsync(3000)
|
|
680
|
+
const result = await promise
|
|
681
|
+
expect(result).toBeDefined()
|
|
682
|
+
})
|
|
722
683
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
])
|
|
729
|
-
)
|
|
730
|
-
eventBusSpy.mockRestore()
|
|
684
|
+
// Option 2: Mock the sleep method (for custom sleep/delay functions)
|
|
685
|
+
it("should complete without waiting", async () => {
|
|
686
|
+
jest.spyOn(provider as any, "sleep_").mockResolvedValue(undefined)
|
|
687
|
+
const result = await provider.longRunningOperation()
|
|
688
|
+
expect(result).toBeDefined()
|
|
731
689
|
})
|
|
732
690
|
```
|
|
733
691
|
|
|
734
|
-
###
|
|
692
|
+
### SWC Regex Limitation
|
|
693
|
+
|
|
694
|
+
SWC's regex parser fails on certain complex regex literals. If you get a `Syntax Error` from SWC on a line with a regex:
|
|
735
695
|
|
|
736
696
|
```typescript
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
input: { orderId: order.id },
|
|
740
|
-
})
|
|
697
|
+
// WRONG — SWC may fail to parse this
|
|
698
|
+
const pattern = /^(\*|[0-9]+)(\/[0-9]+)?$|^\*\/[0-9]+$/
|
|
741
699
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
expect(result.inventoryCheck).toBeDefined()
|
|
745
|
-
expect(result.customerDetails.name).toBe("Jane Doe")
|
|
746
|
-
expect(result.inventoryCheck.available).toBe(true)
|
|
747
|
-
})
|
|
700
|
+
// RIGHT — use RegExp constructor
|
|
701
|
+
const pattern = new RegExp("^(\\*|[0-9]+)(\\/[0-9]+)?$|^\\*\\/[0-9]+$")
|
|
748
702
|
```
|
|
749
703
|
|
|
750
|
-
###
|
|
704
|
+
### Verifying Error Paths
|
|
751
705
|
|
|
752
|
-
|
|
753
|
-
it("should permanently fail without compensation", async () => {
|
|
754
|
-
const { errors } = await validatePaymentWorkflow(container).run({
|
|
755
|
-
input: { paymentId: "fraudulent-id" },
|
|
756
|
-
throwOnError: false,
|
|
757
|
-
})
|
|
706
|
+
When testing error cases, **read the implementation** to determine whether the method throws or returns an error object. Don't assume from the return type alone.
|
|
758
707
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
708
|
+
```typescript
|
|
709
|
+
// If the method catches errors and returns { success: false }:
|
|
710
|
+
const result = await provider.process(badInput)
|
|
711
|
+
expect(result.success).toBe(false)
|
|
763
712
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
input: { orderId: order.id, skipLoyaltyPoints: true },
|
|
767
|
-
})
|
|
713
|
+
// If the method throws (no internal try/catch on that path):
|
|
714
|
+
await expect(provider.process(badInput)).rejects.toThrow("invalid")
|
|
768
715
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
})
|
|
716
|
+
// If validation runs BEFORE a try/catch block:
|
|
717
|
+
// validateInput() throws → not caught by the try/catch in process()
|
|
718
|
+
await expect(provider.process(badInput)).rejects.toThrow("validation")
|
|
773
719
|
```
|
|
774
720
|
|
|
775
|
-
|
|
721
|
+
**Tip:** Look for `try/catch` blocks in the implementation. Code that runs BEFORE or OUTSIDE a `try/catch` throws directly. Code INSIDE a `try/catch` may return an error result instead.
|
|
776
722
|
|
|
777
|
-
|
|
778
|
-
it("should execute hook handler", async () => {
|
|
779
|
-
const hookResult: any[] = []
|
|
723
|
+
---
|
|
780
724
|
|
|
781
|
-
|
|
782
|
-
workflow.hooks.orderCreated((input) => {
|
|
783
|
-
hookResult.push(input)
|
|
784
|
-
})
|
|
725
|
+
## Testing Plugins with Dependencies
|
|
785
726
|
|
|
786
|
-
|
|
787
|
-
input: { customerId: "cus_123", items: [{ sku: "SKU-001", qty: 1 }] },
|
|
788
|
-
})
|
|
727
|
+
When your plugin depends on other plugins (declared in `definePlugin({ dependencies })`), the test runner validates that all dependencies are installed. For workspace plugins that depend on other workspace plugins, use `skipDependencyValidation`:
|
|
789
728
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
)
|
|
729
|
+
```typescript
|
|
730
|
+
integrationTestRunner({
|
|
731
|
+
mode: "plugin",
|
|
732
|
+
pluginPath: process.cwd(),
|
|
733
|
+
skipDependencyValidation: true,
|
|
734
|
+
injectedDependencies: {
|
|
735
|
+
// Mock services your plugin expects from peer plugins
|
|
736
|
+
peerModuleService: { list: jest.fn().mockResolvedValue([]) },
|
|
737
|
+
},
|
|
738
|
+
testSuite: ({ container }) => { ... },
|
|
794
739
|
})
|
|
795
740
|
```
|
|
796
741
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
## Cross-Module Testing with additionalModules
|
|
742
|
+
## Configuring Providers in Plugin Tests
|
|
800
743
|
|
|
801
|
-
|
|
744
|
+
Use `pluginModuleOptions` to pass per-module options (e.g., provider configuration) keyed by module name:
|
|
802
745
|
|
|
803
746
|
```typescript
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
pluginIntegrationTestRunner({
|
|
747
|
+
integrationTestRunner({
|
|
748
|
+
mode: "plugin",
|
|
807
749
|
pluginPath: process.cwd(),
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
customerId: "cus_123",
|
|
816
|
-
items: [{ sku: "SKU-001", qty: 2 }],
|
|
817
|
-
paymentMethod: "card",
|
|
750
|
+
pluginModuleOptions: {
|
|
751
|
+
tron: {
|
|
752
|
+
providers: [
|
|
753
|
+
{
|
|
754
|
+
resolve: "./src/providers/energy/own-pool",
|
|
755
|
+
id: "own-pool",
|
|
756
|
+
options: { apiKey: "test-key" },
|
|
818
757
|
},
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
expect.objectContaining({ status: "captured" })
|
|
822
|
-
)
|
|
823
|
-
})
|
|
758
|
+
],
|
|
759
|
+
},
|
|
824
760
|
},
|
|
761
|
+
testSuite: ({ container }) => { ... },
|
|
825
762
|
})
|
|
826
763
|
```
|
|
827
764
|
|
|
828
|
-
|
|
765
|
+
`pluginModuleOptions` is merged into the discovered module config AFTER `pluginOptions`, so module-specific options override plugin-level ones.
|
|
829
766
|
|
|
830
|
-
|
|
767
|
+
---
|
|
831
768
|
|
|
832
|
-
|
|
769
|
+
## Anti-Patterns — NEVER Do These
|
|
833
770
|
|
|
834
771
|
```typescript
|
|
835
|
-
|
|
772
|
+
// WRONG — using deprecated runner names
|
|
773
|
+
import { pluginIntegrationTestRunner } from "@acmekit/test-utils" // ❌
|
|
774
|
+
import { moduleIntegrationTestRunner } from "@acmekit/test-utils" // ❌
|
|
775
|
+
// RIGHT — use unified runner with mode
|
|
776
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
777
|
+
integrationTestRunner({ mode: "plugin", pluginPath: process.cwd(), ... })
|
|
778
|
+
|
|
779
|
+
// WRONG — trying to use `api` in container-only plugin tests
|
|
780
|
+
integrationTestRunner({
|
|
781
|
+
mode: "plugin",
|
|
782
|
+
pluginPath: process.cwd(),
|
|
783
|
+
testSuite: ({ api }) => { // ❌ api is not available without http: true
|
|
784
|
+
await api.get("/admin/plugin")
|
|
785
|
+
},
|
|
786
|
+
})
|
|
836
787
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
788
|
+
// WRONG — hardcoding pluginPath
|
|
789
|
+
integrationTestRunner({ mode: "plugin", pluginPath: "/absolute/path" }) // ❌
|
|
790
|
+
// RIGHT
|
|
791
|
+
integrationTestRunner({ mode: "plugin", pluginPath: process.cwd() })
|
|
840
792
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
793
|
+
// WRONG — no auth in HTTP tests
|
|
794
|
+
it("should list", async () => {
|
|
795
|
+
await api.get("/admin/plugin") // ❌ 401
|
|
796
|
+
})
|
|
844
797
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
798
|
+
// WRONG — asserting error responses without catching (axios throws!)
|
|
799
|
+
const response = await api.post("/admin/plugin/greetings", {}, adminHeaders)
|
|
800
|
+
expect(response.status).toEqual(400) // ❌ never reached
|
|
801
|
+
// RIGHT
|
|
802
|
+
const { response } = await api.post("/admin/plugin/greetings", {}, adminHeaders).catch((e: any) => e)
|
|
803
|
+
expect(response.status).toEqual(400)
|
|
849
804
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
})
|
|
805
|
+
// WRONG — calling workflow without passing container
|
|
806
|
+
await createGreetingsWorkflow.run({ input: {} }) // ❌
|
|
807
|
+
// RIGHT
|
|
808
|
+
await createGreetingsWorkflow(container).run({ input: {} })
|
|
855
809
|
|
|
856
|
-
|
|
810
|
+
// WRONG — using .rejects.toThrow() on workflow errors
|
|
811
|
+
await expect(
|
|
812
|
+
createGreetingsWorkflow(container).run({ input: {} })
|
|
813
|
+
).rejects.toThrow() // ❌ plain object, not Error instance
|
|
814
|
+
// RIGHT
|
|
815
|
+
const { errors } = await createGreetingsWorkflow(container).run({
|
|
816
|
+
input: {},
|
|
817
|
+
throwOnError: false,
|
|
857
818
|
})
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
---
|
|
819
|
+
expect(errors[0].error.message).toContain("message")
|
|
861
820
|
|
|
862
|
-
|
|
821
|
+
// WRONG — calling waitSubscribersExecution AFTER triggering event
|
|
822
|
+
await eventBus.emit({ name: "greeting.created", data: { id } })
|
|
823
|
+
await TestEventUtils.waitSubscribersExecution("greeting.created", eventBus) // ❌
|
|
824
|
+
// RIGHT — capture promise BEFORE
|
|
825
|
+
const done = TestEventUtils.waitSubscribersExecution("greeting.created", eventBus)
|
|
826
|
+
await eventBus.emit({ name: "greeting.created", data: { id } })
|
|
827
|
+
await done
|
|
828
|
+
|
|
829
|
+
// WRONG — exact object assertions (timestamps/IDs change)
|
|
830
|
+
expect(result).toEqual({ id: "123", message: "Test" }) // ❌
|
|
831
|
+
// RIGHT
|
|
832
|
+
expect(result).toEqual(expect.objectContaining({
|
|
833
|
+
id: expect.any(String),
|
|
834
|
+
message: "Test",
|
|
835
|
+
}))
|
|
863
836
|
|
|
864
|
-
|
|
837
|
+
// WRONG — non-standard Jest matchers
|
|
838
|
+
expect(value).toBeOneOf([expect.any(String), null]) // ❌
|
|
839
|
+
// RIGHT
|
|
840
|
+
expect(value === null || typeof value === "string").toBe(true)
|
|
865
841
|
|
|
866
|
-
|
|
842
|
+
// WRONG — resolve path as relative from test file
|
|
843
|
+
integrationTestRunner({ mode: "module", moduleName: "greeting", resolve: "../index" }) // ❌
|
|
844
|
+
// RIGHT
|
|
845
|
+
integrationTestRunner({ mode: "module", moduleName: "greeting", resolve: process.cwd() + "/src/modules/greeting" })
|
|
867
846
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
- `inputSchema` rejection with specific error messages
|
|
872
|
-
- Compensation on failure (verify side-effects rolled back)
|
|
873
|
-
- `when()` branches (verify skipped/executed paths)
|
|
874
|
-
- `parallelize()` results (verify both branches complete)
|
|
875
|
-
- `StepResponse.permanentFailure()` (no compensation)
|
|
876
|
-
- `StepResponse.skip()` (optional steps)
|
|
877
|
-
- Hook handlers (`workflow.hooks.hookName(handler)`)
|
|
847
|
+
// WRONG — using jsonwebtoken directly
|
|
848
|
+
import jwt from "jsonwebtoken" // ❌
|
|
849
|
+
// RIGHT — use generateJwtToken from @acmekit/framework/utils
|
|
878
850
|
|
|
879
|
-
|
|
851
|
+
// WRONG — using old publishable API key names
|
|
852
|
+
{ type: "publishable" } // ❌
|
|
853
|
+
{ "x-publishable-api-key": token } // ❌
|
|
854
|
+
// RIGHT
|
|
855
|
+
{ type: ApiKeyType.CLIENT }
|
|
856
|
+
{ [CLIENT_API_KEY_HEADER]: token }
|
|
857
|
+
|
|
858
|
+
// WRONG — unused imports, JSDoc blocks, type casts in tests
|
|
859
|
+
|
|
860
|
+
// WRONG — referencing file-level const/let inside jest.mock factory (TDZ)
|
|
861
|
+
const mockFn = jest.fn()
|
|
862
|
+
jest.mock("lib", () => ({ thing: mockFn })) // ❌ ReferenceError
|
|
863
|
+
// RIGHT — create mocks inside factory, access via require()
|
|
864
|
+
jest.mock("lib", () => {
|
|
865
|
+
const mocks = { thing: jest.fn() }
|
|
866
|
+
return { ...mocks, __mocks: mocks }
|
|
867
|
+
})
|
|
868
|
+
const { __mocks } = require("lib")
|
|
869
|
+
|
|
870
|
+
// WRONG — no mock cleanup between tests
|
|
871
|
+
describe("A", () => { it("calls mock", () => { mockFn() }) })
|
|
872
|
+
describe("B", () => { it("mock is clean", () => {
|
|
873
|
+
expect(mockFn).not.toHaveBeenCalled() // ❌ fails — leaked from A
|
|
874
|
+
}) })
|
|
875
|
+
// RIGHT — add beforeEach(() => jest.clearAllMocks())
|
|
876
|
+
|
|
877
|
+
// WRONG — real timers in unit tests cause timeouts
|
|
878
|
+
it("should process", async () => {
|
|
879
|
+
await relay.process(data) // ❌ hangs — code calls sleep(3000) internally
|
|
880
|
+
})
|
|
881
|
+
// RIGHT — mock timers or sleep method
|
|
882
|
+
jest.useFakeTimers()
|
|
883
|
+
// or: jest.spyOn(relay as any, "sleep_").mockResolvedValue(undefined)
|
|
880
884
|
|
|
881
|
-
|
|
885
|
+
// WRONG — complex regex literal that SWC can't parse
|
|
886
|
+
const re = /^(\*|[0-9]+)(\/[0-9]+)?$/ // ❌ SWC Syntax Error
|
|
887
|
+
// RIGHT
|
|
888
|
+
const re = new RegExp("^(\\*|[0-9]+)(\\/[0-9]+)?$")
|
|
882
889
|
|
|
883
|
-
|
|
890
|
+
// WRONG — assuming method returns error without reading implementation
|
|
891
|
+
const result = await provider.process(bad)
|
|
892
|
+
expect(result.success).toBe(false) // ❌ actually throws
|
|
893
|
+
// RIGHT — read implementation first to check if it throws or returns
|
|
894
|
+
```
|