Preview Release -- Kest is currently in
0.xpreview. The API may change based on feedback. Breaking changes can occur in any0.xrelease. A stable1.0.0will be released once the API solidifies. Feel free to open an issue if you have any feedback.
Kubernetes E2E testing designed for humans and AI alike
Kest makes it easy to write reliable end-to-end tests for Kubernetes controllers, operators, and admission webhooks. You write test scenarios in TypeScript with full type safety, autocompletion, and the familiar expect() API. When a test fails, Kest generates structured Markdown reports that are easy for humans to scan and for AI assistants to parse -- making troubleshooting straightforward regardless of who (or what) is debugging.
import { expect } from "bun:test";
import { test } from "@appthrust/kest";
test("Deployment creates expected ReplicaSet", async (s) => {
s.given("a namespace exists");
const ns = await s.newNamespace();
s.when("I apply a Deployment");
await ns.apply({
apiVersion: "apps/v1",
kind: "Deployment",
metadata: { name: "my-app" },
spec: {
replicas: 2,
selector: { matchLabels: { app: "my-app" } },
template: {
metadata: { labels: { app: "my-app" } },
spec: { containers: [{ name: "app", image: "nginx" }] },
},
},
});
s.then("the Deployment should be available");
await ns.assert({
apiVersion: "apps/v1",
kind: "Deployment",
name: "my-app",
test() {
expect(this.status?.availableReplicas).toBe(2);
},
});
// Cleanup is automatic: resources are deleted in reverse order,
// then the namespace is removed.
});YAML and Go are the norm in the Kubernetes ecosystem, so why TypeScript?
Why not YAML? E2E tests are inherently procedural -- apply resources, wait for reconciliation, assert state, clean up. YAML is a data format, not a programming language, and becomes clunky when you try to express these sequential workflows directly.
Why not Go? Go has an excellent Kubernetes client ecosystem, but TypeScript object literals are far more concise than Go structs for expressing Kubernetes manifests inline. Tests read closer to the YAML you already know, without the boilerplate of typed struct initialization and pointer helpers.
What TypeScript brings:
- Editor support -- autocompletion, inline type checking, go-to-definition
- Readability -- object literals map naturally to Kubernetes manifests
- Flexibility -- loops, conditionals, helper functions, and shared fixtures are just code
- Ecosystem -- use any npm package for setup, assertions, or data generation
Each test gets an isolated, auto-generated namespace (e.g. kest-a1b2c). Resources are confined to this namespace, eliminating interference between tests and enabling safe parallel execution. The namespace is deleted when the test ends.
const ns = await s.newNamespace();
// All resources applied through `ns` are scoped to this namespace.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.
Created: Namespace → ConfigMap → Deployment → Service
Cleaned: Service → Deployment → ConfigMap → Namespace
Kubernetes is eventually consistent. Kest retries assertions automatically until they pass or a timeout expires, so you don't need fragile sleep() calls.
await ns.assert({
apiVersion: "v1",
kind: "ConfigMap",
name: "my-config",
test() {
// Retried until this passes (default: 5s timeout, 200ms interval)
expect(this.data?.mode).toBe("production");
},
});Custom timeouts are supported per action:
await ns.assert(
{
apiVersion: "apps/v1",
kind: "Deployment",
name: "my-app",
test() {
expect(this.status?.availableReplicas).toBe(3);
},
},
{ timeout: "30s", interval: "1s" },
);Apply resources using whichever format is most convenient:
// Inline YAML string
await ns.apply(`
apiVersion: v1
kind: ConfigMap
metadata:
name: my-config
data:
mode: demo
`);
// TypeScript object literal (with type checking)
await ns.apply<ConfigMap>({
apiVersion: "v1",
kind: "ConfigMap",
metadata: { name: "my-config" },
data: { mode: "demo" },
});
// Imported YAML file
await ns.apply(import("./manifests/config-map.yaml"));Test scenarios that span multiple clusters:
test("resources sync across clusters", async (s) => {
const primary = await s.useCluster({ context: "kind-primary" });
const secondary = await s.useCluster({
context: "kind-secondary",
kubeconfig: ".kubeconfig.yaml",
});
const ns1 = await primary.newNamespace();
const ns2 = await secondary.newNamespace();
await ns1.apply(/* ... */);
await ns2.assert(/* ... */);
});Simulate controller behavior by applying status subresources via server-side apply:
await ns.applyStatus({
apiVersion: "example.com/v1",
kind: "MyResource",
metadata: { name: "my-resource" },
status: {
conditions: [
{
type: "Ready",
status: "True",
lastTransitionTime: "2026-01-01T00:00:00Z",
reason: "Reconciled",
message: "Resource is ready.",
},
],
},
});Assert against a collection of resources:
await ns.assertList<ConfigMap>({
apiVersion: "v1",
kind: "ConfigMap",
test() {
expect(this.some((c) => c.metadata.name === "my-config")).toBe(true);
expect(this.some((c) => c.metadata.name === "deleted-config")).toBe(false);
},
});Run arbitrary shell commands with optional revert handlers for cleanup:
const name = await s.exec({
do: async ({ $ }) => {
const name = "my-secret";
await $`kubectl create secret generic ${name} --from-literal=password=s3cr3t`.quiet();
return name;
},
revert: async ({ $ }) => {
await $`kubectl delete secret my-secret`.quiet();
},
});Structure tests with Given/When/Then annotations for readable output:
test("ConfigMap lifecycle", async (s) => {
s.given("a namespace exists");
const ns = await s.newNamespace();
s.when("I apply a ConfigMap");
await ns.apply(/* ... */);
s.then("the ConfigMap should have the expected data");
await ns.assert(/* ... */);
});When a test fails (or when KEST_SHOW_REPORT=1 is set), Kest generates a detailed Markdown report showing every action, the exact kubectl commands executed, stdout/stderr output, and cleanup results. This provides full transparency into what happened during the test, making troubleshooting straightforward -- for both humans and AI assistants.
# ConfigMap lifecycle
## Scenario Overview
| # | Action | Resource | Status |
| --- | ---------------- | ------------------- | ------ |
| 1 | Create namespace | kest-9hdhj | ✅ |
| 2 | Apply | ConfigMap/my-config | ✅ |
| 3 | Assert | ConfigMap/my-config | ✅ |
## Scenario Details
### Given: a namespace exists
✅ Create Namespace "kest-9hdhj"
...
### Cleanup
| # | Action | Resource | Status |
| --- | ---------------- | ------------------- | ------ |
| 1 | Delete | ConfigMap/my-config | ✅ |
| 2 | Delete namespace | kest-9hdhj | ✅ |- Bun v1.3.8 or later
kubectlconfigured with access to a Kubernetes cluster- A running Kubernetes cluster (e.g. kind, minikube, or a remote cluster)
bun add -d @appthrust/kestCreate a test file, e.g. my-operator.test.ts:
import { expect } from "bun:test";
import { test } from "@appthrust/kest";
test("ConfigMap is created with correct data", async (s) => {
s.given("a new namespace exists");
const ns = await s.newNamespace();
s.when("I apply a ConfigMap");
await ns.apply({
apiVersion: "v1",
kind: "ConfigMap",
metadata: { name: "app-config" },
data: { environment: "test" },
});
s.then("the ConfigMap should contain the expected data");
await ns.assert({
apiVersion: "v1",
kind: "ConfigMap",
name: "app-config",
test() {
expect(this.data?.environment).toBe("test");
},
});
});bun testTo always show the Markdown test report (not just on failure):
KEST_SHOW_REPORT=1 bun testEntry point for defining a test scenario. The callback receives a Scenario object.
| Option | Type | Default | Description |
|---|---|---|---|
timeout |
string |
"60s" |
Maximum duration for the entire test |
The top-level API surface available in every test callback.
| Method | Description |
|---|---|
apply(manifest, options?) |
Apply a Kubernetes manifest and register cleanup |
applyStatus(manifest, options?) |
Apply a status subresource (server-side apply) |
delete(resource, options?) |
Delete a resource by API version, kind, and name |
get(resource, options?) |
Fetch a resource by API version, kind, and name |
assert(resource, options?) |
Fetch a resource and run assertions with retries |
assertList(resource, options?) |
Fetch a list of resources and run assertions |
newNamespace(name?, options?) |
Create an ephemeral namespace |
exec(input, options?) |
Execute shell commands with optional revert |
useCluster(ref) |
Create a cluster-bound API surface |
given(desc) / when(desc) / then(desc) / and(desc) / but(desc) |
BDD annotations for reporting |
Returned by newNamespace() and useCluster() respectively. They expose the same core methods (apply, applyStatus, delete, get, assert, assertList) scoped to their namespace or cluster context. Cluster additionally supports newNamespace.
All actions accept an optional options object for retry configuration.
| Option | Type | Default | Description |
|---|---|---|---|
timeout |
string |
"5s" |
Maximum retry duration |
interval |
string |
"200ms" |
Delay between retry attempts |
Duration strings support units like "200ms", "5s", "1m".
Define TypeScript interfaces for your Kubernetes resources to get full type checking in manifests and assertions:
import type { K8sResource } from "@appthrust/kest";
interface MyCustomResource extends K8sResource {
apiVersion: "example.com/v1";
kind: "MyResource";
metadata: { name: string };
spec: {
replicas: number;
image: string;
};
status?: {
conditions: Array<{
type: string;
status: "True" | "False" | "Unknown";
}>;
};
}
// Full autocompletion and type checking:
await ns.apply<MyCustomResource>({
apiVersion: "example.com/v1",
kind: "MyResource",
metadata: { name: "my-instance" },
spec: { replicas: 3, image: "my-app:latest" },
});
await ns.assert<MyCustomResource>({
apiVersion: "example.com/v1",
kind: "MyResource",
name: "my-instance",
test() {
// `this` is typed as MyCustomResource
expect(this.spec.replicas).toBe(3);
},
});| Variable | Description |
|---|---|
KEST_SHOW_REPORT |
Set to "1" to show Markdown reports for all tests (not just failures) |
KEST_SHOW_EVENTS |
Set to "1" to dump raw recorder events for debugging |