Focus mode
A robust testing process can minimize the amount of bugs developers introduce into production code by catching them before they pose a real issue.
We'll be covering two types of tests in this lesson: unit tests and integration tests.Ā
Unit testsĀ are small and more focused, testing one module in isolation at a time, and can test private interfaces.Ā
Integration testsĀ are entirely external to your library and use your code in the same way any other external code would, using only the public interface and potentially exercising multiple modules per test.
The purpose of unit tests is to test each unit of code in isolation from the rest of the code to quickly pinpoint where code is and isnāt working as expected.
Unit tests in Rust generally reside in the file with the code they are testing.
Unit tests are declared inside a module namedĀ tests
Ā annotated withĀ cfg(test)
tests
Ā module with theĀ #[test]
Ā attribute.cfg
Ā attribute stands forĀ configuration and tells Rust that the following item should only be included given a certain configuration option.#[cfg(test)]
Ā annotation tells Cargo to compile our test code only if we run cargo test-bpf
.cargo test-bpf
, every function inside this module marked as a test will be run.You can also create helper functions that are not tests in the module
// Example testing module with a single test
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
let result = 2 + 2;
assert_eq!(result, 4);
}
fn helper_function() {
doSomething()
}
}
Use theĀ [solana_sdk](https://docs.rs/solana-sdk/latest/solana_sdk/)
Ā crate to build unit tests for Solana programs.
This crate is essentially the Rust equivalent of theĀ @solana/web3.js
Ā Typescript package.
[solana_program_test](https://docs.rs/solana-program-test/latest/solana_program_test/#)
is also used for testing Solana programs and contains a BanksClient-based testing framework.
In the code snippet, we created a public key to use as ourĀ program_id
Ā and then initialized aĀ ProgramTest
.
TheĀ banks_client
Ā returned from theĀ ProgramTest
Ā will act as our interface into the testing environment
TheĀ payer
Ā variable is a newly generated keypair with SOL that will be used to sign/pay for transactions.
Then, we create a secondĀ Keypair
Ā and build ourĀ Transaction
Ā with the appropriate parameters.
Finally, we used theĀ banks_client
Ā that was returned when callingĀ ProgramTest::new
Ā to process this transaction and check that the return value is equal toĀ Ok(_)
.
The function is annotated with theĀ #[tokio::test]
Ā attribute.
TokioĀ is a Rust crate to help with writing asynchronous code. This just denotes our test function as async.
// Inside processor.rs
#[cfg(test)]
mod tests {
use {
super::*,
assert_matches::*,
solana_program::instruction::{AccountMeta, Instruction},
solana_program_test::*,
solana_sdk::{signature::Signer, transaction::Transaction, signer::keypair::Keypair},
};
#[tokio::test]
async fn it_works() {
let program_id = Pubkey::new_unique();
let (mut banks_client, payer, recent_blockhash) = ProgramTest::new(
"program_name",
program_id,
processor!(process_instruction),
)
.start()
.await;
let test_acct = Keypair::new();
let mut transaction = Transaction::new_with_payer(
&[Instruction {
program_id,
accounts: vec![
AccountMeta::new(payer.pubkey(), true),
AccountMeta::new(test_acct.pubkey(), true)
],
data: vec![1, 2, 3],
}],
Some(&payer.pubkey()),
);
transaction.sign(&[&payer, &test_acct], recent_blockhash);
assert_matches!(banks_client.process_transaction(transaction).await, Ok(_);
}
}
Integration tests are meant to be entirely external to the code they are testing.
These tests are meant to interact with your code via its public interface in the manner that itās intended to be accessed by others.
Their purpose is to test whether many parts of your library work together correctly.
Units of code that work correctly on their own could have problems when integrated, so test coverage of the integrated code is important as well.'
To create integration tests, you first need to create aĀ tests
Ā directory at the top level of your projectās directory.
We can then make as many test files as we want inside thisĀ tests
Ā directory, each file will act as its own integration test.
tests
Ā directory is a separate crate, so we will need to bring our library of code that we want to test into each fileās scope - thatās what theĀ use example_lib
Ā line is doing.tests
Ā directory withĀ #[cfg(test)]
Ā because Cargo will only compile files inside theĀ tests
Ā directory when we runĀ cargo test-bpf
.// Example of integration test inside /tests/integration_test.rs file
use example_lib;
#[test]
fn it_adds_two() {
assert_eq!(4, example_lib::add_two(2));
}
Once you have tests written (either unit, integration, or both), all you need to do is runĀ cargo test-bpf
Ā and they will execute.
The three sections of output include:
Cargo
Ā functionality to execute code examples in any documentation you might have in your code base.cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished test [unoptimized + debuginfo] target(s) in 1.31s
Running unittests (target/debug/deps/adder-1082c4b063a8fbe6)
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)
running 1 test
test it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
The alternative method to test your program is by deploying it to either Devnet or a local validator and sending transactions to it from some client that you created.
Write client testing script in Typescript using:
Install Mocha and Chai withĀ npm install mocha chai
Then update theĀ package.json
Ā file inside your Typescript project. This tells the compiler to execute the Typescript file or files inside theĀ /test
Ā directory when the commandĀ npm run test
Ā is run.
Youāll have to make sure the path here is the correct path to where your testing script is located.
// Inside package.json
"scripts": {
"test": "mocha -r ts-node/register ./test/*.ts"
},
Mocha testing sections are declared with theĀ describeĀ keyword, which tells the compiler that mocha tests are inside of it.
describe
Ā section, each test is designated withĀ it
Ā function that can easily compare values.
describe("begin tests", async () => {
// First Mocha test
it('first test', async () => {
// Initialization code here to send the transaction
...
// Fetch account info and deserialize
const acct_info = await connection.getAccountInfo(pda)
const acct = acct_struct.decode(acct_info.data)
// Compare the value in the account to what you expect it to be
chai.expect(acct.num).to.equal(1)
}
})
Running npm run test
will execute all of the tests inside theĀ describe
Ā block and return something like this indicating whether or not each one has passed or failed.
> [email protected] test
> mocha -r ts-node/register ./test/*.ts
ā first test (1308ms)
ā second test
2 passing (1s)
Program errors are often displayed in a hexadecimal representation of the errorās decimal index inside the error enum of the program that returned it.
For example, if you were to receive an error sending a transaction to the SPL Token Program with the error codeĀ 0x01
, the decimal equivalent of this is 1.Ā
Looking at the source code of the Token Program, we can see that the error located at this index in the program's error enum isĀ InsufficientFunds
.
You'll need to have access to the source code of any program that returns a custom program error code to translate it.
Solana makes it very easy to create new custom logs with theĀ msg!()
Ā macro
Note when writing unit tests in Rust, you cannot use theĀ msg!()
Ā macro to log information within the test itself.
Youāll have to use the Rust nativeĀ println!()
Ā macro.Ā
msg!()
Ā statements inside the program code will still work, you just can't log within the test with it.
Developing on a blockchain comes with some unique constraints, one of those on Solana is the compute budget.
The compute budget is meant to prevent a program from abusing resources.
When the program consumes its entire budget or exceeds a bound, the runtime halts the program and returns an error.
By default the compute budget is set the product of 200k compute units * number of instructions, with a max of 1.4M compute units.
The Base Fee is 5,000 Lamports. A microLamport is 0.000001 Lamports.
UseĀ ComputeBudgetProgram.setComputeUnitLimit({ units: number })
to set the new compute budget.
ComputeBudgetProgram.setComputeUnitPrice({ microLamports: number })
will increase the transaction fee above the base fee (5,000 Lamports).
To change the compute budget for a transaction, you must make the one of the first three instructions of the transaction the instruction that sets the budget.
const modifyComputeUnits = ComputeBudgetProgram.setComputeUnitLimit({
units: 1000000
});
const addPriorityFee = ComputeBudgetProgram.setComputeUnitPrice({
microLamports: 1
});
const transaction = new Transaction()
.add(modifyComputeUnits)
.add(addPriorityFee)
.add(
SystemProgram.transfer({
fromPubkey: payer.publicKey,
toPubkey: toAccount,
lamports: 10000000,
})
);
The functionĀ sol_log_compute_units()
Ā is available to use to print exactly how many compute units are remaining for the program to consume within the current instruction.
use solana_program::log::sol_log_compute_units;
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
sol_log_compute_units();
...
}
Every program has access to 4KB of stack frame size when executing. All values in Rust are stack allocated by default.
In a systems programming language like Rust, whether a value is on the stack or the heap can make a large difference - especially when working within a constrained environment like a blockchain.
You'll start to run into issues with using up all of the 4KB of memory when working with larger, more complex programs.
This is often called "blowing the stack", orĀ stack overflow.
Programs can reach the stack limit two ways:
Here's an example of the error message you might see when the stack violation is originating from a dependent crate.
Error: Function _ZN16curve25519_dalek7edwards21EdwardsBasepointTable6create17h178b3d2411f7f082E Stack offset of -30728 exceeded max offset of -4096 by 26632 bytes, please minimize large stack variables
If a program reaches it's 4KB stack at runtime, it will halt and return anĀ AccessViolation
error:
Program failed to complete: Access violation in stack frame 3 at address 0x200003f70 of size 8 by instruction #5128
To get around this, you can either refactor your code to make it more memory efficient or allocate some memory to the heap instead.
All programs have access to a 32KB runtime heap that can help you free up some memory on the stack.
To do so, you'll have to make use of theĀ BoxĀ struct.
A box is a smart pointer to a heap allocated value of typeĀ T
.
Boxed values can be dereferenced using theĀ *
Ā operator.
In this example, the value returned from theĀ Pubkey::create_program_address, which is just a public key, will be stored on the heap and theĀ authority_pubkeyĀ variable will hold a pointer to the location on the heap where the public key is stored.
let authority_pubkey = Box::new(Pubkey::create_program_address(authority_signer_seeds, program_id)?);
if *authority_pubkey != *authority_info.key {
msg!("Derived lending market authority {} does not match the lending market authority provided {}");
return Err();
}
Programs to Accelerate Your Progress in a Software Career
Join our 4-8 month intensive Patika+ bootcamps, start with the fundamentals and gain comprehensive knowledge to kickstart your software career!
You need to enroll in the course to be able to comment!