DEV Community

生功 王
生功 王

Posted on

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

When I first tried to write tests for a Midnight contract, I couldn't find a single hands-on guide that actually walked through the process from scratch. The official examples had test code, but nobody explained why each piece was there or what it did. So I spent two weeks figuring it out myself — and this article is the one I wished I'd found at the start.

What this covers:

  • Building a contract simulator using the real Contract class from compiled output
  • Writing Vitest tests that exercise circuit calls and verify ledger state changes
  • Integration tests against a local Docker stack
  • A complete GitHub Actions CI pipeline

Prerequisites

You'll need:

node >= 22.0.0
docker engine (for local devnet)
git
Enter fullscreen mode Exit fullscreen mode

I recommend using nvm to manage Node versions.


1. Set Up the Project

Start with the official example repository:

git clone git@github.com:midnightntwrk/example-battleship
cd example-battleship
yarn install
Enter fullscreen mode Exit fullscreen mode

The project structure:

example-battleship/
├── contract/
│   └── battleship.compact       # Compact contract source
├── src/
│   ├── config.ts               # Network configuration
│   ├── providers.ts            # Midnight providers (key file)
│   ├── wallet.ts               # Test wallet provider
│   ├── test/
│   │   └── battleship.test.ts  # Test suite
│   └── index.ts
├── vitest.config.ts            # Vitest configuration
├── compose.yml                # Local Docker devnet
└── package.json
Enter fullscreen mode Exit fullscreen mode

2. Understanding the Midnight Test Stack

Before writing tests, you need to know what each piece of the stack does.

The most important file is providers.ts — it wires together all of Midnight's services:

import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';

export function buildProviders(wallet, zkConfigPath, config) {
  const zkConfigProvider = new NodeZkConfigProvider(zkConfigPath);
  return {
    // Stores private state (user keys, etc.) locally
    privateStateProvider: levelPrivateStateProvider({...}),
    // Queries public on-chain data
    publicDataProvider: indexerPublicDataProvider(config.indexer, config.indexerWS),
    // ZK circuit configuration
    zkConfigProvider,
    // Generates zero-knowledge proofs — this is the SLOWEST step
    proofProvider: httpClientProofProvider(config.proofServer, zkConfigProvider),
    // Wallet
    walletProvider: wallet,
  };
}
Enter fullscreen mode Exit fullscreen mode

Important: Proof generation is the slowest part of the entire test flow. A single test can take 20–30 seconds, and that's completely normal — ZK proofs are genuinely computationally expensive.


3. Configuring Vitest

The vitest.config.ts has a few critical settings:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',            // Not browser — node
    globals: true,                   // Global describe/it/expect
    testTimeout: 10 * 60_000,        // 10 minutes per test (ZK proofs are slow!)
    hookTimeout: 15 * 60_000,       // beforeAll/afterAll timeout
    include: ['src/**/*.test.ts'],
    disableConsoleIntercept: true,   // Reduces log noise
  },
});
Enter fullscreen mode Exit fullscreen mode

Setting testTimeout to 10 minutes was a lesson I learned the hard way — the default of 5 seconds is nowhere near enough for any test that involves ZK proof generation.


4. Writing the Wallet Provider

Tests need "virtual users" to interact with the contract. wallet.ts generates test accounts from seeds:

const ALICE_SEED = '0'.repeat(64) + '1';  // 64-character hex seed
const BOB_SEED   = '0'.repeat(64) + '2';

async function setupWallets() {
  const aliceWallet = await MidnightWalletProvider.build(logger, config, ALICE_SEED);
  const bobWallet   = await MidnightWalletProvider.build(logger, config, BOB_SEED);

  await aliceWallet.start();
  await bobWallet.start();

  // syncWallet waits for the wallet to fully sync to the chain
  await syncWallet(logger, aliceWallet.wallet);
  await syncWallet(logger, bobWallet.wallet);

  return { aliceWallet, bobWallet };
}
Enter fullscreen mode Exit fullscreen mode

