Builder· Lesson 6 of 8· 5 min
Testing programs: local validator, node:test and time travel
A program is only as good as the failures you tried to cause. The locker's real test harness: a local validator with the program loaded, plain node:test, assertions on error codes, and a clock you can wait on or set.
Where a test can run
| LiteSVM / Bankrun | The runtime in-process, no validator. Milliseconds per test, and you can set the clock and slot to anything. Best for logic and edge cases. |
|---|---|
| solana-test-validator | A real one-node cluster started for the run. Real fees, rent, confirmation and time. What anchor test and the locker's suite use. |
| Devnet | Other people's programs and real RPC behaviour. For integration with Raydium, Metaplex, wallets. |
| Mainnet | A tiny transaction from a burner, then the real thing. Never the first place code runs. |
The harness
const { test, before } = require("node:test");
const assert = require("node:assert/strict");
const anchor = require("@coral-xyz/anchor");
const { Connection, Keypair, PublicKey, LAMPORTS_PER_SOL } = require("@solana/web3.js");
const spl = require("@solana/spl-token");
const IDL = require("../target/idl/token_locker.json");
const RPC = process.env.RPC_URL || "http://127.0.0.1:8899";
const DECIMALS = 6;
const ONE = 10n ** BigInt(DECIMALS);
const ctx = {};
before(async () => {
ctx.connection = new Connection(RPC, "confirmed");
ctx.wallet = loadWallet(); // ~/.config/solana/id.json
ctx.provider = new anchor.AnchorProvider(ctx.connection, new anchor.Wallet(ctx.wallet), {
commitment: "confirmed",
});
ctx.program = new anchor.Program(IDL, ctx.provider);
if ((await ctx.connection.getBalance(ctx.wallet.publicKey)) < 5 * LAMPORTS_PER_SOL) {
await fund(ctx.wallet.publicKey, 50); // the local validator airdrops freely
}
});
async function fund(pubkey, sol = 10) {
const sig = await ctx.connection.requestAirdrop(pubkey, Math.round(sol * LAMPORTS_PER_SOL));
await ctx.connection.confirmTransaction(sig, "confirmed");
}
async function chainNow() {
return ctx.connection.getBlockTime(await ctx.connection.getSlot("confirmed"));
}
async function waitUntilChain(ts) {
for (;;) {
const now = await chainNow();
if (now >= ts) return now;
await new Promise((r) => setTimeout(r, Math.min(1000, Math.max(200, (ts - now) * 1000))));
}
}Two details matter. The test reads the chain's clock, not the machine's: the program compares against Clock::get(), and a validator's block time can drift from Date.now() by seconds. And every test funds its own fresh keypairs, so tests never share state and can run in any order.
The happy path
async function newMint(authority) {
return spl.createMint(ctx.connection, authority, authority.publicKey, null, DECIMALS);
}
test("a cliff lock releases everything at the cliff", async () => {
const creator = await fundedKeypair();
const recipient = Keypair.generate();
const mint = await newMint(creator);
const creatorAta = await spl.createAssociatedTokenAccount(ctx.connection, creator, mint, creator.publicKey);
await spl.mintTo(ctx.connection, creator, mint, creatorAta, creator, 1000n * ONE);
const lockId = nextId++;
const cliff = (await chainNow()) + 4;
await ctx.program.methods
.createLock({
lockId: new BN(lockId), amount: new BN((1000n * ONE).toString()),
cliffTs: new BN(cliff), endTs: new BN(cliff), period: new BN(1), cliffAmount: new BN(0),
cancelable: false, transferable: false, name: nameBytes("test"),
})
.accounts({
creator: creator.publicKey, recipient: recipient.publicKey, mint, creatorTokenAccount: creatorAta,
lock: lockPda(mint, creator.publicKey, lockId), vault: ata(mint, lockPda(mint, creator.publicKey, lockId)),
recipientTokenAccount: ata(mint, recipient.publicKey), tokenProgram: spl.TOKEN_PROGRAM_ID,
})
.signers([creator])
.rpc();
const vault = await spl.getAccount(ctx.connection, ata(mint, lockPda(mint, creator.publicKey, lockId)));
assert.equal(vault.amount, 1000n * ONE); // the tokens really moved
await waitUntilChain(cliff);
await release(mint, creator.publicKey, lockId, recipient.publicKey);
const got = await spl.getAccount(ctx.connection, ata(mint, recipient.publicKey));
assert.equal(got.amount, 1000n * ONE);
assert.equal(await ctx.connection.getAccountInfo(lockPda(mint, creator.publicKey, lockId)), null); // closed
});Asserting the failures
The tests that matter are the ones where the program says no. Assert on the error's name, not on the message text or on “it threw”, so a refactor that changes a message or throws for the wrong reason is caught.
async function expectAnchorError(promise, code) {
try {
await promise;
} catch (e) {
const got = e.error?.errorCode?.code ?? e.message;
assert.equal(got, code, `expected ${code}, got ${got}`);
return;
}
assert.fail(`expected ${code}, but it succeeded`);
}
test("nothing can be released before the cliff", async () => {
// ...create a lock with a cliff an hour away...
await expectAnchorError(release(mint, creator.publicKey, lockId, recipient.publicKey), "NothingToRelease");
});
test("a non-cancelable lock cannot be cancelled by its creator", async () => {
await expectAnchorError(cancel(lock, creator), "NotCancelable");
});
test("the wrong vault is rejected", async () => {
// pass another lock's vault: has_one = vault fails before any token moves
await expectAnchorError(releaseWithVault(lock, otherVault), "VaultMismatch");
});
test("release requires no particular signer", async () => {
const stranger = await fundedKeypair(1);
await waitUntilChain(cliff);
await releaseAs(stranger, lock); // succeeds: it is permissionless by design
});Time
On a real validator you wait: the harness sets cliffs a few seconds ahead and polls the block time. That keeps the whole suite under a minute, but it cannot test “what happens in month twelve”. LiteSVM can, because the clock is yours:
import { LiteSVM } from "litesvm";
const svm = new LiteSVM();
svm.addProgramFromFile(TOKEN_LOCKER_PROGRAM_ID, "target/deploy/token_locker.so");
svm.airdrop(creator.publicKey, 10_000_000_000n);
// ...build the createLock transaction exactly as before and svm.sendTransaction(tx)...
const clock = svm.getClock();
clock.unixTimestamp = BigInt(endTs + 1); // jump a year ahead in a microsecond
svm.setClock(clock);
const result = svm.sendTransaction(releaseTx);
// assert on result: the vault is empty and the lock account is goneWhat else to cover
- Both token programs. The locker's suite creates a Token-2022 mint with a transfer fee and asserts the locked total is what landed, not what was sent.
- Boundaries: a period equal to the vesting window, a cliff equal to the end, amount 1, amount u64::MAX.
- Re-runs: calling release twice, creating the same lock_id twice (must fail on
init), closing then reusing an address. - Every error code in the IDL, at least once. If a code cannot be triggered, ask why it exists.
# the repo's wrapper: build, start a validator with the program loaded, run node --test, stop it
scripts/onchain.sh test token-locker
# by hand
anchor build
solana-test-validator --reset --bpf-program <PROGRAM_ID> target/deploy/token_locker.so &
node --test tests/What to remember
- Unit-test on LiteSVM for speed and clock control; run the suite on a local validator for realism; touch devnet for integrations.
- Read the chain's clock, not the machine's. Fund fresh keypairs per test so nothing is shared.
- Assert failures by error name. Cover every code in the IDL.
- Test the permissionless paths succeed for strangers and the guarded paths fail for the wrong accounts.
- Boundaries and re-runs find more bugs than happy paths.