@appthrust/kest

中文文档
0.18.0发布于 2026/04/13MITTypeScriptES Module近 30 天下载 1.1万

Kubernetes E2E测试框架为人类和AI设计

中文使用文档
文档目录45 节
  1. 为什么是TypeScript?
  2. 特征
  3. 以弗命名空间
  4. 自动清理( 反向命令, 屏蔽)
  5. 基于重试的请求
  6. 创建资源
  7. 多种多形式
  8. 多组支持
  9. 现有命名空间
  10. 次级资源支助
  11. 列表请求
  12. 单资源列表
  13. 缺席
  14. 请求错误
  15. 标签资源
  16. Shell 命令执行
  17. BDD- 系统报告
  18. 标记下测试报告
  19. 开始
  20. 先决条件
  21. 安装
  22. 类型脚本配置
  23. 写您的第一次测试
  24. 运行测试
  25. API 参考
  26. test(label, callback, options?)
  27. 假设
  28. 命名空间/ 集群
  29. 行动选项
  30. 最佳做法
  31. 测试 API 合同而非控制器
  32. 选择在 E2E 中测试什么
  33. 组织测试文件
  34. 每个文件一个方案
  35. 在测试中显示显示
  36. 用于更新的突变的再使用表
  37. 避免测试之间发生命名相冲突
  38. 使用 toMatchObject 没有类型参数的断言
  39. 更喜欢一整组的主张 超过在 assertList
  40. 类型安全
  41. 提示
  42. 调试失败的测试
  43. Biome 配置
  44. 环境变量
  45. 许可证

凯斯

Kest – Kubernetes E2E testing designed for humans and AI alike

预览发布 - Kest 已经进入 0.x 预览。 API可能根据反馈而改变. 断裂变化可以在任意 0.x 释放。 一个马厩 1.0.0 一旦API得到巩固,将释放。 随便你 打开一个问题 如果你有任何反馈.

Kubernetes E2E测试为人类和AI设计

Kest使库伯内特斯控制器,操作器,以及录入的webhooks很容易写出可靠的端到端测试. 您在 TypeScript 中写有全类型安全、自动补全和熟悉的测试方案 expect() API (英语). 当测试失败时, Kest 生成结构化的 Markdown 报告, 方便人类扫描和 AI 助手解析—— 使故障清除直接, 不管是谁( 或是什么) 调试.

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.
});

为什么是TypeScript?

YAML 和 Go 是Kubernetes 生态系统的规范, 为什么是 TypeScript ?

为什么不是YAML? E2E测试本质上是程序性的 -- -- 应用资源,等待调节,坚持状态,清理. YAML是一种数据格式,而不是编程语言,在尝试直接表达这些相继的工作流程时会变成clunky.

为什么不去? Go有一个优异的Kubernetes客户端生态系统,但TypeScript对象字面比Go structs更简洁,用于表达Kubernetes的内含. 测试读取的更接近你已经知道的YAML,没有打入的struct初始化和指针帮助器的锅炉板.

类型脚本带来什么 :

  • 编辑器支持 -- 自动补全、 内置类型检查、 定义
  • 可读性 - 自然地将对象字面图映射到Kubernetes 显示
  • 灵活性 -- 循环、条件、帮助器函数和共享固定装置只是代码
  • 生态系统 - 使用任何 npm 软件包进行设置、断言或数据生成

特征

以弗命名空间

每个测试都得到一个孤立的,自动生成的命名空间(例如. kest-a1b2c) (中文(简体) ). 资源仅限于这个命名空间,消除了测试之间的干扰并使得能够安全地平行执行. 测试结束时, 命名空间会被删除 .

const ns = await s.newNamespace();
// All resources applied through `ns` are scoped to this namespace.

您也可以为生成的名称空间名称指定自定义前缀 generateName数字 :

const ns = await s.newNamespace({ generateName: "foo-" });
// Namespace name will be like "foo-d7kpn"

返回对象有一个 name 拥有生成的命名空间名称的属性 :

const ns = await s.newNamespace({ generateName: "foo-" });
console.log(ns.name); //=> "foo-d7kpn"

自动清理( 反向命令, 屏蔽)

资源按创建时的倒序被删除(LIFO). Kest 等待每个资源全部取出后再继续运行,防止由遗留资源引起的故障或 Terminating 名称空间.

Created:   Namespace → ConfigMap → Deployment → Service
Cleaned:   Service → Deployment → ConfigMap → Namespace

基于重试的请求

库伯涅茨最终是一贯的. Kest 自动重复断言直到它们通过或超时,所以你不需要脆弱 sleep() 打电话.

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");
  },
});

每个动作都支持自定义超时 :

await ns.assert({
  apiVersion: "apps/v1",
  kind: "Deployment",
  name: "my-app",
  test() {
    expect(this.status?.availableReplicas).toBe(3);
  },
});

创建资源

