@kensio/isolated-testing-style 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
+ "name": "isolated-testing-style",
4
+ "version": "1.3.0",
5
+ "description": "An opinionated testing style: start from given/when/then, use real collaborators through simulation instead of stubs, take isolation from randomised data instead of shared setup and teardown, and assert behaviour instead of call counts.",
6
+ "author": {
7
+ "name": "Kensio Software",
8
+ "email": "hugh@kensiosoftware.co.uk"
9
+ },
10
+ "homepage": "https://kensio.ai",
11
+ "repository": "https://github.com/KensioSoftware/kensio.ai",
12
+ "license": "Apache-2.0",
13
+ "keywords": ["testing", "vitest", "typescript", "test-style"]
14
+ }
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # @kensio/isolated-testing-style
2
+
3
+ An opinionated testing style, packaged as a Claude Code skill. Real collaborators through simulation
4
+ instead of stubs, isolation from randomised data instead of setup and teardown, and assertions on
5
+ behaviour instead of call counts.
6
+
7
+ Every rule in it comes from a specific failure it would have caught, and the failure is written down
8
+ next to the rule.
9
+
10
+ ## Install
11
+
12
+ From the marketplace:
13
+
14
+ ```bash
15
+ claude plugin marketplace add KensioSoftware/kensio.ai
16
+ claude plugin install isolated-testing-style@kensio
17
+ ```
18
+
19
+ From npm:
20
+
21
+ ```bash
22
+ npm install @kensio/isolated-testing-style
23
+ ```
24
+
25
+ ## What it covers
26
+
27
+ **Prefer real collaborators through simulation.** A stub asserts that your code called something. A
28
+ simulator asserts that it called the service correctly. A stub answers whatever you told it to
29
+ answer, so it agrees with your understanding of the API by construction, and cannot find the case
30
+ where that understanding is wrong.
31
+
32
+ **Get isolation from the data, not from setup and teardown.** Randomised values from faker mean two
33
+ tests cannot collide, so there is nothing to tear down and no ordering to depend on. A shared
34
+ environment built the way production is built is closer to production than a minimal one rebuilt per
35
+ test, and it is faster.
36
+
37
+ **Assert behaviour, not call counts.** To prove a value is cached, delete the underlying resource
38
+ and show the cached value survives. To prove a retry, make the first call fail and the second
39
+ succeed. Both hold however the code is implemented, which is what makes refactoring safe.
40
+
41
+ **Never pin a value computed by the code under test.** Pinning a hash you generated by running the
42
+ same function only proves the function is deterministic. It keeps passing after the function becomes
43
+ wrong. Pin against an independent authority, or let a real implementation validate it.
44
+
45
+ **Put test support beside the code.** Factories and helpers live in a test-support module next to
46
+ what they support, so test files hold tests.
47
+
48
+ **Comment test bodies with Given, When and Then**, saying why the lines are there rather than
49
+ restating them.
50
+
51
+ ## The other two skills
52
+
53
+ This skill is the philosophy. Two Kensio packages are tools that serve it, each with its own skill:
54
+
55
+ - [`yulin-aws-simulation`](https://github.com/KensioSoftware/kensio.ai/tree/main/plugins/yulin-aws-simulation)
56
+ covers [`@kensio/yulin`](https://yulinsim.dev/), an in-process AWS simulator, which is how the
57
+ first rule is applied to AWS.
58
+ - [`part-factory-test-data`](https://github.com/KensioSoftware/kensio.ai/tree/main/plugins/part-factory-test-data)
59
+ covers [`@kensio/part-factory`](https://partfactory.dev/), which builds the randomised objects the
60
+ second rule depends on.
61
+
62
+ Part of [kensio.ai](https://github.com/KensioSoftware/kensio.ai). Licensed under the Apache License
63
+ 2.0. See the [LICENSE](https://github.com/KensioSoftware/kensio.ai/blob/main/LICENSE) in the
64
+ repository root.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@kensio/isolated-testing-style",
3
+ "version": "1.3.0",
4
+ "description": "An opinionated testing style: start from given/when/then, use real collaborators through simulation instead of stubs, take isolation from randomised data instead of shared setup and teardown, and assert behaviour instead of call counts.",
5
+ "keywords": [
6
+ "claude",
7
+ "claude-code",
8
+ "claude-code-plugin",
9
+ "kensio",
10
+ "skill",
11
+ "test-style",
12
+ "testing",
13
+ "typescript",
14
+ "vitest"
15
+ ],
16
+ "homepage": "https://kensio.ai",
17
+ "license": "Apache-2.0",
18
+ "author": "Kensio Software <hugh@kensiosoftware.co.uk>",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/KensioSoftware/kensio.ai.git",
22
+ "directory": "plugins/isolated-testing-style"
23
+ },
24
+ "files": [
25
+ ".claude-plugin",
26
+ "skills",
27
+ "README.md"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ }
32
+ }
@@ -0,0 +1,289 @@
1
+ ---
2
+ name: isolated-testing-style
3
+ description: Write tests that start from given/when/then, use real collaborators through simulation rather than stubs and mocks, take their isolation from randomised data rather than shared setup and teardown, and assert behaviour rather than call counts. Use when writing or reviewing tests, when a test needs a collaborator faked, when reaching for a mock, spy, `toHaveBeenCalledWith`, `beforeEach`/`afterEach` fixtures or a hardcoded expected hash, when test setup has grown tangled, and when asked "how should I test this?".
4
+ ---
5
+
6
+ # Isolated testing style
7
+
8
+ An opinionated way of writing tests. The examples are TypeScript and vitest, but the rules are about
9
+ test design rather than any framework. Each rule exists because of a specific failure it would have
10
+ caught.
11
+
12
+ ## Start with Given, When, Then
13
+
14
+ Write the three comments before the test body, and before the code they will drive.
15
+
16
+ ```typescript
17
+ it("refuses an order once the offer has closed", async () => {
18
+ // Given an offer that closed while the customer was on the page.
19
+ // When the order is placed against it.
20
+ // Then it is refused rather than accepted late.
21
+ });
22
+ ```
23
+
24
+ Starting here rather than arriving here does three things.
25
+
26
+ **It lowers the cost of starting.** You only have to say what the situation is, what happens, and
27
+ what should result. That is a smaller question than "how do I test this?", and you can usually
28
+ answer it before you can answer the bigger one.
29
+
30
+ **It designs the interface.** Filling in `// When` forces you to name the single action under test,
31
+ in the caller's vocabulary rather than the implementation's. A step you cannot write as one
32
+ `// When` is usually telling you the interface is wrong, not the test.
33
+
34
+ **It keeps the test readable as documentation.** Tests are read far more often than they are
35
+ written, and without the structure it is easy to produce a body where essential behaviour and
36
+ incidental setup look alike.
37
+
38
+ Then fill each comment in with the case, not with a restatement of the code:
39
+
40
+ ```typescript
41
+ it("refuses an order once the offer has closed", async () => {
42
+ // Given an offer that closed while the customer was on the page.
43
+ const offer = await offerFactory.make({ closesAt: aMinuteAgo });
44
+
45
+ // When the order is placed against it.
46
+ const placing = placeOrder(orderFactory.make({ offerId: offer.id }));
47
+
48
+ // Then it is refused rather than accepted late.
49
+ await expect(placing).rejects.toThrow(OfferClosedError);
50
+ });
51
+ ```
52
+
53
+ `// Given an offer` restates the code and is worth nothing.
54
+ `// Given an offer that closed while the customer was on the page` says which case this is and why
55
+ it matters. This holds whether or not the test is written first.
56
+
57
+ ## Prefer real collaborators through simulation
58
+
59
+ A stub asserts that your code called something. A simulator asserts that it called the service
60
+ correctly.
61
+
62
+ That difference is the whole argument. A stub answers whatever you told it to answer, so it agrees
63
+ with your understanding of the API by construction. It cannot disagree with you, which means it
64
+ cannot find the case where your understanding is wrong. A simulator holds real state and applies the
65
+ real rules, so a wrong call fails at the point the real service would have failed.
66
+
67
+ The evidence: on a real project, replacing AWS SDK stubs with a simulator immediately caught two
68
+ bugs that had already shipped.
69
+
70
+ - A Secrets Manager secret name ending in a hyphen and six characters. AWS appends exactly that
71
+ suffix to a secret ARN, so the name was ambiguous with the ARN form, which AWS advises against.
72
+ The stub had no opinion, because a stub has no naming rules.
73
+ - A Cognito `SECRET_HASH` computed the wrong way. The stub accepted it, because the stub was never
74
+ going to check a signature.
75
+
76
+ So the order to reach for things:
77
+
78
+ 1. A simulator that holds real state and applies real rules.
79
+ 2. The real thing, when it runs in process and needs nothing external.
80
+ 3. A stub, only for something with no rules worth modelling, such as a clock or a random source.
81
+
82
+ The first two options need the implementation to be swappable, so give each collaborator a single
83
+ point of entry: one place that wires the real service in production and a simulation in tests. The
84
+ driver pattern is one way to arrange that, but the name matters far less than the swap having one
85
+ home.
86
+
87
+ Simulating in process pays off beyond avoiding stubs. There is nothing to deploy before running the
88
+ tests, a debugger steps through the collaborator's state alongside your own, and no state is shared
89
+ between processes, so several layers can be exercised together and still run in parallel at the
90
+ speed of a unit test.
91
+
92
+ Be honest about the limit. An in-memory implementation is not the real service and cannot be relied
93
+ on to behave identically. Keep a thin layer of tests against the real thing for the flows where that
94
+ matters, and treat any divergence you find as a bug in the simulation rather than a quirk to work
95
+ around.
96
+
97
+ ## Keep setup cheap and independent
98
+
99
+ Tangled shared fixtures are not a discipline failure. Teams share setup roughly in proportion to how
100
+ expensive it is to build. When getting a test into the right state means threading through a web of
101
+ existing fixtures, reusing what is already there is the rational move — and each reuse adds another
102
+ edge to the graph. That is how a suite arrives at setup that no one dares to touch.
103
+
104
+ The fix is not to share less. Shared factories for test entities are exactly what you want: a
105
+ factory that constructs a type is worth writing once and using everywhere. What has to be avoided is
106
+ those factories getting tangled up with each other. Each piece of setup should stand on its own.
107
+
108
+ Independence comes from taking dependencies explicitly rather than reaching for ambient state. A
109
+ factory that is handed what it needs stays pure, and the test decides what to hand it.
110
+ `@kensio/part-factory` builds this in: factories take a `dependencies` object as a second argument
111
+ at call time, so a factory that needs a simulated AWS is given one rather than going looking for
112
+ one.
113
+
114
+ ```typescript
115
+ // Given an order that exists in this test's own simulated AWS.
116
+ const simAws = new SimAws();
117
+ const order = await orderFactory.make({ total: 5000 }, { simAws });
118
+ ```
119
+
120
+ A factory built that way can be shared as widely as you like and still stand on its own, because
121
+ nothing it does depends on what another factory did first. Prefer collaborators and factories that
122
+ need only instantiation: no side effects, nothing to undo, nothing to coordinate.
123
+
124
+ For a step specific to one test rather than a reusable factory, ask whether the step belongs to the
125
+ test or the test belongs to the step. A helper confined to one file can be pulled back inline later
126
+ if it stops earning its place, whereas a fixture that a large part of the suite is built on cannot.
127
+ It is that reversibility worth preserving, not locality for its own sake.
128
+
129
+ ## Get isolation from the data, not from setup and teardown
130
+
131
+ Randomised values make collisions impossible, so there is nothing to tear down and no ordering to
132
+ depend on. Randomised is enough. Guaranteed uniqueness is not needed, and a UUID has no realistic
133
+ chance of colliding anyway.
134
+
135
+ Do not do this:
136
+
137
+ ```typescript
138
+ // Anti-pattern: shared name, mutable handle, teardown to undo it.
139
+ let bucketName: string;
140
+
141
+ beforeEach(async () => {
142
+ bucketName = "uploads-bucket";
143
+ await createBucket(bucketName);
144
+ });
145
+
146
+ afterEach(async () => {
147
+ await emptyBucket(bucketName);
148
+ await deleteBucket(bucketName);
149
+ });
150
+ ```
151
+
152
+ That test cannot run beside another test using the same name, the `let` is only mutable so that
153
+ `afterEach` can reach it, and a failure part way through leaves the next test to fail for a reason
154
+ that has nothing to do with it.
155
+
156
+ Do this instead:
157
+
158
+ ```typescript
159
+ it("serves an uploaded object", async () => {
160
+ // Given a bucket no other test can be talking about.
161
+ const bucketName = `uploads-${faker.string.uuid()}`;
162
+ await createBucket(bucketName);
163
+
164
+ // ...
165
+ });
166
+ ```
167
+
168
+ The environment those tests run against can be shared, and should be built the way production is
169
+ built. A realistic environment that several tests read from is closer to production than a minimal
170
+ one rebuilt per test, and it is faster. The isolation comes from the names and identifiers, so
171
+ sharing the environment costs nothing.
172
+
173
+ Faker is the source of these values. Prefer a generator that produces a realistic value of the right
174
+ kind (`faker.internet.email()`, `faker.string.uuid()`, `faker.company.name()`) over a counter or a
175
+ literal with a suffix, so the test data also exercises the shapes production data has.
176
+
177
+ ## Assert behaviour, not call counts
178
+
179
+ A call count asserts how the code is written today. A behaviour assertion holds however it is
180
+ written, which is what lets you refactor.
181
+
182
+ To prove a value is cached, do not count calls. Delete the underlying resource, then show the cached
183
+ value still comes back:
184
+
185
+ ```typescript
186
+ it("keeps serving the secret after it is deleted", async () => {
187
+ // Given a secret that has been read once, so it is cached.
188
+ const first = await config.databasePassword();
189
+
190
+ // When the underlying secret goes away.
191
+ await simAws.secretsManager().deleteSecret(
192
+ new DeleteSecretCommand({ SecretId: secretName }),
193
+ );
194
+
195
+ // Then the cached value is still served, without going back to the service.
196
+ expect(await config.databasePassword()).toEqual(first);
197
+ });
198
+ ```
199
+
200
+ To prove a retry, make the first call fail and the second succeed, then assert on the result:
201
+
202
+ ```typescript
203
+ // Given an endpoint that fails once and then works.
204
+ // When the client calls it.
205
+ // Then it gets the successful response.
206
+ ```
207
+
208
+ Both hold whether the cache is a `Map`, a memoised promise or a decorator, and whether the retry is
209
+ a loop, a middleware or a library.
210
+
211
+ ## Never pin a value computed by the code under test
212
+
213
+ Pinning a hash you generated by running the same function only proves the function is deterministic.
214
+ It will keep passing after the function becomes wrong, as long as it is wrong consistently.
215
+
216
+ ```typescript
217
+ // Anti-pattern: this string came from running computeSecretHash.
218
+ expect(computeSecretHash(username, clientId, clientSecret)).toBe(
219
+ "z0Xq9k1e4mVQ...",
220
+ );
221
+ ```
222
+
223
+ Two ways out, in order of preference:
224
+
225
+ 1. Let a real implementation validate it. A simulated Cognito checks a `SECRET_HASH` the way Cognito
226
+ checks it, so a sign-in that succeeds against the simulation is evidence the hash is right.
227
+ 2. Pin against an independent authority: a value from the service's own documentation, a published
228
+ test vector, or a value produced by a different implementation.
229
+
230
+ The same rule covers snapshot tests of anything the code under test formats. A snapshot records what
231
+ the code does, not what it should do.
232
+
233
+ ## Nothing at the top level but imports
234
+
235
+ In an ideal vitest or jest file, the only things outside the top-level `describe()` are the imports.
236
+ State, construction and helpers all live inside it, so the file reads as a description of behaviour
237
+ rather than as a program that happens to contain some tests.
238
+
239
+ ```typescript
240
+ import { describe, expect, it } from "vitest";
241
+ import { faker } from "@faker-js/faker";
242
+
243
+ import { placeOrder } from "./place-order";
244
+ import { offerFactory } from "./order.test-support";
245
+
246
+ describe("placing an order", () => {
247
+ // Everything else — state, helpers, tests — lives in here.
248
+ });
249
+ ```
250
+
251
+ This is mostly a consequence of the other rules rather than an extra one. What usually accumulates
252
+ at the top level of a test file is module-level state the tests share, a mutable handle that exists
253
+ so `afterEach` can reach it, and hoisted mock registrations — and the rules above have already
254
+ turned down all three. So a top level that will not stay empty is a useful signal that something
255
+ further up has slipped. Imported factories are not a problem here: they arrive as imports precisely
256
+ because they stand on their own.
257
+
258
+ ## Put test support beside the code, not in the test file
259
+
260
+ A test file should hold tests. Where support lives depends on what it is for.
261
+
262
+ A factory for a **type** belongs beside the type it constructs, exported, so that no consumer ever
263
+ hand-rolls the literal:
264
+
265
+ ```
266
+ src/orders/
267
+ ├── order.ts
268
+ ├── order.test.ts
269
+ └── order.test-support.ts # factories and helpers for order.ts
270
+ ```
271
+
272
+ A library that defines a shape other code has to construct should export the factory for it.
273
+
274
+ A step written for **one test** stays in that test's file, inside the `describe`. Promoting it later
275
+ when another test wants it is fine — make it independent first, so what spreads is a self-contained
276
+ factory rather than a dependency on how some other test left things.
277
+
278
+ If a test file is mostly setup, that is a signal — but the fix is usually cheaper construction, not
279
+ a shared fixture. Scroll the file and see how much of it is `it(...)` bodies making assertions.
280
+
281
+ ## Tools that help
282
+
283
+ These serve the style; they are not the style, which holds without them.
284
+
285
+ - [Faker](https://fakerjs.dev/) for randomised, realistic values.
286
+ - [`@kensio/part-factory`](https://partfactory.dev/) for typed factories that need only
287
+ instantiation. See the `part-factory-test-data` skill.
288
+ - [`@kensio/yulin`](https://yulinsim.dev/) simulates AWS in process, when AWS is the collaborator.
289
+ See the `yulin-aws-simulation` skill.