Build Testnet · v1.10 · all tests passing
Estimated read time: 8 minutes

Testing Contracts

Why on-chain testing is different

A unit test for a normal function asserts on its return value. A unit test for a smart contract has to assert on its return value and on the storage changes it made and on the events it emitted and on the gas it spent — and it has to do all of that against an environment that simulates the consensus rules. The Valdium test harness gives you that environment in-process, without spinning up a node.

The test framework

Tests live in any file named *.test.js. The runner is shipped as part of the CLI:

valdium test # or to run a single file valdium test counter.test.js

Under the hood, valdium test spins up an in-process Valdium VM (the same one validators run), creates a fresh genesis block, funds a handful of test accounts, and hands you a Node.js test runner with three globals: describe, it, and expect — Mocha-shaped, intentionally familiar.

First test

Here's the canonical first test against the Counter contract:

import { test } from '@valdium/test'; import { Counter } from './counter.js'; test.describe('Counter', () => { test.it('starts at zero', async (vm) => { const counter = await vm.deploy(Counter); expect(await counter.get()).toBe(0); }); test.it('increments', async (vm) => { const counter = await vm.deploy(Counter); await counter.increment(); expect(await counter.get()).toBe(1); }); test.it('charges gas for state writes', async (vm) => { const counter = await vm.deploy(Counter); const receipt = await counter.increment(); expect(receipt.gasUsed).toBeGreaterThan(0); expect(receipt.gasUsed).toBeLessThan(50_000); }); });

The vm parameter is a fresh VM handed to each it block — tests are isolated by default, so order doesn't matter and one failing test can't poison another.

Time travel and block production

Contracts that depend on time (vesting schedules, auction deadlines, rate limits) need controllable time. The VM exposes block-level controls:

test.it('vesting unlocks after 30 days', async (vm) => { const vest = await vm.deploy(Vesting, [alice.address, 30 * DAY]); // try to claim before unlock — should revert await expect(vest.connect(alice).claim()).toRevert(/locked/); // jump forward 31 days worth of blocks await vm.mineBlocks((31 * DAY) / 2); // 2-second blocks // now it should succeed await vest.connect(alice).claim(); expect(await vest.claimed()).toBe(true); });

vm.mineBlocks(n) instantly produces n empty blocks. vm.setNextBlockTimestamp(ts) nails the next block to a specific UTC timestamp if you need exact control.

Multiple accounts

The VM hands you ten pre-funded test accounts by default: vm.accounts[0..9]. Each has 1,000 testnet VLD. To run a call from a specific account:

const [alice, bob, mallory] = vm.accounts; await token.connect(alice).transfer(bob.address, 100); await expect( token.connect(mallory).transfer(bob.address, 100) ).toRevert(/insufficient/);

Asserting on events

Events come back attached to the receipt and can be asserted on by name and args:

const receipt = await token.transfer(bob.address, 100); expect(receipt).toEmit(token, 'Transfer', { from: alice.address, to: bob.address, value: 100n, });

Snapshots and rollback

Complex tests sometimes need a common setup that you don't want to repeat. vm.snapshot() captures full VM state; vm.revert(id) rewinds:

const snap = await vm.snapshot(); await someExpensiveSetup(); // run the actual assertion await assertSomething(); // rewind for the next test, including time await vm.revert(snap);

Coverage

Pass --coverage to valdium test and the runner instruments every function call in your contracts, writes a report to coverage/, and emits an HTML viewer. Lines that never executed during the suite are flagged in red — every contract should hit 100% line coverage before deploy.

Fuzz testing

For contracts where the shape of input matters more than specific values, test.fuzz generates random inputs and looks for any that violate an invariant:

test.fuzz('balances always sum to total supply', async (vm) => { const token = await vm.deploy(Token, [1_000_000n]); return [test.fuzz.address(), test.fuzz.uint256(), test.fuzz.address()]; }, async (vm, token, [from, amount, to]) => { await token.connect(from).transfer(to, amount); expect(await token.totalSupply()).toBe(1_000_000n); });

The runner shrinks failing inputs to a minimal counter-example you can paste into a regular it block.