使用 kubectl create 改为 kubectl apply 当您需要确保资源被新创建时(例如,资源必须不存在,或者您想要使用) generateName:

await ns.create({
  apiVersion: "v1",
  kind: "ConfigMap",
  metadata: { name: "my-config" },
  data: { mode: "demo" },
});

喜欢 apply, (中文(简体) ), create 注册一个在测试结束时删除资源的清理处理器。 关键区别在于 kubectl create 如果资源已经存在,则失败,而 kubectl apply 执行上调.

多种多形式

使用最方便的格式应用资源 :

// 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("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(/* ... */);
});

现有命名空间

当测试 kest 未创建的命名空间中的资源( 如 Helm 图表所安装的系统命名空间或命名空间) , 使用 useNamespace 在不创建或清理命名空间的情况下,获取相同名称的空间扫描DSL:

test("istio root cert exists", async (s) => {
  const istio = await s.useNamespace("istio-system");
  await istio.assert({
    apiVersion: "v1",
    kind: "ConfigMap",
    name: "istio-ca-root-cert",
    test() {
      expect(this.data["root-cert.pem"]).toBeDefined();
    },
  });
});

useNamespace 也可上网查阅 Cluster数字 :

const cluster = await s.useCluster({ context: "kind-kind" });
const kubeSystem = await cluster.useNamespace("kube-system");

CAPI 动态集群

凯斯特可以解决 分组 API (CAPI) 自动提供集群. 通过 a ClusterResourceReference 改为 useCluster kest将:

  1. 传票CAPI Cluster 资源,直到报告准备好
  2. 从中获取 kubeconfig <name>-kubeconfig 密钥( 数据密钥) value(中文(简体) )
  3. 写入临时 kubeconfig 文件并将 kubectl 绑入其中
  4. 注册清除以便在测试结束时删除临时文件
test("CAPI multi-hop: management → child → workload", async (s) => {
  // 1. Connect to the permanent management cluster
  const pmc = await s.useCluster({ context: "kind-pmc" });

  // 2. Wait for the child cluster to become ready, then connect
  const cc = await pmc.useCluster({
    apiVersion: "cluster.x-k8s.io/v1beta1",
    kind: "Cluster",
    name: "child-cluster",
    namespace: "capi-system",
  });

  // 3. Chain further — the child cluster manages a workload cluster
  const wlc = await cc.useCluster({
    apiVersion: "cluster.x-k8s.io/v1beta1",
    kind: "Cluster",
    name: "workload-cluster",
    namespace: "default",
  });

  const ns = await wlc.newNamespace();
  await ns.apply(/* ... */);
});

准备条件:

API 版本条件类型
cluster.x-k8s.io/v1beta1Ready
cluster.x-k8s.io/v1beta2Available

秘密公约: Kest 读取一个名为 Kubeconfig 的秘密 ${clusterName}-kubeconfig 带有数据密钥 value。这遵循默认的 CAPI 公约。 空管局必须批准 get 关于集群资源所在命名空间中的秘密.

Temp Kubeconfig 清理 : 在测试清理过程中会自动删除临时的kubeconfig文件. 设定 KEST_PRESERVE_ON_FAILURE=1 以跳过对失败的清理,它保留了调试所需的临时文件.

次级资源支助

通过服务器侧应用状态子资源来模拟控制器行为 :

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.",
      },
    ],
  },
});

列表请求

抵制资源收集:

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);
  },
});

单资源列表

确定确实存在一种资源(或匹配上游资源),并对其进行测试:

// Assert exactly one ConfigMap exists and check its data
await ns.assertOne<ConfigMap>({
  apiVersion: "v1",
  kind: "ConfigMap",
  test() {
    expect(this.data?.mode).toBe("demo");
  },
});

使用可选 where 在存在多种资源的情况下,向狭义候选人过渡:

// Find the one ConfigMap whose name starts with "generated-"
await ns.assertOne<ConfigMap>({
  apiVersion: "v1",
  kind: "ConfigMap",
  where: (cm) => cm.metadata.name.startsWith("generated-"),
  test() {
    expect(this.data?.mode).toBe("auto");
  },
});

assertOne 如果零或多个资源匹配,则丢出,并重试,直到找到一个或超时结束.

缺席

确定资源不存在( 如删除后或验证控制器未创建某种内容) :

await ns.assertAbsence({
  apiVersion: "v1",
  kind: "ConfigMap",
  name: "deleted-config",
});

请求错误

认定应用或创建资源会产生出错(例如,一个承认的webhook拒绝请求,或者验证规则失败). 这个 test 回调检查错误.. this 绑定到 Error数字 :

await ns.assertApplyError({
  apply: {
    apiVersion: "example.com/v1",
    kind: "MyResource",
    metadata: { name: "my-resource" },
    spec: { immutableField: "changed" },
  },
  test() {
    expect(this.message).toContain("field is immutable");
  },
});

