Mollusk is a lightweight test harness for Solana programs. It provides a simple interface for testing Solana program executions in a minified Solana Virtual Machine (SVM) environment.
It does not create any semblance of a validator runtime, but instead provisions a program execution pipeline directly from lower-level SVM components.
In summary, the main processor - process_instruction
- creates minified
instances of Agave's program cache, transaction context, and invoke
context. It uses these components to directly execute the provided
program's ELF using the BPF Loader.
Because it does not use AccountsDB, Bank, or any other large Agave components, the harness is exceptionally fast. However, it does require the user to provide an explicit list of accounts to use, since it has nowhere to load them from.
The test environment can be further configured by adjusting the compute
budget, feature set, or sysvars. These configurations are stored directly
on the test harness (the Mollusk
struct), but can be manipulated through
a handful of helpers.
Four main API methods are offered:
process_instruction
: Process an instruction and return the result.process_and_validate_instruction
: Process an instruction and perform a series of checks on the result, panicking if any checks fail.process_instruction_chain
: Process a chain of instructions and return the result.process_and_validate_instruction_chain
: Process a chain of instructions and perform a series of checks on each result, panicking if any checks fail.
Both process_instruction
and process_and_validate_instruction
deal with
single instructions. The former simply processes the instruction and
returns the result, while the latter processes the instruction and then
performs a series of checks on the result. In both cases, the result is
also returned.
use {
mollusk_svm::Mollusk,
solana_sdk::{account::Account, instruction::{AccountMeta, Instruction}, pubkey::Pubkey},
};
let program_id = Pubkey::new_unique();
let key1 = Pubkey::new_unique();
let key2 = Pubkey::new_unique();
let instruction = Instruction::new_with_bytes(
program_id,
&[],
vec![
AccountMeta::new(key1, false),
AccountMeta::new_readonly(key2, false),
],
);
let accounts = vec![
(key1, Account::default()),
(key2, Account::default()),
];
let mollusk = Mollusk::new(&program_id, "my_program");
// Execute the instruction and get the result.
let result = mollusk.process_instruction(&instruction, &accounts);
To apply checks via process_and_validate_instruction
, developers can use
the Check
enum, which provides a set of common checks.
use {
mollusk_svm::{Mollusk, result::Check},
solana_sdk::{
account::Account,
instruction::{AccountMeta, Instruction},
pubkey::Pubkey
system_instruction,
system_program,
},
};
let sender = Pubkey::new_unique();
let recipient = Pubkey::new_unique();
let base_lamports = 100_000_000u64;
let transfer_amount = 42_000u64;
let instruction = system_instruction::transfer(&sender, &recipient, transfer_amount);
let accounts = [
(
sender,
Account::new(base_lamports, 0, &system_program::id()),
),
(
recipient,
Account::new(base_lamports, 0, &system_program::id()),
),
];
let checks = vec![
Check::success(),
Check::compute_units(system_processor::DEFAULT_COMPUTE_UNITS),
Check::account(&sender)
.lamports(base_lamports - transfer_amount)
.build(),
Check::account(&recipient)
.lamports(base_lamports + transfer_amount)
.build(),
];
Mollusk::default().process_and_validate_instruction(
&instruction,
&accounts,
&checks,
);
Note: Mollusk::default()
will create a new Mollusk
instance without
adding any provided BPF programs. It will still contain a subset of the
default builtin programs. For more builtin programs, you can add them
yourself or use the all-builtins
feature.
Both process_instruction_chain
and
process_and_validate_instruction_chain
deal with chains of instructions.
The former processes each instruction in the chain and returns the final
result, while the latter processes each instruction in the chain and then
performs a series of checks on each result. In both cases, the final result
is also returned.
use {
mollusk_svm::Mollusk,
solana_sdk::{account::Account, pubkey::Pubkey, system_instruction},
};
let mollusk = Mollusk::default();
let alice = Pubkey::new_unique();
let bob = Pubkey::new_unique();
let carol = Pubkey::new_unique();
let dave = Pubkey::new_unique();
let starting_lamports = 500_000_000;
let alice_to_bob = 100_000_000;
let bob_to_carol = 50_000_000;
let bob_to_dave = 50_000_000;
mollusk.process_instruction_chain(
&[
system_instruction::transfer(&alice, &bob, alice_to_bob),
system_instruction::transfer(&bob, &carol, bob_to_carol),
system_instruction::transfer(&bob, &dave, bob_to_dave),
],
&[
(alice, system_account_with_lamports(starting_lamports)),
(bob, system_account_with_lamports(starting_lamports)),
(carol, system_account_with_lamports(starting_lamports)),
(dave, system_account_with_lamports(starting_lamports)),
],
);
Just like with process_and_validate_instruction
, developers can use the
Check
enum to apply checks via process_and_validate_instruction_chain
.
Notice that process_and_validate_instruction_chain
takes a slice of
tuples, where each tuple contains an instruction and a slice of checks.
This allows the developer to apply specific checks to each instruction in
the chain. The result returned by the method is the final result of the
last instruction in the chain.
use {
mollusk_svm::{Mollusk, result::Check},
solana_sdk::{account::Account, pubkey::Pubkey, system_instruction},
};
let mollusk = Mollusk::default();
let alice = Pubkey::new_unique();
let bob = Pubkey::new_unique();
let carol = Pubkey::new_unique();
let dave = Pubkey::new_unique();
let starting_lamports = 500_000_000;
let alice_to_bob = 100_000_000;
let bob_to_carol = 50_000_000;
let bob_to_dave = 50_000_000;
mollusk.process_and_validate_instruction_chain(
&[
(
// 0: Alice to Bob
&system_instruction::transfer(&alice, &bob, alice_to_bob),
&[
Check::success(),
Check::account(&alice)
.lamports(starting_lamports - alice_to_bob) // Alice pays
.build(),
Check::account(&bob)
.lamports(starting_lamports + alice_to_bob) // Bob receives
.build(),
Check::account(&carol)
.lamports(starting_lamports) // Unchanged
.build(),
Check::account(&dave)
.lamports(starting_lamports) // Unchanged
.build(),
],
),
(
// 1: Bob to Carol
&system_instruction::transfer(&bob, &carol, bob_to_carol),
&[
Check::success(),
Check::account(&alice)
.lamports(starting_lamports - alice_to_bob) // Unchanged
.build(),
Check::account(&bob)
.lamports(starting_lamports + alice_to_bob - bob_to_carol) // Bob pays
.build(),
Check::account(&carol)
.lamports(starting_lamports + bob_to_carol) // Carol receives
.build(),
Check::account(&dave)
.lamports(starting_lamports) // Unchanged
.build(),
],
),
(
// 2: Bob to Dave
&system_instruction::transfer(&bob, &dave, bob_to_dave),
&[
Check::success(),
Check::account(&alice)
.lamports(starting_lamports - alice_to_bob) // Unchanged
.build(),
Check::account(&bob)
.lamports(starting_lamports + alice_to_bob - bob_to_carol - bob_to_dave) // Bob pays
.build(),
Check::account(&carol)
.lamports(starting_lamports + bob_to_carol) // Unchanged
.build(),
Check::account(&dave)
.lamports(starting_lamports + bob_to_dave) // Dave receives
.build(),
],
),
],
&[
(alice, system_account_with_lamports(starting_lamports)),
(bob, system_account_with_lamports(starting_lamports)),
(carol, system_account_with_lamports(starting_lamports)),
(dave, system_account_with_lamports(starting_lamports)),
],
);
It's important to understand that instruction chains should not be considered equivalent to Solana transactions. Mollusk does not impose constraints on instruction chains, such as loaded account keys or size. Developers should recognize that instruction chains are primarily used for testing program execution.
The Mollusk Compute Unit Bencher can be used to benchmark the compute unit usage of Solana programs. It provides a simple API for developers to write benchmarks for their programs, which can be checked while making changes to the program.
A markdown file is generated, which captures all of the compute unit benchmarks. If a benchmark has a previous value, the delta is also recorded. This can be useful for developers to check the implications of changes to the program on compute unit usage.
use {
mollusk_svm_bencher::MolluskComputeUnitBencher,
mollusk_svm::Mollusk,
/* ... */
};
// Optionally disable logging.
solana_logger::setup_with("");
/* Instruction & accounts setup ... */
let mollusk = Mollusk::new(&program_id, "my_program");
MolluskComputeUnitBencher::new(mollusk)
.bench(("bench0", &instruction0, &accounts0))
.bench(("bench1", &instruction1, &accounts1))
.bench(("bench2", &instruction2, &accounts2))
.bench(("bench3", &instruction3, &accounts3))
.must_pass(true)
.out_dir("../target/benches")
.execute();
The must_pass
argument can be provided to trigger a panic if any defined
benchmark tests do not pass. out_dir
specifies the directory where the
markdown file will be written.
Developers can invoke this benchmark test with cargo bench
. They may need
to add a bench to the project's Cargo.toml
.
[[bench]]
name = "compute_units"
harness = false
The markdown file will contain entries according to the defined benchmarks.
| Name | CUs | Delta |
|--------|-------|--------|
| bench0 | 450 | -- |
| bench1 | 579 | -129 |
| bench2 | 1,204 | +754 |
| bench3 | 2,811 | +2,361 |
Mollusk also supports working with multiple kinds of fixtures, which can
help expand testing capabilities. Note this is all gated behind either the
fuzz
or fuzz-fd
feature flags.
A fixture is a structured representation of a test case, containing the input data, the expected output data, and any additional context required to run the test. One fixture maps to one instruction.
A classic use case for such fixtures is the act of testing two versions of a program against each other, to ensure the new version behaves as expected. The original version's test suite can be used to generate a set of fixtures, which can then be used as inputs to test the new version. Although you could also simply replace the program ELF file in the test suite to achieve a similar result, fixtures provide exhaustive coverage.
Mollusk is capable of generating fixtures from any defined test case. If
the EJECT_FUZZ_FIXTURES
environment variable is set during a test run,
Mollusk will serialize every invocation of process_instruction
into a
fixture, using the provided inputs, current Mollusk configurations, and
result returned. EJECT_FUZZ_FIXTURES_JSON
can also be set to write the
fixtures in JSON format.
EJECT_FUZZ_FIXTURES="./fuzz-fixtures" cargo test-sbf ...
Note that Mollusk currently supports two types of fixtures: Mollusk's own fixture layout and the fixture layout used by the Firedancer team. Both of these layouts stem from Protobuf definitions.
These layouts live in separate crates, but a snippet of the Mollusk input data for a fixture can be found below:
/// Instruction context fixture.
pub struct Context {
/// The compute budget to use for the simulation.
pub compute_budget: ComputeBudget,
/// The feature set to use for the simulation.
pub feature_set: FeatureSet,
/// The runtime sysvars to use for the simulation.
pub sysvars: Sysvars,
/// The program ID of the program being invoked.
pub program_id: Pubkey,
/// Accounts to pass to the instruction.
pub instruction_accounts: Vec<AccountMeta>,
/// The instruction data.
pub instruction_data: Vec<u8>,
/// Input accounts with state.
pub accounts: Vec<(Pubkey, Account)>,
}
Mollusk can also execute fixtures, just like it can with instructions. The
process_fixture
method will process a fixture and return the result, while
process_and_validate_fixture
will process a fixture and compare the result
against the fixture's effects.
An additional method, process_and_partially_validate_fixture
, allows
developers to compare the result against the fixture's effects using a
specific subset of checks, rather than the entire set of effects. This
may be useful if you wish to ignore certain effects, such as compute units
consumed.
use {
mollusk_svm::{Mollusk, fuzz::check::FixtureCheck},
solana_sdk::{account::Account, pubkey::Pubkey, system_instruction},
std::{fs, path::Path},
};
let mollusk = Mollusk::default();
for file in fs::read_dir(Path::new("fixtures-dir"))? {
let fixture = Fixture::load_from_blob_file(&entry?.file_name());
// Execute the fixture and apply partial checks.
mollusk.process_and_partially_validate_fixture(
&fixture,
&[
FixtureCheck::ProgramResult,
FixtureCheck::ReturnData,
FixtureCheck::all_resulting_accounts(),
],
);
}
Fixtures can be loaded from files or decoded from raw blobs. These capabilities are provided by the respective fixture crates.