If you want reliable tests for Midnight Compact contracts, split your suite into two layers:
The key pattern is to build a thin simulator wrapper around the generated Contract class emitted by the Compact compiler. That wrapper should do three things well:
From there, use Vitest for assertions and structure your repository so that:
test/unit covers local simulation,test/integration covers the Docker stack,One important constraint: the Compact language reference and getting-started docs describe the compiled artifacts,including a JS/TS driver and contract metadata,but the exact generated TypeScript API surface for the runtime Contract class is versioned and should be taken from your local compiler output, not invented in prose. In this draft, the Compact code uses verified syntax from the official language reference, and the TypeScript test harness is written as a stable adapter pattern you can bind directly to the generated Contract class in your repository. Use the generated .d.ts files in your build output as the source of truth for method names and constructor parameters. See Midnight Getting Started and the Compact language reference linked from the docs.
Testing on Midnight is slightly different from testing a typical EVM contract because Compact contracts compile into more than one artifact. According to the Midnight documentation, compilation produces:
with verifier keys used on-chain for proof verification. That means your tests are not only checking business logic; they are also validating how your application code drives the generated contract runtime and how ledger changes appear after circuit execution. See the official docs: Midnight Getting Started and the Compact language reference in the developer docs.
This tutorial follows the bounty checklist exactly:
Contract class,I will also stay within one important boundary: I will not fabricate undocumented Midnight runtime APIs. Where the exact generated Contract method names are compiler-version-specific, I will say so explicitly and show the safe pattern: inspect the generated driver and .d.ts files, then bind your simulator to that real surface. For scaffolding conventions, the best public reference in the bounty brief is example-battleship.
Contract classThe simulator is not a mock. It is a small wrapper around the real compiled contract runtime. The purpose of the wrapper is to make tests readable and stable even if the generated driver has verbose or versioned APIs.
For a testing tutorial, a tiny contract is better than a feature-rich one because every state transition is easy to reason about. The following Compact contract uses syntax verified by the official 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);
}
This example demonstrates two testable behaviors:
setValue writes to the public ledger,add reads the current ledger value, computes a new value, and writes it back.A few notes grounded in the docs:
ledger declarations define public on-chain state.circuit declarations are the callable entry points.disclose(...) is the pattern shown in the language reference for assigning values into public ledger state.The Midnight docs state that the compiler emits a JS runtime and TypeScript definitions. In practice, that generated runtime is what your tests should instantiate. The bounty specifically asks for the real Contract class from compiled output, so do not reimplement contract semantics in JavaScript. Use the generated class directly.
A practical repository layout looks like this:
.
├── 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
Two reasons:
Tests should express domain behavior, not driver plumbing.
await simulator.add(4n) is easier to read than a series of generated runtime calls plus witness or context setup.
The generated API can change by compiler version.
Your wrapper becomes the seam between generated code and tests. When the runtime shape changes, you usually update one file instead of your entire suite.
Because the exact generated methods on Contract are not documented in the bounty brief, define your simulator against a small interface and implement that interface by delegating to the generated class from your local build.
// src/simulator/generated-driver.ts
export interface CounterDriver {
setValue(x: bigint): Promise<void>;
add(x: bigint): Promise<void>;
readLedger(): Promise<{ value: bigint }>;
}
Now write the simulator in terms of that 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;
}
}
This is intentionally boring. That is good. Your simulator should not contain business logic. It should expose a stable test surface over the generated runtime.
Contract classThis is the one place where you must consult your actual compiled output. Open the generated .d.ts file and map its real methods into the driver interface.
A version-specific implementation will look conceptually like this:
// 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) };
}
}
The method bodies here are the only repository lines that depend on the generated runtime shape. Everything else,tests, CI, repository structure,stays stable.
Keep the simulator focused on test ergonomics:
fresh() or snapshot().Do not hide important semantics. For example, if your real contract requires explicit witness setup or transaction context, let the simulator surface that cleanly rather than pretending it does not exist.
Once you have a simulator, your unit tests become straightforward. The goal is to prove three things:
Vitest is a natural fit for a TypeScript repository and works well in CI. Official docs: Vitest.
A minimal package.json script setup:
{
"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"
}
}
And a minimal Vitest config:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["test/**/*.test.ts"]
}
});
A good first test verifies a single ledger write:
// 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);
});
});
These tests are small, but they already cover the contract’s main obligations:
setValue updates the ledger,add reads and writes state correctly,Positive tests are not enough. If your contract constrains inputs, add negative tests that verify the driver rejects invalid calls. The exact failure shape depends on the generated runtime, so assert conservatively unless you own the error type.
For example:
it("rejects values outside the contract's accepted range", async () => {
await expect(simulator.setValue(-1n)).rejects.toBeDefined();
});
If the runtime throws rich errors, prefer checking stable fragments rather than brittle full-message strings.
When you test a Compact contract, the public ledger is usually the most important observable effect. Make ledger assertions direct and readable:
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);
});
A common mistake is to only test that a circuit call “does not throw.” That confirms almost nothing. A useful test always verifies the postcondition.
Unit-style tests are your fast feedback loop. They should run on every local save and on every pull request.
For Compact contracts, “unit-style” does not mean mocking the contract logic in plain JavaScript. It means:
This is why the simulator matters. It lets you test the real contract runtime without dragging the entire stack into every test.
For most contracts, unit-style simulator tests should cover:
Does the contract accept the first valid state transition?
it("accepts the first write", async () => {
await simulator.setValue(1n);
await expect(simulator.value()).resolves.toBe(1n);
});
Does state accumulate correctly across calls?
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’s sized integer types make boundary testing important. If your contract uses Uint<32>, add tests for 0, small numbers, and upper-range values that your app allows.
it("handles zero correctly", async () => {
await simulator.setValue(0n);
await simulator.add(0n);
expect(await simulator.value()).toBe(0n);
});
If your contract promises an invariant,monotonic counters, one-time initialization, bounded totals,state that invariant in a test name and assert it after each call.
Do not share simulator instances across unrelated tests unless you are intentionally testing persistence. Fresh state per test makes failures easier to diagnose and prevents order-dependent bugs.
This is why the beforeEach block above creates a new simulator each time.
If you find yourself writing three lines to seed state in every test, add a helper:
async function seeded(value: bigint): Promise<CounterSimulator> {
const simulator = new CounterSimulator(new GeneratedCounterDriver());
await simulator.setValue(value);
return simulator;
}
Use helpers to reduce noise, not to hide critical actions.
Unit tests prove your contract logic and local runtime usage. Integration tests prove your environment wiring: compiled artifacts, local services, network endpoints, transaction submission, and state reads through the same path your application will use.
The bounty asks specifically for integration tests against a local Docker stack. The exact Compose services depend on Midnight’s current local stack instructions and your chosen tooling, so treat the official docs and example repositories as the source of truth for container names, ports, and health checks. Start with Midnight Getting Started and compare against example-battleship.
Integration tests are slower and more failure-prone because they involve:
That is normal. Keep them in a separate directory and run them separately in CI.
Your docker-compose.yml should define the local Midnight services required by your project. Because the bounty brief does not provide a canonical stack file, I recommend keeping your tutorial repository explicit about two things:
A typical pattern is:
services:
midnight-node:
image: your-midnight-local-node-image
ports:
- "9944:9944"
healthcheck:
test: ["CMD", "sh", "-c", "your-healthcheck-command"]
interval: 5s
timeout: 5s
retries: 20
midnight-indexer:
image: your-midnight-local-indexer-image
ports:
- "8080:8080"
depends_on:
midnight-node:
condition: service_healthy
Replace the images and health checks with the exact values from the current Midnight local-stack documentation or reference repo.
Integration tests should reuse your simulator or driver where possible, but configure it with real endpoints from the Docker stack.
// test/integration/counter-stack.test.ts
import { beforeAll, describe, expect, it } from "vitest";
import { CounterSimulator } from "../../src/simulator/counter-simulator";
import { GeneratedCounterDriver } from "../../src/simulator/generated-contract-driver";
let simulator: CounterSimulator;
describe("counter contract against local Docker stack", () => {
beforeAll(async () => {
// If your generated Contract class requires endpoint configuration,
// read it from process.env here and pass it into the driver.
simulator = new CounterSimulator(new GeneratedCounterDriver());
});
it("submits a state-changing call through the local stack", async () => {
await simulator.setValue(11n);
await simulator.add(31n);
expect(await simulator.value()).toBe(42n);
});
});
The important difference from unit tests is not the assertion style. It is the execution path. In integration tests, the driver should be connected to real local services rather than a purely in-process simulation mode.
At minimum, include one happy-path test that verifies:
If your repository has time budget for more, add one failure-path integration test, such as a rejected invalid transition or a missing-service startup check.
CI should do exactly what you do locally:
Official references: GitHub Actions and Docker Compose.
# .github/workflows/ci.yml
name: ci
on:
push:
pull_request:
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build contracts and test harness
run: npm run build
- name: Run unit tests
run: npm run test:unit
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build contracts and test harness
run: npm run build
- name: Start local Docker stack
run: docker compose up -d
- name: Wait for stack health
run: |
for i in $(seq 1 30); do
if docker compose ps; then
break
fi
sleep 2
done
- name: Run integration tests
env:
MIDNIGHT_NODE_URL: http://127.0.0.1:9944
MIDNIGHT_INDEXER_URL: http://127.0.0.1:8080
run: npm run test:integration
- name: Print container logs
if: always()
run: docker compose logs --no-color
- name: Tear down stack
if: always()
run: docker compose down -v
Running unit tests before integration tests gives you faster failure signals and avoids starting containers for obvious compile or local-runtime failures. It also makes CI output easier to interpret:
unit-tests fails, fix the contract or simulator,integration-tests fails, investigate environment or service wiring.Two small practices help a lot:
This avoids the classic problem where CI says “connection refused” and gives you no context.
This section pulls the pieces together into one minimal, publishable pattern.
export ledger value: Uint<32>;
export circuit setValue(x: Uint<32>): [] {
value = disclose(x);
}
export circuit add(x: Uint<32>): [] {
value = disclose(value + x);
}
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> {
return (await this.driver.readLedger()).value;
}
}
import { describe, expect, it } from "vitest";
import { CounterSimulator } from "../../src/simulator/counter-simulator";
import { GeneratedCounterDriver } from "../../src/simulator/generated-contract-driver";
describe("counter unit simulation", () => {
it("updates public ledger state", async () => {
const simulator = new CounterSimulator(new GeneratedCounterDriver());
await simulator.setValue(40n);
await simulator.add(2n);
expect(await simulator.value()).toBe(42n);
});
});
import { describe, expect, it } from "vitest";
import { CounterSimulator } from "../../src/simulator/counter-simulator";
import { GeneratedCounterDriver } from "../../src/simulator/generated-contract-driver";
describe("counter integration", () => {
it("works against the local Docker stack", async () => {
const simulator = new CounterSimulator(new GeneratedCounterDriver());
await simulator.setValue(21n);
await simulator.add(21n);
expect(await simulator.value()).toBe(42n);
});
});
This is the most serious mistake. If your simulator computes state transitions itself, you are testing your simulator, not your Compact contract. Always delegate to the compiled Contract runtime.
.d.tsThe Midnight docs confirm that TypeScript definitions are emitted during compilation. Use them. Do not guess constructor parameters, circuit-call names, or ledger-read methods. Treat the generated .d.ts files as authoritative for your compiler version.
A suite that never asserts on invalid inputs or rejected transitions will miss real bugs. Add at least one negative test per important constraint.
Generated runtimes and dependent libraries often change wording. Prefer checking structured error properties or stable message fragments.
If a test needs Docker, network endpoints, or service health checks, it is an integration test. Keep it out of the unit suite so local feedback stays fast.
A fresh simulator per test is usually the right default. Shared mutable state causes order-dependent failures that are hard to reproduce.
When containerized integration tests fail in GitHub Actions, logs are often the only useful clue. Always print them in an if: always() step.
Contract class: section: “Building a contract simulator using the Contract class”..github/workflows/ci.yml are provided in the draft; generated Contract binding is explicitly described as derived from compiled output .d.ts files.ledger, export circuit, Uint<32>, [], and disclose(...).Contract class” and “Pitfalls and common errors”; version-specific generated runtime methods are deferred to emitted .d.ts files and example repository inspection.Thanks for reading this far. If “Testing Compact contracts (Vitest + CI)” connected with where you are, three concrete next steps:
The full Midnight ZK Cookbook index has 17 tutorials across Midnight, Aleo, Aztec, Noir, and risc0 plus 4 Chinese translations. Adjacent tutorials are listed by ecosystem on that page.
Bounty Radar tracks open ZK bounties across Algora, GitHub labels, Drips Wave, Code4rena, and Bountycaster. Browse the Midnight sub-feed; JSON at /midnight.json. The free tier is poll-based; the $19/mo Hobbyist tier pushes one filter to your Telegram in real time.
zk-pipeline-doctor is the free MIT-licensed CLI that scores any ZK project on tests, CI, docs, security, reproducibility, and language toolchain (supports Compact, Leo, Noir, Cairo, and 7 Rust zkVMs). Drop it into a GitHub Action with zk-doctor-action for diff-aware PR comments. The $15/mo Pro tier adds four cross-ecosystem deep detectors (circuit complexity, proving-system pitfalls, verifier soundness, multi-file consistency).
Drafted with AI assistance and reviewed by the author before publishing. See DISCLOSURE for the full process.
Three ways to support this work, pick whichever matches your situation:
Free alternative: Sponsor on GitHub · Star the repo · Share with one ZK developer who'd benefit