测试 Compact 合约:单元测试、断言与本地仿真

TL;DR

如果你想为 Midnight Compact 合约写出可靠的测试,建议把测试套件拆成两层:

  1. Unit 风格的 simulator 测试:运行快,在本地实例化编译后的合约,调用 circuits,并对 public ledger 的变化做断言。
  2. Integration 测试:用同样的流程跑一遍 本地 Docker stack,这样在 CI 或代码评审之前,就能提前发现环境、连接配置和 transaction 提交相关的问题。

核心模式是:围绕 Compact 编译器生成的 Contract class,做一层很薄的 simulator wrapper。这个 wrapper 需要把三件事做好:

然后,用 Vitest 做断言,并把仓库结构组织成这样:

一个重要约束是:Compact language reference 和 getting-started 文档描述了 编译产物,,包括 JS/TS driver 和 contract metadata,,但运行时 Contract class 的具体 generated TypeScript API surface 是带版本的,应该以你本地编译器生成的结果为准,不能靠文档脑补。在这篇草稿里,Compact 代码使用的是官方 language reference 中已验证过的语法,而 TypeScript test harness 则采用稳定的 adapter pattern,你可以直接把它绑定到自己仓库里生成出来的 Contract class 上。请把构建输出中的 generated .d.ts 文件当成 method name 和 constructor parameter 的唯一事实来源。参考 Midnight Getting Started 以及文档中链接的 Compact language reference。

Context

在 Midnight 上做测试,和测试典型的 EVM 合约有点不一样,因为 Compact 合约编译出来的不止一个 artifact。根据 Midnight 文档,编译产物包括:

另外 verifier keys 会在链上用于 proof verification。这意味着你的测试不只是验证业务逻辑;它还在验证应用代码是如何驱动 generated contract runtime 的,以及 circuit 执行后 ledger 的变化是怎样体现出来的。官方文档见:Midnight Getting Started 和开发者文档中的 Compact language reference。

这篇教程会严格按照 bounty checklist 来写:

同时我也会守住一个重要边界:我不会虚构文档里没有写过的 Midnight runtime API。凡是 Contract 上那些跟 compiler version 绑定的 method name,我都会明确说明,并展示安全做法:检查 generated driver 和 .d.ts 文件,然后把 simulator 绑定到真实的 surface 上。关于脚手架约定,bounty brief 里最值得参考的公开示例是 example-battleship

Building a contract simulator using the Contract class

simulator 不是 mock。它是一个对真实编译后 contract runtime 的小型 wrapper。这个 wrapper 的目的,是让测试代码保持可读、稳定,即使 generated driver 的 API 比较冗长,或者会随版本变化。

Start with a minimal Compact contract

对于测试教程来说,一个很小的合约通常比功能很多的合约更合适,因为每次 state transition 都更容易推理。下面这个 Compact 合约使用的是官方 reference 已验证过的语法:

export ledger value: Uint<32>;

export circuit setValue(x: Uint<32>): [] {
  value = disclose(x);
}

export circuit add(x: Uint<32>): [] {
  value = disclose(value + x);
}

这个例子演示了两个可测试行为:

基于文档,这里补充几点说明:

What the compiler gives you

Midnight 文档说明,编译器会产出 JS runtime 和 TypeScript definitions。实际开发里,你的测试就应该实例化这个 generated runtime。bounty 明确要求使用编译产物里的真实 Contract class,所以不要在 JavaScript 里重写合约语义。直接使用 generated class。

一个实用的仓库布局大概是这样:

.
├── contracts/
│   └── counter.compact
├── build/
│   └── counter/
│       ├── contract.js
│       ├── contract.d.ts
│       ├── contract-info.json
│       └── ...
├── src/
│   └── simulator/
│       ├── counter-simulator.ts
│       └── generated-driver.ts
├── test/
│   ├── unit/
│   │   └── counter-simulator.test.ts
│   └── integration/
│       └── counter-stack.test.ts
├── docker-compose.yml
├── package.json
├── tsconfig.json
└── .github/
    └── workflows/
        └── ci.yml

Why use a wrapper at all?

两个原因:

  1. 测试应该表达 domain behavior,而不是 driver plumbing。
    await simulator.add(4n) 比起一串 generated runtime 调用再加 witness 或 context setup,明显更易读。

  2. generated API 可能随 compiler version 变化。
    你的 wrapper 就是 generated code 和测试之间的接缝。runtime shape 变了,通常只需要改一个文件,而不是整套测试全改。

