Tests with solana-bankrun
Solana programs run on validators. To test one, you need something that runs your program code and lets you check what happened. There are several ways to do this, and which one you pick affects how fast your test loop runs. The default for Anchor projects in 2026 is solana-bankrun: an in-process simulated runtime that runs your program directly, with TypeScript bindings that integrate cleanly with the Anchor SDK. This lecture walks through the landscape so you can recognize the alternatives, then shows what a real bankrun test actually looks like.The test stack landscape
There are four meaningfully different ways to test a Solana program. Each one is the right tool for a different situation.
A quick tour through the four options. solana-test-validator is the heavy hammer: a real validator running in a subprocess, communicating over a real RPC port. Startup is slow, ten to thirty seconds, and every transaction goes through real slot timing of around 400ms. The realism is the point. You're testing your code against an actual validator the same way it'll run in production. Useful right before a release, when you want to verify the full integration including your client-side RPC code. Painful as an inner-loop tool because the suite takes minutes to run.
solana-bankrun is the inner-loop tool. It wraps solana-program-test, the Rust framework that runs your program's compiled BPF bytecode in-process against an in-memory Bank. The TypeScript bindings make it work with the Anchor SDK without any setup beyond installing a package. Tests run in milliseconds. You can warp time to any future slot, set account state directly, and inspect outcomes through the same Anchor client SDK production code uses. This is what most Anchor projects use, and it's the default for this course.
LiteSVM is the newer alternative. The idea is similar to bankrun, an in-process VM with fast iteration, but the runtime is thinner and often runs faster. The catch is that it's less feature-complete: some sysvars are partially implemented, some edge cases around rent and clock differ from a real validator. It's growing quickly. If your bankrun suite ever gets large enough that you feel the milliseconds, LiteSVM is the migration path. For most projects starting out, bankrun is the safer choice because the ecosystem and documentation are larger.
Mollusk is in a different niche. It's a minimalist single-instruction SVM tester written in Rust, used heavily for testing native Solana programs, meaning non-Anchor ones, and for fuzz testing. It's extremely fast because it does very little beyond running one instruction at a time and snapshotting account state. If you're writing native programs or doing security fuzzing, Mollusk is the right tool. For Anchor projects with TypeScript tests, it's not the fit.
For this course, bankrun is what you'll use. The rest of the lecture shows what that looks like.
What a bankrun test actually looks like
Here's a complete test file for a counter program built earlier in the course. It imports the program, sets up a context, calls initialize, then increment, and asserts the resulting state.
import { startAnchor } from "solana-bankrun";
import { BankrunProvider } from "anchor-bankrun";
import { Program, BN } from "@coral-xyz/anchor";
import { Keypair, LAMPORTS_PER_SOL } from "@solana/web3.js";
import { expect } from "chai";
import { Counter } from "../target/types/counter";
import IDL from "../target/idl/counter.json";
describe("counter", () => {
let context: any;
let provider: BankrunProvider;
let program: Program<Counter>;
let admin: Keypair;
before(async () => {
// 1. Spin up an in-process Bank with your program loaded
context = await startAnchor("./", [], []);
provider = new BankrunProvider(context);
program = new Program<Counter>(IDL as Counter, provider);
// 2. Create an admin keypair with some SOL to pay fees
admin = Keypair.generate();
context.setAccount(admin.publicKey, {
lamports: 10 * LAMPORTS_PER_SOL,
data: Buffer.alloc(0),
owner: SystemProgram.programId,
executable: false,
});
});
it("initializes a counter", async () => {
const [counterPda] = PublicKey.findProgramAddressSync(
[Buffer.from("counter"), admin.publicKey.toBuffer()],
program.programId,
);
await program.methods
.initialize(new BN(100)) // max value
.accounts({
counter: counterPda,
admin: admin.publicKey,
systemProgram: SystemProgram.programId,
})
.signers([admin])
.rpc();
const counter = await program.account.counter.fetch(counterPda);
expect(counter.value.toNumber()).to.equal(0);
expect(counter.max.toNumber()).to.equal(100);
expect(counter.admin.toString()).to.equal(admin.publicKey.toString());
expect(counter.locked).to.be.false;
});
it("increments the counter", async () => {
const [counterPda] = PublicKey.findProgramAddressSync(
[Buffer.from("counter"), admin.publicKey.toBuffer()],
program.programId,
);
await program.methods
.increment(new BN(5))
.accounts({
counter: counterPda,
caller: admin.publicKey,
})
.signers([admin])
.rpc();
const counter = await program.account.counter.fetch(counterPda);
expect(counter.value.toNumber()).to.equal(5);
});
it("rejects increment from non-admin", async () => {
const [counterPda] = PublicKey.findProgramAddressSync(
[Buffer.from("counter"), admin.publicKey.toBuffer()],
program.programId,
);
const attacker = Keypair.generate();
context.setAccount(attacker.publicKey, {
lamports: LAMPORTS_PER_SOL,
data: Buffer.alloc(0),
owner: SystemProgram.programId,
executable: false,
});
try {
await program.methods
.increment(new BN(1))
.accounts({
counter: counterPda,
caller: attacker.publicKey,
})
.signers([attacker])
.rpc();
expect.fail("expected the call to revert");
} catch (err: any) {
expect(err.toString()).to.include("Unauthorized");
}
});
it("rejects zero increment", async () => {
const [counterPda] = PublicKey.findProgramAddressSync(
[Buffer.from("counter"), admin.publicKey.toBuffer()],
program.programId,
);
try {
await program.methods
.increment(new BN(0))
.accounts({
counter: counterPda,
caller: admin.publicKey,
})
.signers([admin])
.rpc();
expect.fail("expected the call to revert");
} catch (err: any) {
expect(err.toString()).to.include("ZeroIncrement");
}
});
});There's a lot in this file, so it's worth walking through what each piece does.
The before block at the top runs once before all the tests. startAnchor("./", [], []) finds your Anchor.toml, builds your program, and loads it into an in-process Bank. The two empty arrays are for additional programs to deploy and additional accounts to pre-populate, neither of which you need here. The result is a context object that owns the simulated chain, plus a provider that wraps it for the Anchor SDK.
context.setAccount is the god-mode call. You give it a pubkey and the account data you want at that address, and the simulated chain stores it. Here you're funding the admin keypair with 10 SOL so it can pay transaction fees. No airdrops, no faucets, no waiting for a network. The account exists because you wrote it into existence.
The actual tests use program.methods.X(...).accounts({...}).signers([...]).rpc(), which is the standard Anchor client SDK call. The same code would work against a real validator without modification. The only difference is the provider underneath, which routes the call through the in-process Bank instead of an RPC connection. That's the whole point of bankrun: production-shaped client code, against an in-memory runtime.
Each test ends with state assertions. program.account.counter.fetch(counterPda) reads the account at that address and decodes it using the IDL. You then check fields with chai assertions. Both successful and failed paths get tested: the happy path verifies state changed correctly, the unhappy paths verify that the right error came back. Notice that the failure tests use try/catch and assert on the error message, because that's how Anchor surfaces custom errors to the client side.
Run this with anchor test --skip-build after you've built your program once, and the whole suite finishes in well under a second.
Time-warping for time-dependent logic
The single feature that bankrun has and solana-test-validator doesn't is control over the chain's clock. You can fast-forward to any future slot. The Clock sysvar inside your program reflects the warped time. Logic gated on a future timestamp can be tested without actually waiting.
Imagine the counter has a lockup feature: once the value hits max, it locks for 24 hours before the admin can reset it. Testing this on a real validator would mean waiting 24 hours. With bankrun:
it("respects the 24-hour lockup", async () => {
// ... earlier: increment the counter to max so it locks ...
// trying to reset immediately fails
try {
await program.methods.reset()
.accounts({ counter: counterPda, admin: admin.publicKey })
.signers([admin]).rpc();
expect.fail("expected the reset to fail");
} catch (err: any) {
expect(err.toString()).to.include("StillLocked");
}
// warp 24 hours forward (approx 216,000 slots at 400ms/slot)
const currentSlot = await context.banksClient.getSlot();
await context.warpToSlot(currentSlot + 216_000n);
// now the reset succeeds
await program.methods.reset()
.accounts({ counter: counterPda, admin: admin.publicKey })
.signers([admin]).rpc();
const counter = await program.account.counter.fetch(counterPda);
expect(counter.locked).to.be.false;
expect(counter.value.toNumber()).to.equal(0);
});context.warpToSlot jumps the chain's slot counter to the value you specify. The Clock sysvar updates accordingly, so when your handler reads Clock::get()?.unix_timestamp, it sees the new value. The whole test runs in milliseconds despite simulating a day passing.
This is the feature that justifies bankrun over any client-only test approach. Vesting contracts that release tokens over a year, staking pools that accrue rewards by the second, timelocks on treasury withdrawals, subscriptions that expire after thirty days. Without time control, none of these is properly testable. With bankrun, you write the test the way you describe the feature: "after 24 hours, the admin can reset," and the assertion runs in a millisecond.
Common patterns and small mistakes
A few habits worth picking up from the start.
One assertion path per test. A test that verifies five different things at once tells you nothing useful when it fails. Break out separate tests for the happy path, each error case, and each edge case. Bankrun's speed means there's no excuse for combining them.
Test the failure cases. Every error variant your program defines should have at least one test that triggers it. The pattern is the try/catch shown above, asserting on the error name in the message. Errors are part of your program's contract with clients, and they break silently if not tested.
Use setAccount for setup, never for shortcuts. It's tempting to skip writing the initialize call by just pre-populating the counter account with the right state. Don't. Tests that depend on hand-crafted state can pass even when the initialization logic is broken. Use setAccount only to seed prerequisites your test isn't trying to exercise, like funding keypairs.
Re-derive PDAs in every test. The PublicKey.findProgramAddressSync call shows up in every test. You could share it via a helper, but inlining it makes each test more self-contained, which matters when you're debugging one in isolation.
warpToSlot for time, never for retries. Don't use time-warping to "settle" something asynchronous. Bankrun is synchronous, with nothing to settle. Use it strictly to advance the chain past a time gate your program is testing for.
A program with a clean bankrun suite under it is a program you can refactor confidently. Every later instruction you write, every constraint you add, every error variant you introduce, your suite tells you in seconds whether you broke anything. That's the payoff of investing in tests early, and it's what makes bankrun's millisecond feedback loop worth learning over the alternatives.