这个 test callback 参与重试——如果它扔出,动作会被重试到召回或超时结束. 当一个 Webhook 被同步设置时, 这是有用的 :

await ns.assertApplyError({
  apply: {
    apiVersion: "example.com/v1",
    kind: "MyResource",
    metadata: { name: "my-resource" },
    spec: { immutableField: "changed" },
  },
  test(error) {
    expect(error.message).toContain("field is immutable");
  },
});

assertCreateError 工作对象相同 kubectl create数字 :

await ns.assertCreateError({
  create: {
    apiVersion: "v1",
    kind: "ConfigMap",
    metadata: { name: "already-exists" },
  },
  test(error) {
    expect(error.message).toContain("already exists");
  },
});

如果应用程序/创建出乎意料地成功(例如webhook尚未激活),则资源立即被还原,动作重试直至预期出错.

标签资源

使用 Kubernetes 资源添加、更新或删除标签 kubectl label数字 :

await ns.label({
  apiVersion: "v1",
  kind: "ConfigMap",
  name: "my-config",
  labels: {
    env: "production", // add a label
    deprecated: null, // remove a label
  },
});

要覆盖已有标签, 设置 overwrite: true数字 :

await ns.label({
  apiVersion: "apps/v1",
  kind: "Deployment",
  name: "my-app",
  labels: {
    version: "v2",
  },
  overwrite: true,
});

Shell 命令执行

使用可选的还原处理器运行任意 shell 命令进行清理 :

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();
  },
});

BDD- 系统报告

可读产出的结构测试:

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(/* ... */);
});

标记下测试报告