A safe adapter pattern

由于 bounty brief 没有文档化 Contract 上的具体 generated methods,所以应该先定义一个很小的 interface,让 simulator 面向这个 interface 编程;然后再由本地 build 产出的 generated class 去实现它。

// src/simulator/generated-driver.ts

export interface CounterDriver {
  setValue(x: bigint): Promise<void>;
  add(x: bigint): Promise<void>;
  readLedger(): Promise<{ value: bigint }>;
}

接着,让 simulator 基于这个 driver 来写:

// src/simulator/counter-simulator.ts

import type { CounterDriver } from "./generated-driver";

export class CounterSimulator {
  public constructor(private readonly driver: CounterDriver) {}

  public async setValue(x: bigint): Promise<void> {
    await this.driver.setValue(x);
  }

  public async add(x: bigint): Promise<void> {
    await this.driver.add(x);
  }

  public async value(): Promise<bigint> {
    const ledger = await this.driver.readLedger();
    return ledger.value;
  }
}

这段代码刻意写得很“无聊”。这是好事。你的 simulator 不应该承载业务逻辑。它的职责,是在 generated runtime 之上提供一个稳定的测试 surface。

Binding the real generated Contract class

这一步必须以你实际编译产出的内容为准。打开 generated .d.ts 文件,把真实 methods 映射到 driver interface 上。

一个带版本相关性的实现,从概念上会像这样:

// src/simulator/generated-contract-driver.ts

import type { CounterDriver } from "./generated-driver";
import { Contract } from "../../build/counter/contract.js";

// Replace constructor args and method names below with the exact
// ones emitted by your compiler version. Use build/counter/contract.d.ts
// as the source of truth.
export class GeneratedCounterDriver implements CounterDriver {
  private readonly contract: InstanceType<typeof Contract>;

  public constructor() {
    this.contract = new Contract();
  }

  public async setValue(x: bigint): Promise<void> {
    // Replace with actual generated circuit invocation.
    await this.contract.setValue(x);
  }

  public async add(x: bigint): Promise<void> {
    // Replace with actual generated circuit invocation.
    await this.contract.add(x);
  }

  public async readLedger(): Promise<{ value: bigint }> {
    // Replace with actual generated ledger read API.
    const ledger = await this.contract.ledger();
    return { value: BigInt(ledger.value) };
  }
}

这里的 method body 是仓库里唯一依赖 generated runtime shape 的地方。其他内容,,测试、CI、仓库结构,,都可以保持稳定。

What belongs in the simulator

让 simulator 专注于提升测试可用性:

不要把重要语义藏起来。比如,如果真实合约需要显式的 witness setup 或 transaction context,那就让 simulator 用清晰的方式暴露出来,而不是假装这些东西不存在。

Writing Vitest tests for circuit calls and ledger state

有了 simulator 之后,unit tests 就会非常直接。目标是证明三件事:

  1. circuit 能被成功调用,
  2. ledger 的变化和预期完全一致,
  3. 多次调用之后 state 仍能正确地在各次 transition 之间保留下来。

Install and configure Vitest

Vitest 很适合 TypeScript 仓库,也很适合跑在 CI 里。官方文档见:Vitest

一个最小可用的 package.json script 配置:

{
  "type": "module",
  "scripts": {
    "build": "npm run compile:contracts && tsc -p tsconfig.json",
    "test": "vitest run",
    "test:unit": "vitest run test/unit",
    "test:integration": "vitest run test/integration",
    "test:watch": "vitest"
  },
  "devDependencies": {
    "typescript": "^5.6.0",
    "vitest": "^2.1.0"
  }
}

以及一个最小的 Vitest config:

// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
    include: ["test/**/*.test.ts"]
  }
});

Unit tests should assert behavior, not implementation details

一个好的起步测试,是验证一次简单的 ledger 写入:

// test/unit/counter-simulator.test.ts

import { beforeEach, describe, expect, it } from "vitest";
import { CounterSimulator } from "../../src/simulator/counter-simulator";
import { GeneratedCounterDriver } from "../../src/simulator/generated-contract-driver";