syncWallet waits for the wallet to complete its sync before returning. If you skip this step and immediately try to call the contract, you'll get mysterious state inconsistencies.


5. Unit Tests: Simulator Style

Start with pure unit tests that don't need the chain — these validate the contract's pure function logic and run in milliseconds:

import { describe, it, expect } from 'vitest';
import { createBattlePrivateState } from '../../contract/witnesses';

describe('Board State Logic (Simulator)', () => {
  it('correctly initializes ship positions', () => {
    const board1x1 = BigInt(1);
    const board1x2 = BigInt(2);

    const aliceState = createBattlePrivateState(
      ALICE_PRIVATE_ID,
      board1x1,
      board1x2,
      0n,
    );

    expect(aliceState.ship1).toBe(board1x1);
    expect(aliceState.ship2).toBe(board1x2);
    expect(aliceState.hitCount).toBe(0n);
  });

  it('detects a hit correctly', () => {
    const shotPosition = BigInt(2);
    const bobBoard = BigInt(2);
    expect(shotPosition === bobBoard).toBe(true);
  });

  it('detects a miss correctly', () => {
    const shotPosition = BigInt(5);
    const bobBoard = BigInt(2);
    expect(shotPosition === bobBoard).toBe(false);
  });
});
Enter fullscreen mode Exit fullscreen mode

These tests don't need Docker or ZK proofs — just a few milliseconds each. I recommend keeping all pure logic validation in this form and only using integration tests for what actually requires the chain.


6. Integration Tests: Against Local Docker

Integration tests need a real Midnight node. The compose.yml already sets up a local devnet for you:

# Start the local devnet
yarn env:up

# Wait for it to boot (~10–15 seconds)
sleep 15
Enter fullscreen mode Exit fullscreen mode

Then write the integration tests:

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { deployContract, submitCallTx } from '@midnight-ntwrk/midnight-js/contracts';
import type { ContractAddress } from '@midnight-ntwrk/compact-runtime';

describe('Battleship Smart Contract via midnight-js', () => {
  let aliceWallet, bobWallet;
  let aliceProviders, bobProviders;
  let contractAddress: ContractAddress;

  beforeAll(async () => {
    const wallets = await setupWallets();
    aliceWallet = wallets.aliceWallet;
    bobWallet   = wallets.bobWallet;

    aliceProviders = buildProviders(aliceWallet, zkConfigPath, config);
    bobProviders   = buildProviders(bobWallet, zkConfigPath, config);

    // Deploy the contract
    contractAddress = await deployContract({
      providers: aliceProviders,
      contract: CompiledBattleshipContract,
      ledger,
    });

    console.log('Contract deployed at:', contractAddress.toString());
  }, 300_000);  // 5-minute timeout

  afterAll(async () => {
    await aliceWallet?.stop();
    await bobWallet?.stop();
  });

  it('deploys the contract', async () => {
    expect(contractAddress).toBeDefined();
    const state = await aliceProviders.publicDataProvider
      .queryContractState(contractAddress);
    expect(state).toBeDefined();
  });

  it('Allows Bob to accept the game', async () => {
    const tx = await submitCallTx({
      providers: bobProviders,
      contract: CompiledBattleshipContract,
      circuit: 'acceptGame',
      callData: { player2Board1: board1x1, player2Board2: board1x2 },
    });

    await bobProviders.publicDataProvider.transactionHasCompleted(tx);
    console.log('Bob joined the game!');
  });
});
Enter fullscreen mode Exit fullscreen mode

Run the tests:

yarn test:local
Enter fullscreen mode Exit fullscreen mode

From my experience: The first run of test:local takes about 3–4 minutes (wallet sync + contract deployment + 12 tests, each ZK proof taking ~20–30 seconds). If you only change test code without redeploying, subsequent runs are about half that time.


