
A test that initializes one account and prints “success” proves that the program deployed and one path did not explode. That is about it.
A Solana instruction gets attacker-controlled instruction data and an attacker-selected account list. Your test suite should map every assumption about those inputs.
Default to instruction tests and end-to-end program flows. Execute the public instructions in an SVM and check the state they leave behind, both individually and across a complete user lifecycle. Add unit tests where isolated logic deserves them, especially math, and keep a smaller set of high-fidelity RPC and external-program tests.
| Layer | What belongs here | Typical runner |
|---|---|---|
| Focused unit tests, where useful | Math, rounding, overflow, and other isolated logic with meaningful edge cases | cargo test |
| One complete instruction | Account validation, signer checks, state changes, CPIs, exact errors | LiteSVM or Mollusk |
| End-to-end program flows | Several instructions sharing real state across multiple transactions | LiteSVM or a local validator backend |
| Full integration | Generated client, IDL, RPC, external programs, transaction construction, logs and events | anchor test, Surfpool, or solana-test-validator |
| Public-cluster smoke tests | Deployment, real RPC behavior, confirmation, version compatibility | Devnet or a private staging cluster |
Start with an inventory from the program's public instructions or IDL. One row per instruction. An end-to-end test calling an instruction once does not make that instruction covered.
For every instruction, write one test with the smallest legitimate setup and assert all observable effects:
LiteSVM runs an in-process SVM. It can load compiled programs, manipulate sysvars, inspect accounts, and report compute use. Its own documentation says it is less like a real RPC node, so keep a separate integration layer. LiteSVM documentation
Mollusk is useful when the test really is “execute this instruction against these exact accounts.” It executes a single instruction or a chain in a minified SVM and can validate results, account state, and compute units. It deliberately does not create a validator runtime, AccountsDB, or Bank. Mollusk documentation
For a wider tour of the Solana testing toolkits, including Rust unit tests, solana-program-test, anchor test, and LiteSVM, see the testing section of our 100 Solana tips.
A simple instruction matrix makes omissions visible:
| Instruction | Happy path | State and balance assertions | Error rules covered | Boundaries covered | Lifecycle test |
|---|---|---|---|---|---|
initialize |
yes | PDA, authority, version, rent | already initialized, wrong PDA, wrong payer | max config size | create-to-close |
deposit |
yes | user position, vault, shares | signer, mint, token program, zero amount | min deposit, cap | create-to-close |
withdraw |
yes | position, vault, recipient | signer, insufficient shares, paused | one below/all/one above | create-to-close |
close |
yes | account gone, rent returned | non-empty, wrong authority | exact zero balance | create-to-close |
Keep this matrix in the repository. It is more useful during review than a raw test count.
Per-instruction tests reset the world frequently. Plenty of bugs need history.
Write at least one test that uses the same user and the same accounts through the full supported lifecycle:
Do not inject a half-finished position directly into the harness for this test. Create it through the public instruction. Shortcuts are fine in focused instruction tests, but they defeat the purpose of a lifecycle test.
Assert invariants after every step:
Add a two-user flow when users can affect shared state. Interleave their actions. Deposit as Alice, deposit as Bob, act as Alice, withdraw as Bob, then settle both. This catches accidental use of a global field, account aliasing, and order-dependent accounting that a single-user path can miss.
Also test transaction composition. Solana transactions are atomic, so if a later instruction fails, earlier state changes in the same transaction are reverted. Fees are still charged on a failed transaction. Solana's transaction documentation describes both behaviors. Build a transaction with two valid instructions and verify both effects. Then make the second instruction fail and verify that neither instruction effect remains.
For each instruction, list every trust assumption and business rule. Then break them one at a time.
A useful Solana checklist is:
remaining_accounts;Anchor exposes many of these rules directly as account constraints, including signer, PDA seeds, has_one, address, owner, executable, duplicate mutable accounts, and close behavior. Use the account-constraint list as a test inventory. A native Rust program needs the same tests around its manual checks.
This checklist maps closely to the manual checks an auditor reaches for first, so it doubles as a reading order for review. We wrote about that order in The First Thing I Look at in Every Solana Program Audit.
Every failure test should prove three things:
1. The call failed.
2. It failed with the expected program or constraint error.
3. Program state and token balances are unchanged, apart from documented runtime effects.
is_err() only proves that something failed. Maybe the authority check worked. Maybe the transaction died earlier because the test forgot a writable account. Anchor errors include names and numeric codes for framework and custom errors, so assert the code or typed error instead of matching a vague log substring. Anchor error documentation
Snapshot the relevant accounts before the call, execute the failing transaction, assert the exact error, then read the accounts again. Include token balances and lamports when a CPI could have happened before the error. Account for the transaction fee and durable-nonce behavior if the test environment applies them. A validated nonce transaction that later fails execution still advances the nonce and charges the fee. Solana documents this failure behavior explicitly. Everything else the failed instructions tried to change should be reverted.
The best unhappy-path tests are often one unit away from being valid. They catch > versus >=, rounding mistakes, stale timestamps, and caps enforced on the wrong side of a state change.
For a rule min <= amount <= max, test:
min - 1 -> expected failure
min -> expected success
min + 1 -> expected success
max - 1 -> expected success
max -> expected success
max + 1 -> expected failure
Use the same pattern for:
deadline - 1, deadline, and deadline + 1;You do not need all six numeric cases when some are duplicates or impossible, such as 0 - 1 for a u64. You do need a test on the valid side of every boundary. Otherwise a failing test may only prove that the instruction is broken for everybody.
Default to instruction and end-to-end tests. AI makes them easier to write, so there is less reason to test every helper separately. Unit tests should be the exception. Math with rounding or overflow edge cases is a good reason to write one.
AI-generated unit tests often copy the implementation's formula into the assertion. The same mistake can then appear on both sides and the test passes. Use expected values worked out from the intended rule, and be clear about which mistake each test catches.
For example, suppose a protocol charges a 1% fee, rounded up in the token's smallest units:
fn fee(amount: u64) -> u64 {
amount.div_ceil(100)
}
#[test]
fn fee_rounds_up_without_overflow() {
for (amount, expected) in [
(0, 0),
(1, 1),
(99, 1),
(100, 1),
(101, 2),
(u64::MAX, 184_467_440_737_095_517),
] {
assert_eq!(fee(amount), expected, "amount={amount}");
}
}
These cases catch rounding down, charging an extra unit at an exact multiple, and overflow from computing (amount + 99) / 100. Keep tests like this beside the Rust module. The instruction test still needs to check that the correct fee was deducted from the user and credited to the fee account. A correct helper does not prove the balances are correct.
It depends on how you develop and how much of the program has been planned before implementation starts.
If the instruction interface, account model, invariants, and error behavior are already clear, writing tests first works well. The tests become an executable version of the plan. This is especially useful for math, state transitions, authorization rules, and boundary conditions where the expected behavior is known before the handler exists.
Exploratory development is different. You may still be changing account layouts, instruction arguments, or even the state machine. Writing a large integration test before those choices settle can create a lot of throwaway code. In that case, write down the invariants and expected failure cases first, then add executable tests as each piece becomes stable.
A useful default cadence is:
| Point in development | Tests to write |
|---|---|
| Before the instruction | Add its matrix row. List the required accounts, signers, state changes, errors, and boundaries. Write math unit tests first where they provide useful checks against known results. |
| While implementing it | Build the instruction test setup and expected state assertions. Add focused unit tests only where isolated logic has meaningful cases to check. |
| As soon as the handler works | Add the full instruction happy path, then its signer, account, business-rule, and boundary failures. Finish these before moving far into the next instruction. |
| When related instructions exist | Add the realistic lifecycle that connects them, using state created through the public instructions. |
| Before merging a feature | Run the instruction matrix, regression corpus, lifecycle tests, and the relevant client integration. Check that a small deliberate mutation makes the right test fail. |
| When a bug or audit finding appears | Reproduce it with a failing regression before or alongside the fix. Keep that test forever. |
| Before a release | Run the full deterministic integration suite, external-program fixtures, version compatibility checks, and the small staging or Devnet smoke flow. |
Strict test-first development is optional. Delaying the whole suite until the program is finished is dangerous. By then the exact invariants, edge cases, and intended errors are harder to reconstruct, and developers have already built more code on top of untested assumptions.
For most teams, a good compromise is to plan the matrix before an instruction, write focused tests while implementing it, and finish the instruction-level happy and unhappy paths immediately afterward. Lifecycle and full integration tests naturally arrive a little later because they need several working pieces.
When development, fuzzing, production monitoring, or an audit finds a bug, preserve the trigger before fixing it.
Use this workflow:
Put the regression at the cheapest layer that faithfully reproduces the bug. A rounding bug belongs in a unit test. A missing signer check belongs in an instruction test. A stale-account bug that requires deposit -> update -> withdraw belongs in a lifecycle test. An IDL or CPI compatibility bug belongs in full integration.
For security findings, reproduce the consequence that mattered through the public instruction. If the bug allowed Alice to withdraw Bob's funds, make the regression require an authorization error and an unchanged Bob balance. It should fail on the vulnerable code because the unauthorized transaction succeeds or changes the balance, then pass after the fix. A test that merely checks a helper function misses the public attack path.
If reproduction needs real account data, store a minimized, versioned fixture. Record the source slot, program IDs, binary or fixture hashes, and why each account is present. Avoid pulling live mainnet state in every pull request because that makes the suite change underneath you.
Full integration means exercising the interfaces that ship:
Current Anchor's anchor test deploys workspace programs and runs the configured integration suite. On localnet, Surfpool is now the default backend; anchor test --validator legacy selects solana-test-validator. Anchor CLI reference. Pin the toolchain in CI because this behavior changes across framework versions.
For external programs, use frozen fixtures in deterministic CI and run a scheduled job against fresher state. Anchor can clone accounts and upgradeable programs into the test cluster, load account JSON files, and configure Surfpool's remote data source. Anchor.toml testing configuration
RPC simulation is useful for asserting logs, inner instructions, errors, returned accounts, and compute units. The simulateTransaction response exposes those fields. Simulation still does not prove a transaction will land or finalize, so keep a very small staging smoke test that submits and confirms transactions.
Use Devnet for that public smoke test, not as the deterministic core of the suite. Solana documents Devnet as the public application-testing cluster, and also notes that it can reset, is rate-limited, and may run a newer minor release than Mainnet. Clusters and public RPC endpoints
A practical CI split:
| When | Run |
|---|---|
| Every pull request | all per-instruction tests, affected lifecycle tests, boundary tests, regression corpus, focused unit tests via cargo test |
| Every merge | complete lifecycle tests and deterministic local integration |
| Nightly or before a release | external-program fixtures, validator/backend compatibility, client integration, Devnet smoke tests |
Rust's LLVM tooling reports function, instantiation, line, and region coverage. Region coverage is more granular because one line can contain several separately executed expressions. rustc coverage documentation
cargo-llvm-cov is a convenient wrapper:
cargo llvm-cov --workspace --all-features --html
cargo llvm-cov --workspace --all-features --fail-under-lines N
Replace N with the current accepted baseline, then raise it deliberately. The tool supports HTML output and fail-under thresholds. cargo-llvm-cov documentation
Read the report instead of chasing one impressive percentage. Uncovered authorization branches, arithmetic errors, migrations, and close paths deserve attention. An uncovered debug formatter probably does not. Use some judgment.
Source coverage shows which host-instrumented Rust regions executed. It does not tell you that assertions were correct, that every account permutation was tried, or that the deployed sBPF path behaved like a validator. Track three views together:
Do one small falsification check before trusting a green suite. Temporarily change an authorization comparison, flip a boundary from >= to >, or remove a state update. The relevant test must fail. Revert the mutation immediately. This catches tests that execute a lot of code and prove nothing useful.
Before marking an instruction complete, check:
Start with the instruction matrix, not a coverage target. Once every public instruction has a happy path, every rule has a failure case, and the main user lifecycle works from creation to close, the coverage report becomes useful for finding what you still forgot.
Verification note: the standalone Rust fee-rounding example above was compiled and run with rustc 1.89.0; the test passed. The broader matrix is a test-plan template, not a benchmark against a specific Solana program.
Yes, you will probably end up with hundreds of tests for an average Solana program. That is fine. Every instruction has a happy path, several account and authorization failures, business-rule failures, boundary cases, and usually a place in one or more lifecycle tests. The count grows quickly because the program has a large input surface.
Make the suite easy to navigate:
Good names describe the contract:
deposit_succeeds_at_minimum_amount
deposit_fails_with_wrong_mint
withdraw_fails_when_authority_did_not_sign
close_fails_while_position_has_remaining_shares
audit_2026_04_duplicate_remaining_account_is_rejected
Avoid names such as test_deposit_2 or one giant test called works. When CI fails, the test name should already tell you which guarantee broke.
Write the tests as the program takes shape, label them well, and keep the regressions. A few hundred focused tests are much easier to deal with than one mainnet bug nobody can reproduce.