How to Test Native Solana Programs in Rust
Use Rust to test your Solana programs FAST!
First of all, why test in Rust?
In a recently concluded Solana Talent Olympics (Summer 2024) we were given 4 days to complete challenges as proof of work.
Unfortunately, I had to be very swift with my submission because I only had 3 days to work with a Merkle Tree Whitelist-gated Token Sale in Native Solana!
How did I cut down the time to ensure I ship my program?
...by using Rust to test my Native Solana program.
Don't get me wrong, I had my own Native Solana Typescript template ready to write tests in Typescript (Jest), but using Rust saved me a lot of time in reconstructing my abstractions with Typescript.
So the answer?
Drum Roll...3...2...1...
Speed. ⚡
Setting up your project
In this guide, we'll use a simple Counter program written in Native Solana with an Initialize
and Increment
instruction for our demo.
It is assumed that you already have experience with Solana programs.
Here's the reference Github repository for a quick full overview of what it looks like.
Install test dependencies with cargo add --dev <CRATE>
Add the following dev
dependencies:
solana-program-test
- the main crate we'll use for testing in Rust.
solana-sdk
- the Rust "equivalent"(not 100%) of @solana/web3.js
in Typescript). This will be used for invoking client calls with solana-program-test
.
After adding dev
dependencies, your Cargo.toml
should look similar to below.
Ignore the [features]
entry for now. It's a nice-to-have but something you don't have to think about for now.
[package]
name = "counter_test"
version = "0.1.0"
edition = "2021"
[features]
test-sbf = []
[dependencies]
borsh = "1.5.1"
num-derive = "0.4.2"
num-traits = "0.2.19"
shank = "0.4.2"
solana-program = "2.0.0"
spl-discriminator = "0.3.0"
thiserror = "1.0.61"
[dev-dependencies]
assert_matches = "1.5.0"
solana-logger = "=2.0.2"
solana-program-test = "=2.0.2"
solana-sdk = "=2.0.2"
borsh = "1.5.1"
[lib]
name="counter_test"
crate-type = ["cdylib", "rlib"]
doctest = false
Setup test files
Create a test.rs
file
Include it in the module tree by importing it into your lib.rs
In your test.rs
add the following code and a test_sanity
test to confirm that it's properly set up.
// test.rs
#[cfg(test)]
mod tests {
use std::assert_eq;
// tokio imported here
use solana_program_test::*;
#[tokio::test]
async fn test_sanity() {
assert_eq!(true, true)
}
}
The #[cfg(test)]
attribute is used to mark your tests when running cargo test-sbf
.
The #[tokio::test]
attribute is used to mark asynchronous tests inside your test
module. This means that your test functions must be async fn
as well.
Test that your setup is working by running: cargo test-sbf
.
If everything is set up correctly it should look similar to the image below.
Testing a program instruction
In this section, we'll test the Initialize
instruction for our Counter Test
Native Solana program.
test_initialize
test setup
Begin by adding another test function to your test.rs
file named test_initialize
.
#[cfg(test)]
mod tests {
use std::assert_eq;
use solana_program_test::*;
#[tokio::test]
async fn test_sanity() {
assert_eq!(true, true)
}
#[tokio::test]
async fn test_initialize() {}
}
Import the necessary crates in your test module.
#[cfg(test)]
mod tests {
use std::assert_eq;
use crate::*;
use borsh::{BorshDeserialize, BorshSerialize};
use {
solana_program_test::*,
solana_sdk::{
instruction::{AccountMeta, Instruction},
pubkey::Pubkey,
signature::Signer,
system_program::ID as SYSTEM_PROGRAM_ID,
transaction::Transaction,
},
};
#[tokio::test]
async fn test_sanity() {
assert_eq!(true, true)
}
#[tokio::test]
async fn test_initialize() {}
}
Notice how it's imported inside mod tests {}
and not at the top? This keeps the dependencies inside your test
module only.
In this test, we'll be using standard Solana constructs we often encounter on our client side via @solana/web3.js
with Typescript.
Instantiating banks_client
with solana-program-test
What is banks_client
?
banks_client
is your entry point into the test validator. You don't need to run solana-test-validator
to have a local validator to interface your tests with.
banks_client
does the heavy lifting for you and allows you to load you fixtures
also known as your programs (.so)
.
You'll see banks_client
in action throughout this guide. (Read more from the docs here)
Going back...
In your test_initialize
function, use ProgramTest::new()
to create a new banks_client
loaded with your test programs.
The first argument is the name of your program
in your project's /target/deploy
folder.
#[cfg(test)]
mod tests {
#[tokio::test]
async fn test_initialize() {
// show program logs when testing
// solana_logger::setup_with_default("solana_program::message=debug");
let program_id = Pubkey::new_unique();
let program_test = ProgramTest::new(
// .so fixture is retrieved from /target/deploy
"counter_test",
program_id,
// shank is incompatible with instantiating the BuiltInFunction
None,
);
let (mut banks_client, payer, recent_blockhash) = program_test.start().await;
}
}
Testing with banks_client
Use the banks_client
to simulate sending Instructions
and Transactions
to your program.
Here's a sample of how below.
Tip: Use functions for PDA derivations in your program to reduce code smell by reusing the same PDA derivation functions in your test.rs
// create counter
let (counter_pda, counter_canonical_bump) =
pda::find_counter_pda(&program_id, &payer.pubkey());
// create Initialize instruction
let initialize_ix = instruction::CounterTestInstruction::Initialize;
let mut initialize_ix_data = Vec::new();
initialize_ix.serialize(&mut initialize_ix_data).unwrap();
// create transaction
let transaction = Transaction::new_signed_with_payer(
&[Instruction {
program_id,
accounts: vec![
AccountMeta::new(counter_pda, false),
AccountMeta::new(payer.pubkey(), true),
AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false),
],
data: initialize_ix_data,
}],
Some(&payer.pubkey()),
&[&payer],
recent_blockhash,
);
// send tx
banks_client.process_transaction(transaction).await.unwrap();
Then retrieve the state of your accounts with banks_client.get_account()
and test for values using assertions like assert_eq!()
.
Like this example below.
Without Borsh
// confirm state
let counter_account_info = banks_client
.get_account(counter_pda)
.await
.unwrap()
.unwrap();
let counter = state::Counter::try_from_slice(&counter_account_info.data).unwrap();
// check right authority
assert_eq!(counter.authority, payer.pubkey());
// check counter is 0
assert_eq!(counter.count, 0);
// check canonical bump is stored
assert_eq!(counter.bump, counter_canonical_bump);
With Borsh
// confirm state with borsh
let counter = banks_client
.get_account_data_with_borsh::<state::Counter>(counter_pda)
.await
.unwrap();
// check right authority
assert_eq!(counter.authority, payer.pubkey());
// check counter is 0
assert_eq!(counter.count, 0);
// check canonical bump is stored
assert_eq!(counter.bump, counter_canonical_bump);
Run your tests again with cargo test-sbf
then you're done!
Here's a full run of the entire Counter test suite.
What to do next?
Subscribe to my blog for more Solana guides.