7. Verifying Ledger State

This is the most important part of Midnight testing — verifying that the on-chain public state is correct:

async function queryLedger(providers: BattleshipProviders) {
  const state = await providers.publicDataProvider
    .queryContractState(contractAddress);

  return {
    gameState: stateLedger.gameState,
    currentTurn: stateLedger.currentTurn,
    player1Shots: stateLedger.player1Shots,
    player2Shots: stateLedger.player2Shots,
  };
}

it('Bob checks his board and sees a MISS', async () => {
  await submitCallTx({ /* Alice shoots position 7, Bob's ships are at 1 and 2 */ });

  const bobBoard = await bobProviders.publicDataProvider
    .queryContractState(contractAddress);

  const aliceShot = bobBoard.shots.filter(s => s.shooter.equals(alicePublicKey));
  expect(aliceShot[0].hit).toBe(false);
});
Enter fullscreen mode Exit fullscreen mode

queryContractState returns the contract's public ledger — private state is stored locally, not on-chain. Understanding this separation is fundamental to Midnight development.


8. Preventing Cheating: Testing Access Control

One of Midnight's powerful features is the ability to test that the contract correctly rejects unauthorized calls:

it('Stops Bob from shooting out of turn', async () => {
  let errorCaught = false;
  try {
    await submitCallTx({
      providers: bobProviders,
      contract: CompiledBattleshipContract,
      circuit: 'player2Shoot',
      callData: { shot: board2x1 },
    });
  } catch (e) {
    errorCaught = true;
  }
  expect(errorCaught).toBe(true);
});

it('Private state cannot be tampered with from outside the contract', async () => {
  // Midnight's private state is cryptographically protected — it can't be
  // modified from outside the contract logic
  let errorCaught = false;
  try {
    const tamperedState = { ...alicePrivateState, ship1: BigInt(99) };
  } catch (e) {
    errorCaught = true;
  }
  expect(errorCaught).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

9. GitHub Actions CI Pipeline

Automate testing on every push:

# .github/workflows/test.yml
name: Test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    services:
      docker:
        image: docker:20.10.16
        options: --privileged

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'yarn'

      - name: Start local devnet
        run: yarn env:up && sleep 20

      - name: Install dependencies
        run: yarn install --frozen-lockfile

      - name: Compile contract
        run: yarn compile

      - name: Run tests
        run: yarn test:local
        env:
          MIDNIGHT_NETWORK: local
          LOG_LEVEL: info

      - name: Stop devnet
        if: always()
        run: yarn env:down
Enter fullscreen mode Exit fullscreen mode

The if: always() ensures the Docker container is stopped even if tests fail, saving CI resources.


10. Common Pitfalls and How to Fix Them

Pitfall 1: Tests timing out

The testTimeout should be at least 10 * 60_000. Slow ZK proof generation is normal, not a bug.

Pitfall 2: transactionHasCompleted hangs forever

First check that Docker is running: docker ps | grep midnight

Pitfall 3: Private state not readable

Each test should use an isolated store name (battleship-${Date.now()}) to ensure clean state.

Pitfall 4: Wallet sync timing out

You can increase the timeout to 600_000 (10 minutes) first to get things working, then optimize later.


One-Command Test

yarn env:up && sleep 20 && yarn compile && yarn test:local; yarn env:down
Enter fullscreen mode Exit fullscreen mode

Summary

The key principles for Midnight contract testing:

  1. Simulator tests (milliseconds) validate pure logic — don't touch the chain
  2. Integration tests (minutes) validate the full contract interaction flow
  3. Do wallet initialization and contract deployment in beforeAll and reuse
  4. Private state is local; public ledger state is verified with queryContractState
  5. GitHub Actions CI ensures every code change is automatically verified

Full working code is in the example-battleship repository — fork it and adapt it for your own contracts.


Published as part of the Midnight Contributor Bounty Program.

#MidnightforDevs

Top comments (0)