vitekube

Real Kubernetes tests that feel like unit tests.

Write a normal Vitest test and run it. A real cluster comes up, does the work, and stays ready for the next run. There is nothing to look after.

$npm install --save-dev vitest vitekube

Early days. A community project.

Start with one test

Here is a whole test. It applies your YAML, waits for the rollout, and asks the server a question.

web.test.ts
test("the web server answers", async ({ k8s, namespace }) => {
  await k8s.applyFile(namespace, "web.yaml");
  await k8s.waitForRollout({ namespace, name: "web" }, { timeoutMs: 120_000 });

  await using web = await k8s.portForward({
    namespace,
    resource: "service/web",
    remotePort: 80,
  });

  expect((await fetch(web.url)).status).toBe(200);
});

That was a real Deployment and a real Service, giving a real answer over HTTP. No mocks. No fake API server. No shell scripts wrapped around kubectl.

The test got a namespace of its own when it began, and the namespace was gone when it ended. The first run creates the cluster. Every run after that starts straight away.

It is still just Vitest, so the commands you know still work. To run this one test and no other, use npx vitest run -t "the web server answers".

Give it a name

Soon you will want your tests to talk about your product and leave the cluster out of it. So wrap the setup once, and give it a name.

fixtures.ts
export const test = base.extend("web", async ({ k8s, namespace }, { onCleanup }) => {
  await k8s.applyFile(namespace, "web.yaml");
  await k8s.waitForRollout({ namespace, name: "web" }, { timeoutMs: 120_000 });

  const forward = await k8s.portForward({
    namespace,
    resource: "service/web",
    remotePort: 80,
  });
  onCleanup(() => forward.close());

  return { get: (path: string) => fetch(new URL(path, forward.url)) };
});

Now any test can ask for it.

web.test.ts
test("the home page loads", async ({ web }) => {
  const response = await web.get("/");

  expect(response.status).toBe(200);
});

Each test gets its own copy, in its own namespace. The fixture is fully typed, and a test that does not ask for it does not pay for it.

Bring the whole system

One server is a good place to start, but few systems are that small. Here is a bigger one: a database, a cache, and a message bus in a namespace of its own. Migrations to run. An API and a worker, built from your own Dockerfiles and installed with your production charts.

k8s.setup.ts
export default defineK8sGlobalSetup({
  cluster: { provider: "k3d", name: "reports", reuse: true, agents: 1 },

  environment: {
    images: [
      { name: "api", dockerfile: "api/Dockerfile", context: "." },
      { name: "worker", dockerfile: "worker/Dockerfile", context: "." },
    ],

    stages: [
      {
        name: "dependencies",
        manifests: ["k8s/postgres.yaml", "k8s/redis.yaml", "k8s/nats.yaml"],
        waitFor: [
          { namespace: "default", name: "postgres" },
          { namespace: "default", name: "redis" },
          { namespace: "nats", name: "nats" },
        ],
      },
      {
        name: "migrations",
        dependsOn: ["dependencies"],
        inputs: ["db/migrations"],
        run: runMigrations, // your own code
      },
      {
        name: "api",
        dependsOn: ["migrations"],
        images: ["api"],
        charts: [{ release: "api", chart: "charts/api", restart: ["api"] }],
      },
      {
        name: "worker",
        dependsOn: ["migrations"],
        images: ["worker"],
        charts: [{ release: "worker", chart: "charts/worker", restart: ["worker"] }],
      },
    ],
  },
});

That is the whole setup, in one file. Nobody has to stand the cluster up by hand, or keep it running, or remember what order things go in.

Change one file in the worker and run your tests again. Only the worker is rebuilt. Everything else stays up and waits. A rerun can take seconds.

npx vitest run
[vitekube] imported worker:local in 1.2s
[vitekube] stage dependencies: skipped, unchanged
[vitekube] stage migrations: skipped, unchanged
[vitekube] stage api: skipped, unchanged
[vitekube] stage worker: running, because its inputs changed
[vitekube] stage worker: done in 4.1s

Break it on purpose

With the whole system up, you can ask it harder questions. Say the worker dies. Does a report wait for it to come back, or is it lost? Take the worker down and find out.

reports.test.ts
const WORKER = { namespace: "default", name: "worker" };

test("a report waits out a dead worker", async ({ reports, k8s }) => {
  await using worker = await k8s.scaleTemporarily(WORKER, 0);
  await reports.waitForWorkerGone();

  const report = await reports.request("march");
  expect(await reports.status(report)).toBe("waiting");

  await worker.restore();

  const job = await k8s.waitForJobCompletion(report.job, { timeoutMs: 120_000 });
  expect(job).toHaveCondition("Complete");
  expect(await reports.waitForEnd(report)).toBe("done");
});

reports is a fixture of your own, built the same way as web. The test talks to your API, then checks the real Job in the cluster. If anything throws along the way, the worker still comes back.

When a test fails, it says why

Cleanup is a fine thing, until it sweeps away the clues. So vitekube gathers the events, the pods and the logs before cleanup runs, and prints them right next to the failure.

npx vitest run
--- events in vk8s-the-web-server-answers-4fa999
Normal Started Pod/web-6fc4c4d4df-lzjsd: Started container web
--- pods in vk8s-the-web-server-answers-4fa999
web-6fc4c4d4df-lzjsd {"phase":"Running","conditions":[...]}
--- logs in vk8s-the-web-server-answers-4fa999
web-6fc4c4d4df-lzjsd:
nginx-config-is-broken

 FAIL  web.test.ts > the web server answers
TypeError: fetch failed

The answer is right there in the logs, and you did not have to open a second terminal. It is plain text too, so a coding agent can read it as well as you can.

It keeps to its own ground

vitekube works from a private kubeconfig, and you always name the cluster, so your kubectl context stays where you left it. It will not reuse or delete anything it did not create. And a value you mark as sensitive is scrubbed from everything captured, even in its base64 form.

Try it

Your first test is five short steps away. You will need Docker, k3d, kubectl, Vitest 5 and a recent Node.