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
Contractclass 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
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
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
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,
};
}
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
},
});
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 };
}
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);
});
});
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
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!');
});
});
Run the tests:
yarn test:local
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);
});
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);
});
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
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
Summary
The key principles for Midnight contract testing:
- Simulator tests (milliseconds) validate pure logic — don't touch the chain
- Integration tests (minutes) validate the full contract interaction flow
- Do wallet initialization and contract deployment in
beforeAlland reuse - Private state is local; public ledger state is verified with
queryContractState - 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)