如果你想为 Midnight Compact 合约写出可靠的测试,建议把测试套件拆成两层:
核心模式是:围绕 Compact 编译器生成的 Contract class,做一层很薄的 simulator wrapper。这个 wrapper 需要把三件事做好:
然后,用 Vitest 做断言,并把仓库结构组织成这样:
test/unit 负责本地 simulation,test/integration 负责 Docker stack,一个重要约束是: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。
在 Midnight 上做测试,和测试典型的 EVM 合约有点不一样,因为 Compact 合约编译出来的不止一个 artifact。根据 Midnight 文档,编译产物包括:
另外 verifier keys 会在链上用于 proof verification。这意味着你的测试不只是验证业务逻辑;它还在验证应用代码是如何驱动 generated contract runtime 的,以及 circuit 执行后 ledger 的变化是怎样体现出来的。官方文档见:Midnight Getting Started 和开发者文档中的 Compact language reference。
这篇教程会严格按照 bounty checklist 来写:
Contract class 构建 contract simulator,同时我也会守住一个重要边界:我不会虚构文档里没有写过的 Midnight runtime API。凡是 Contract 上那些跟 compiler version 绑定的 method name,我都会明确说明,并展示安全做法:检查 generated driver 和 .d.ts 文件,然后把 simulator 绑定到真实的 surface 上。关于脚手架约定,bounty brief 里最值得参考的公开示例是 example-battleship。
Contract classsimulator 不是 mock。它是一个对真实编译后 contract runtime 的小型 wrapper。这个 wrapper 的目的,是让测试代码保持可读、稳定,即使 generated driver 的 API 比较冗长,或者会随版本变化。
对于测试教程来说,一个很小的合约通常比功能很多的合约更合适,因为每次 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);
}
这个例子演示了两个可测试行为:
setValue 会写入 public ledger,add 会读取当前 ledger value,计算出新值,再写回去。基于文档,这里补充几点说明:
ledger 声明定义的是公开的链上 state。circuit 声明是可调用的 entry point。disclose(...) 是 language reference 里展示过的、把值写入 public ledger state 的模式。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
两个原因:
测试应该表达 domain behavior,而不是 driver plumbing。
await simulator.add(4n) 比起一串 generated runtime 调用再加 witness 或 context setup,明显更易读。
generated API 可能随 compiler version 变化。
你的 wrapper 就是 generated code 和测试之间的接缝。runtime shape 变了,通常只需要改一个文件,而不是整套测试全改。
由于 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。
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、仓库结构,,都可以保持稳定。
让 simulator 专注于提升测试可用性:
fresh() 或 snapshot()。不要把重要语义藏起来。比如,如果真实合约需要显式的 witness setup 或 transaction context,那就让 simulator 用清晰的方式暴露出来,而不是假装这些东西不存在。
有了 simulator 之后,unit tests 就会非常直接。目标是证明三件事:
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"]
}
});
一个好的起步测试,是验证一次简单的 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);
});
});
这些测试虽然很小,但已经覆盖了合约的主要职责:
setValue 会更新 ledger,add 能正确读写 state,只有正向测试还不够。如果你的合约对输入有限制,就应该补上负向测试,验证 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 字符串。
测试 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 风格测试是你最快的反馈回路。它们应该在每次本地保存时都能跑,也应该在每个 pull request 上都跑。
对 Compact 合约来说,这里的 “unit-style” 不是指用普通 JavaScript 去 mock 合约逻辑。它的意思是:
这也是为什么 simulator 很重要。它让你可以测试真实的 contract runtime,而不需要在每个测试里都把整套 stack 拖进来。
对大多数合约来说,Unit 风格的 simulator 测试应该覆盖:
合约能否接受第一次合法的 state transition?
it("accepts the first write", async () => {
await simulator.setValue(1n);
await expect(simulator.value()).resolves.toBe(1n);
});
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);
});
Compact 的定长整数类型让 boundary testi
读到这里很感谢。如果 “Compact 合约测试 (Vitest + CI)” 这个话题正好契合你目前的工作,下面三条路径任选其一:
完整的 Midnight ZK Cookbook 索引 覆盖 Midnight、Aleo、Aztec、Noir、risc0 共 17 篇英文教程加 4 篇中文翻译,按生态分组列出。
Bounty Radar 聚合 Algora、GitHub labels、Drips Wave、Code4rena、Bountycaster 的开放 ZK bounty。Midnight 子 feed 只列出该生态匹配项,JSON 在 /midnight.json。免费版需轮询;$19/月 Hobbyist 档 会把一个 filter 实时推送到 Telegram。
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。