Run a real Kubernetes API server (kube-apiserver + etcd) for integration tests, from Node or Bun — a pure-TypeScript port of controller-runtime's envtest.
No Docker, no cluster, no Go toolchain. Startup is ~2–5 seconds once binaries are cached.
npm install --save-dev @pixelbitsltd/envtest-js # or: bun add -d @pixelbitsltd/envtest-jsimport { TestEnvironment, restRequestOk } from "@pixelbitsltd/envtest-js";
const env = new TestEnvironment({
crdDirectoryPaths: ["./config/crd"], // optional: files or directories
});
const config = await env.start();
// config.server -> https://127.0.0.1:<port>
// config.kubeconfigPath -> ready-to-use kubeconfig (works with kubectl and @kubernetes/client-node)
// config.caPem / certPem / keyPem (+ base64 caData/certData/keyData)
// Tiny built-in REST client for tests that don't want a client library:
await restRequestOk(config, "POST", "/api/v1/namespaces", {
apiVersion: "v1", kind: "Namespace", metadata: { name: "test" },
});
await env.stop();With @kubernetes/client-node:
import { KubeConfig, CoreV1Api } from "@kubernetes/client-node";
const kc = new KubeConfig();
kc.loadFromFile(config.kubeconfigPath); // or kc.loadFromString(config.kubeconfigYaml)
const core = kc.makeApiClient(CoreV1Api);If you build Kubernetes operators, controllers, or admission webhooks in TypeScript, your integration-test options used to be two bad extremes.
- Mocked clients don't exercise anything the apiserver actually does — CRD structural-schema validation, RBAC, resourceVersion conflicts, finalizers, watch semantics — and admission/conversion webhooks can't be tested against a mock at all, because the real behavior is the apiserver calling back into your handler over TLS it trusts.
- Real clusters via Docker (
@testcontainers/k3s, kind) validate everything but cost 20–30s startup, require Docker, and aren't cheap enough to be hermetic per suite.
Go teams have had the middle ground for years: envtest — the real kube-apiserver + etcd, no Docker, throwaway state, seconds to start. But upstream envtest is two layers, only one of which is reusable from Node:
- The binaries (
etcd,kube-apiserver,kubectl) are language-agnostic, and envtest-js reuses that layer entirely: same release index, same SHA-512 verification,KUBEBUILDER_ASSETS/TEST_ASSET_*honored with upstream precedence — a CI host set up for Go envtest works unchanged. - The orchestration (throwaway PKI, apiserver flags, readiness, kubeconfig, CRD install, webhook injection) is a Go library, not a CLI — there's no
envtest startto shell out to.
Same deliberate limits as upstream envtest: this is the API surface only — no scheduler or controller-manager, so Pods never actually run, Deployments don't create ReplicaSets, and garbage collection doesn't fire. If you need a complete cluster and have Docker available, @testcontainers/k3s is the better tool for that job.
- Binary acquisition — fetches the upstream release index (
envtest-releases.yamlfrom kubernetes-sigs/controller-tools), downloads the per-OS/arch tarball, verifies its SHA-512, and caches the binaries in the platform data dir (~/.local/share/envtest-js,%LOCALAPPDATA%\envtest-js, …). Compatible with existing CI setups:KUBEBUILDER_ASSETSandTEST_ASSET_ETCD/TEST_ASSET_KUBE_APISERVER/TEST_ASSET_KUBECTLare honored with the same precedence as Go envtest. The cache is inspectable and prunable likesetup-envtest list/cleanup:listCachedVersions()returns the cached versions (newest first), andcleanupCachedVersions({ version: "<1.30" })removes the matching ones — both accept version (exact or semver range),os, andarchfilters. - Full-fidelity PKI (no
insecure-skip-tls-verifyanywhere) — a throwaway CA (ECDSA P-256, via @peculiar/x509 on WebCrypto) signs:- the apiserver serving cert (SANs:
localhost,127.0.0.1,::1,kubernetes.default.svc, …), - an admin client cert with
CN=envtest-admin, O=system:masters— Kubernetes maps CN→username and O→groups, so this is real RBAC-backed client-cert auth; - plus an RSA-2048 keypair for genuine service-account token signing (
--service-account-signing-key-file).
- the apiserver serving cert (SANs:
- Process lifecycle — starts etcd on a free port, then kube-apiserver with the same default flag set as upstream envtest (secure serving only,
--authorization-mode=RBAC,ServiceAccountadmission disabled), polls/readyzover mTLS, and tears down hard onstop()(SIGTERM → SIGKILL, temp dirs removed). Processes are spawned via execa withcleanupenabled, so children are killed even when the test runner dies from a signal. - Client config — a self-contained kubeconfig (verified in e2e against real
kubectland the official@kubernetes/client-node) plus in-memory PEM/base64 credentials.env.addUser({ name, groups })provisions additional identities (like upstreamEnvironment.AddUser): the environment's CA signs a client cert withCN=name, O=groups, returned as a REST config plus its own kubectl-ready kubeconfig — e.g. for testing RBAC as a non-admin. - CRD install — applies
CustomResourceDefinitionmanifests (create-or-replace), from files/directories and/or in-memory objects, and waits for theEstablishedcondition, likeenvtest.InstallCRDs;uninstallCRDsdeletes them again (missing ones skipped), likeenvtest.UninstallCRDs. - Webhook support — runs your admission and CRD conversion webhooks in the test process, like upstream's
WebhookInstallOptions: a separate throwaway CA mints a serving cert for your HTTPS server; each(Validating|Mutating)WebhookConfigurationgets itsclientConfigrewritten tohttps://127.0.0.1:<port><service.path>with the CA injected ascaBundle; CRDs declaringspec.conversion.strategy: Webhookget the same treatment (defaulting to controller-runtime's/convertpath). See below.
import https from "node:https";
const env = new TestEnvironment({
webhookInstallOptions: { paths: ["./config/webhook"] },
});
const config = await env.start();
const wh = config.webhook!; // host, port, certPem/keyPem (+ certDir with tls.crt/tls.key)
const server = https.createServer({ cert: wh.certPem, key: wh.keyPem }, admissionHandler);
await new Promise<void>((resolve) => server.listen(wh.port, wh.host, resolve));
await env.waitForWebhookServer(); // dial-check verifying the serving cert against the webhook CA
// ...requests matching your webhook rules now round-trip through your handler.The webhook configurations are installed with failurePolicy as authored — with Fail, matching requests are rejected until your server is up, exactly as in Go envtest.
CRD conversion webhooks: any CRD passed via crdDirectoryPaths that declares spec.conversion.strategy: Webhook is automatically pointed at the same local serving address (path from the authored service.path, defaulting to /convert) with the CA bundle injected. Serve ConversionReview on that path from the same HTTPS server. Upstream decides convertibility from the Go scheme; with no scheme in JS, the manifest's declared strategy is the trigger.
new TestEnvironment({
version: "1.36", // exact ("v1.36.2"), semver range ("1.36", ">=1.35 <1.37"), or omit for latest stable
binaryAssetsDirectory: "...", // skip download, use these binaries
crdDirectoryPaths: [...], // CRD manifests to install on start
crds: [{ ... }], // in-memory CRD manifests, installed alongside crdDirectoryPaths
apiServerFlags: { "max-requests-inflight": "800", "allow-privileged": null }, // override / remove (null) defaults; arrays repeat the flag
etcdFlags: { ... },
listenAddress: "172.17.0.1", // apiserver bind + serving-cert SAN + kubeconfig URL move together
// (e.g. a Docker bridge IP so containers reach the host's apiserver);
// default 127.0.0.1. Wildcards (0.0.0.0) bind everything but only
// loopback names land in the SANs.
securePort: 6443, // fixed apiserver port; default: an OS-assigned free port
attachOutput: true, // pipe etcd/apiserver logs to stderr
startTimeoutMs: 60_000,
readyPollIntervalMs: 150, // interval between etcd/apiserver readiness checks
useExistingCluster: true, // attach to a pre-existing cluster instead of spawning one (below)
config: { server, caPem, certPem, keyPem }, // explicit credentials for useExistingCluster
})Like upstream's Environment.UseExistingCluster, useExistingCluster: true (or the USE_EXISTING_CLUSTER=true environment variable, when the option is unset) skips etcd/kube-apiserver entirely and attaches to a cluster you already have. Credentials come from config when provided, otherwise from the kubeconfig at KUBECONFIG (first readable entry — missing files are skipped like kubectl, but kubectl-style merging is not done) or ~/.kube/config; only client-certificate kubeconfigs are supported (what kind/k3d/minikube issue — token, exec-plugin, and basic auth are not). Scheme-less server: values are normalized to https:// like kubectl; plain http is rejected. config.user is the client certificate's CN — the identity the apiserver actually sees — not the kubeconfig's arbitrary user-entry name. CRDs and webhook configurations still install on start(), and stop() leaves the cluster — including anything installed into it — running. Careful with webhooks on shared clusters: installed webhook configurations point back at the test process, so once it exits, a failurePolicy: Fail webhook blocks matching requests until you delete the configuration. To leave the cluster as you found it, opt into cleanup: crdInstallOptions: { cleanUpAfterUse: true } (upstream: CRDInstallOptions.CleanUpAfterUse) makes stop() uninstall the CRDs start() installed, and webhookInstallOptions.cleanUpAfterUse (an envtest-js extension — upstream always leaves webhook configurations behind) does the same for webhook configurations; both run before teardown, while the apiserver is still reachable. config.binaries/config.etcdURL are absent in this mode, and addUser() is unavailable (the environment doesn't own the cluster's CA). The parsing helpers are exported too: parseKubeconfig(yaml) / loadKubeconfig({ path?, context? }).
Recommended test-runner pattern (same as upstream): one control plane per suite, not per test. Glue for the two main runners ships with the package:
Everything client-facing is verified mTLS: the apiserver serves only HTTPS with a throwaway CA, clients authenticate with certs (no tokens, no insecure-skip-tls-verify), webhook callbacks are verified via the injected caBundle, and waitForWebhookServer verifies the serving cert rather than dial-checking blindly. Private keys and the kubeconfig are written 0600 inside a 0700 temp dir. Two deliberate limits, both inherited from upstream envtest: etcd listens in plaintext without authentication on loopback (only the co-located apiserver is meant to talk to it, but any local process could — don't run envtest on hosts with untrusted local users), and the apiserver's default anonymous-auth stays enabled (RBAC denies anonymous everything beyond health/version discovery, and the health endpoints need it).
Node ≥ 24 and Bun.
One known Bun limitation: Bun caches the TLS trust context of the first mTLS request process-wide (node:https, node:http, and native fetch all sit behind that cache; per-request ca/cert/key are ignored afterwards). Since every TestEnvironment mints its own throwaway CA, this means one environment per Bun process — a second environment's apiserver can never be verified. The recommended pattern (one shared environment per run, via the test-runner glue) is unaffected. On Node, multiple concurrent environments work fine.
- Structured helpers beyond CRDs (e.g. applying arbitrary manifests) — use
restRequest/kubectl/a client library.