@appthrust/kest 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +407 -7
- package/package.json +1 -1
- package/ts/actions/assert-one.ts +93 -0
- package/ts/apis/index.ts +133 -0
- package/ts/scenario/index.ts +4 -0
package/README.md
CHANGED
|
@@ -78,6 +78,13 @@ const ns = await s.newNamespace({ generateName: "foo-" });
|
|
|
78
78
|
// Namespace name will be like "foo-d7kpn"
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
+
The returned object has a `name` property that holds the generated namespace name:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const ns = await s.newNamespace({ generateName: "foo-" });
|
|
85
|
+
console.log(ns.name); //=> "foo-d7kpn"
|
|
86
|
+
```
|
|
87
|
+
|
|
81
88
|
### Automatic Cleanup (Reverse-Order, Blocking)
|
|
82
89
|
|
|
83
90
|
Resources are deleted in the reverse order they were created (LIFO). Kest waits until each resource is fully removed before proceeding, preventing flaky failures caused by lingering resources or `Terminating` namespaces.
|
|
@@ -219,6 +226,37 @@ await ns.assertList<ConfigMap>({
|
|
|
219
226
|
});
|
|
220
227
|
```
|
|
221
228
|
|
|
229
|
+
### Single-Resource List Assertions
|
|
230
|
+
|
|
231
|
+
Assert that exactly one resource of a kind exists (or matches a predicate) and test it:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
// Assert exactly one ConfigMap exists and check its data
|
|
235
|
+
await ns.assertOne<ConfigMap>({
|
|
236
|
+
apiVersion: "v1",
|
|
237
|
+
kind: "ConfigMap",
|
|
238
|
+
test() {
|
|
239
|
+
expect(this.data?.mode).toBe("demo");
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Use the optional `where` predicate to narrow candidates when multiple resources exist:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
// Find the one ConfigMap whose name starts with "generated-"
|
|
248
|
+
await ns.assertOne<ConfigMap>({
|
|
249
|
+
apiVersion: "v1",
|
|
250
|
+
kind: "ConfigMap",
|
|
251
|
+
where: (cm) => cm.metadata.name.startsWith("generated-"),
|
|
252
|
+
test() {
|
|
253
|
+
expect(this.data?.mode).toBe("auto");
|
|
254
|
+
},
|
|
255
|
+
}, { timeout: "30s", interval: "1s" });
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`assertOne` throws if zero or more than one resource matches, and retries until exactly one is found or the timeout expires.
|
|
259
|
+
|
|
222
260
|
### Absence Assertions
|
|
223
261
|
|
|
224
262
|
Assert that a resource does not exist (e.g. after deletion or to verify a controller hasn't created something):
|
|
@@ -439,6 +477,7 @@ The top-level API surface available in every test callback.
|
|
|
439
477
|
| `assert(resource, options?)` | Fetch a resource and run assertions with retries |
|
|
440
478
|
| `assertAbsence(resource, options?)` | Assert that a resource does not exist |
|
|
441
479
|
| `assertList(resource, options?)` | Fetch a list of resources and run assertions |
|
|
480
|
+
| `assertOne(resource, options?)` | Assert exactly one resource matches, then run assertions |
|
|
442
481
|
| `newNamespace(name?, options?)` | Create an ephemeral namespace (supports `{ generateName }`) |
|
|
443
482
|
| `generateName(prefix)` | Generate a random-suffix name (statistical uniqueness) |
|
|
444
483
|
| `exec(input, options?)` | Execute shell commands with optional revert |
|
|
@@ -447,7 +486,13 @@ The top-level API surface available in every test callback.
|
|
|
447
486
|
|
|
448
487
|
### Namespace / Cluster
|
|
449
488
|
|
|
450
|
-
Returned by `newNamespace()` and `useCluster()` respectively. They expose the same core methods (`apply`, `create`, `applyStatus`, `delete`, `label`, `get`, `assert`, `assertAbsence`, `assertList`) scoped to their namespace or cluster context. `Cluster` additionally supports `newNamespace`.
|
|
489
|
+
Returned by `newNamespace()` and `useCluster()` respectively. They expose the same core methods (`apply`, `create`, `applyStatus`, `delete`, `label`, `get`, `assert`, `assertAbsence`, `assertList`, `assertOne`) scoped to their namespace or cluster context. `Cluster` additionally supports `newNamespace`.
|
|
490
|
+
|
|
491
|
+
`Namespace` also exposes a `name` property:
|
|
492
|
+
|
|
493
|
+
| Property | Type | Description |
|
|
494
|
+
| -------- | -------- | -------------------------------------------------- |
|
|
495
|
+
| `name` | `string` | The generated namespace name (e.g. `"kest-abc12"`) |
|
|
451
496
|
|
|
452
497
|
### Action Options
|
|
453
498
|
|
|
@@ -462,17 +507,366 @@ Duration strings support units like `"200ms"`, `"5s"`, `"1m"`.
|
|
|
462
507
|
|
|
463
508
|
## Best Practices
|
|
464
509
|
|
|
510
|
+
### Test API contracts, not controllers
|
|
511
|
+
|
|
512
|
+
E2E tests should describe **how an API behaves from the user's perspective**, not how a specific controller implements that behavior internally. The subject of every test should be the API resource, not the controller behind it.
|
|
513
|
+
|
|
514
|
+
Why? Controllers are an implementation detail. They get renamed, split, merged, or rewritten -- but as long as the API contract is unchanged, users are unaffected. If your tests are written in terms of controllers, a harmless refactor can break your entire test suite.
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
// ✅ Good — the subject is the API resource
|
|
518
|
+
test("Tenant API creates namespaces for each tenant", async (s) => {
|
|
519
|
+
s.given("a Tenant resource is applied");
|
|
520
|
+
const ns = await s.newNamespace();
|
|
521
|
+
await ns.apply({
|
|
522
|
+
apiVersion: "example.com/v1",
|
|
523
|
+
kind: "Tenant",
|
|
524
|
+
metadata: { name: "acme" },
|
|
525
|
+
spec: { namespaces: ["dev", "staging"] },
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
s.then("the Tenant reports Ready=True");
|
|
529
|
+
await ns.assert({
|
|
530
|
+
apiVersion: "example.com/v1",
|
|
531
|
+
kind: "Tenant",
|
|
532
|
+
name: "acme",
|
|
533
|
+
test() {
|
|
534
|
+
expect(this.status?.conditions).toContainEqual(
|
|
535
|
+
expect.objectContaining({ type: "Ready", status: "True" }),
|
|
536
|
+
);
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
```ts
|
|
543
|
+
// ❌ Bad — the subject is the controller (implementation detail)
|
|
544
|
+
test("tenant-controller creates namespaces", async (s) => {
|
|
545
|
+
s.given("tenant-controller is running");
|
|
546
|
+
// ...
|
|
547
|
+
s.then("tenant-controller creates child namespaces");
|
|
548
|
+
// ...
|
|
549
|
+
});
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
The same principle applies to BDD annotations -- keep `s.given()`, `s.when()`, and `s.then()` free of controller names:
|
|
553
|
+
|
|
554
|
+
| ❌ Controller-centric | ✅ API-centric |
|
|
555
|
+
| ------------------------------------------------- | ----------------------------------------- |
|
|
556
|
+
| `s.given("tenant-controller is running")` | `s.given("a Tenant resource exists")` |
|
|
557
|
+
| `s.when("tenant-controller reconciles")` | `s.when("the Tenant spec is updated")` |
|
|
558
|
+
| `s.then("tenant-controller creates a Namespace")` | `s.then("the expected Namespace exists")` |
|
|
559
|
+
|
|
560
|
+
### Choosing what to test in E2E
|
|
561
|
+
|
|
562
|
+
E2E tests are powerful for validating **user-observable behavior** but expensive for verifying internal details. Placing implementation details in E2E tests makes refactoring harder without giving users any extra confidence.
|
|
563
|
+
|
|
564
|
+
**Good candidates for E2E (API contract):**
|
|
565
|
+
|
|
566
|
+
- Status transitions -- e.g. a resource reaches `Ready=True` after creation
|
|
567
|
+
- Error feedback -- e.g. invalid input produces an explanatory condition like `Ready=False, reason=InvalidSpec`
|
|
568
|
+
- User-facing side effects -- e.g. resources that users are expected to observe or interact with
|
|
569
|
+
|
|
570
|
+
```ts
|
|
571
|
+
// ✅ Assert a user-observable status condition
|
|
572
|
+
await ns.assert({
|
|
573
|
+
apiVersion: "example.com/v1",
|
|
574
|
+
kind: "Database",
|
|
575
|
+
name: "my-db",
|
|
576
|
+
test() {
|
|
577
|
+
expect(this.status?.conditions).toContainEqual(
|
|
578
|
+
expect.objectContaining({ type: "Ready", status: "True" }),
|
|
579
|
+
);
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
**Better left to unit / integration tests (implementation details):**
|
|
585
|
+
|
|
586
|
+
- Internal label keys, annotation formats, or hash values
|
|
587
|
+
- Intermediate resources that users don't directly interact with
|
|
588
|
+
- Controller-internal reconciliation logic and branching
|
|
589
|
+
|
|
590
|
+
```ts
|
|
591
|
+
// ❌ Avoid — internal label format is an implementation detail
|
|
592
|
+
await ns.assert({
|
|
593
|
+
apiVersion: "example.com/v1",
|
|
594
|
+
kind: "Database",
|
|
595
|
+
name: "my-db",
|
|
596
|
+
test() {
|
|
597
|
+
// This label may change without affecting users
|
|
598
|
+
expect(this.metadata?.labels?.["internal.example.com/config-hash"]).toBe("a1b2c3");
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
When you find yourself wanting to E2E-test an intermediate resource, ask: _"Is this part of the public API contract?"_ If yes, document it as such and test it. If no, push the assertion down to a cheaper test layer and keep E2E focused on what users actually see.
|
|
604
|
+
|
|
605
|
+
### Organizing test files
|
|
606
|
+
|
|
607
|
+
Structure test directories around **API resources**, not controllers. This makes the test suite resilient to internal refactoring and immediately tells readers _which API behavior_ is being verified.
|
|
608
|
+
|
|
609
|
+
```
|
|
610
|
+
# ✅ Good — organized by API resource
|
|
611
|
+
tests/e2e/
|
|
612
|
+
├── tenant-api/
|
|
613
|
+
│ ├── creation.test.ts
|
|
614
|
+
│ └── deletion.test.ts
|
|
615
|
+
├── database-api/
|
|
616
|
+
│ └── provisioning.test.ts
|
|
617
|
+
|
|
618
|
+
# ❌ Bad — organized by controller (implementation detail)
|
|
619
|
+
tests/e2e/
|
|
620
|
+
├── tenant-controller/
|
|
621
|
+
│ ├── creation.test.ts
|
|
622
|
+
│ └── deletion.test.ts
|
|
623
|
+
├── database-controller/
|
|
624
|
+
│ └── provisioning.test.ts
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
**Refactoring-friendliness checklist** -- the more "yes" answers, the better your E2E tests:
|
|
628
|
+
|
|
629
|
+
- [ ] Is the subject of every test an API resource (not a controller)?
|
|
630
|
+
- [ ] Can a reader understand the test from the manifest and assertions alone?
|
|
631
|
+
- [ ] Do `then` assertions only check user-observable state (`status`, contracted outputs)?
|
|
632
|
+
- [ ] Would splitting, merging, or renaming controllers leave all tests passing?
|
|
633
|
+
|
|
634
|
+
### One scenario per file
|
|
635
|
+
|
|
636
|
+
Put exactly one test scenario in one file -- from the start, not "when it gets big enough."
|
|
637
|
+
|
|
638
|
+
It may be tempting to group several short scenarios into one file while they are small. But E2E tests grow: assertions get richer, edge-case inputs appear, debug aids are added. Splitting later is far harder than starting separate, and deciding _when_ to split is an unnecessary judgment call. The simplest policy is the best one: one scenario, one file, always.
|
|
639
|
+
|
|
640
|
+
```
|
|
641
|
+
tests/e2e/tenant-api/
|
|
642
|
+
├── creates-namespaces-for-each-tenant.test.ts
|
|
643
|
+
├── rejects-duplicate-tenant-names.test.ts
|
|
644
|
+
├── updates-status-on-namespace-failure.test.ts
|
|
645
|
+
└── deletes-child-namespaces-on-removal.test.ts
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
**Why not "split when it gets big"?**
|
|
649
|
+
|
|
650
|
+
- Tests grow incrementally -- no single commit feels like "the moment to split," so it never happens.
|
|
651
|
+
- Splitting a file retroactively means rewriting imports, moving fixtures, and touching unrelated tests in the same PR.
|
|
652
|
+
- The threshold itself becomes a debate ("Is 250 lines too many? 400?"). A universal rule eliminates the discussion.
|
|
653
|
+
|
|
654
|
+
Starting with one file per scenario reserves room to grow. Each file has built-in headroom for richer assertions, additional setup steps, and debug annotations -- without ever crowding a neighbor.
|
|
655
|
+
|
|
656
|
+
**What you get:**
|
|
657
|
+
|
|
658
|
+
- **Self-contained reading** -- open one file, see the full Given/When/Then without scrolling past unrelated scenarios.
|
|
659
|
+
- **Surgical diffs** -- a change touches exactly one scenario, keeping PRs small and reviews focused.
|
|
660
|
+
- **Failure as an address** -- a failing file name tells you which API contract broke, before you read a single line of output.
|
|
661
|
+
- **Conflict-free collaboration** -- teammates edit different files, not different sections of the same file.
|
|
662
|
+
|
|
663
|
+
**Name files after the behavior they verify.** A reader should know what a test checks without opening the file:
|
|
664
|
+
|
|
665
|
+
| ✅ Good | ❌ Bad |
|
|
666
|
+
| ------------------------------------------ | -------------------------- |
|
|
667
|
+
| `creates-namespaces-with-labels.test.ts` | `tenant-test-1.test.ts` |
|
|
668
|
+
| `rejects-reserved-selector-labels.test.ts` | `validation.test.ts` |
|
|
669
|
+
| `rolls-out-when-image-changes.test.ts` | `deployment-tests.test.ts` |
|
|
670
|
+
|
|
671
|
+
**Exceptions.** Tiny negative-case variations of the same API -- where each case is only a few lines and they are always read together (e.g. boundary-condition lists) -- may share a file. When you do this, leave a comment explaining why, because the exception easily becomes the norm.
|
|
672
|
+
|
|
673
|
+
**One-scenario-per-file checklist:**
|
|
674
|
+
|
|
675
|
+
- [ ] Does each test file contain exactly one scenario?
|
|
676
|
+
- [ ] Does the file name describe the behavior under test (not the controller)?
|
|
677
|
+
- [ ] If a file has multiple scenarios, is there a comment justifying the exception?
|
|
678
|
+
|
|
679
|
+
### Keep manifests visible in your tests
|
|
680
|
+
|
|
681
|
+
E2E tests double as living documentation. Every test should read as a self-contained specification: what was applied (Given), what changed (When), and what should be true (Then). When a helper function assembles the entire manifest behind the scenes, the test body may _look_ cleaner -- but it stops serving as documentation because the reader can no longer see the actual input.
|
|
682
|
+
|
|
683
|
+
```ts
|
|
684
|
+
// ❌ Bad — looks tidy, but the actual manifest is hidden inside makeDeployment().
|
|
685
|
+
// A reader cannot tell what is being applied without jumping to the helper.
|
|
686
|
+
// The diff between v1 and v2 is buried in a parameter change.
|
|
687
|
+
function makeDeployment(name: string, image: string) {
|
|
688
|
+
return {
|
|
689
|
+
apiVersion: "apps/v1",
|
|
690
|
+
kind: "Deployment",
|
|
691
|
+
metadata: { name, labels: { app: name } },
|
|
692
|
+
spec: {
|
|
693
|
+
replicas: 2,
|
|
694
|
+
selector: { matchLabels: { app: name } },
|
|
695
|
+
template: {
|
|
696
|
+
metadata: { labels: { app: name } },
|
|
697
|
+
spec: { containers: [{ name: "app", image }] },
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
test("rollout updates the image", async (s) => {
|
|
704
|
+
const ns = await s.newNamespace();
|
|
705
|
+
await ns.apply(makeDeployment("my-app", "my-app:v1"));
|
|
706
|
+
await ns.apply(makeDeployment("my-app", "my-app:v2"));
|
|
707
|
+
// What exactly changed? You have to read makeDeployment to find out.
|
|
708
|
+
});
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
```ts
|
|
712
|
+
// ✅ Good — the full manifest is right here; the diff between steps is obvious.
|
|
713
|
+
test("rollout updates the image", async (s) => {
|
|
714
|
+
const ns = await s.newNamespace();
|
|
715
|
+
|
|
716
|
+
s.when("I apply a Deployment at v1");
|
|
717
|
+
await ns.apply({
|
|
718
|
+
apiVersion: "apps/v1",
|
|
719
|
+
kind: "Deployment",
|
|
720
|
+
metadata: { name: "my-app" },
|
|
721
|
+
spec: {
|
|
722
|
+
replicas: 2,
|
|
723
|
+
selector: { matchLabels: { app: "my-app" } },
|
|
724
|
+
template: {
|
|
725
|
+
metadata: { labels: { app: "my-app" } },
|
|
726
|
+
spec: { containers: [{ name: "app", image: "my-app:v1" }] },
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
s.when("I update to v2");
|
|
732
|
+
await ns.apply({
|
|
733
|
+
apiVersion: "apps/v1",
|
|
734
|
+
kind: "Deployment",
|
|
735
|
+
metadata: { name: "my-app" },
|
|
736
|
+
spec: {
|
|
737
|
+
replicas: 2,
|
|
738
|
+
selector: { matchLabels: { app: "my-app" } },
|
|
739
|
+
template: {
|
|
740
|
+
metadata: { labels: { app: "my-app" } },
|
|
741
|
+
spec: { containers: [{ name: "app", image: "my-app:v2" }] },
|
|
742
|
+
// ^^^^^^^^^ the diff
|
|
743
|
+
},
|
|
744
|
+
},
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
The duplication is intentional -- in E2E tests, **readability beats DRY**. Duplicated manifests make each test self-explanatory and keep failure reports easy to match against the test source. Notice the `^^^ the diff` comment in the example above -- adding a short inline comment to highlight the key change is a simple technique that makes the intent even clearer.
|
|
750
|
+
|
|
751
|
+
When manifests become too large to inline comfortably, extract them into **static fixture files** instead of helper functions. Both YAML and TypeScript files work:
|
|
752
|
+
|
|
753
|
+
```ts
|
|
754
|
+
// ✅ Good — static fixture files keep the input visible at a glance
|
|
755
|
+
await ns.apply(import("./fixtures/deployment-v1.yaml"));
|
|
756
|
+
await ns.apply(import("./fixtures/deployment-v2.yaml"));
|
|
757
|
+
|
|
758
|
+
// TypeScript files work too — useful when converting from inline objects
|
|
759
|
+
await ns.apply(import("./fixtures/deployment-v1.ts"));
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
**Manifest-visibility checklist:**
|
|
763
|
+
|
|
764
|
+
- [ ] Can you understand the full input by reading just the test file?
|
|
765
|
+
- [ ] Is the diff between test steps visible as a spec-level change?
|
|
766
|
+
- [ ] Can you reconstruct the applied manifest from the failure report alone?
|
|
767
|
+
|
|
465
768
|
### Avoiding naming collisions between tests
|
|
466
769
|
|
|
467
|
-
When tests run in parallel, hard-coded resource names can collide (
|
|
770
|
+
When tests run in parallel, hard-coded resource names can collide. In most cases, `newNamespace()` is all you need -- each test gets its own namespace, so names like `"my-config"` or `"my-app"` are already isolated:
|
|
468
771
|
|
|
469
|
-
|
|
772
|
+
```ts
|
|
773
|
+
const ns = await s.newNamespace();
|
|
470
774
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
775
|
+
await ns.apply({
|
|
776
|
+
apiVersion: "v1",
|
|
777
|
+
kind: "ConfigMap",
|
|
778
|
+
metadata: { name: "app-config" }, // safe — no other test shares this namespace
|
|
779
|
+
data: { mode: "test" },
|
|
780
|
+
});
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
Because the namespace itself is unique, the resource names inside it don't need to be randomized. **You only need `s.generateName` when `newNamespace` alone is not enough.**
|
|
474
784
|
|
|
475
|
-
|
|
785
|
+
A common mistake is to reach for `s.generateName` on _every_ resource "just to be safe." This adds no real protection when the resources are already in an isolated namespace, and it makes tests harder to read and debug:
|
|
786
|
+
|
|
787
|
+
```ts
|
|
788
|
+
// ❌ Bad — generateName on namespaced resources inside an isolated namespace.
|
|
789
|
+
// The random suffixes add noise without preventing any actual collisions.
|
|
790
|
+
const ns = await s.newNamespace();
|
|
791
|
+
|
|
792
|
+
const configName = s.generateName("cfg-");
|
|
793
|
+
const deployName = s.generateName("deploy-");
|
|
794
|
+
|
|
795
|
+
await ns.apply({
|
|
796
|
+
apiVersion: "v1",
|
|
797
|
+
kind: "ConfigMap",
|
|
798
|
+
metadata: { name: configName }, // "cfg-x7k2m" — hard to find in logs
|
|
799
|
+
data: { mode: "test" },
|
|
800
|
+
});
|
|
801
|
+
await ns.apply({
|
|
802
|
+
apiVersion: "apps/v1",
|
|
803
|
+
kind: "Deployment",
|
|
804
|
+
metadata: { name: deployName }, // "deploy-p3n8r" — what was this test about?
|
|
805
|
+
// ...
|
|
806
|
+
});
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
```ts
|
|
810
|
+
// ✅ Good — fixed, descriptive names inside an isolated namespace.
|
|
811
|
+
// The namespace already guarantees no collisions. Fixed names are
|
|
812
|
+
// easy to read, easy to grep in logs, and match the failure report.
|
|
813
|
+
const ns = await s.newNamespace();
|
|
814
|
+
|
|
815
|
+
await ns.apply({
|
|
816
|
+
apiVersion: "v1",
|
|
817
|
+
kind: "ConfigMap",
|
|
818
|
+
metadata: { name: "app-config" },
|
|
819
|
+
data: { mode: "test" },
|
|
820
|
+
});
|
|
821
|
+
await ns.apply({
|
|
822
|
+
apiVersion: "apps/v1",
|
|
823
|
+
kind: "Deployment",
|
|
824
|
+
metadata: { name: "my-app" },
|
|
825
|
+
// ...
|
|
826
|
+
});
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
**When to use `s.generateName`:**
|
|
830
|
+
|
|
831
|
+
| Situation | Approach |
|
|
832
|
+
| ------------------------------------------------------ | ----------------------------------------- |
|
|
833
|
+
| Namespaced resource inside `newNamespace()` | Use a fixed name (default) |
|
|
834
|
+
| Same-kind resources created multiple times in one test | `s.generateName` or numbered fixed names |
|
|
835
|
+
| Cluster-scoped resource (e.g. `ClusterRole`, `CRD`) | `s.generateName` (no namespace isolation) |
|
|
836
|
+
| Fixed name causes unintended upsert / side effects | `s.generateName` |
|
|
837
|
+
|
|
838
|
+
Choose **necessary and sufficient** over "safe side" -- every random suffix is a readability trade-off.
|
|
839
|
+
|
|
840
|
+
**How the two helpers work:**
|
|
841
|
+
|
|
842
|
+
- `newNamespace(...)` creates a `Namespace` via `kubectl create` and retries on name collisions (regenerating a new name each attempt), so once it succeeds the namespace name is guaranteed unique in the cluster.
|
|
843
|
+
- `s.generateName(...)` is a pure string helper that provides **statistical uniqueness** only (collisions are extremely unlikely, but not impossible).
|
|
844
|
+
|
|
845
|
+
**Using `newNamespace` with `.name` (cross-namespace references):**
|
|
846
|
+
|
|
847
|
+
Every `Namespace` object returned by `newNamespace` has a `.name` property. When resources in one namespace need to reference another namespace by name -- for example in cross-namespace `spec.*Ref.namespace` fields -- use `.name`:
|
|
848
|
+
|
|
849
|
+
```ts
|
|
850
|
+
const nsA = await s.newNamespace({ generateName: "infra-" });
|
|
851
|
+
const nsB = await s.newNamespace({ generateName: "app-" });
|
|
852
|
+
|
|
853
|
+
s.when("I apply a resource in nsB that references nsA");
|
|
854
|
+
await nsB.apply({
|
|
855
|
+
apiVersion: "example.com/v1",
|
|
856
|
+
kind: "AppConfig",
|
|
857
|
+
metadata: { name: "my-app" },
|
|
858
|
+
spec: {
|
|
859
|
+
secretStoreRef: {
|
|
860
|
+
namespace: nsA.name, // e.g. "infra-k7rtn"
|
|
861
|
+
name: "vault",
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
});
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
**Using `s.generateName` (for cluster-scoped resources):**
|
|
868
|
+
|
|
869
|
+
For cluster-scoped resources (where `newNamespace` is not applicable), use `s.generateName`:
|
|
476
870
|
|
|
477
871
|
```ts
|
|
478
872
|
s.given("a cluster-scoped resource name should not collide with other tests");
|
|
@@ -492,6 +886,12 @@ await s.create({
|
|
|
492
886
|
});
|
|
493
887
|
```
|
|
494
888
|
|
|
889
|
+
**Naming-collision checklist:**
|
|
890
|
+
|
|
891
|
+
- [ ] Are namespaced resources inside `newNamespace` using fixed names (not `generateName`)?
|
|
892
|
+
- [ ] Is `generateName` reserved for cluster-scoped resources or multi-instance cases?
|
|
893
|
+
- [ ] Can you identify every resource in the failure report without decoding random suffixes?
|
|
894
|
+
|
|
495
895
|
## Type Safety
|
|
496
896
|
|
|
497
897
|
Define TypeScript interfaces for your Kubernetes resources to get full type checking in manifests and assertions:
|
package/package.json
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { YAML } from "bun";
|
|
2
|
+
import type { K8sResource, ResourceOneTest } from "../apis";
|
|
3
|
+
import { parseK8sResourceListYaml } from "../k8s-resource";
|
|
4
|
+
import type { QueryDef } from "./types";
|
|
5
|
+
|
|
6
|
+
export const assertOne = {
|
|
7
|
+
type: "query",
|
|
8
|
+
name: "AssertOne",
|
|
9
|
+
query:
|
|
10
|
+
({ kubectl }) =>
|
|
11
|
+
async <T extends K8sResource>(
|
|
12
|
+
condition: ResourceOneTest<T>
|
|
13
|
+
): Promise<T> => {
|
|
14
|
+
const yaml = await kubectl.list(toKubectlType(condition));
|
|
15
|
+
const result = parseK8sResourceListYaml(yaml);
|
|
16
|
+
if (!result.ok) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`Invalid Kubernetes resource list: ${result.violations.join(", ")}`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const fetched = result.value;
|
|
23
|
+
for (const item of fetched) {
|
|
24
|
+
assertSameGVK(condition, item);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const typed = fetched as Array<T>;
|
|
28
|
+
const found = findExactlyOne(typed, condition);
|
|
29
|
+
await condition.test.call(found, found);
|
|
30
|
+
return found;
|
|
31
|
+
},
|
|
32
|
+
describe: <T extends K8sResource>(condition: ResourceOneTest<T>): string => {
|
|
33
|
+
return `Assert one \`${condition.kind}\` resource`;
|
|
34
|
+
},
|
|
35
|
+
} satisfies QueryDef<ResourceOneTest, K8sResource>;
|
|
36
|
+
|
|
37
|
+
function findExactlyOne<T extends K8sResource>(
|
|
38
|
+
items: Array<T>,
|
|
39
|
+
condition: ResourceOneTest<T>
|
|
40
|
+
): T {
|
|
41
|
+
const candidates = condition.where ? items.filter(condition.where) : items;
|
|
42
|
+
const hasWhere = condition.where !== undefined;
|
|
43
|
+
|
|
44
|
+
if (candidates.length === 0) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
hasWhere
|
|
47
|
+
? `No ${condition.kind} resource found matching the "where" predicate`
|
|
48
|
+
: `No ${condition.kind} resource found`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (candidates.length > 1) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
hasWhere
|
|
55
|
+
? `Expected exactly one ${condition.kind} matching the "where" predicate, but found ${candidates.length}`
|
|
56
|
+
: `Expected exactly one ${condition.kind}, but found ${candidates.length}`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return candidates[0] as T;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isSameGVK<T extends K8sResource>(
|
|
64
|
+
finding: Pick<ResourceOneTest<T>, "apiVersion" | "kind">,
|
|
65
|
+
fetched: K8sResource
|
|
66
|
+
): fetched is T {
|
|
67
|
+
return (
|
|
68
|
+
finding.apiVersion === fetched.apiVersion && finding.kind === fetched.kind
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function assertSameGVK<T extends K8sResource>(
|
|
73
|
+
finding: Pick<ResourceOneTest<T>, "apiVersion" | "kind">,
|
|
74
|
+
fetched: K8sResource
|
|
75
|
+
): void {
|
|
76
|
+
if (!isSameGVK(finding, fetched)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Fetched Kubernetes resource: ${YAML.stringify(fetched)} is not expected: ${YAML.stringify(finding)}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function toKubectlType<T extends K8sResource>(
|
|
84
|
+
condition: Pick<ResourceOneTest<T>, "apiVersion" | "kind">
|
|
85
|
+
): string {
|
|
86
|
+
const { kind, apiVersion } = condition;
|
|
87
|
+
const [group, version] = apiVersion.split("/");
|
|
88
|
+
if (version === undefined) {
|
|
89
|
+
// core group cannot include version in the type
|
|
90
|
+
return kind;
|
|
91
|
+
}
|
|
92
|
+
return [kind, version, group].filter(Boolean).join(".");
|
|
93
|
+
}
|
package/ts/apis/index.ts
CHANGED
|
@@ -296,6 +296,52 @@ export interface Scenario {
|
|
|
296
296
|
options?: undefined | ActionOptions
|
|
297
297
|
): Promise<Array<T>>;
|
|
298
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Fetches a list of Kubernetes resources, asserts that exactly one matches
|
|
301
|
+
* the optional `where` predicate, and runs a test function on it.
|
|
302
|
+
*
|
|
303
|
+
* When `where` is omitted, all resources of the given kind are candidates;
|
|
304
|
+
* the method then asserts that exactly one resource of that kind exists.
|
|
305
|
+
*
|
|
306
|
+
* The `test` callback is invoked with `this` bound to the matching resource.
|
|
307
|
+
* If the callback throws (or rejects), the assertion fails and the whole
|
|
308
|
+
* action is retried until it succeeds or times out.
|
|
309
|
+
*
|
|
310
|
+
* @template T - The expected Kubernetes resource shape.
|
|
311
|
+
* @param resource - Group/version/kind selector, optional `where` predicate,
|
|
312
|
+
* and test callback.
|
|
313
|
+
* @param options - Retry options such as timeout and polling interval.
|
|
314
|
+
*
|
|
315
|
+
* @example
|
|
316
|
+
* ```ts
|
|
317
|
+
* // Assert exactly one ConfigMap exists and check its data
|
|
318
|
+
* await s.assertOne({
|
|
319
|
+
* apiVersion: "v1",
|
|
320
|
+
* kind: "ConfigMap",
|
|
321
|
+
* test() {
|
|
322
|
+
* expect(this.data?.mode).toBe("demo");
|
|
323
|
+
* },
|
|
324
|
+
* });
|
|
325
|
+
* ```
|
|
326
|
+
*
|
|
327
|
+
* @example
|
|
328
|
+
* ```ts
|
|
329
|
+
* // Find the one ConfigMap whose name starts with "generated-"
|
|
330
|
+
* await s.assertOne({
|
|
331
|
+
* apiVersion: "v1",
|
|
332
|
+
* kind: "ConfigMap",
|
|
333
|
+
* where: (cm) => cm.metadata.name.startsWith("generated-"),
|
|
334
|
+
* test() {
|
|
335
|
+
* expect(this.data?.mode).toBe("auto");
|
|
336
|
+
* },
|
|
337
|
+
* });
|
|
338
|
+
* ```
|
|
339
|
+
*/
|
|
340
|
+
assertOne<T extends K8sResource>(
|
|
341
|
+
resource: ResourceOneTest<T>,
|
|
342
|
+
options?: undefined | ActionOptions
|
|
343
|
+
): Promise<T>;
|
|
344
|
+
|
|
299
345
|
/**
|
|
300
346
|
* Creates a new namespace and returns a namespaced API surface.
|
|
301
347
|
*
|
|
@@ -686,6 +732,32 @@ export interface Cluster {
|
|
|
686
732
|
options?: undefined | ActionOptions
|
|
687
733
|
): Promise<Array<T>>;
|
|
688
734
|
|
|
735
|
+
/**
|
|
736
|
+
* Fetches a list of Kubernetes resources, asserts that exactly one matches
|
|
737
|
+
* the optional `where` predicate, and runs a test function on it.
|
|
738
|
+
*
|
|
739
|
+
* @template T - The expected Kubernetes resource shape.
|
|
740
|
+
* @param resource - Group/version/kind selector, optional `where` predicate,
|
|
741
|
+
* and test callback.
|
|
742
|
+
* @param options - Retry options such as timeout and polling interval.
|
|
743
|
+
*
|
|
744
|
+
* @example
|
|
745
|
+
* ```ts
|
|
746
|
+
* await cluster.assertOne({
|
|
747
|
+
* apiVersion: "v1",
|
|
748
|
+
* kind: "Namespace",
|
|
749
|
+
* where: (ns) => ns.metadata.name === "my-namespace",
|
|
750
|
+
* test() {
|
|
751
|
+
* expect(this.metadata.labels?.env).toBe("production");
|
|
752
|
+
* },
|
|
753
|
+
* });
|
|
754
|
+
* ```
|
|
755
|
+
*/
|
|
756
|
+
assertOne<T extends K8sResource>(
|
|
757
|
+
resource: ResourceOneTest<T>,
|
|
758
|
+
options?: undefined | ActionOptions
|
|
759
|
+
): Promise<T>;
|
|
760
|
+
|
|
689
761
|
/**
|
|
690
762
|
* Creates a new namespace in this cluster and returns a namespaced API.
|
|
691
763
|
*
|
|
@@ -963,6 +1035,32 @@ export interface Namespace {
|
|
|
963
1035
|
resource: ResourceListTest<T>,
|
|
964
1036
|
options?: undefined | ActionOptions
|
|
965
1037
|
): Promise<Array<T>>;
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Fetches a list of namespaced Kubernetes resources, asserts that exactly one
|
|
1041
|
+
* matches the optional `where` predicate, and runs a test function on it.
|
|
1042
|
+
*
|
|
1043
|
+
* @template T - The expected Kubernetes resource shape.
|
|
1044
|
+
* @param resource - Group/version/kind selector, optional `where` predicate,
|
|
1045
|
+
* and test callback.
|
|
1046
|
+
* @param options - Retry options such as timeout and polling interval.
|
|
1047
|
+
*
|
|
1048
|
+
* @example
|
|
1049
|
+
* ```ts
|
|
1050
|
+
* await ns.assertOne({
|
|
1051
|
+
* apiVersion: "v1",
|
|
1052
|
+
* kind: "ConfigMap",
|
|
1053
|
+
* where: (cm) => cm.metadata.name.startsWith("generated-"),
|
|
1054
|
+
* test() {
|
|
1055
|
+
* expect(this.data?.mode).toBe("auto");
|
|
1056
|
+
* },
|
|
1057
|
+
* });
|
|
1058
|
+
* ```
|
|
1059
|
+
*/
|
|
1060
|
+
assertOne<T extends K8sResource>(
|
|
1061
|
+
resource: ResourceOneTest<T>,
|
|
1062
|
+
options?: undefined | ActionOptions
|
|
1063
|
+
): Promise<T>;
|
|
966
1064
|
}
|
|
967
1065
|
|
|
968
1066
|
/**
|
|
@@ -1146,6 +1244,41 @@ export interface ResourceListTest<T extends K8sResource = K8sResource> {
|
|
|
1146
1244
|
) => unknown | Promise<unknown>;
|
|
1147
1245
|
}
|
|
1148
1246
|
|
|
1247
|
+
/**
|
|
1248
|
+
* A test definition for {@link Scenario.assertOne}.
|
|
1249
|
+
*
|
|
1250
|
+
* Fetches a list of resources, filters by an optional `where` predicate, asserts
|
|
1251
|
+
* that exactly one resource matches, then runs the `test` callback on it.
|
|
1252
|
+
*/
|
|
1253
|
+
export interface ResourceOneTest<T extends K8sResource = K8sResource> {
|
|
1254
|
+
/**
|
|
1255
|
+
* Kubernetes API version (e.g. `"v1"`, `"apps/v1"`).
|
|
1256
|
+
*/
|
|
1257
|
+
readonly apiVersion: T["apiVersion"];
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Kubernetes kind (e.g. `"ConfigMap"`, `"Deployment"`).
|
|
1261
|
+
*/
|
|
1262
|
+
readonly kind: T["kind"];
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Optional predicate to narrow which resources are candidates.
|
|
1266
|
+
*
|
|
1267
|
+
* When omitted, all resources of the given kind are candidates.
|
|
1268
|
+
* Combined with the strict uniqueness check this means "assert there is
|
|
1269
|
+
* exactly one resource of this kind."
|
|
1270
|
+
*/
|
|
1271
|
+
readonly where?: undefined | ((resource: T) => boolean);
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Assertion callback.
|
|
1275
|
+
*
|
|
1276
|
+
* The callback is invoked with `this` bound to the single matching resource.
|
|
1277
|
+
* Throwing (or rejecting) signals a failed assertion.
|
|
1278
|
+
*/
|
|
1279
|
+
readonly test: (this: T, resource: T) => unknown | Promise<unknown>;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1149
1282
|
/**
|
|
1150
1283
|
* Kubernetes cluster selector for {@link Scenario.useCluster}.
|
|
1151
1284
|
*/
|
package/ts/scenario/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { applyStatus } from "../actions/apply-status";
|
|
|
3
3
|
import { assert } from "../actions/assert";
|
|
4
4
|
import { assertAbsence } from "../actions/assert-absence";
|
|
5
5
|
import { assertList } from "../actions/assert-list";
|
|
6
|
+
import { assertOne } from "../actions/assert-one";
|
|
6
7
|
import { create } from "../actions/create";
|
|
7
8
|
import {
|
|
8
9
|
type CreateNamespaceInput,
|
|
@@ -46,6 +47,7 @@ export function createScenario(deps: CreateScenarioOptions): InternalScenario {
|
|
|
46
47
|
assert: createQueryFn(deps, assert),
|
|
47
48
|
assertAbsence: createQueryFn(deps, assertAbsence),
|
|
48
49
|
assertList: createQueryFn(deps, assertList),
|
|
50
|
+
assertOne: createQueryFn(deps, assertOne),
|
|
49
51
|
given: bdd.given(deps),
|
|
50
52
|
when: bdd.when(deps),
|
|
51
53
|
// biome-ignore lint/suspicious/noThenProperty: BDD DSL uses `then()` method name
|
|
@@ -210,6 +212,7 @@ const createNewNamespaceFn =
|
|
|
210
212
|
assert: createQueryFn(namespacedDeps, assert),
|
|
211
213
|
assertAbsence: createQueryFn(namespacedDeps, assertAbsence),
|
|
212
214
|
assertList: createQueryFn(namespacedDeps, assertList),
|
|
215
|
+
assertOne: createQueryFn(namespacedDeps, assertOne),
|
|
213
216
|
};
|
|
214
217
|
};
|
|
215
218
|
|
|
@@ -233,6 +236,7 @@ const createUseClusterFn =
|
|
|
233
236
|
assert: createQueryFn(clusterDeps, assert),
|
|
234
237
|
assertAbsence: createQueryFn(clusterDeps, assertAbsence),
|
|
235
238
|
assertList: createQueryFn(clusterDeps, assertList),
|
|
239
|
+
assertOne: createQueryFn(clusterDeps, assertOne),
|
|
236
240
|
newNamespace: createNewNamespaceFn(clusterDeps),
|
|
237
241
|
};
|
|
238
242
|
};
|