Help us improve
Share bugs, ideas, or general feedback.
From midnight-cq
This skill should be used when the user asks to write Compact contract tests, test a contract with the simulator, set up createSimulator, mock contract patterns, override witnesses in tests, write unit tests for Compact circuits, set up Vitest for a Compact project, test access control or error cases, use describe.each or it.each for parameterized tests, check invariants with afterEach, property-based testing with fast-check, or ZK commitment testing.
npx claudepluginhub devrelaicom/midnight-expert --plugin midnight-cqHow this skill is triggered — by the user, by Claude, or both
Slash command
/midnight-cq:compact-testingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
> "For mission critical Compact code, the quality of the tests is just as
Mandates invoking relevant skills via tools before any response in coding sessions. Covers access, priorities, and adaptations for Claude Code, Copilot CLI, Gemini CLI.
Share bugs, ideas, or general feedback.
"For mission critical Compact code, the quality of the tests is just as important (if not more so) than the code itself." — Moloch Testing Guide
Flaky tests are categorically unacceptable. Every test must be deterministic, isolated, and precise. A test that sometimes passes is worse than no test: it builds false confidence. If a test is not always green on a clean run, fix it before merging.
This skill covers unit testing Compact contracts using the OpenZeppelin
simulator framework (@openzeppelin-compact/contracts-simulator). That
framework eliminates manual context threading by wrapping createSimulator()
around the contract, exposing a clean class-based API for callers, witnesses,
and private state.
Every Compact test project follows this layout:
src/
MyContract.compact # Compact source
witnesses/
MyContractWitnesses.ts # Witness implementations + PrivateState type
test/
mocks/
MockMyContract.compact # Thin wrapper: imports module, forwards circuits
simulators/
MyContractSimulator.ts # createSimulator() config + user-friendly methods
MyContract.test.ts # Vitest test file
| Layer | Purpose |
|---|---|
.compact source | Production contract logic |
witnesses/ | TypeScript witness implementations; defines PrivateState |
test/mocks/ | Thin Compact wrappers that import the module under test and forward circuits; adds isInit: Boolean constructor param to toggle initialized vs. uninitialized state |
test/simulators/ | createSimulator() config with contractFactory, defaultPrivateState, contractArgs, ledgerExtractor, witnessesFactory; extends the returned class with user-friendly methods |
test/*.test.ts | Vitest test files; use the simulator exclusively — never touch raw contract APIs |
Mock contracts re-export key types (ZswapCoinPublicKey, ContractAddress,
Either, Maybe) so test files have a single import point.
// GOOD: test both authorized and unauthorized callers
describe('transferOwnership', () => {
it('should transfer when called by owner', () => {
ownable.as(OWNER).transferOwnership(Z_NEW_OWNER);
expect(ownable.owner()).toEqual(Z_NEW_OWNER);
});
it('should fail when called by unauthorized', () => {
expect(() => {
ownable.as(UNAUTHORIZED).transferOwnership(Z_NEW_OWNER);
}).toThrow('Ownable: caller is not the owner');
});
});
// BAD: only testing the happy path
it('should transfer ownership', () => {
ownable.as(OWNER).transferOwnership(Z_NEW_OWNER);
// No test that unauthorized callers are rejected
});
// GOOD: assert the exact error message
expect(() => {
token.as(OWNER).transfer(utils.ZERO_KEY, AMOUNT);
}).toThrow('FungibleToken: invalid receiver');
// BAD: only catching that "something threw"
expect(() => {
token.as(OWNER).transfer(utils.ZERO_KEY, AMOUNT);
}).toThrow(); // passes for any error, masks wrong-message bugs
// GOOD: test zero values and overflow boundaries explicitly
it('should catch mint overflow', () => {
token._mint(Z_RECIPIENT, MAX_UINT128);
expect(() => {
token._mint(Z_RECIPIENT, 1n);
}).toThrow('FungibleToken: arithmetic overflow');
});
it('should allow transfer of 0 tokens', () => {
const txSuccess = token.as(OWNER).transfer(Z_RECIPIENT, 0n);
expect(txSuccess).toBe(true);
});
// BAD: only testing "normal" amounts
it('should transfer tokens', () => {
token.as(OWNER).transfer(Z_RECIPIENT, 100n);
expect(token.balanceOf(Z_RECIPIENT)).toEqual(100n);
});
Full annotated examples (Ownable, FungibleToken, ZOwnablePK) are in
references/test-examples.md.
.as(caller) — Caller Identitysimulator.as(hexPubKey) sets the caller for the next circuit call only.
The key is a raw hex public key string (not a ZswapCoinPublicKey).
// Generate paired (raw, encoded) keys for testing
const [OWNER, Z_OWNER] = utils.generateEitherPubKeyPair('OWNER');
const [UNAUTHORIZED, _] = utils.generateEitherPubKeyPair('UNAUTHORIZED');
ownable.as(OWNER).assertOnlyOwner(); // passes
ownable.as(UNAUTHORIZED).assertOnlyOwner(); // throws
describe.each — Type CombinationsUse describe.each when the same test suite must run for multiple input types
(e.g., pubkey owner vs contract address owner):
const ownerTypes = [
['contract', Z_OWNER_CONTRACT],
['pubkey', Z_OWNER],
] as const;
describe.each(ownerTypes)('when the owner is a %s', (_, owner) => {
it('should return balance', () => {
token._unsafeMint(owner, AMOUNT);
expect(token.balanceOf(owner)).toEqual(AMOUNT);
});
});
it.each — Parameterized CasesUse it.each for a single test run across many parameter combinations:
const circuitsToFail: [method: keyof OwnableSimulator, args: unknown[]][] = [
['owner', []],
['transferOwnership', [Z_OWNER]],
['renounceOwnership', []],
];
it.each(circuitsToFail)(
'should fail when calling circuit "%s" before init',
(circuitName, args) => {
ownable = new OwnableSimulator(Z_OWNER, isBadInit);
expect(() => {
(ownable[circuitName] as (...args: unknown[]) => unknown)(...args);
}).toThrow('Initializable: contract not initialized');
},
);
afterEach — Invariant ChecksPlace invariant assertions in afterEach to verify them after every test in a
describe block without duplicating them:
describe('transfer', () => {
beforeEach(() => {
token._mint(Z_OWNER, AMOUNT);
});
// Runs after every test in this describe — totalSupply must never change
afterEach(() => {
expect(token.totalSupply()).toEqual(AMOUNT);
});
it('should transfer partial', () => {
token.as(OWNER).transfer(Z_RECIPIENT, AMOUNT - 1n);
expect(token.balanceOf(Z_OWNER)).toEqual(1n);
});
it('should fail with insufficient balance', () => {
expect(() => {
token.as(OWNER).transfer(Z_RECIPIENT, AMOUNT + 1n);
}).toThrow('FungibleToken: insufficient balance');
});
});
beforeEach — Fresh Simulator Per TestAlways create a fresh simulator in beforeEach. Never share simulator
instances across tests — shared state causes order-dependent failures.
let ownable: OwnableSimulator;
describe('when initialized', () => {
beforeEach(() => {
ownable = new OwnableSimulator(Z_OWNER, isInit);
});
it('should return owner', () => {
expect(ownable.owner()).toEqual(Z_OWNER);
});
it('should transfer ownership', () => {
ownable.as(OWNER).transferOwnership(Z_NEW_OWNER);
expect(ownable.owner()).toEqual(Z_NEW_OWNER);
});
});
Override a single witness for one test without rebuilding the simulator:
simulator.overrideWitness('local_nonce', () => [privateState, BAD_NONCE]);
Replace all witnesses at once:
simulator.witnesses = {
local_nonce: () => [privateState, secretNonce],
local_salt: () => [privateState, instanceSalt],
};
Witness overrides are useful for testing ZK commitment verification: compute
the expected commitment locally with the same persistentHash logic the
contract uses, then assert the circuit output matches.
| Anti-Pattern | Why It's Wrong | Fix |
|---|---|---|
| Shared simulator across tests | State bleeds between tests; order-dependent failures | Use beforeEach to create fresh instance |
.toThrow() with no message | Passes for wrong errors; masks regressions | Always assert the exact error string |
| Testing only the happy path | Misses all rejection logic | For every assert in the contract, write a failing test |
Ignoring afterEach invariants | Supply invariants can silently break | Add afterEach for any property that must hold across all operations |
| Hard-coding hex keys inline | Brittle and unreadable | Use utils.generateEitherPubKeyPair('LABEL') for named keys |
| Skipping uninitialized-state tests | Real deployments can fail before init | Test every circuit in uninitialized state with isBadInit = false |
| Testing type variants once | Pubkey vs contract address behave differently | Use describe.each over ownerTypes / recipientTypes |
| Asserting only return value | Ledger state may be wrong despite correct return | Assert ledger state and balances after every mutation |
| Topic | Reference |
|---|---|
| Full annotated test examples (Ownable, FungibleToken, ZOwnablePK) — good vs bad pairs | references/test-examples.md |
createSimulator() config, circuit proxies, caller simulation, state access | references/simulator-api.md |
Mock contract structure, isInit pattern, re-exported types and ledger fields | references/mock-patterns.md |
Witness file structure, WitnessContext, overrides, private state injection | references/witness-testing.md |