@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
|
@@ -1,61 +1,95 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: write-test
|
|
3
|
-
description: "Generates integration or unit tests using the
|
|
3
|
+
description: "Generates integration or unit tests using the unified integrationTestRunner. Auto-detects the correct mode (app for HTTP/workflows, module for isolated services). Use when adding tests for modules, workflows, subscribers, jobs, or API routes."
|
|
4
4
|
argument-hint: "[module-or-feature]"
|
|
5
5
|
allowed-tools: Bash, Read, Write, Edit, Grep, Glob
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Write Test
|
|
9
9
|
|
|
10
|
-
Generate tests for AcmeKit applications using the correct
|
|
10
|
+
Generate tests for AcmeKit applications using `integrationTestRunner` with the correct mode and patterns.
|
|
11
11
|
|
|
12
12
|
## Current Test Files
|
|
13
13
|
|
|
14
14
|
- Existing tests: !`find src -name "*.spec.ts" -o -name "*.test.ts" 2>/dev/null | head -20 || echo "(none)"`
|
|
15
15
|
- HTTP integration tests: !`ls integration-tests/http/*.spec.ts 2>/dev/null || echo "(none)"`
|
|
16
|
+
- App integration tests: !`ls integration-tests/app/*.spec.ts 2>/dev/null || echo "(none)"`
|
|
16
17
|
|
|
17
18
|
## Critical Gotchas — Every Test Must Get These Right
|
|
18
19
|
|
|
19
|
-
1. **
|
|
20
|
-
2.
|
|
21
|
-
3. **`resolve` in
|
|
22
|
-
4. **Workflow errors are plain objects
|
|
23
|
-
5. **`.rejects.toThrow()` DOES work for service errors** (
|
|
24
|
-
6.
|
|
25
|
-
7. **Axios throws on 4xx/5xx.** Use `.catch((e) => e)` for error
|
|
20
|
+
1. **Unified runner only.** `import { integrationTestRunner } from "@acmekit/test-utils"`. NEVER use `acmekitIntegrationTestRunner` or `moduleIntegrationTestRunner` — those are deprecated.
|
|
21
|
+
2. **Service resolution key = module constant.** `getContainer().resolve(BLOG_MODULE)` where `BLOG_MODULE = "blog"` (the string passed to `Module()`). NEVER guess `"blogModuleService"`.
|
|
22
|
+
3. **`resolve` in module mode = absolute path.** Use `resolve: process.cwd() + "/src/modules/post"`. NEVER pass the imported module object.
|
|
23
|
+
4. **Workflow errors are plain objects.** NEVER use `.rejects.toThrow()` on workflows. Use `throwOnError: false` + `errors` array. Error path: `errors[0].error.message`.
|
|
24
|
+
5. **`.rejects.toThrow()` DOES work for service errors** (real `Error` instances). Only workflow errors are serialized.
|
|
25
|
+
6. **Inline auth setup.** There is no `createAdminUser` helper. Resolve `Modules.USER`, `Modules.AUTH`, `Modules.API_KEY` directly in `beforeEach`.
|
|
26
|
+
7. **Axios throws on 4xx/5xx.** Use `.catch((e: any) => e)` for error assertions.
|
|
26
27
|
|
|
27
28
|
## Instructions
|
|
28
29
|
|
|
29
30
|
### Step 1: Determine Test Type
|
|
30
31
|
|
|
31
|
-
| What to test |
|
|
32
|
+
| What to test | Mode | File location |
|
|
32
33
|
|---|---|---|
|
|
33
|
-
| API routes (HTTP
|
|
34
|
-
|
|
|
35
|
-
|
|
|
36
|
-
|
|
|
37
|
-
| Pure functions | Plain Jest `describe/it` | `src/**/__tests__/<name>.unit.spec.ts` |
|
|
34
|
+
| API routes (HTTP) | `mode: "app"` | `integration-tests/http/<feature>.spec.ts` |
|
|
35
|
+
| Workflows, subscribers, jobs | `mode: "app"` | `integration-tests/app/<feature>.spec.ts` |
|
|
36
|
+
| Module service methods | `mode: "module"` | `src/modules/<mod>/__tests__/<name>.spec.ts` |
|
|
37
|
+
| Pure functions | Plain Jest | `src/**/__tests__/<name>.unit.spec.ts` |
|
|
38
38
|
|
|
39
39
|
### Step 2: Generate Test File
|
|
40
40
|
|
|
41
41
|
#### HTTP Integration Test
|
|
42
42
|
|
|
43
43
|
```typescript
|
|
44
|
-
import {
|
|
44
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
45
|
+
import {
|
|
46
|
+
ApiKeyType,
|
|
47
|
+
CLIENT_API_KEY_HEADER,
|
|
48
|
+
ContainerRegistrationKeys,
|
|
49
|
+
generateJwtToken,
|
|
50
|
+
Modules,
|
|
51
|
+
} from "@acmekit/framework/utils"
|
|
45
52
|
|
|
46
53
|
jest.setTimeout(60 * 1000)
|
|
47
54
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
55
|
+
integrationTestRunner({
|
|
56
|
+
mode: "app",
|
|
57
|
+
testSuite: ({ api, getContainer }) => {
|
|
58
|
+
let adminHeaders: Record<string, any>
|
|
59
|
+
|
|
60
|
+
beforeEach(async () => {
|
|
61
|
+
const container = getContainer()
|
|
62
|
+
const userModule = container.resolve(Modules.USER)
|
|
63
|
+
const authModule = container.resolve(Modules.AUTH)
|
|
64
|
+
|
|
65
|
+
const user = await userModule.createUsers({ email: "admin@test.js" })
|
|
66
|
+
const authIdentity = await authModule.createAuthIdentities({
|
|
67
|
+
provider_identities: [
|
|
68
|
+
{ provider: "emailpass", entity_id: "admin@test.js" },
|
|
69
|
+
],
|
|
70
|
+
app_metadata: { user_id: user.id },
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const config = container.resolve(ContainerRegistrationKeys.CONFIG_MODULE)
|
|
74
|
+
const { jwtSecret, jwtOptions } = config.projectConfig.http
|
|
75
|
+
|
|
76
|
+
const token = generateJwtToken(
|
|
77
|
+
{
|
|
78
|
+
actor_id: user.id,
|
|
79
|
+
actor_type: "user",
|
|
80
|
+
auth_identity_id: authIdentity.id,
|
|
81
|
+
app_metadata: { user_id: user.id },
|
|
82
|
+
},
|
|
83
|
+
{ secret: jwtSecret, expiresIn: "1d", jwtOptions }
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
adminHeaders = { headers: { authorization: `Bearer ${token}` } }
|
|
87
|
+
})
|
|
51
88
|
|
|
52
|
-
acmekitIntegrationTestRunner({
|
|
53
|
-
testSuite: ({ api, getContainer, utils }) => {
|
|
54
89
|
describe("GET /admin/posts", () => {
|
|
55
90
|
it("should list posts", async () => {
|
|
56
91
|
const response = await api.get("/admin/posts", adminHeaders)
|
|
57
|
-
expect(response.status).
|
|
58
|
-
expect(response.data.posts).toBeDefined()
|
|
92
|
+
expect(response.status).toEqual(200)
|
|
59
93
|
})
|
|
60
94
|
})
|
|
61
95
|
|
|
@@ -63,143 +97,207 @@ acmekitIntegrationTestRunner({
|
|
|
63
97
|
it("should create a post", async () => {
|
|
64
98
|
const response = await api.post(
|
|
65
99
|
"/admin/posts",
|
|
66
|
-
{ title: "Launch Announcement"
|
|
100
|
+
{ title: "Launch Announcement" },
|
|
67
101
|
adminHeaders
|
|
68
102
|
)
|
|
69
|
-
expect(response.status).
|
|
70
|
-
expect(response.data.post
|
|
103
|
+
expect(response.status).toEqual(200)
|
|
104
|
+
expect(response.data.post).toEqual(
|
|
105
|
+
expect.objectContaining({
|
|
106
|
+
id: expect.any(String),
|
|
107
|
+
title: "Launch Announcement",
|
|
108
|
+
})
|
|
109
|
+
)
|
|
71
110
|
})
|
|
72
111
|
|
|
73
|
-
it("should reject missing required fields
|
|
112
|
+
it("should reject missing required fields", async () => {
|
|
74
113
|
const { response } = await api
|
|
75
114
|
.post("/admin/posts", {}, adminHeaders)
|
|
76
|
-
.catch((e) => e)
|
|
115
|
+
.catch((e: any) => e)
|
|
77
116
|
expect(response.status).toEqual(400)
|
|
78
117
|
})
|
|
79
118
|
})
|
|
80
119
|
|
|
81
120
|
describe("DELETE /admin/posts/:id", () => {
|
|
82
121
|
it("should delete and confirm", async () => {
|
|
83
|
-
const
|
|
84
|
-
"/admin/posts",
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
)
|
|
122
|
+
const created = (
|
|
123
|
+
await api.post("/admin/posts", { title: "To Remove" }, adminHeaders)
|
|
124
|
+
).data.post
|
|
125
|
+
|
|
88
126
|
const response = await api.delete(
|
|
89
|
-
`/admin/posts/${
|
|
127
|
+
`/admin/posts/${created.id}`,
|
|
90
128
|
adminHeaders
|
|
91
129
|
)
|
|
92
|
-
expect(response.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
130
|
+
expect(response.data).toEqual({
|
|
131
|
+
id: created.id,
|
|
132
|
+
object: "post",
|
|
133
|
+
deleted: true,
|
|
134
|
+
})
|
|
96
135
|
})
|
|
97
136
|
})
|
|
98
137
|
},
|
|
99
138
|
})
|
|
100
139
|
```
|
|
101
140
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
#### Module Integration Test
|
|
141
|
+
#### App Integration Test (workflows, subscribers, jobs)
|
|
105
142
|
|
|
106
143
|
```typescript
|
|
107
|
-
import {
|
|
108
|
-
import {
|
|
144
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
145
|
+
import { createPostsWorkflow } from "../../src/workflows/workflows"
|
|
146
|
+
import { POST_MODULE } from "../../src/modules/post"
|
|
109
147
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
])
|
|
119
|
-
expect(result).toHaveLength(1)
|
|
120
|
-
expect(result[0]).toEqual(
|
|
121
|
-
expect.objectContaining({ title: "Quarterly Report" })
|
|
122
|
-
)
|
|
148
|
+
jest.setTimeout(60 * 1000)
|
|
149
|
+
|
|
150
|
+
integrationTestRunner({
|
|
151
|
+
mode: "app",
|
|
152
|
+
testSuite: ({ getContainer }) => {
|
|
153
|
+
it("should create via workflow", async () => {
|
|
154
|
+
const { result } = await createPostsWorkflow(getContainer()).run({
|
|
155
|
+
input: { posts: [{ title: "My Post" }] },
|
|
123
156
|
})
|
|
157
|
+
expect(result[0]).toEqual(
|
|
158
|
+
expect.objectContaining({ title: "My Post" })
|
|
159
|
+
)
|
|
160
|
+
})
|
|
124
161
|
|
|
125
|
-
|
|
126
|
-
|
|
162
|
+
it("should reject invalid input", async () => {
|
|
163
|
+
const { errors } = await createPostsWorkflow(getContainer()).run({
|
|
164
|
+
input: { posts: [{ title: "", status: "bad" }] },
|
|
165
|
+
throwOnError: false,
|
|
127
166
|
})
|
|
167
|
+
expect(errors).toHaveLength(1)
|
|
168
|
+
expect(errors[0].error.message).toContain("Invalid")
|
|
128
169
|
})
|
|
129
170
|
},
|
|
130
171
|
})
|
|
131
172
|
```
|
|
132
173
|
|
|
133
|
-
|
|
174
|
+
#### Subscriber Test
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
import { integrationTestRunner, TestEventUtils } from "@acmekit/test-utils"
|
|
178
|
+
import { Modules } from "@acmekit/framework/utils"
|
|
179
|
+
import { POST_MODULE } from "../../src/modules/post"
|
|
180
|
+
|
|
181
|
+
jest.setTimeout(60 * 1000)
|
|
182
|
+
|
|
183
|
+
integrationTestRunner({
|
|
184
|
+
mode: "app",
|
|
185
|
+
testSuite: ({ getContainer }) => {
|
|
186
|
+
it("should execute subscriber side-effect", async () => {
|
|
187
|
+
const container = getContainer()
|
|
188
|
+
const service: any = container.resolve(POST_MODULE)
|
|
189
|
+
const eventBus = container.resolve(Modules.EVENT_BUS)
|
|
190
|
+
|
|
191
|
+
const [post] = await service.createPosts([
|
|
192
|
+
{ title: "Test", content: "Original" },
|
|
193
|
+
])
|
|
194
|
+
|
|
195
|
+
// CRITICAL: create promise BEFORE emitting
|
|
196
|
+
const subscriberDone = TestEventUtils.waitSubscribersExecution(
|
|
197
|
+
"post.published",
|
|
198
|
+
eventBus
|
|
199
|
+
)
|
|
200
|
+
await eventBus.emit({ name: "post.published", data: { id: post.id } })
|
|
201
|
+
await subscriberDone
|
|
202
|
+
|
|
203
|
+
const updated = await service.retrievePost(post.id)
|
|
204
|
+
expect(updated.content).toBe("Original [notified]")
|
|
205
|
+
})
|
|
206
|
+
},
|
|
207
|
+
})
|
|
208
|
+
```
|
|
134
209
|
|
|
135
|
-
####
|
|
210
|
+
#### Job Test
|
|
136
211
|
|
|
137
212
|
```typescript
|
|
138
|
-
import {
|
|
213
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
214
|
+
import archiveOldPostsJob from "../../src/jobs/archive-old-posts"
|
|
215
|
+
import { POST_MODULE } from "../../src/modules/post"
|
|
139
216
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
217
|
+
jest.setTimeout(60 * 1000)
|
|
218
|
+
|
|
219
|
+
integrationTestRunner({
|
|
220
|
+
mode: "app",
|
|
221
|
+
testSuite: ({ getContainer }) => {
|
|
222
|
+
it("should archive old posts", async () => {
|
|
223
|
+
const container = getContainer()
|
|
224
|
+
const service: any = container.resolve(POST_MODULE)
|
|
225
|
+
|
|
226
|
+
await service.createPosts([
|
|
227
|
+
{ title: "Old", status: "archived" },
|
|
228
|
+
{ title: "Active", status: "published" },
|
|
229
|
+
])
|
|
230
|
+
|
|
231
|
+
await archiveOldPostsJob(container)
|
|
232
|
+
|
|
233
|
+
const remaining = await service.listPosts()
|
|
234
|
+
expect(remaining).toHaveLength(1)
|
|
235
|
+
expect(remaining[0].title).toBe("Active")
|
|
236
|
+
})
|
|
237
|
+
},
|
|
144
238
|
})
|
|
145
239
|
```
|
|
146
240
|
|
|
147
|
-
####
|
|
241
|
+
#### Unit Test (No Framework Bootstrap)
|
|
242
|
+
|
|
243
|
+
For providers, utilities, and standalone classes. **CRITICAL:** `jest.mock()` factories are hoisted above `const`/`let`. Create mocks INSIDE the factory, access via `require()`.
|
|
148
244
|
|
|
149
245
|
```typescript
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
246
|
+
jest.mock("external-sdk", () => {
|
|
247
|
+
const mocks = { doThing: jest.fn() }
|
|
248
|
+
const MockClient = jest.fn().mockImplementation(() => ({
|
|
249
|
+
doThing: mocks.doThing,
|
|
250
|
+
}))
|
|
251
|
+
return { Client: MockClient, __mocks: mocks }
|
|
156
252
|
})
|
|
157
253
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
254
|
+
const { __mocks: sdkMocks } = require("external-sdk")
|
|
255
|
+
|
|
256
|
+
import MyProvider from "../my-provider"
|
|
257
|
+
|
|
258
|
+
describe("MyProvider", () => {
|
|
259
|
+
const mockContainer = {} as any
|
|
260
|
+
|
|
261
|
+
beforeEach(() => {
|
|
262
|
+
jest.clearAllMocks()
|
|
163
263
|
})
|
|
164
|
-
expect(errors).toHaveLength(1)
|
|
165
|
-
expect(errors[0].error.message).toContain("title") // errors[0].error.message — NOT errors[0].message
|
|
166
|
-
})
|
|
167
264
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const { errors } = await chargeCardWorkflow(getContainer()).run({
|
|
171
|
-
input: { cardToken: "invalid", amount: 100 },
|
|
172
|
-
throwOnError: false,
|
|
265
|
+
it("should have correct identifier", () => {
|
|
266
|
+
expect(MyProvider.identifier).toBe("my-provider")
|
|
173
267
|
})
|
|
174
|
-
expect(errors[0].error.message).toContain("Card declined")
|
|
175
|
-
})
|
|
176
268
|
|
|
177
|
-
it("should
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
269
|
+
it("should delegate to SDK", async () => {
|
|
270
|
+
sdkMocks.doThing.mockResolvedValue({ success: true })
|
|
271
|
+
const provider = new MyProvider(mockContainer, { apiKey: "key" })
|
|
272
|
+
const result = await provider.doSomething({ input: "test" })
|
|
273
|
+
expect(result.success).toBe(true)
|
|
181
274
|
})
|
|
182
|
-
// SkipStep doesn't produce errors — workflow continues
|
|
183
|
-
expect(result).toBeDefined()
|
|
184
275
|
})
|
|
185
276
|
```
|
|
186
277
|
|
|
187
|
-
|
|
278
|
+
**Timer mocking:** Use `jest.useFakeTimers()` + `jest.advanceTimersByTimeAsync()` or `jest.spyOn(instance, "sleep_").mockResolvedValue(undefined)`.
|
|
279
|
+
|
|
280
|
+
**SWC regex:** Use `new RegExp("...")` instead of complex regex literals.
|
|
281
|
+
|
|
282
|
+
#### Module Integration Test
|
|
188
283
|
|
|
189
284
|
```typescript
|
|
190
|
-
import {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
285
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
286
|
+
|
|
287
|
+
jest.setTimeout(30000)
|
|
288
|
+
|
|
289
|
+
integrationTestRunner<IPostModuleService>({
|
|
290
|
+
mode: "module",
|
|
291
|
+
moduleName: "post",
|
|
292
|
+
resolve: process.cwd() + "/src/modules/post",
|
|
293
|
+
testSuite: ({ service }) => {
|
|
294
|
+
it("should create a post", async () => {
|
|
295
|
+
const result = await service.createPosts([{ title: "Test" }])
|
|
296
|
+
expect(result[0]).toEqual(
|
|
297
|
+
expect.objectContaining({ title: "Test" })
|
|
298
|
+
)
|
|
299
|
+
})
|
|
300
|
+
},
|
|
203
301
|
})
|
|
204
302
|
```
|
|
205
303
|
|
|
@@ -208,17 +306,23 @@ it("should emit post.created event", async () => {
|
|
|
208
306
|
```bash
|
|
209
307
|
pnpm test:unit # Unit tests
|
|
210
308
|
pnpm test:integration:modules # Module integration tests
|
|
309
|
+
pnpm test:integration:app # App integration tests (workflows/subscribers/jobs)
|
|
211
310
|
pnpm test:integration:http # HTTP integration tests
|
|
212
311
|
```
|
|
213
312
|
|
|
214
313
|
## Key Patterns
|
|
215
314
|
|
|
315
|
+
- Use `integrationTestRunner` with `mode` — never the old deprecated runner names
|
|
216
316
|
- Match the `jest.config.js` test buckets — don't invent new locations
|
|
217
|
-
-
|
|
218
|
-
-
|
|
219
|
-
-
|
|
220
|
-
- Pass body directly to `api.post(url, body, headers)` — NOT `{ body: {...} }`
|
|
221
|
-
- Use `.catch((e) => e)` for error-case assertions — axios throws on 4xx/5xx
|
|
317
|
+
- Inline auth setup in `beforeEach` — no `createAdminUser` helper
|
|
318
|
+
- Pass body directly: `api.post(url, body, headers)` — NOT `{ body: {...} }`
|
|
319
|
+
- Use `.catch((e: any) => e)` for error assertions — axios throws on 4xx/5xx
|
|
222
320
|
- Use `expect.objectContaining()` with `expect.any(String)` for IDs/timestamps
|
|
223
321
|
- Call `waitSubscribersExecution` BEFORE triggering the event
|
|
322
|
+
- Import jobs/subscribers directly and call with container
|
|
224
323
|
- Use realistic test data, not "test" or "foo"
|
|
324
|
+
- **Always `beforeEach(() => jest.clearAllMocks())`** in unit tests — mock state leaks between describes
|
|
325
|
+
- **Never reference file-level `const`/`let` inside `jest.mock()` factories** — TDZ error (SWC/Babel hoisting)
|
|
326
|
+
- **Mock timers or sleep** when code under test has delays — prevents test timeouts
|
|
327
|
+
- **Use `new RegExp()` over complex regex literals** — SWC parser fails on some patterns
|
|
328
|
+
- **Read implementation to verify error paths** — check if errors are thrown vs returned
|