AI文章摘要
This article was originally published on my previous mirror.xyz site on 27 Nov 2022 and moved to my new space
Having been a Solidity Smart Contract Developer for almost a year. I have a chance to learn how to develop smart contract on Solana recently. In this article, I will share how I did unit testing when I wrote Solana program.
Smart contracts on Solana are called program
The development community of Solana is relatively small when compared with Solidity. You may find it hard to get development resources or tutorial on developing program for Solana in Google. Personally, I learn it from Solana’s official doc, this tutorial by paulx and wormhole’s Github. (Wormhole is one of the cross-chain bridge between Ethereum and Solana, recently, they are going to support other chains as well)
Wormhole also has a very good example on solana’s web3 JS framework as well.
My test cases in this article are written based on my implementation following this tutorial by paulx. You can find the code in my Github repo.
To get started, please follow the tutorial by paulx and Solana’s official doc to install the development environment and dependencies. (Remarks: I used a M1 MacBook to do my development and encountered some issue with the ARM architecture compatibility. Therefore, I use a AWS Linux remote development environment with VSCode to build and test the program)
Unlike the suggestions on Solana’s official doc, I opted to use solana-validator
for unit testing. The solana-validator will mock a Solana chain while running the test cases. This experience is more similar to writing and running unit test on Solidity, which we use Truffle and Hardhat with Mocha to run the test.
Firstly, create a folder named test
under the root folder. Then update the Cargo.toml as follow:
...
[features]
test-bpf = [][dev-dependencies]
...
solana-validator = "1.7.4"
solana-account-decoder = "1.7.4"
We can now kick-start to write our test case. Create a test case file named escrow_test.rs
.
Set the configuration option and import dependencies in the file:
#![cfg(feature = "test-bpf")]use std::{println as info, println as warn};
use {
assert_matches::*,
solana_program::{
instruction::{AccountMeta, Instruction},
system_instruction::{create_account, SystemInstruction},
},
solana_sdk::{
commitment_config::CommitmentConfig,
account::AccountSharedData,
native_token::*,
pubkey::Pubkey,
signature::{read_keypair_file, Keypair, Signer},
system_instruction,
transaction::Transaction,
},
solana_validator::test_validator::*,
solana_client::{
rpc_client::RpcClient,
rpc_config::RpcSendTransactionConfig,
rpc_request::TokenAccountsFilter,
},
solana_account_decoder::{
parse_token::token_amount_to_ui_amount,
},
};use spl_escrow::{
instruction::{
EscrowInstruction, EscrowInstruction::*,
},
state::*,
};use spl_token::{
self,
instruction::*,
native_mint,
state::{Account, Mint},
};use solana_sdk::program_pack::Pack;
Define errors as follow:
type Error = Box<dyn std::error::Error>;
type CommmandResult = Result<Option<Transaction>, Error>;
We can write our first test case. Our first test case is to check whether the token escrow account can be initialized by the initialize_bridge
instruction.
Each test case shall start with the test
attribute. And it is more convenient to use Result in Test function to avoid using match function in creating and executing transactions.
#[test]
fn test_escrow_initalization() -> Result<(), Error> {
/// Test case here...
}
Initialise the test validator in the test case as follow:
let program_id = Pubkey::new_unique(); let alice = Keypair::new();
let alice_account_data
= AccountSharedData::new(10000000000000, 0, &solana_program::system_program::id());let (test_validator, payer) = TestValidatorGenesis::default() .add_program("spl_escrow", program_id) .add_account(alice.pubkey(), alice_account_data)
.start(); let (rpc_client, recent_blockhash, _fee_calculator)
= test_validator.rpc_client();let commitment_config = CommitmentConfig::processed();
Let’s see what we have done here. We have initialised Alice’s account with 10000000 Sol in the account. (Remarks: You must create AccountSharedData to add an account to the test validator. Airdrop transactions will fail because of rate limit in this test-validator.)
We have also deployed the spl_escrow
program to the test-validator. And we have get the connection information of the test-validator.
The next step is to create the escrow account.
In Solana program, states are stored in accounts.
Firstly, we create a keypair for the account.
let escrow_account = Keypair::new();
After that, we create a create escrow account transaction:
let minimum_balance_for_rent_exemption
= rpc_client.get_minimum_balance_for_rent_exemption(Escrow::LEN)?;let mut transaction = Transaction::new_with_payer(
&[
create_account(
&fee_payer.pubkey(),
&escrow_account.pubkey(),
minimum_balance_for_rent_exemption,
Escrow::LEN as u64,
&owner
),
],
Some(&payer.pubkey()),
);let (recent_blockhash, fee_calculator)
= rpc_client.get_recent_blockhash()?;check_fee_payer_balance(
&payer.pubkey(),
rpc_client,
minimum_balance_for_rent_exemption
+ fee_calculator.calculate_fee(&transaction.message()),
)?;transaction.sign(
&[payer, escrow_account],
recent_blockhash,
);
We use the create_account
system instruction in Solana SDK to construct the create account transaction. The transaction is then signed by the fee payer account and escrow account’s key.
We will need to use the check_fee_payer_balance function to check whether the fee payer has sufficient balance to create the account and exempt for rent. To learn more about rent
in Solana, you can read this official doc.
fn check_fee_payer_balance(
fee_payer: &Pubkey,
rpc_client: &RpcClient,
required_balance: u64
) -> Result<(), Error> { let balance = rpc_client.get_balance(&fee_payer)?; if balance < required_balance {
Err(format!(
"Fee payer, {}, has insufficient balance: {} required, {} available",
fee_payer,
lamports_to_sol(required_balance),
lamports_to_sol(balance)
)
.into())
}
else {
Ok(())
}
}
After building the transaction, we can send the transaction to the test-validator.
rpc_client.
send_and_confirm_transaction_with_spinner_and_commitment(
&transaction,
commitment_config,
)?;
Following the escrow account is created, we can create the token X and token Y for the transfer in the escrow program.
let token_x = Keypair::new();let minimum_balance_for_rent_exemption
= rpc_client .get_minimum_balance_for_rent_exemption(Mint::LEN)?;let mut transaction = Transaction::new_with_payer(
&[
solana_sdk::system_instruction::create_account(
&payer.pubkey(),
&token_x.pubkey(),
minimum_balance_for_rent_exemption,
Mint::LEN as u64,
&spl_token::id(),
),
initialize_mint(
&spl_token::id(),
&token_x.pubkey(),
&payer.pubkey(),
None,
decimals,
)?,
],
Some(&payer.pubkey()),
);let (recent_blockhash, fee_calculator)
= rpc_client.get_recent_blockhash()?;check_fee_payer_balance(
&payer.pubkey(),
rpc_client,
minimum_balance_for_rent_exemption
+ fee_calculator.calculate_fee(&transaction.message()), )?;transaction.sign(
&[payer, token_x],
recent_blockhash,
);rpc_client.
send_and_confirm_transaction_with_spinner_and_commitment(
&transaction,
commitment_config,
)?;
Once again, we are using system instruction in Solana SDK to construct the create token transaction. Then we sign the transaction with the fee payer and token account’s key. Afterwards, we we can send the transaction to the test-validator as we did for the escrow account.
Next, we have to create a token X and token Y account for Alice.
In Solana, each token has a designated token account for each user account
let alice_token_x_account = Keypair::new();let minimum_balance_for_rent_exemption
= rpc_client.get_minimum_balance_for_rent_exemption(Account::LEN)?;let mut transaction = Transaction::new_with_payer(
&[
system_instruction::create_account(
&fee_payer.pubkey(),
&alice_token_x_account(),
minimum_balance_for_rent_exemption,
Account::LEN as u64,
&spl_token::id(),
),
initialize_account(
&spl_token::id(),
&alice_token_x_account(),
&token,
&alice.pubkey(),
)?,
],
Some(&fee_payer.pubkey()),
);let (recent_blockhash, fee_calculator)
= rpc_client.get_recent_blockhash()?;check_fee_payer_balance(
&fee_payer.pubkey(),
rpc_client,
minimum_balance_for_rent_exemption
+ fee_calculator.calculate_fee(&transaction.message()), )?;transaction.sign(
&[fee_payer, alice_token_x_account],
recent_blockhash,
);rpc_client.
send_and_confirm_transaction_with_spinner_and_commitment(
&transaction,
commitment_config,
)?;
After creating token accounts for Alice, let’s mint some token X to Alice’s token X account.
let commitment_config = CommitmentConfig::processed();let recipient_token_balance
= rpc_client.get_token_account_balance_with_commitment(&recipient, commitment_config)?
.value;let amount = spl_token::ui_amount_to_amount(
100000.0,
recipient_token_balance.decimals
);let mut transaction = Transaction::new_with_payer(
&[mint_to(
&spl_token::id(),
&token,
&recipient,
&payer.pubkey(),
&[],
amount,
)?],
Some(&payer.pubkey()),
);let (recent_blockhash, fee_calculator)
= rpc_client.get_recent_blockhash()?; check_fee_payer_balance(
&payer.pubkey(),
rpc_client,
fee_calculator.calculate_fee(&transaction.message()),
)?;transaction.sign(
&[payer],
recent_blockhash
);rpc_client.
send_and_confirm_transaction_with_spinner_and_commitment(
&transaction,
commitment_config,
)?;
We can check whether the token is successfully mint to the token account:
let alice_token_account_balance
= rpc_client.get_token_account_balance_with_commitment(
&alice_token_x_account.pubkey(),
commitment_config
)?;assert_eq!(
alice_token_account_balance.value.ui_amount_string,
"100000"
);
After setting up the accounts, we can test our instruction by building a transaction and send to the test-validator.
let ix = initialize(
&program_id,
&initializer.pubkey(), /// Alice's account
&initializer_send_token_account, /// Alice's token X account
&initializer_receive_token_account, /// Alice's token Y account
&escrow_account,
amount
)?;let mut transaction = Transaction::new_with_payer(
&[ix],
Some(&initializer.pubkey())
);let (recent_blockhash, fee_calculator)
= rpc_client.get_recent_blockhash()?;check_fee_payer_balance(
&initializer.pubkey(),
rpc_client,
fee_calculator.calculate_fee(&transaction.message()),
)?;transaction.sign(
&[initializer],
recent_blockhash);rpc_client.
send_and_confirm_transaction_with_spinner_and_commitment(
&transaction,
commitment_config,
)?;
After sending the transaction, we can check whether the bridge is initialized.
let result = rpc_client.get_account(&escrow_account.pubkey())?; let mut escrow_info = Escrow::unpack_unchecked(&result.data)?; assert_eq!(escrow_info.is_initialized, true);
Congratulations! You have just written a test case for Solana program. You can run the test by:
cargo test-bpf
If you want to display the logs during test, you can run:
cargo test-bpf -- --nocapture
The test may take a bit long time to run because it requires to build the program first.
If you can run the above test case, you can add other test cases on another instruction and run some negative case.
评论 (0)