Testing Compact Contracts: Unit Tests, Assertions, and Local Simulation

TL;DR

If you want reliable tests for Midnight Compact contracts, split your suite into two layers:

  1. Unit-style simulator tests that run fast, instantiate the compiled contract locally, call circuits, and assert on public ledger changes.
  2. Integration tests that exercise the same flows against a local Docker stack, so you catch environment, wiring, and transaction-submission issues before CI or review.

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:

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.

Context

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:

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.

Building a contract simulator using the Contract class

The 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.

Start with a minimal Compact contract

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:

A few notes grounded in the docs:

What the compiler gives you

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

Why use a wrapper at all?

Two reasons:

  1. 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.

  2. 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.

A safe adapter pattern

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.

Binding the real generated Contract class

This 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.

What belongs in the simulator

Keep the simulator focused on test ergonomics:

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.

Writing Vitest tests for circuit calls and ledger state

Once you have a simulator, your unit tests become straightforward. The goal is to prove three things:

  1. a circuit can be called successfully,
  2. the ledger changes exactly as expected,
  3. repeated calls preserve state correctly across transitions.

Install and configure Vitest

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

Unit tests should assert behavior, not implementation details

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:

Assert on failures too

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.

Keep ledger assertions explicit

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 simulator tests

Unit-style tests are your fast feedback loop. They should run on every local save and on every pull request.

What “unit-style” means here

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:

1. Initialization and first write

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

2. Sequential state transitions

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

3. Boundary inputs

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

4. Invariant preservation

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.

Isolate each test case

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.

Add helpers only when they remove repetition

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.

Integration tests against a local Docker stack

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.

Why separate integration from unit tests

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.

A practical Docker Compose approach

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 test structure

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.

What integration tests should prove

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.

Setting up a GitHub Actions CI pipeline

CI should do exactly what you do locally:

  1. install dependencies,
  2. build the contract and TypeScript harness,
  3. run unit tests,
  4. start the Docker stack,
  5. run integration tests,
  6. always collect logs and tear the stack down.

Official references: GitHub Actions and Docker Compose.

A practical workflow

# .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

Why split the jobs?

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:

Make CI failures debuggable

Two small practices help a lot:

This avoids the classic problem where CI says “connection refused” and gives you no context.

Working examples

This section pulls the pieces together into one minimal, publishable pattern.

Compact contract

export ledger value: Uint<32>;

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

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

Simulator

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

Unit test

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

Integration test

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

Pitfalls and common errors

1. Reimplementing contract logic in the test harness

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.

2. Inventing the generated API instead of reading .d.ts

The 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.

3. Only testing success paths

A suite that never asserts on invalid inputs or rejected transitions will miss real bugs. Add at least one negative test per important constraint.

4. Coupling tests to brittle error strings

Generated runtimes and dependent libraries often change wording. Prefer checking structured error properties or stable message fragments.

5. Mixing unit and integration concerns

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.

6. Not resetting state between tests

A fresh simulator per test is usually the right default. Shared mutable state causes order-dependent failures that are hard to reproduce.

7. CI without logs

When containerized integration tests fail in GitHub Actions, logs are often the only useful clue. Always print them in an if: always() step.

References

Bounty spec coverage


Where to go next

Thanks for reading this far. If “Testing Compact contracts (Vitest + CI)” connected with where you are, three concrete next steps:

Learn more in Midnight

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.

Find paid work in Midnight

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.

Audit your own ZK pipeline

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.

If this saved you time

Three ways to support this work, pick whichever matches your situation:

$15 · One-time
All 17 tutorials as one Markdown + companion code repos. Offline-readable.
Get the Bundle →
$19 / month
Real-time bounty alerts pushed to your Telegram, filtered by ecosystem.
Try Radar →
$99 · One-time
Pre-flight audit of your ZK repo. See sample.
Order Audit →

Free alternative: Sponsor on GitHub · Star the repo · Share with one ZK developer who'd benefit

Related projects