Files

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

  1. Unit tests (fast, in-process): LiteSVM or Mollusk
  2. Integration tests (realistic state, full RPC): Surfpool — CLI-spawned or embedded via the @solana/surfpool SDK
  3. 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 run cargo surfpool-install. The txtx/taps Homebrew tap is stale (pinned to v1.0.0); don't use it.

Two Ways to Run

  1. CLI-spawned: NO_DNA=1 surfpool start (or --ci --daemon in CI). Tests connect to http://127.0.0.1:8899.
  2. 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 as bigint, so u64 values survive past 2^53.
  • client.surfnet.stop() is idempotent — always wire it into afterAll so failed runs don't leak processes. Nothing disposes a module-scoped client for you.
  • Pass surfpool({ rpcUrl }) to attach to an already-running surfpool start instead of booting one — that form is synchronous and needs a payer already on the client.
  • npm package @solana/surfpool ships 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_streamAccount re-fetches an account from the remote on every access (pass {"includeOwnedAccounts": true} to cascade); surfnet_streamAccounts registers several at once; surfnet_offlineAccount pins an account so it is never re-fetched.
  • Oracle/protocol scenarios: surfnet_registerScenario schedules account overrides on a slot timeline using built-in templates (Pyth, Switchboard, Raydium, Kamino, Drift, ...). Example: set BTC/USD to $67,500 with template pyth_btcusd and values {"price_message.price_value": 67500}. Use fetchBeforeUse on 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 with surfpool start --snapshot ./snap.json.
  • Snapshot → offline unit-test fixtures: with {"scope": {"preTransaction": "<signature>"}}, surfnet_exportSnapshot returns 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 init generates targets that link libfuzzer-sys directly, 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:

  1. Embedded SDK (preferred) — no daemon to manage; vitest runs Surfnet.start() per suite on dynamic ports.
  2. CLI daemon — NO_DNA=1 surfpool start --ci --daemon (--ci disables TUI, Studio, profiling, and logs; --daemon is 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/surfpool SDK 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_pauseClock for deterministic time-dependent tests
  • Track CU regressions with surfnet_profileTransaction tags + surfnet_getProfileResultsByTag (integration) and Mollusk benches (unit)
  • Export snapshots of interesting forked states and commit them for reproducible --snapshot runs
  • Use deterministic PDAs and seeded keypairs for reproducibility
  • Run integration tests in a separate, serial CI stage to control runtime