测试失败时(或 KEST_SHOW_REPORT=1 kest 生成详细的 Markdown 报告,显示每个动作, 准确 kubectl 命令已执行(包括stdin运算),stdout/stderr输出,以及清理结果。 这为测试期间发生的事情提供了充分的透明度,使解决问题变得直截了当 -- -- 对人和人工智能助理来说都是如此.

# ConfigMap lifecycle

## Scenario Overview

| #   | Action                         | Status |
| --- | ------------------------------ | ------ |
| 1   | Apply Namespace `kest-9hdhj`   | ✅     |
| 2   | Apply `ConfigMap` "my-config"  | ✅     |
| 3   | Assert `ConfigMap` "my-config" | ✅     |

## Scenario Details

### Given: a namespace exists

**✅ Apply Namespace `kest-9hdhj`**

```shell
kubectl apply -f - <<EOF
apiVersion: v1
kind: Namespace
metadata:
  name: kest-9hdhj
EOF
```

...

### Cleanup

| #   | Action                         | Status |
| --- | ------------------------------ | ------ |
| 1   | Delete `ConfigMap` "my-config" | ✅     |
| 2   | Delete Namespace `kest-9hdhj`  | ✅     |

```shellsession
$ kubectl delete ConfigMap/my-config -n kest-9hdhj
configmap "my-config" deleted

$ kubectl delete namespace/kest-9hdhj
namespace "kest-9hdhj" deleted
```

开始

先决条件

  • 阿宝 v1.3.8 或较后
  • kubectl 配置为 Kubernetes 集群
  • 一个运行中的Kubernetes集群(例如. 类型, (中文(简体) ), 微调,或远程集群)

安装

bun add -d @appthrust/kest

类型脚本配置

如果你的项目没有 tsconfig.json 然而,安装 Bun TypeScript 预设并创建一个:

bun add -D @types/bun @tsconfig/bun
// tsconfig.json
{
  "extends": "@tsconfig/bun"
}

这样可以进行适当的类型检查,自动补全,并与 Bun 的 API 兼容 .

写您的第一次测试

创建测试文件, 例如 . 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 test

总是显示 Markdown 测试报告( 不仅仅是故障):

KEST_SHOW_REPORT=1 bun test

API 参考

test(label, callback, options?)

用于定义测试情景的切入点. 回调收到一个 Scenario 对象.

选项类型默认说明
timeoutstring"60s"整个测试的最大持续时间

假设

每个测试回调中可用的顶级API表面.

方法说明
apply(manifest, options?)应用 Kubernetes 列表并登记清理
create(manifest, options?)创建 Kubernetes 资源并注册清理
assertApplyError(input, options?)说 kubectl apply 生成出错
assertCreateError(input, options?)说 kubectl create 生成出错
applyStatus(manifest, options?)应用状态子资源( 服务器侧应用)
delete(resource, options?)按 API 版本、 类型和名称删除资源
label(input, options?)在资源上添加、更新或删除标签
get(resource, options?)按 API 版本、 类型和名称获取资源
assert(resource, options?)获取资源并运行重复的断言
assertAbsence(resource, options?)确定不存在资源
assertList(resource, options?)获取资源列表并运行断言
assertOne(resource, options?)精确显示一个资源匹配, 然后运行断言
newNamespace(name?, options?)创建电子命名空间( 支持) { generateName }(中文(简体) )
useNamespace(name, options?)获取 a Namespace 用于当前命名空间( 无清理)
generateName(prefix)生成随机后缀名称( 统计独有性 )
exec(input, options?)执行可选还原的 shell 命令
useCluster(ref, options?)创建带集群的 API 表面
given(desc) 编号 : when(desc) 编号 : then(desc) 编号 : and(desc) 编号 : but(desc)BDD 报告说明

命名空间/ 集群

归来 newNamespace(), (中文(简体) ), useNamespace(),以及 useCluster() 页:1 它们暴露出相同的核心方法(例如:apply, (中文(简体) ), create, (中文(简体) ), assertApplyError, (中文(简体) ), assertCreateError, (中文(简体) ), applyStatus, (中文(简体) ), delete, (中文(简体) ), label, (中文(简体) ), get, (中文(简体) ), assert, (中文(简体) ), assertAbsence, (中文(简体) ), assertList, (中文(简体) ), assertOne缩放到其命名空间或集群上下文. Cluster 额外支持 newNamespace, (中文(简体) ), useNamespace,以及 useCluster。 。 。 .

Namespace 也暴露出一个 name 属性 :

财产类型说明
namestring生成的命名空间名称( 例如 ). "kest-abc12"(中文(简体) )

行动选项

所有动作接受一个可选选项对象进行重试配置.

选项类型默认说明
timeoutstring"5s"最大重试时间
intervalstring"200ms"重试尝试之间的延迟

持续时间字符串支持单位, 如 "200ms", (中文(简体) ), "5s", (中文(简体) ), "1m"。 。 。 .

最佳做法

测试 API 合同而非控制器

E2E测试应说明 aPI 如何从用户的角度进行行为,而不是特定控制器如何在内部执行这种行为。 每次测试的主体应该是API资源,而不是背后的控制器.

为什么? 主计长是一个执行细节。 他们被重新命名,拆分,合并,或重写——但只要API合同不变,用户就不会受到影响. 如果你的测试是用控制器写的, 一个无害的再因子可以打破你的整个测试套件.

// ✅ Good — the subject is the API resource
test("Tenant API creates namespaces for each tenant", async (s) => {
  s.given("a Tenant resource is applied");
  const ns = await s.newNamespace();
  await ns.apply({
    apiVersion: "example.com/v1",
    kind: "Tenant",
    metadata: { name: "acme" },
    spec: { namespaces: ["dev", "staging"] },
  });

  s.then("the Tenant reports Ready=True");
  await ns.assert({
    apiVersion: "example.com/v1",
    kind: "Tenant",
    name: "acme",
    test() {
      expect(this.status?.conditions).toContainEqual(
        expect.objectContaining({ type: "Ready", status: "True" }),
      );
    },
  });
});
// ❌ Bad — the subject is the controller (implementation detail)
test("tenant-controller creates namespaces", async (s) => {
  s.given("tenant-controller is running");
  // ...
  s.then("tenant-controller creates child namespaces");
  // ...
});

同样的原则适用于BDD说明 -- -- 保留 s.given(), (中文(简体) ), s.when(),以及 s.then() 无控制器名称 :

以主计长为中心以API为中心
s.given("tenant-controller is running")s.given("a Tenant resource exists")
s.when("tenant-controller reconciles")s.when("the Tenant spec is updated")
s.then("tenant-controller creates a Namespace")s.then("the expected Namespace exists")

选择在 E2E 中测试什么

E2E 测试对验证很有效 用户可观察的行为 用来核实内部细节 在E2E测试中放置执行细节,使得重构更加困难,而不会给用户任何额外的信心.

E2E(API合同)的优秀候选人:

  • 状态过渡 -- -- 例如抵达的资源 Ready=True 创建后
  • 错误反馈——例如无效的输入产生一个解释性条件,例如 Ready=False, reason=InvalidSpec
  • 面向用户的副作用 -- -- 例如期望用户观测或与之互动的资源
// ✅ Assert a user-observable status condition
await ns.assert({
  apiVersion: "example.com/v1",
  kind: "Database",
  name: "my-db",
  test() {
    expect(this.status?.conditions).toContainEqual(
      expect.objectContaining({ type: "Ready", status: "True" }),
    );
  },
});

最好留给单元/集成测试(实施细节):

  • 内部标签密钥、 注释格式或散列值
  • 用户不直接交互的中间资源
  • 内部调节逻辑和分支
// ❌ Avoid — internal label format is an implementation detail
await ns.assert({
  apiVersion: "example.com/v1",
  kind: "Database",
  name: "my-db",
  test() {
    // This label may change without affecting users
    expect(this.metadata?.labels?.["internal.example.com/config-hash"]).toBe("a1b2c3");
  },
});

当你发现自己想要测试一个中间资源时,请问: "这是公共API合同的一部分吗?" 如果是,请按此记录并测试。 如果不是,则将断言推向更便宜的测试层,并保持E2E专注于用户实际看到的.

组织测试文件

围绕结构测试目录 API 资源,而不是控制器。 这使得测试套件对内部重构具有弹性,并立即告诉读者 aPI 行为 正在验证中.

# ✅ Good — organized by API resource
tests/e2e/
├── tenant-api/
│   ├── creation.test.ts
│   └── deletion.test.ts
├── database-api/
│   └── provisioning.test.ts

# ❌ Bad — organized by controller (implementation detail)
tests/e2e/
├── tenant-controller/
│   ├── creation.test.ts
│   └── deletion.test.ts
├── database-controller/
│   └── provisioning.test.ts

修改方便清单 - "是"回答越多 你的E2E测试越好:

  • 每次测试的对象都是API资源(不是控制器)吗?
  • 读者能单从表率和断言中理解考验吗?
  • 执行 then 断言只检查用户可观测状态( e)status合同产出)?
  • 拆分、合并或重新命名控制器会让所有测试都过去吗?

每个文件一个方案

将一个精确的测试方案放在一个文件中——从一开始,不要"当它变得足够大的时候"

将几个简短的情景组合成一个文件,而其规模很小,这可能会诱人。 但E2E测试增长:断言更丰富,边缘大小写输入出现,调试辅助被添加. 以后拆分 远难于开始 分开,并决定 何时 分开是不必要的判断 最简单的政策是最好的政策:一个方案,一个文件,永远.

tests/e2e/tenant-api/
├── creates-namespaces-for-each-tenant.test.ts
├── rejects-duplicate-tenant-names.test.ts
├── updates-status-on-namespace-failure.test.ts
└── deletes-child-namespaces-on-removal.test.ts

为什么不"当它变得大"?

  • 测试逐渐地增长——没有任何一项承诺感到像"分裂的时刻",所以从来没有发生.
  • 追溯分拆一个文件意味着重写导入,移动固定装置,并触碰同一PR中无关的测试.
  • 门槛本身就成了争论("250行是否太多? (第400条?) 普遍规则消除了讨论.

从一个文件开始,每个场景的预留室要增长。 每个文件都有内置的头室,用于更丰富的断言,额外的设置步骤,以及调试说明——而不会挤近邻居.

你得到的东西是:

  • 自足读取 -- 打开一个文件, 查看完整的 Given/ When/ 然后, 不滚动过去无关的情景 .
  • 外科医生 - 改变完全涉及一种情况,使公关规模小,审查重点突出.
  • 未能作为地址 ——一个失败的文件名称告诉你,在读取一行输出之前,哪个API合同破了.
  • 二. 无冲突的合作 --队友编辑不同的文件,而不是同一文件的不同章节.

在他们核实的行为后命名文件 . 阅读者应该知道什么是测试检查而不打开文件:

不错- 不好 - 不错 - Bad
creates-namespaces-with-labels.test.tstenant-test-1.test.ts
rejects-reserved-selector-labels.test.tsvalidation.test.ts
rolls-out-when-image-changes.test.tsdeployment-tests.test.ts

例外情况. 同一个API的微小的负大小写变异——每个大小写只是几行,总是一起阅读(例如边界条件列表)——可能共享一个文件. 当你这样做的时候,留下一个评论来解释原因,因为例外很容易成为规范.

一份清单:

  • 每个测试文件是否包含完全一种情景?
  • 文件名称是否描述测试中的行为( 不是控制器) ?
  • 如果一个文件有多种情景,是否有评论证明例外是合理的?

在测试中显示显示

E2E测试双倍作为活文件. 每个测试应读作自成一体的规格:应用的(给予)、变化的(何时)和应当真实的(然后)。 当一个帮助者函数集合整个显示在幕后时,测试机构可能 听着 更干净 -- 但是它不再作为文档使用,因为读者再也看不到实际输入.

// ❌ Bad — looks tidy, but the actual manifest is hidden inside makeDeployment().
//    A reader cannot tell what is being applied without jumping to the helper.
//    The diff between v1 and v2 is buried in a parameter change.
function makeDeployment(name: string, image: string) {
  return {
    apiVersion: "apps/v1",
    kind: "Deployment",
    metadata: { name, labels: { app: name } },
    spec: {
      replicas: 2,
      selector: { matchLabels: { app: name } },
      template: {
        metadata: { labels: { app: name } },
        spec: { containers: [{ name: "app", image }] },
      },
    },
  };
}

test("rollout updates the image", async (s) => {
  const ns = await s.newNamespace();
  await ns.apply(makeDeployment("my-app", "my-app:v1"));
  await ns.apply(makeDeployment("my-app", "my-app:v2"));
  // What exactly changed? You have to read makeDeployment to find out.
});
// ✅ Good — the full manifest is right here; the diff between steps is obvious.
test("rollout updates the image", async (s) => {
  const ns = await s.newNamespace();

  s.when("I apply a Deployment at v1");
  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: "my-app:v1" }] },
      },
    },
  });

  s.when("I update to v2");
  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: "my-app:v2" }] },
        //                                          ^^^^^^^^^ the diff
      },
    },
  });
});

