# @appthrust/kest 中文文档

规范地址：https://npmcn.com/package/%40appthrust/kest/

版本：0\.18\.0
npm 元数据更新时间：2026-04-13T00:33:12.937Z
中文译文更新时间：2026-11-06T12:41:43.426Z
软件包许可：MIT

本文由上游 README 翻译，可能存在偏差，请结合原文核对 API、命令与配置。版权与许可归原作者所有。

原始 npm 页面：https://www.npmjs.com/package/%40appthrust/kest
原文对照：https://npmcn.com/package/%40appthrust/kest/?lang=original
项目主页：https://github.com/appthrust/kest#readme
源码仓库：https://github.com/appthrust/kest

## 关键信息（来自 npm 元数据）

- 包体积（解压后）：258 kB，54 个文件
- 模块格式：ES Module
- TypeScript 类型：包内自带
- 运行依赖：2 个
- peer 依赖：typescript ^5
- 首次发布：2026-02-06T01:25:01.631Z
- 最新版本发布：2026-04-13T00:33:12.398Z
- 近 30 天下载量：11246（2026-10-07 至 2026-11-05，npm 官方下载统计）
- 已发布版本数：25（近 12 个月 25 个）

## 中文简介

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

## 安装

```sh
npm install @appthrust/kest
```

也可以使用：`pnpm add @appthrust/kest`、`yarn add @appthrust/kest`、`bun add @appthrust/kest`

## README

# 凯斯

![Kest – Kubernetes E2E testing designed for humans and AI alike](https://npmcn.com/package/%40appthrust/kest/social-preview.svg)

> **预览发布** - Kest 已经进入 `0.x` 预览。 API可能根据反馈而改变. 断裂变化可以在任意 `0.x` 释放。 一个马厩 `1.0.0` 一旦API得到巩固,将释放。 随便你 [打开一个问题](https://github.com/appthrust/kest/issues/new) 如果你有任何反馈.

**Kubernetes E2E测试为人类和AI设计**

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

```ts
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`) (中文(简体) ). 资源仅限于这个命名空间,消除了测试之间的干扰并使得能够安全地平行执行. 测试结束时, 命名空间会被删除 .

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

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

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

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

```ts
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()` 打电话.

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

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

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

### 创建资源

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

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

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

### 多种多形式

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

```ts
// 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"));
```

### 多组支持

跨越多组的测试情景:

```ts
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:

```ts
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`数字 :

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

#### CAPI 动态集群

凯斯特可以解决 [分组 API](https://cluster-api.sigs.k8s.io/) (CAPI) 自动提供集群. 通过 a `ClusterResourceReference` 改为 `useCluster` kest将:

1.  传票CAPI `Cluster` 资源,直到报告准备好
2.  从中获取 kubeconfig `<name>-kubeconfig` 密钥( 数据密钥) `value`(中文(简体) )
3.  写入临时 kubeconfig 文件并将 kubectl 绑入其中
4.  注册清除以便在测试结束时删除临时文件

```ts
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/v1beta1` | `Ready` |
| `cluster.x-k8s.io/v1beta2` | `Available` |

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

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

### 次级资源支助

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

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

### 列表请求

抵制资源收集:

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

### 单资源列表

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

```ts
// 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` 在存在多种资源的情况下,向狭义候选人过渡:

```ts
// 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` 如果零或多个资源匹配,则丢出,并重试,直到找到一个或超时结束.

### 缺席

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

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

### 请求错误

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

```ts
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 被同步设置时, 这是有用的 :

```ts
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`数字 :

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

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

### 标签资源

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

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

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

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

### Shell 命令执行

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

```ts
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- 系统报告

可读产出的结构测试:

```ts
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输出,以及清理结果。 这为测试期间发生的事情提供了充分的透明度,使解决问题变得直截了当 -- -- 对人和人工智能助理来说都是如此.

````markdown
# 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
```
````

## 开始

### 先决条件

-   [阿宝](https://bun.sh/) v1.3.8 或较后
-   `kubectl` 配置为 Kubernetes 集群
-   一个运行中的Kubernetes集群(例如. [类型](https://kind.sigs.k8s.io/), (中文(简体) ), [微调](https://minikube.sigs.k8s.io/),或远程集群)

### 安装

```sh
bun add -d @appthrust/kest
```

### 类型脚本配置

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

```sh
bun add -D @types/bun @tsconfig/bun
```

```json
// tsconfig.json
{
  "extends": "@tsconfig/bun"
}
```

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

### 写您的第一次测试

创建测试文件, 例如 . `my-operator.test.ts`数字 :

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

### 运行测试

```sh
bun test
```

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

```sh
KEST_SHOW_REPORT=1 bun test
```

## API 参考

### `test(label, callback, options?)`

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

| 选项 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `timeout` | `string` | `"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` 属性 :

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

### 行动选项

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

| 选项 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `timeout` | `string` | `"5s"` | 最大重试时间 |
| `interval` | `string` | `"200ms"` | 重试尝试之间的延迟 |

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

## 最佳做法

### 测试 API 合同而非控制器

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

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

```ts
// ✅ 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" }),
      );
    },
  });
});
```

```ts
// ❌ 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`
-   面向用户的副作用 -- -- 例如期望用户观测或与之互动的资源

```ts
// ✅ 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" }),
    );
  },
});
```

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

-   内部标签密钥、 注释格式或散列值
-   用户不直接交互的中间资源
-   内部调节逻辑和分支

```ts
// ❌ 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.ts` | `tenant-test-1.test.ts` |
| `rejects-reserved-selector-labels.test.ts` | `validation.test.ts` |
| `rolls-out-when-image-changes.test.ts` | `deployment-tests.test.ts` |

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

**一份清单:**

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

### 在测试中显示显示

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

```ts
// ❌ 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.
});
```

```ts
// ✅ 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 文件的工作 :

```ts
// ✅ 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` 独立。 不过在 **更新设想** - 如果一个测试以小的改动两次应用同一资源 -- 重复整个显示会模糊意图。 读者得在精神上分出两个大块 才能发现实际的变化:

```ts
// ❌ 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` 创建副本, 并只变异情景所关心的字段 。 变异线 **为:** 他们告诉读者到底有什么变化:

```ts
// ✅ 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"` 已隔离 :

```ts
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` 打开 _每个_ 资源"只是为了安全。" 当资源已经处于一个孤立的命名空间时, 这使得测试更难读取和调试:

```ts
// ❌ 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?
  // ...
});
```

```ts
// ✅ 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`数字 :

```ts
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`数字 :

```ts
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`,强制类型投放在每个访问:

```ts
// ❌ 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` 相反,它接受一个普通对象,并检查是否 资源包含预期子集,不需要缩小类型:

```ts
// ✅ 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` ——当一个断言失败时,错误无法识别导致它的哪个项目:

```ts
// ❌ 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 接口, 以获取在清单和断言中的全部类型检查 :

```ts
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`数字 :

```sh
KEST_PRESERVE_ON_FAILURE=1 bun test
```

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

### Biome 配置

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

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

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

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

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

## 环境变量

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

## 许可证

[麻省理工学院](https://npmcn.com/package/%40appthrust/kest/LICENSE)

## 常见问题

### @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 和提交记录判断。