describe("CounterSimulator", () => {
  let simulator: CounterSimulator;

  beforeEach(() => {
    simulator = new CounterSimulator(new GeneratedCounterDriver());
  });

  it("sets the ledger value", async () => {
    await simulator.setValue(7n);
    await expect(simulator.value()).resolves.toBe(7n);
  });

  it("adds to the existing ledger value", async () => {
    await simulator.setValue(10n);
    await simulator.add(5n);

    await expect(simulator.value()).resolves.toBe(15n);
  });

  it("preserves state across multiple circuit calls", async () => {
    await simulator.setValue(1n);
    await simulator.add(2n);
    await simulator.add(3n);
    await simulator.add(4n);

    await expect(simulator.value()).resolves.toBe(10n);
  });
});

这些测试虽然很小,但已经覆盖了合约的主要职责:

Assert on failures too

只有正向测试还不够。如果你的合约对输入有限制,就应该补上负向测试,验证 driver 会拒绝非法调用。具体的 failure shape 取决于 generated runtime,所以除非你完全掌控错误类型,否则断言尽量保守。

例如:

it("rejects values outside the contract's accepted range", async () => {
  await expect(simulator.setValue(-1n)).rejects.toBeDefined();
});

如果 runtime 会抛出 richer errors,优先断言稳定的片段,而不是脆弱的整段 message 字符串。

Keep ledger assertions explicit

测试 Compact 合约时,public ledger 往往是最重要的可观察效果。对 ledger 的断言应该直接、清晰:

it("writes the expected final ledger state", async () => {
  await simulator.setValue(20n);
  await simulator.add(22n);

  const ledgerValue = await simulator.value();
  expect(ledgerValue).toBe(42n);
});

一个常见错误是,只测试 circuit 调用“不会抛异常”。这种测试几乎说明不了任何问题。真正有用的测试,一定会验证 postcondition。

Unit-style simulator tests

Unit 风格测试是你最快的反馈回路。它们应该在每次本地保存时都能跑,也应该在每个 pull request 上都跑。

What “unit-style” means here

对 Compact 合约来说,这里的 “unit-style” 是指用普通 JavaScript 去 mock 合约逻辑。它的意思是:

这也是为什么 simulator 很重要。它让你可以测试真实的 contract runtime,而不需要在每个测试里都把整套 stack 拖进来。

对大多数合约来说,Unit 风格的 simulator 测试应该覆盖:

1. Initialization and first write

合约能否接受第一次合法的 state transition?

it("accepts the first write", async () => {
  await simulator.setValue(1n);
  await expect(simulator.value()).resolves.toBe(1n);
});

2. Sequential state transitions

state 能否在多次调用中按顺序正确累积?

it("applies sequential updates in order", async () => {
  await simulator.setValue(3n);
  await simulator.add(3n);
  await simulator.add(3n);

  expect(await simulator.value()).toBe(9n);
});

3. Boundary inputs

Compact 的定长整数类型让 boundary testi



下一步去哪里

读到这里很感谢。如果 “Compact 合约测试 (Vitest + CI)” 这个话题正好契合你目前的工作,下面三条路径任选其一:

继续学习 Midnight

完整的 Midnight ZK Cookbook 索引 覆盖 Midnight、Aleo、Aztec、Noir、risc0 共 17 篇英文教程加 4 篇中文翻译,按生态分组列出。

接 Midnight 的付费活

Bounty Radar 聚合 Algora、GitHub labels、Drips Wave、Code4rena、Bountycaster 的开放 ZK bounty。Midnight 子 feed 只列出该生态匹配项,JSON 在 /midnight.json。免费版需轮询;$19/月 Hobbyist 档 会把一个 filter 实时推送到 Telegram。

给自己的 ZK 项目做体检

zk-pipeline-doctor 是 MIT 开源的免费 CLI,对任意 ZK 项目按测试、CI、文档、安全、可复现性、语言工具链六个维度打分(支持 Compact、Leo、Noir、Cairo 和 7 个 Rust zkVM)。用 zk-doctor-action 接进 GitHub Action 可获得 PR diff 评论。$15/月 Pro 档 增加 4 个跨生态深度检测器(电路复杂度、证明系统坑点、verifier soundness、多文件一致性)。


由 AI 辅助起草,作者在发布前逐行审阅。完整流程见 DISCLOSURE

Related projects