在E2E测试中, 可读性比 DRY 强. 重复的运单使每次测试都能够自我解释,并使故障报告容易与测试源相匹配。 注意到 ^^^ the diff 以上例子中的评论——增加一个简短的内行评论来突出关键变化,是一种简单的技术,使意图更加清晰.

当清单变得过大,无法舒适地内入时,请将其取入 静态固定文件 而不是帮助函数。 YAML 和 TypeScript 文件的工作 :

// ✅ Good — static fixture files keep the input visible at a glance
await ns.apply(import("./fixtures/deployment-v1.yaml"));
await ns.apply(import("./fixtures/deployment-v2.yaml"));

// TypeScript files work too — useful when converting from inline objects
await ns.apply(import("./fixtures/deployment-v1.ts"));

明显清单:

  • 你能通过只读测试文件来理解全部输入吗?
  • 测试步骤之间的diff是否可视为光谱级变化?
  • 你能单独从失败报告中 重建应用清单吗?

用于更新的突变的再使用表

"保持显出可见"的原则在每一个 apply 独立。 不过在 更新设想 - 如果一个测试以小的改动两次应用同一资源 -- 重复整个显示会模糊意图。 读者得在精神上分出两个大块 才能发现实际的变化:

// ❌ Bad — the two manifests are nearly identical.
//    The reader must scan every line to find the one difference.
test("scaling up increases available replicas", async (s) => {
  const ns = await s.newNamespace();

  s.when("I apply a Deployment with 1 replica");
  await ns.apply({
    apiVersion: "apps/v1",
    kind: "Deployment",
    metadata: { name: "my-app" },
    spec: {
      replicas: 1,
      selector: { matchLabels: { app: "my-app" } },
      template: {
        metadata: { labels: { app: "my-app" } },
        spec: { containers: [{ name: "app", image: "nginx" }] },
      },
    },
  });

  s.when("I scale to 3 replicas");
  await ns.apply({
    apiVersion: "apps/v1",
    kind: "Deployment",
    metadata: { name: "my-app" },
    spec: {
      replicas: 3, // <-- the only change, buried in a wall of duplication
      selector: { matchLabels: { app: "my-app" } },
      template: {
        metadata: { labels: { app: "my-app" } },
        spec: { containers: [{ name: "app", image: "nginx" }] },
      },
    },
  });

  s.then("the Deployment should have 3 available replicas");
  await ns.assert({
    apiVersion: "apps/v1",
    kind: "Deployment",
    name: "my-app",
    test() {
      expect(this.status?.availableReplicas).toBe(3);
    },
  });
});

