@appthrust/kest 0.4.0 → 0.4.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.
Files changed (2) hide show
  1. package/README.md +274 -0
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -462,6 +462,280 @@ Duration strings support units like `"200ms"`, `"5s"`, `"1m"`.
462
462
 
463
463
  ## Best Practices
464
464
 
465
+ ### Test API contracts, not controllers
466
+
467
+ 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.
468
+
469
+ 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.
470
+
471
+ ```ts
472
+ // ✅ Good — the subject is the API resource
473
+ test("Tenant API creates namespaces for each tenant", async (s) => {
474
+ s.given("a Tenant resource is applied");
475
+ const ns = await s.newNamespace();
476
+ await ns.apply({
477
+ apiVersion: "example.com/v1",
478
+ kind: "Tenant",
479
+ metadata: { name: "acme" },
480
+ spec: { namespaces: ["dev", "staging"] },
481
+ });
482
+
483
+ s.then("the Tenant reports Ready=True");
484
+ await ns.assert({
485
+ apiVersion: "example.com/v1",
486
+ kind: "Tenant",
487
+ name: "acme",
488
+ test() {
489
+ expect(this.status?.conditions).toContainEqual(
490
+ expect.objectContaining({ type: "Ready", status: "True" }),
491
+ );
492
+ },
493
+ });
494
+ });
495
+ ```
496
+
497
+ ```ts
498
+ // ❌ Bad — the subject is the controller (implementation detail)
499
+ test("tenant-controller creates namespaces", async (s) => {
500
+ s.given("tenant-controller is running");
501
+ // ...
502
+ s.then("tenant-controller creates child namespaces");
503
+ // ...
504
+ });
505
+ ```
506
+
507
+ The same principle applies to BDD annotations -- keep `s.given()`, `s.when()`, and `s.then()` free of controller names:
508
+
509
+ | ❌ Controller-centric | ✅ API-centric |
510
+ | ------------------------------------------------- | ----------------------------------------- |
511
+ | `s.given("tenant-controller is running")` | `s.given("a Tenant resource exists")` |
512
+ | `s.when("tenant-controller reconciles")` | `s.when("the Tenant spec is updated")` |
513
+ | `s.then("tenant-controller creates a Namespace")` | `s.then("the expected Namespace exists")` |
514
+
515
+ ### Choosing what to test in E2E
516
+
517
+ 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.
518
+
519
+ **Good candidates for E2E (API contract):**
520
+
521
+ - Status transitions -- e.g. a resource reaches `Ready=True` after creation
522
+ - Error feedback -- e.g. invalid input produces an explanatory condition like `Ready=False, reason=InvalidSpec`
523
+ - User-facing side effects -- e.g. resources that users are expected to observe or interact with
524
+
525
+ ```ts
526
+ // ✅ Assert a user-observable status condition
527
+ await ns.assert({
528
+ apiVersion: "example.com/v1",
529
+ kind: "Database",
530
+ name: "my-db",
531
+ test() {
532
+ expect(this.status?.conditions).toContainEqual(
533
+ expect.objectContaining({ type: "Ready", status: "True" }),
534
+ );
535
+ },
536
+ });
537
+ ```
538
+
539
+ **Better left to unit / integration tests (implementation details):**
540
+
541
+ - Internal label keys, annotation formats, or hash values
542
+ - Intermediate resources that users don't directly interact with
543
+ - Controller-internal reconciliation logic and branching
544
+
545
+ ```ts
546
+ // ❌ Avoid — internal label format is an implementation detail
547
+ await ns.assert({
548
+ apiVersion: "example.com/v1",
549
+ kind: "Database",
550
+ name: "my-db",
551
+ test() {
552
+ // This label may change without affecting users
553
+ expect(this.metadata?.labels?.["internal.example.com/config-hash"]).toBe("a1b2c3");
554
+ },
555
+ });
556
+ ```
557
+
558
+ 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.
559
+
560
+ ### Organizing test files
561
+
562
+ 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.
563
+
564
+ ```
565
+ # ✅ Good — organized by API resource
566
+ tests/e2e/
567
+ ├── tenant-api/
568
+ │ ├── creation.test.ts
569
+ │ └── deletion.test.ts
570
+ ├── database-api/
571
+ │ └── provisioning.test.ts
572
+
573
+ # ❌ Bad — organized by controller (implementation detail)
574
+ tests/e2e/
575
+ ├── tenant-controller/
576
+ │ ├── creation.test.ts
577
+ │ └── deletion.test.ts
578
+ ├── database-controller/
579
+ │ └── provisioning.test.ts
580
+ ```
581
+
582
+ **Refactoring-friendliness checklist** -- the more "yes" answers, the better your E2E tests:
583
+
584
+ - [ ] Is the subject of every test an API resource (not a controller)?
585
+ - [ ] Can a reader understand the test from the manifest and assertions alone?
586
+ - [ ] Do `then` assertions only check user-observable state (`status`, contracted outputs)?
587
+ - [ ] Would splitting, merging, or renaming controllers leave all tests passing?
588
+
589
+ ### One scenario per file
590
+
591
+ Put exactly one test scenario in one file -- from the start, not "when it gets big enough."
592
+
593
+ 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.
594
+
595
+ ```
596
+ tests/e2e/tenant-api/
597
+ ├── creates-namespaces-for-each-tenant.test.ts
598
+ ├── rejects-duplicate-tenant-names.test.ts
599
+ ├── updates-status-on-namespace-failure.test.ts
600
+ └── deletes-child-namespaces-on-removal.test.ts
601
+ ```
602
+
603
+ **Why not "split when it gets big"?**
604
+
605
+ - Tests grow incrementally -- no single commit feels like "the moment to split," so it never happens.
606
+ - Splitting a file retroactively means rewriting imports, moving fixtures, and touching unrelated tests in the same PR.
607
+ - The threshold itself becomes a debate ("Is 250 lines too many? 400?"). A universal rule eliminates the discussion.
608
+
609
+ 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.
610
+
611
+ **What you get:**
612
+
613
+ - **Self-contained reading** -- open one file, see the full Given/When/Then without scrolling past unrelated scenarios.
614
+ - **Surgical diffs** -- a change touches exactly one scenario, keeping PRs small and reviews focused.
615
+ - **Failure as an address** -- a failing file name tells you which API contract broke, before you read a single line of output.
616
+ - **Conflict-free collaboration** -- teammates edit different files, not different sections of the same file.
617
+
618
+ **Name files after the behavior they verify.** A reader should know what a test checks without opening the file:
619
+
620
+ | ✅ Good | ❌ Bad |
621
+ | --- | --- |
622
+ | `creates-namespaces-with-labels.test.ts` | `tenant-test-1.test.ts` |
623
+ | `rejects-reserved-selector-labels.test.ts` | `validation.test.ts` |
624
+ | `rolls-out-when-image-changes.test.ts` | `deployment-tests.test.ts` |
625
+
626
+ **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.
627
+
628
+ **One-scenario-per-file checklist:**
629
+
630
+ - [ ] Does each test file contain exactly one scenario?
631
+ - [ ] Does the file name describe the behavior under test (not the controller)?
632
+ - [ ] If a file has multiple scenarios, is there a comment justifying the exception?
633
+
634
+ ### Keep manifests visible in your tests
635
+
636
+ 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.
637
+
638
+ ```ts
639
+ // ❌ Bad — looks tidy, but the actual manifest is hidden inside makeDeployment().
640
+ // A reader cannot tell what is being applied without jumping to the helper.
641
+ // The diff between v1 and v2 is buried in a parameter change.
642
+ function makeDeployment(name: string, image: string) {
643
+ return {
644
+ apiVersion: "apps/v1",
645
+ kind: "Deployment",
646
+ metadata: { name, labels: { app: name } },
647
+ spec: {
648
+ replicas: 2,
649
+ selector: { matchLabels: { app: name } },
650
+ template: {
651
+ metadata: { labels: { app: name } },
652
+ spec: { containers: [{ name: "app", image }] },
653
+ },
654
+ },
655
+ };
656
+ }
657
+
658
+ test("rollout updates the image", async (s) => {
659
+ const ns = await s.newNamespace();
660
+ await ns.apply(makeDeployment("my-app", "my-app:v1"));
661
+ await ns.apply(makeDeployment("my-app", "my-app:v2"));
662
+ // What exactly changed? You have to read makeDeployment to find out.
663
+ });
664
+ ```
665
+
666
+ ```ts
667
+ // ✅ Good — the full manifest is right here; the diff between steps is obvious.
668
+ test("rollout updates the image", async (s) => {
669
+ const ns = await s.newNamespace();
670
+
671
+ s.when("I apply a Deployment at v1");
672
+ await ns.apply({
673
+ apiVersion: "apps/v1",
674
+ kind: "Deployment",
675
+ metadata: { name: "my-app" },
676
+ spec: {
677
+ replicas: 2,
678
+ selector: { matchLabels: { app: "my-app" } },
679
+ template: {
680
+ metadata: { labels: { app: "my-app" } },
681
+ spec: { containers: [{ name: "app", image: "my-app:v1" }] },
682
+ },
683
+ },
684
+ });
685
+
686
+ s.when("I update to v2");
687
+ await ns.apply({
688
+ apiVersion: "apps/v1",
689
+ kind: "Deployment",
690
+ metadata: { name: "my-app" },
691
+ spec: {
692
+ replicas: 2,
693
+ selector: { matchLabels: { app: "my-app" } },
694
+ template: {
695
+ metadata: { labels: { app: "my-app" } },
696
+ spec: { containers: [{ name: "app", image: "my-app:v2" }] },
697
+ // ^^^^^^^^^ the diff
698
+ },
699
+ },
700
+ });
701
+ });
702
+ ```
703
+
704
+ 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.
705
+
706
+ When manifests become too large to inline comfortably, extract them into **static fixture files** instead of helper functions. Both YAML and TypeScript files work:
707
+
708
+ ```ts
709
+ // ✅ Good — static fixture files keep the input visible at a glance
710
+ await ns.apply(import("./fixtures/deployment-v1.yaml"));
711
+ await ns.apply(import("./fixtures/deployment-v2.yaml"));
712
+
713
+ // TypeScript files work too — useful when converting from inline objects
714
+ await ns.apply(import("./fixtures/deployment-v1.ts"));
715
+ ```
716
+
717
+ If you do use helpers, limit them to **name generation** -- never to assembling `spec`:
718
+
719
+ ```ts
720
+ // ✅ Good — helper generates a name; the manifest stays in the test
721
+ const name = s.generateName("deploy-");
722
+ await ns.apply({
723
+ apiVersion: "apps/v1",
724
+ kind: "Deployment",
725
+ metadata: { name },
726
+ spec: {
727
+ /* full spec here, not hidden in a function */
728
+ },
729
+ });
730
+ ```
731
+
732
+ **Manifest-visibility checklist:**
733
+
734
+ - [ ] Can you understand the full input by reading just the test file?
735
+ - [ ] Is the diff between test steps visible as a spec-level change?
736
+ - [ ] Can you reconstruct the applied manifest from the failure report alone?
737
+ - [ ] Are helper functions limited to name generation (no `spec` assembly)?
738
+
465
739
  ### Avoiding naming collisions between tests
466
740
 
467
741
  When tests run in parallel, hard-coded resource names can collide (especially when you create cluster-scoped resources).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthrust/kest",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Kubernetes E2E testing framework designed for humans and AI alike",
5
5
  "type": "module",
6
6
  "module": "ts/index.ts",