How to Test Native Solana Programs in Rust

Use Rust to test your Solana programs FAST!

How to Test Native Solana Programs in Rust
How to test Native Solana Programs in Rust Blog Cover

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.

GitHub - kquirapas/counter-test
Contribute to kquirapas/counter-test development by creating an account on GitHub.

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

NerdTree screenshot of the work directory

Include it in the module tree by importing it into your lib.rs

Importing test.rs in lib.rs via pub mod

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.

Screenshot of a successful test_sanity test

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.

Test suite run resulting to OK with 3 passed

What to do next?

Subscribe to my blog for more Solana guides.