反之,一次定义表,然后使用 structuredClone 创建副本, 并只变异情景所关心的字段 。 变异线 为: 他们告诉读者到底有什么变化:

// ✅ Good — the base manifest is visible, and the mutation is the scenario.
test("scaling up increases available replicas", async (s) => {
  const ns = await s.newNamespace();

  s.when("I apply a Deployment with 1 replica");
  const deployment = {
    apiVersion: "apps/v1" as const,
    kind: "Deployment" as const,
    metadata: { name: "my-app" },
    spec: {
      replicas: 1,
      selector: { matchLabels: { app: "my-app" } },
      template: {
        metadata: { labels: { app: "my-app" } },
        spec: { containers: [{ name: "app", image: "nginx" }] },
      },
    },
  };
  await ns.apply(deployment);

  s.when("I scale to 3 replicas");
  const scaled = structuredClone(deployment);
  scaled.spec.replicas = 3;
  await ns.apply(scaled);

  s.then("the Deployment should have 3 available replicas");
  await ns.assert({
    apiVersion: "apps/v1",
    kind: "Deployment",
    name: "my-app",
    test() {
      expect(this.status?.availableReplicas).toBe(3);
    },
  });
});

structuredClone 是一个标准的API(没有依赖性),它创建了真实的深层复制,所以突变永远不会影响原作. 底物表在测试中保持了完全可见,而突变线条使得意向不可混淆.

何时使用这种方法:

假设方针
每个 apply 是不同的资源或独立输入将每个表单内划出( 显示表显示)
同一资源经小改动后两次应用structuredClone + 目标突变
货单太大了 无法舒适地融入静态固定文件( E)import("./fixtures/...")(中文(简体) )

变异可读性清单:

  • 在试验中,基准是否被确定一次并完全可见?
  • 只有与情景相关的领域有变异吗?
  • 读者能只读变异行来理解更新吗?

避免测试之间发生命名相冲突

