19 KiB
title, description
| title | description |
|---|---|
| Testing Strategy | A testing pyramid for Solana programs using LiteSVM and Mollusk for fast unit tests and Surfpool (CLI or embedded SDK) as the integration-testing centerpiece, with mainnet forking, cheatcodes, and CI patterns. |
Testing Strategy (LiteSVM / Mollusk / Surfpool)
Contents
- Testing Pyramid
- Unit Tests: LiteSVM
- Unit Tests: Mollusk
- Integration Tests: Surfpool
- Cluster Smoke Tests
- Fuzz Testing
- Test Layout Recommendation
- CI Guidance
- Best Practices
Testing Pyramid
- Unit tests (fast, in-process): LiteSVM or Mollusk
- Integration tests (realistic state, full RPC): Surfpool — CLI-spawned or embedded via the
@solana/surfpoolSDK - Cluster smoke tests: devnet/testnet/mainnet as needed
Surfpool is the centerpiece for integration testing: sub-second startup, lazy mainnet forking, 26 surfnet_* cheatcodes, transaction profiling, and an embeddable SDK so tests need no external daemon.
Unit Tests: LiteSVM
A lightweight Solana Virtual Machine that runs directly in your test process. Surfpool itself is built on LiteSVM, so unit tests and integration tests share the same SVM semantics.
When to Use LiteSVM
- Fast execution without validator overhead
- Direct account state manipulation
- Built-in CU reporting
- Multi-language support (Rust, TypeScript, Python)
Rust Setup
cargo add --dev litesvm # 0.14.x, Agave 4.1-based
use litesvm::LiteSVM;
use solana_sdk::{pubkey::Pubkey, signature::Keypair, transaction::Transaction};
#[test]
fn test_deposit() {
let mut svm = LiteSVM::new();
// Load your program
let program_id = pubkey!("YourProgramId11111111111111111111111111111");
svm.add_program_from_file(program_id, "target/deploy/program.so");
// Create accounts
let payer = Keypair::new();
svm.airdrop(&payer.pubkey(), 1_000_000_000).unwrap();
// Build and send transaction
let tx = Transaction::new_signed_with_payer(
&[/* instructions */],
Some(&payer.pubkey()),
&[&payer],
svm.latest_blockhash(),
);
let result = svm.send_transaction(tx);
assert!(result.is_ok());
}
For CPI call-tree assertions, see the companion litesvm-cpi-tree crate (added in litesvm 0.14).
TypeScript Setup (Kit litesvm plugin)
Use @solana/kit (7.x) with the LiteSVM plugin — the same client API as production code, backed by an in-process SVM instead of an RPC:
npm i --save-dev litesvm @solana/kit-plugin-litesvm @solana/kit-plugin-signer
npm i @solana/kit @solana-program/system
import { createClient, lamports } from '@solana/kit';
import { litesvm } from '@solana/kit-plugin-litesvm';
import { airdropSigner, generatedSigner } from '@solana/kit-plugin-signer';
import { getTransferSolInstruction } from '@solana-program/system';
const client = await createClient()
.use(generatedSigner()) // async — await the final client
.use(litesvm())
.use(airdropSigner(lamports(1_000_000_000n)));
// Direct access to the underlying LiteSVM instance
client.svm.addProgramFromFile(programId, 'target/deploy/program.so');
const ix = getTransferSolInstruction({
source: client.payer,
destination: recipient,
amount: lamports(1_000n),
});
await client.sendTransaction([ix]);
Do not use @solana/web3.js v1-style imports (Connection, PublicKey) in new test code — Kit is the standard client.
Advanced LiteSVM Features (Rust)
// Modify clock sysvar
svm.set_sysvar(&Clock { slot: 1000, .. });
// Warp to slot
svm.warp_to_slot(5000);
// Configure compute budget
svm.set_compute_budget(ComputeBudget { max_units: 400_000, .. });
// Toggle signature verification (useful for testing)
svm.with_sigverify(false);
// Check compute units used
let result = svm.send_transaction(tx)?;
println!("CUs used: {}", result.compute_units_consumed);
Unit Tests: Mollusk
A lightweight test harness (mollusk-svm 0.14.x) providing a direct interface to program execution without full validator runtime. Best for Rust-only testing with fine-grained control.
When to Use Mollusk
- Fast execution for rapid development cycles
- Precise account state manipulation for edge cases
- Detailed performance metrics and CU benchmarking
- Custom syscall testing
Setup
cargo add --dev mollusk-svm
cargo add --dev mollusk-svm-programs-token # For SPL token helpers
cargo add --dev solana-sdk solana-program
Basic Usage
use mollusk_svm::Mollusk;
use mollusk_svm::result::Check;
use solana_sdk::{account::Account, pubkey::Pubkey, instruction::Instruction};
#[test]
fn test_instruction() {
let program_id = Pubkey::new_unique();
let mollusk = Mollusk::new(&program_id, "target/deploy/program");
let payer = (
Pubkey::new_unique(),
Account {
lamports: 1_000_000_000,
data: vec![],
owner: solana_sdk::system_program::ID,
executable: false,
rent_epoch: 0,
},
);
let instruction = Instruction {
program_id,
accounts: vec![/* account metas */],
data: vec![/* instruction data */],
};
mollusk.process_and_validate_instruction(
&instruction,
&[payer],
&[
Check::success(),
Check::compute_units(50_000),
],
);
}
Token Helpers and CU Benchmarking
use mollusk_svm_programs_token::token;
token::add_program(&mut mollusk);
let mint_account = token::mint_account(decimals, supply, mint_authority);
let token_account = token::token_account(mint, owner, amount);
use mollusk_svm::MolluskComputeUnitBencher;
let bencher = MolluskComputeUnitBencher::new(mollusk)
.must_pass(true)
.out_dir("../target/benches");
bencher.bench("deposit_instruction", &instruction, &accounts);
// Generates markdown report with CU usage and deltas
Integration Tests: Surfpool
Surfpool (repo: solana-foundation/surfpool, docs: docs.surfpool.run) provides a local surfnet — a drop-in replacement for solana-test-validator with lazy mainnet forking and 26 cheatcode RPC methods.
When to Use Surfpool
- Complex CPIs requiring mainnet programs (e.g., Jupiter with 40+ accounts)
- Testing against realistic, lazily-cloned mainnet account state
- Time travel, clock control, and oracle/protocol scenario overrides
- CU profiling of full transactions via
surfnet_profileTransaction - Any test that needs a real JSON-RPC + WebSocket endpoint
Install
# Primary install method
curl -sL https://run.surfpool.run/ | bash
# Keep up to date (v1.3.0+, SHA256-verified)
surfpool update
Warning: Never run
cargo install surfpool— the crates.io name is squatted by an unrelated crate. To build from source, clone the repo and runcargo surfpool-install. Thetxtx/tapsHomebrew tap is stale (pinned to v1.0.0); don't use it.
Two Ways to Run
- CLI-spawned:
NO_DNA=1 surfpool start(or--ci --daemonin CI). Tests connect tohttp://127.0.0.1:8899. - Embedded SDK (v1.2.0+): run a full surfnet in-process from the test file itself — no daemon, no port conflicts (dynamic ports). npm:
@solana/surfpool(1.5.0); Rust:surfpool-sdk = "1.5.0".
Prefer the embedded SDK for test suites: each suite owns its surfnet lifecycle and CI needs no service orchestration.
In TypeScript, reach for the Kit plugin at @solana/surfpool/kit rather than driving the Surfnet class by hand. One .use(surfpool()) boots the surfnet, wires a pre-funded payer and the full RPC stack, and installs a typed client.cheatcodes RPC — no hand-rolled JSON-RPC helper.
Full Example: Kit + Embedded Surfpool (vitest)
npm i --save-dev @solana/surfpool vitest
npm i @solana/kit @solana/kit-plugin-rpc @solana/kit-plugin-signer @solana-program/system
import { afterAll, describe, expect, it } from 'vitest';
import {
address,
appendTransactionMessageInstruction,
createClient,
createTransactionMessage,
getBase64EncodedWireTransaction,
lamports,
pipe,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
signTransactionMessageWithSigners,
} from '@solana/kit';
import { surfpool } from '@solana/surfpool/kit';
import { getTransferSolInstruction } from '@solana-program/system';
const USDC_MINT = address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v');
// Embedded surfnet on dynamic ports, pre-funded payer, typed cheatcodes.
// Async in embedded mode — await the chain.
const client = await createClient().use(surfpool());
afterAll(() => client.surfnet.stop()); // idempotent graceful shutdown
describe('deposit flow', () => {
it('credits USDC set up via cheatcode', async () => {
// Give the payer a 1,000 USDC ATA without minting
await client.cheatcodes
.setTokenAccount(client.payer.address, USDC_MINT, { amount: 1_000_000_000n })
.send();
const balance = await client.rpc.getBalance(client.payer.address).send();
expect(balance.value).toBeGreaterThan(0n);
// Exercise the program under test
const ix = getTransferSolInstruction({
source: client.payer,
destination: address('11111111111111111111111111111111'),
amount: lamports(1_000n),
});
await client.sendTransaction([ix]);
});
it('handles time-dependent logic via timeTravel', async () => {
// Jump 30 days ahead; returns the resulting EpochInfo
const epochInfo = await client.cheatcodes
.timeTravel({ absoluteTimestamp: Math.floor(Date.now() / 1000) + 30 * 86_400 })
.send();
expect(epochInfo.absoluteSlot).toBeGreaterThan(0n);
// Assert unlock/vesting/expiry behavior here
});
it('stays under the CU budget', async () => {
// Build + sign the transaction under test, then encode it to
// base64 wire format for profiling
const ix = getTransferSolInstruction({
source: client.payer,
destination: address('11111111111111111111111111111111'),
amount: lamports(1_000n),
});
const { value: blockhash } = await client.rpc.getLatestBlockhash().send();
const signedTx = await signTransactionMessageWithSigners(pipe(
createTransactionMessage({ version: 0 }),
m => setTransactionMessageFeePayerSigner(client.payer, m),
m => setTransactionMessageLifetimeUsingBlockhash(blockhash, m),
m => appendTransactionMessageInstruction(ix, m),
));
const base64VersionedTx = getBase64EncodedWireTransaction(signedTx);
// Simulates WITHOUT committing state; returns CU + pre/post snapshots
const profile = await client.cheatcodes
.profileTransaction(
base64VersionedTx, // base64-encoded VersionedTransaction
'deposit', // optional tag for getProfileResultsByTag
)
.send();
// CUs live on transactionProfile; per-instruction breakdown is in
// profile.instructionProfiles
expect(profile.transactionProfile.computeUnitsConsumed).toBeLessThan(200_000n);
});
});
Notes:
- Cheatcode method names drop the
surfnet_prefix and responses come back unwrapped from their{ context, value }envelope. Integers parse asbigint, sou64values survive past 2^53. client.surfnet.stop()is idempotent — always wire it intoafterAllso failed runs don't leak processes. Nothing disposes a module-scoped client for you.- Pass
surfpool({ rpcUrl })to attach to an already-runningsurfpool startinstead of booting one — that form is synchronous and needs apayeralready on the client. - npm package
@solana/surfpoolships native binaries (napi-rs) for macOS x64/arm64 and Linux x64 GNU. Embedded mode needs one; attach mode does not.
Full plugin reference — entry points, configuration, attach mode, codec-based account seeding: surfpool/kit-plugin.md.
Rust equivalent with surfpool-sdk:
use surfpool_sdk::{Surfnet, BlockProductionMode};
let surfnet = Surfnet::builder()
.block_production_mode(BlockProductionMode::Transaction)
.start()?;
// surfnet.rpc_url(), pre-funded payer, cheatcode helpers
Mainnet-Fork Testing
surfpool start forks mainnet by default — any account or program your test touches is lazily fetched from the remote RPC and cached locally. No --clone lists.
NO_DNA=1 surfpool start # mainnet fork (default)
NO_DNA=1 surfpool start --network devnet # or devnet/testnet
NO_DNA=1 surfpool start --rpc-url https://my-rpc-provider.com
- Live accounts:
surfnet_streamAccountre-fetches an account from the remote on every access (pass{"includeOwnedAccounts": true}to cascade);surfnet_streamAccountsregisters several at once;surfnet_offlineAccountpins an account so it is never re-fetched. - Oracle/protocol scenarios:
surfnet_registerScenarioschedules account overrides on a slot timeline using built-in templates (Pyth, Switchboard, Raydium, Kamino, Drift, ...). Example: set BTC/USD to $67,500 with templatepyth_btcusdand values{"price_message.price_value": 67500}. UsefetchBeforeUseon an override to refresh from the live feed before applying deltas. - Snapshots:
surfnet_exportSnapshot(with sysvar/feature-gate filters since v1.4.0) captures forked state to JSON; reload deterministically withsurfpool start --snapshot ./snap.json. - Snapshot → offline unit-test fixtures: with
{"scope": {"preTransaction": "<signature>"}},surfnet_exportSnapshotreturns the state of every account a transaction touched as it was before execution. Run the flow once against a fork, export the pre-state, and load those accounts into LiteSVM/Mollusk to replay the instruction as a deterministic, offline unit test — see surfpool/cheatcodes.md.
Anchor Projects
Anchor 1.0+ uses surfpool as the default test runner: anchor test and anchor localnet spawn a surfnet automatically (current Anchor: 1.1.2, paired with Solana CLI 3.1.10). Running surfpool start in a project root detects both Anchor and Pinocchio projects and scaffolds txtx deployment runbooks (program names read from Anchor.toml).
For older test suites written against solana-test-validator semantics:
NO_DNA=1 surfpool start --legacy-anchor-compatibility --anchor-test-config-path ./Test.toml
Cluster Smoke Tests
Keep a small suite that runs against devnet before releases: deploy, exercise one happy path per instruction, verify explorer-visible effects. Use Kit with solanaRpc({ rpcUrl }) pointed at devnet and a funded keypair via signerFromFile('~/.config/solana/id.json'). These are slow and flaky by nature — never gate PRs on them.
Fuzz Testing
Fuzzing generates large volumes of randomized inputs and programmatically asserts the program still behaves correctly — surfacing edge cases, logic errors, and economic attack vectors that hand-written tests miss. Solana programs take two input surfaces: instruction data (easy to vary within constraints) and accounts (the hard part — you must synthesize valid account structures with varied ownership, balances, and data layouts).
- Trident (Ackee) — the dedicated Solana fuzzing framework: generates instruction sequences targeting potential vulnerabilities, with account-state modeling built in. Start here for program-level fuzzing.
- Crucible (Asymmetric Research) — High-performance Solana Program Fuzzer written in Rust with LibAFL/LiteSVM backend with sBPF edge coverage and state coverage.
- libFuzzer via
cargo-fuzz— coverage-guided, mutation-based fuzzing for Rust functions without the full Solana runtime. Good for pure helpers (math, parsing);cargo fuzz initgenerates targets that linklibfuzzer-sysdirectly, no C shim required.
Most effective setups combine coverage-guided fuzzing (prioritize inputs hitting untested paths) with transaction-sequence fuzzing (chains of instructions mirroring real user flows). Start with one critical instruction type and expand; even an overnight run often finds edge cases manual tests missed.
Test Layout Recommendation
tests/
├── unit/
│ ├── deposit.rs # LiteSVM or Mollusk
│ ├── withdraw.rs
│ └── mod.rs
├── integration/
│ ├── full_flow.test.ts # Embedded @solana/surfpool + Kit
│ └── fork.test.ts # Mainnet-fork scenarios
├── vitest.config.surfpool.ts
└── fixtures/
└── accounts.rs # Shared test account setup
CI Guidance
Two options:
- Embedded SDK (preferred) — no daemon to manage;
vitestrunsSurfnet.start()per suite on dynamic ports. - CLI daemon —
NO_DNA=1 surfpool start --ci --daemon(--cidisables TUI, Studio, profiling, and logs;--daemonis Linux-only).
Run surfpool-backed suites serially. The solana-foundation/pay-kit pattern uses a dedicated vitest config:
// vitest.config.surfpool.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/integration/**/*.test.ts'],
fileParallelism: false,
maxWorkers: 1,
testTimeout: 60_000,
hookTimeout: 60_000,
},
});
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run unit tests
run: cargo test-sbf
# Embedded SDK: @solana/surfpool ships its own native binaries —
# no Surfpool CLI install step needed.
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- name: Run integration tests (embedded SDK)
run: npx vitest run --config vitest.config.surfpool.ts
# Alternative: CLI-spawned daemon (only this variant needs the CLI installed)
# integration-tests-cli:
# runs-on: ubuntu-latest
# needs: unit-tests
# steps:
# - uses: actions/checkout@v4
# - name: Install Surfpool
# run: curl -sL https://run.surfpool.run/ | bash
# - run: NO_DNA=1 surfpool start --ci --daemon
# - run: cargo test --test integration
Always prefix agent-run surfpool commands with NO_DNA=1 (see no-dna.org).
Best Practices
- Keep unit tests (LiteSVM/Mollusk) as the default CI gate — fast feedback
- Use the embedded
@solana/surfpoolSDK for integration suites; reserve the CLI daemon for local dev with Studio - Set up state with cheatcodes (
surfnet_setAccount,surfnet_setTokenAccount) instead of long funding/minting transaction sequences - Use
surfnet_timeTravel+surfnet_pauseClockfor deterministic time-dependent tests - Track CU regressions with
surfnet_profileTransactiontags +surfnet_getProfileResultsByTag(integration) and Mollusk benches (unit) - Export snapshots of interesting forked states and commit them for reproducible
--snapshotruns - Use deterministic PDAs and seeded keypairs for reproducibility
- Run integration tests in a separate, serial CI stage to control runtime