@kensio/skills 1.13.1

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.
@@ -0,0 +1,324 @@
1
+ ---
2
+ name: yulin-aws-simulation
3
+ description: Use the @kensio/yulin in-process AWS simulator well when testing AWS code, using it directly rather than building a harness around it, driving tests, local dev and production from one synthesized CDK template, intercepting SDK clients with SimSdk, controlling simulated time, matching service errors by name, and handling properties Yulin refuses to simulate. Use when writing or reviewing tests that touch AWS, when replacing aws-sdk-client-mock or hand-rolled AWS stubs, when a CDK stack needs testing, when test setup around Yulin is growing helper classes or wrapper functions, and when Yulin refuses a template property or an SDK command.
4
+ license: Apache-2.0
5
+ metadata:
6
+ version: "1.13.1"
7
+ ---
8
+
9
+ # Testing with Yulin
10
+
11
+ [Yulin](https://yulinsim.dev/) (`@kensio/yulin`) simulates AWS in process, in memory, with no
12
+ network and no AWS account. Its own docs are the authority on the API. This skill covers how to use
13
+ it well, and that lives mostly outside the API.
14
+
15
+ Read the package docs for anything API-shaped. Start with
16
+ [the README](https://github.com/KensioSoftware/yulin#readme), then `docs/sdk/` for interception and
17
+ `docs/services/<name>/` for each simulated service.
18
+
19
+ This skill serves the `isolated-testing-style` skill, the general argument for simulation over
20
+ stubs.
21
+
22
+ Yulin is deliberately flexible, and plenty of shapes work. What follows is the recommended way to
23
+ get the most out of it, offered as guidance. Each one says what it buys. A situation that does not
24
+ want that trade can go the other way knowingly.
25
+
26
+ ## Use what Yulin already gives you
27
+
28
+ The most common way to go wrong with Yulin is to build something on top of it. The failure looks
29
+ like a `TestAwsEnvironment` class, a `setupSimulatedAws()` helper returning six things, a factory
30
+ per service, or a `beforeEach` that reassembles the world (a private framework wrapped around a tool
31
+ that is already the framework).
32
+
33
+ It is worth resisting, because Yulin is built to be used directly:
34
+
35
+ - `new SimAws()` and `new SimSdk()` are plain constructors. No side effects, no network, no cleanup
36
+ and no awaiting. Creating a simulation per test is close to free.
37
+ - Service accessors take the same Command objects the AWS SDK does, so seeding and asserting need no
38
+ translation layer of their own.
39
+ - `using simSdk = new SimSdk()` is the teardown.
40
+ - `deployTemplateFile` is the environment.
41
+
42
+ So the recommended shape of a test is to construct, deploy if a template is involved, intercept,
43
+ exercise, then assert by reading the simulation back. A wrapper around any of those steps hides the
44
+ one thing a reader of the test needs to see, and it has to be maintained forever after.
45
+
46
+ If a sequence genuinely repeats, make it a small function in the same test file, and keep it
47
+ returning the simulation objects themselves. A bespoke return shape starts hiding them. The point at
48
+ which it wants a class, an options interface, or a directory, it has stopped being test setup and
49
+ become a second product.
50
+
51
+ ## One synthesized template, for tests, local dev and production
52
+
53
+ Describe the infrastructure once, in CDK, and let the same synthesized output drive all three.
54
+ Production deploys it. The local dev server deploys it. The tests deploy it:
55
+
56
+ ```typescript
57
+ const stack = await simAws.region("eu-west-2").cloudFormation().deployTemplateFile({
58
+ templatePath: "cdk.out/SiteStack.template.json",
59
+ stackName: "site-stack",
60
+ });
61
+
62
+ await stack.waitForDeployComplete();
63
+ ```
64
+
65
+ A test written against a template you wrote by hand only tests the template you wrote by hand.
66
+ Deploying the synthesized output means a construct change that breaks the system breaks the test.
67
+
68
+ The same argument extends past the test suite. A dev environment that creates its buckets and tables
69
+ by hand is a third description of the infrastructure, drifting away from the other two at its own
70
+ pace, and the drift shows up as a bug that reproduces in exactly one of the three places. Pointing
71
+ the dev server at `cdk.out` as well removes the whole category. What runs locally is what CI tested
72
+ and what production deploys, and `watch` turns a `cdk synth` into a stack update in place.
73
+
74
+ The corollary is that infrastructure belongs in the CDK app even when only a test needs it. A bucket
75
+ conjured in test setup is infrastructure that production does not have.
76
+
77
+ **Do not hand-roll a wrapper that reads the file and calls `deployTemplate`.** `deployTemplateFile`
78
+ already reads it, and it locates the cloud assembly beside the file. The assets manifest and staged
79
+ asset directories resolve. A wrapper that reads the JSON itself loses that, and anything needing a
80
+ CDK asset, such as a `Custom::CDKBucketDeployment` or a `Code.fromAsset` function, fails with
81
+ `No CDK assets manifest is available.`
82
+
83
+ The two options that make a wrapper unnecessary:
84
+
85
+ - **`transform`** is given the parsed template and answers with the one to deploy. It runs on the
86
+ deployment and again on every re-read. A wrapper cannot do that. Use it for what a simulation
87
+ genuinely cannot resolve, such as an ARN carrying a real account, or a hosted zone ID that came
88
+ from `HostedZone.fromLookup`.
89
+ - **`watch`** re-applies the file when it changes, updating the stack in place. This is for dev
90
+ servers. A `cdk synth` becomes a stack update without restarting the process, and resources the
91
+ change left alone keep what they hold.
92
+
93
+ ```typescript
94
+ await simAws.cloudFormation().deployTemplateFile({
95
+ templatePath: "cdk.out/SiteStack.template.json",
96
+ transform: withoutLookedUpHostedZone,
97
+ watch: { onUpdated: () => srv.reload() },
98
+ });
99
+ ```
100
+
101
+ ## Intercept real SDK clients, never hand-roll stubs
102
+
103
+ `SimSdk` replaces the `send` method of an AWS SDK client class or instance, so real clients answer
104
+ from the simulation. The code under test uses the SDK exactly as it does in production and never
105
+ learns there is a simulator behind it.
106
+
107
+ ```typescript
108
+ import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
109
+ import { SimSdk } from "@kensio/yulin/sdk";
110
+
111
+ using simSdk = new SimSdk();
112
+ simSdk.intercept(SecretsManagerClient); // Every instance, including ones made later.
113
+ ```
114
+
115
+ A stub asserts that your code called something. The simulator asserts that it called the service
116
+ correctly. On a real project, swapping stubs for interception caught two bugs the same afternoon,
117
+ both already in production:
118
+
119
+ - A Secrets Manager secret whose name ended in a hyphen and six characters, exactly the suffix
120
+ Secrets Manager appends to an ARN. AWS advises against names of that shape because they are
121
+ ambiguous with the ARN form. A stub has no naming rules. It had accepted it happily.
122
+ - A Cognito `SECRET_HASH` computed the wrong way. The stub had accepted that too, because a stub is
123
+ never going to verify a signature.
124
+
125
+ Intercept the class in most cases, since the code under test usually constructs its own clients.
126
+ Intercept an instance when only one client should reach the simulation.
127
+
128
+ Each `SimSdk` owns a `SimAws`, reachable as `simSdk.simAws` for seeding and inspecting state. Pass
129
+ an existing one with `new SimSdk({ simAws })` to share.
130
+
131
+ ### Intercept what the code actually sends through
132
+
133
+ Interception replaces `send` on the thing it is given. It has to be given the client the code under
134
+ test actually calls. The wrapper clients are where this bites. A `DynamoDBDocumentClient` built over
135
+ a `DynamoDBClient` is what the code sends through. The document client is the one that needs
136
+ intercepting.
137
+
138
+ ```typescript
139
+ using simSdk = new SimSdk();
140
+
141
+ const documents = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "eu-west-2" }));
142
+ simSdk.intercept(documents); // Not the DynamoDBClient it was built from.
143
+ ```
144
+
145
+ Every Command through an intercepted client is routed to the simulation by default. An allow list of
146
+ Command classes narrows that, and is worth reaching for only when something else should genuinely
147
+ handle the rest.
148
+
149
+ ### Prefer `using` over a teardown step
150
+
151
+ `SimSdk` and the interception handles it returns are disposable, so `using simSdk = new SimSdk();`
152
+ restores every intercepted client at the end of the scope. `simSdk.restoreAll()` and
153
+ `interception.restore()` do the same thing by hand.
154
+
155
+ The recommendation is `using`, because it is teardown that cannot be forgotten or skipped by an
156
+ early return, and because it leaves nothing for a later test to inherit if the one before it threw.
157
+ Reach for the explicit calls when interception has to stop somewhere other than the end of a scope.
158
+
159
+ ## Freeze the clock and advance it deliberately
160
+
161
+ Each `SimAws` carries its own clock, independent of the host and of every other simulation in the
162
+ process. The recommendation is to start it frozen at a fixed instant and move it only on purpose:
163
+
164
+ ```typescript
165
+ const simAws = new SimAws({
166
+ clock: new SimFixedClock(new Date("2026-07-26T09:00:00.000Z")),
167
+ });
168
+
169
+ // Given a session that has run out while the caller was idle.
170
+ await simAws.clock().advanceBy({ minutes: 20 });
171
+ ```
172
+
173
+ What this buys is that time-dependent behaviour becomes something a test can assert on in
174
+ microseconds rather than something it waits for or gives up on. A good deal of the simulation keys
175
+ off that clock: EventBridge rules and Scheduler schedules fire only when time is advanced past them,
176
+ DynamoDB items pass their TTL, Secrets Manager deletions come due, `AssumeRole` sessions expire, and
177
+ Lambda event source mappings re-poll. Inside a simulated Lambda, `Date.now()` and `new Date()`
178
+ report simulated time. A handler's own expiry logic is exercised without a stub in sight.
179
+
180
+ That last point is worth drawing out. `isolated-testing-style` allows a stub for a clock, on the
181
+ grounds that a clock has no rules worth modelling. Against Yulin that exception is unnecessary. The
182
+ clock is part of the simulation, and advancing it exercises the real expiry rules of the services
183
+ around it rather than only the code's own arithmetic.
184
+
185
+ `simAws.clock().resume()` switches to tracking the underlying clock, and `simAws.clock().isFrozen`
186
+ reports which mode it is in. Running mode suits a local dev server. A test that wants it usually
187
+ wants an advance instead.
188
+
189
+ ## Assert by reading the simulation back
190
+
191
+ The simulation holds real state. The assertion can read it. After exercising the code, ask the
192
+ service what happened rather than asking the SDK what it was told:
193
+
194
+ ```typescript
195
+ // Then the upload is in the bucket, under the key the handler chose.
196
+ const object = await simAws.s3().getObject(new GetObjectCommand({ Bucket: bucket, Key: key }));
197
+ ```
198
+
199
+ Service accessors take the same Command objects the SDK does. The seeding and assertion code reads
200
+ like the production code between them.
201
+
202
+ This is where simulation pays off over stubs a second time. A call-count assertion holds only for
203
+ today's implementation. A state assertion holds however the handler is rewritten, and it fails if
204
+ the call was made in a way the real service would have rejected.
205
+
206
+ ## Match service errors by name
207
+
208
+ ```typescript
209
+ // Wrong. Passes in production, fails against the simulator.
210
+ if (error instanceof ResourceNotFoundException) { ... }
211
+
212
+ // Right.
213
+ if (error instanceof Error && error.name === "ResourceNotFoundException") { ... }
214
+ ```
215
+
216
+ The SDK exports exception classes, which invites the `instanceof` check. It holds only while exactly
217
+ one copy of the SDK package is in play. Two copies in the module graph, a bundler, or a simulator
218
+ raising its own classes, and it silently stops matching. Yulin's errors carry the service's real
219
+ error names and SDK-shaped `$metadata`, but they are not instances of the SDK classes.
220
+
221
+ This is worth fixing in production code, and working around it in tests hides it. `name` is what the
222
+ wire carries. The `name` check is the one that is right in both places. A version skew between two
223
+ `@aws-sdk/client-*` packages breaks `instanceof` in production too, just less predictably than the
224
+ simulator does.
225
+
226
+ ## Expect refusals, and treat them as a feature
227
+
228
+ Yulin refuses a property it cannot simulate, and never ignores one. That is the right trade.
229
+ silently accepting something that changes real behaviour turns a deploy-time failure into a
230
+ production one.
231
+
232
+ The cost is that one unsupported setting can make a whole stack unsimulatable, and the refusal
233
+ arrives one property at a time. When that happens, **enumerate every refusal in one pass**. Strip
234
+ properties from the synthesized template until it deploys, keeping a list, then raise them together
235
+ upstream.
236
+
237
+ ```typescript
238
+ // A throwaway transform used to find the floor. Delete it afterwards.
239
+ function stripUntilItDeploys(template: CfnTemplateBodyRecord): CfnTemplateBodyRecord {
240
+ // Remove one refused property, re-run, record the next refusal, repeat.
241
+ // Keep the list. Raise it as one issue.
242
+ }
243
+ ```
244
+
245
+ Doing this one release at a time means one round trip per property, and you never learn how far away
246
+ a working simulation actually is. This is the same rule as not discovering service ceilings one
247
+ failed deployment at a time.
248
+
249
+ Not every gap is a refusal. Several services record a property they cannot model and carry on,
250
+ reporting it as an ignored property on the stack and on the resource. Check that report before
251
+ trusting a test that depends on the setting.
252
+
253
+ ## Raise gaps upstream, and weight false passes far above false refusals
254
+
255
+ Fix gaps on [the Yulin repository](https://github.com/KensioSoftware/yulin) at source. A local
256
+ workaround has to be maintained in every project that hits the same gap.
257
+
258
+ When reporting, the asymmetry matters more than the volume:
259
+
260
+ - A simulator that **stays silent** about something costs you little. The test leaves that behaviour
261
+ uncovered, where it was already.
262
+ - A simulator that **says 200 where production says 403** converts a deploy-time failure into a
263
+ production one. That is the opposite of what it is for.
264
+
265
+ So a false pass deserves far more attention than a false refusal. A real example: Yulin authorised a
266
+ Lambda function URL invocation against `lambda:InvokeFunctionUrl` alone. CloudFront origin access
267
+ control also needs `lambda:InvokeFunction`. The tests passed, the release went out, and the endpoint
268
+ 403'd in production. A refusal would have cost an afternoon. The false pass cost an incident.
269
+
270
+ Report a false pass with what production does and what the simulation did. Report a false refusal
271
+ with the property and the template that carries it.
272
+
273
+ ## Deploy expensive context once per test file
274
+
275
+ Vitest gives each test file its own worker, so module-level state is already isolated between files.
276
+ Deploy a stack once for the file and let the tests share it. Isolation inside the file comes from
277
+ randomised names. Rebuilding the environment buys nothing.
278
+
279
+ ```typescript
280
+ let simAws: SimAws;
281
+
282
+ beforeAll(async () => {
283
+ // Given the real synthesized stack, deployed once for this file.
284
+ simAws = new SimAws();
285
+ const stack = await simAws.cloudFormation().deployTemplateFile({
286
+ templatePath: "cdk.out/SiteStack.template.json",
287
+ });
288
+ await stack.waitForDeployComplete();
289
+ });
290
+
291
+ it("stores an upload", async () => {
292
+ // Given a key no other test in this file is using.
293
+ const key = `uploads/${faker.string.uuid()}.png`;
294
+ // ...
295
+ });
296
+ ```
297
+
298
+ Putting a template deployment in `beforeEach` pays for the whole stack once per test for no
299
+ isolation you did not already have.
300
+
301
+ A template deployment is the only thing usually worth hoisting. Everything else (the `SimSdk`, a
302
+ seeded row, a bucket key) is cheap enough to build inside the test that needs it, and it is also
303
+ where it is easiest to read. A `beforeEach` that assembles state for tests that do not all want the
304
+ same state is the beginning of the harness this skill opens by arguing against.
305
+
306
+ ## Run the handler as a real simulated Lambda
307
+
308
+ Yulin can run an in-process handler as a function inside the simulation, in place of a direct call
309
+ from the test. Its SDK calls are routed into the simulation as the execution role. The IAM policies
310
+ in the template are exercised too.
311
+
312
+ Bind a handler to a template function at deploy time with `bindings`:
313
+
314
+ ```typescript
315
+ await simAws.cloudFormation().deployTemplateFile({
316
+ templatePath: "cdk.out/ApiStack.template.json",
317
+ bindings: [{ logicalId: "UploadFunction", handler: uploadHandler }],
318
+ });
319
+ ```
320
+
321
+ The handler still runs in process. It can close over test state and be stepped through in a
322
+ debugger. The difference from calling it directly is that a missing `s3:PutObject` on the execution
323
+ role now fails the test, at the point AWS would have failed it. A binding can target a function by
324
+ `logicalId`, `functionName`, `arn`, `cdkPath`, or `imageRepository` for a container image function.