当测试平行运行时,硬码资源名可以相撞. 在大多数情况下, newNamespace() 每一个测试都得到自己的命名空间,所以名字就像 "my-config" 或者说 "my-app" 已隔离 :

const ns = await s.newNamespace();

await ns.apply({
  apiVersion: "v1",
  kind: "ConfigMap",
  metadata: { name: "app-config" }, // safe — no other test shares this namespace
  data: { mode: "test" },
});

由于命名空间本身是独一无二的,所以它内部的资源名称不需要随机化. 你们只需要 s.generateName 何时 newNamespace 单靠自己是不够的.

一个共同的错误是 达到 s.generateName 打开 每个 资源"只是为了安全。" 当资源已经处于一个孤立的命名空间时, 这使得测试更难读取和调试:

// ❌ Bad — generateName on namespaced resources inside an isolated namespace.
//    The random suffixes add noise without preventing any actual collisions.
const ns = await s.newNamespace();

const configName = s.generateName("cfg-");
const deployName = s.generateName("deploy-");

await ns.apply({
  apiVersion: "v1",
  kind: "ConfigMap",
  metadata: { name: configName }, // "cfg-x7k2m" — hard to find in logs
  data: { mode: "test" },
});
await ns.apply({
  apiVersion: "apps/v1",
  kind: "Deployment",
  metadata: { name: deployName }, // "deploy-p3n8r" — what was this test about?
  // ...
});
// ✅ Good — fixed, descriptive names inside an isolated namespace.
//    The namespace already guarantees no collisions. Fixed names are
//    easy to read, easy to grep in logs, and match the failure report.
const ns = await s.newNamespace();

await ns.apply({
  apiVersion: "v1",
  kind: "ConfigMap",
  metadata: { name: "app-config" },
  data: { mode: "test" },
});
await ns.apply({
  apiVersion: "apps/v1",
  kind: "Deployment",
  metadata: { name: "my-app" },
  // ...
});

何时使用 s.generateName数字 :

情况方针
内有命名空间的资源 newNamespace()使用固定名称( 默认)
先前存在的命名空间中的资源( U)useNamespace()(中文(简体) )使用固定名称( 默认)
在一个测试中创建多次同类资源s.generateName 或编号的固定名称
集群资源(如:. ClusterRole, (中文(简体) ), CRD(中文(简体) )s.generateName (没有命名空间隔离)
固定名称造成意外上升/副作用s.generateName

选择 必要和充分的 而不是"安全的一面"——每个随机后缀都是可读性的取舍.

两位帮手的工作方式:

  • newNamespace(...) 创建一个 Namespace 通过 kubectl create 并重试名称相撞(每次尝试都生成一个新名称),所以一旦成功,命名空间名称就保证在集群中独一无二.
  • s.generateName(...) 是一个纯字符串助手,提供 统计独有性 只有(连结极不可能,但不是不可能).

使用 newNamespace 与 .name (交叉命名空间参考):

每个 Namespace 对象返回 newNamespace 有一个 .name 属性。 当一个命名空间中的资源需要按名称引用另一个命名空间时——例如交叉命名空间中 spec.*Ref.namespace 字段 -- -- 使用 .name数字 :

const nsA = await s.newNamespace({ generateName: "infra-" });
const nsB = await s.newNamespace({ generateName: "app-" });

s.when("I apply a resource in nsB that references nsA");
await nsB.apply({
  apiVersion: "example.com/v1",
  kind: "AppConfig",
  metadata: { name: "my-app" },
  spec: {
    secretStoreRef: {
      namespace: nsA.name, // e.g. "infra-k7rtn"
      name: "vault",
    },
  },
});

使用 s.generateName (用于集群资源):

用于集群资源(其中: newNamespace (不适用),使用 s.generateName数字 :

s.given("a cluster-scoped resource name should not collide with other tests");
const roleName = s.generateName("kest-e2e-role-");

await s.create({
  apiVersion: "rbac.authorization.k8s.io/v1",
  kind: "ClusterRole",
  metadata: { name: roleName },
  rules: [
    {
      apiGroups: [""],
      resources: ["configmaps"],
      verbs: ["get", "list"],
    },
  ],
});

命名- 缩写清单 :

  • 内有命名空间资源 newNamespace 使用固定名称( 不) generateName{\fn黑体\fs22\bord1\shad0\3aHBE\4aH00\fscx67\fscy66\2cHFFFFFF\3cH808080}你觉得呢?
  • 这是 generateName 是否保留用于集群资源或多事件?
  • 您能否在不解码随机后缀的情况下识别失败报告中的所有资源 ?

使用 toMatchObject 没有类型参数的断言

当您从断言动作中省略类型参数时(assert, (中文(简体) ), assertList, (中文(简体) ), assertOne(例如), this 输入为通用 K8sResource自定义字段 喜欢 spec 变成 unknown,强制类型投放在每个访问:

// ❌ Without a type parameter, every field access needs a cast
await ns.assert({
  apiVersion: "example.com/v1",
  kind: "MyResource",
  name: "my-instance",
  test() {
    expect((this.spec as any).replicas).toBe(3);
    expect((this.spec as any).image).toBe("my-app:latest");
  },
});

使用 toMatchObject 相反,它接受一个普通对象,并检查是否 资源包含预期子集,不需要缩小类型:

// ✅ toMatchObject — no casts, no type parameter needed
await ns.assert({
  apiVersion: "example.com/v1",
  kind: "MyResource",
  name: "my-instance",
  test() {
    expect(this).toMatchObject({
      spec: {
        replicas: 3,
        image: "my-app:latest",
      },
    });
  },
});

何时使用:

  • 带有类型参数 (例如:. assert<MyResource>· 风格作品; this 完全输入,所以这是一个偏好问题.
  • 无类型参数 ——始终使用 toMatchObject。 。 。 . this.spec 实值 unknown,以及 toMatchObject 自然而然地,你增加了更多的断言.

更喜欢一整组的主张 超过在 assertList

避免 for 内部循环 assertList ——当一个断言失败时,错误无法识别导致它的哪个项目:

// ❌ Failure messages are opaque — which Secret failed?
test() {
  for (const s of this) {
    expect(s.metadata.labels).toMatchObject({ app: "my-app" });
  }
}

// ✅ Assert the whole array — full per-item diff on failure
test() {
  expect(this).toMatchUnordered([
    { metadata: { name: "secret-a", labels: { app: "my-app" } } },
    { metadata: { name: "secret-b", labels: { app: "my-app" } } },
  ]);
}

toMatchUnordered 使用深方部分匹配( 如 toMatchObject)但忽略了秩序. 匹配器检查阵列长度- 使用 toHaveLength 当你需要精确的数.

匹配者对秩序敏感匹配
toMatchObject([...])对深度部分
toMatchUnordered([...])没有深度部分

类型安全

为您的 Kubernetes 资源定义 TypeScript 接口, 以获取在清单和断言中的全部类型检查 :

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);
  },
});

提示

调试失败的测试

默认情况下,凯斯每次测试后都会清理出所有资源——即使在测试失败时也是如此. 这保持了集群的整齐,但破坏了您调试所需的状态. 为在失败时保存资源, 设置 KEST_PRESERVE_ON_FAILURE=1数字 :

KEST_PRESERVE_ON_FAILURE=1 bun test

当活动时, Kest 跳过失败情景的清理, 这样您就可以用 kubectl。测试报告将显示“清除(滑动)”通知,而不是通常的清理表。 记得在调查结束后手动删除剩余资源.

Biome 配置

如果您使用 生物体 闪闪发光 noDoneCallback 规则可以旗下凯斯 test 调用参数作为假正数 。 参数为 Scenario 对象,不是已完成的回调.

为了抑制这种情况, 扩展 Kest 可分享的 Biome 配置 biome.json数字 :

{
  "extends": ["@appthrust/kest/biome/recommended.json"]
}

此禁用 lint/style/noDoneCallback 却置其他规则于不顾 如果您已经扩展了另一个配置, 在后添加 kest 的配置 :

{
  "extends": ["@suin/biome.json", "@appthrust/kest/biome/recommended.json"]
}

环境变量

变量说明
KEST_SHOW_REPORT设定为 "1" 显示所有测试的 Markdown 报告( 不仅仅是失败)
KEST_SHOW_EVENTS设定为 "1" 用于调试的原始记录器事件
KEST_PRESERVE_ON_FAILURE设定为 "1" 当测试失败时跳过清理, 保存组态用于调试

许可证

麻省理工学院

版本与发布记录

@appthrust/kest 共发布 25 个版本,首次发布于 2026/02/06;过去 12 个月发布了 25 个版本。

版本发布日期距今
0.18.0latest2026/04/136 个月前
0.17.12026/03/177 个月前
0.17.02026/03/068 个月前
0.16.02026/03/048 个月前
0.15.02026/03/028 个月前
0.14.02026/03/028 个月前

关于 @appthrust/kest 的常见问题

@appthrust/kest 是什么?

Kubernetes E2E测试框架为人类和AI设计

如何安装 @appthrust/kest?

在项目目录运行 npm install @appthrust/kest,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:typescript(^5)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@appthrust/kest 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@appthrust/kest 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@appthrust/kest 可以商用吗?

@appthrust/kest 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@appthrust/kest 的下载量有多少?

根据 npm 官方下载统计,@appthrust/kest 在 2026/10/07 – 2026/11/05 这 30 天内被下载 11,246 次,日均约 375 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@appthrust/kest 还在维护吗?

最新版本 0.18.0 发布于 2026/04/13(6 个月前);至今共发布 25 个版本,其中过去 12 个月发布了 25 个;首次发布于 2026/02/06。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。