การทดสอบให้เหมือนกัน (Parity Testing)
การทดสอบ program จะทำให้เรามั่นใจได้ในทั้งคุณภาพ และผลลัพทธ์ที่ได้
เรื่องน่ารู้
Fact Sheet
- คุณสมบัติเฉพาะ (Features) คือความสามารถ (capabilities) ที่มีมากับ Solana validators และต้องเปิดถึงจะสามารถใช้งานได้
- Features อาจจะถูกเปิดเพียง cluster เดียว (เช่นบน testnet) แต่ไม่ได้เปิดให้ใช้ที่อื่น (เช่น mainnet-beta).
- อย่างไรก็ตามเมื่อใช้งาน
solana-test-validator
ด้วยค่าตั้งต้นปกติที่ local, ทุกๆ features จะถูกเปิดให้ใช้งานได้ทั้งหมดตาม Solana version ทำให้ผลที่ได้เวลา testing ที่ local กับ capabilities และผลของการทดสอบอาจจะไม่ตรงกันเวลาที่ deploying และ running ใน cluster อื่นๆ!
Scenario
สมมติว่าเรามี transaction ที่มี (3) instructions และแต่ละ instruction ใช้ประมาณ 100_000 Compute Units (CU) บน Solana 1.8.x, เราจะเห็น instruction CU consumption คล้ายๆ แบบนี้:
Instruction | Starting CU | Execution | Remaining CU |
---|---|---|---|
1 | 200_000 | -100_000 | 100_000 |
2 | 200_000 | -100_000 | 100_000 |
3 | 200_000 | -100_000 | 100_000 |
บน Solana 1.9.2 จะมี feature 'transaction wide compute cap' ที่เพิ่มเข้ามาในเรื่อง transaction โดยค่าปกติจะมี budget ให้ 200_000 CU และ instructions ที่ติดไป จะหัก budget มาจาก transaction นั้นด้วย. การใช้ transaction ที่เคยใช้ไปก่อนหน้าจะได้ผลที่แตกต่างเป็นอย่างมาก:
Instruction | Starting CU | Execution | Remaining CU |
---|---|---|---|
1 | 200_000 | -100_000 | 100_000 |
2 | 100_000 | -100_000 | 0 |
3 | 0 | FAIL!!! | FAIL!!! |
มุแง! ถ้าเราไม่รู้มาก่อนคงตกใจแย่ เพราะเราไม่ได้เปลี่ยน instruction อะไรเลยแล้วใน devnet ก็ใช้ได้แต่ที่ local ทำไมพัง?!?
เราสามารถเพิ่ม Transaction budget โดยรวมได้ประมาณ 300_000 CU เผื่อเราจะได้รู้สึกดีขึ้น ตัวอย่างด้านบนพยายามแสดงให้เราเห็นว่าทำไมการทดสอบด้วย Feature Parity ถึงเป็นเรื่องที่ดีที่จะเตรียมตัวไว้ก่อนเพื่อหลีกเลี่ยงความสับสนในภายหลัง
Feature Status
มันง่ายมากที่จะตรวจสอบว่า features ไหนเปิดให้ใช้สำหรับแต่ละ cluster ด้วยคำสั่ง solana feature status
solana feature status -ud // Displays by feature status for devnet
solana feature status -ut // Displays for testnet
solana feature status -um // Displays for mainnet-beta
solana feature status -ul // Displays for local, requires running solana-test-validator
นอกจากนี้เรายังสามารถใช้ scfsd เพื่อติดตามดูทุกๆ feature ในทุกๆ clusters ตามภามบางส่วนข้างล่าง และมันยังไม่ต้องใช้ solana-test-validator
ในการทำงานด้วย:
Parity Testing
ตามที่บอกไปแล้วว่า solana-test-validator
จะเปิด ทุกๆ features อัตโนมัติ ดังนั้นเพื่อที่จะตอบคำถามที่ว่า "เราจะสามารถทดสอบที่ local ด้วย environment ที่เหมือน devnet, testnet หรือแม้แต่ mainnet-beta ได้ยังไง?".
ทางแก้ไข: PRs ที่เพิ่มเข้ามาใน Solana 1.9.6 สามารถทำให้เราปิด features ต่างๆ ได้:
solana-test-validator --deactivate-feature <FEATURE_PUBKEY> ...
ตัวอย่างง่ายๆ
สมมติว่าคุณมี program ง่ายๆ ที่ log ข้อมูลที่ได้รับใน entry-point และเราจะทดสอบ transaction ที่เพิ่ม (2) instructions สำหรับ program ของเรา.
เปิดทุก features
- เราจะเปิด test validator ใน terminal:
solana config set -ul
solana-test-validator -l ./ledger --bpf-program target/deploy/PROGNAME.so --reset`
- ใน terminal อีกอันให้เปิด log streamer:
solana logs
- แล้วก็ run transaction เราจะเห็น log ที่คุ้นเคยใน terminal (มีปรับให้ดูง่าย):
Program PWDnx8LkjJUn9bAVzG6Fp6BuvB41x7DkBZdo9YLMGcc invoke [1]
Program log: process_instruction: PWDnx8LkjJUn9bAVzG6Fp6BuvB41x7DkBZdo9YLMGcc: 0 accounts, data=[0]
Program PWDnx8LkjJUn9bAVzG6Fp6BuvB41x7DkBZdo9YLMGcc consumed 12843 of 200000 compute units
Program PWDnx8LkjJUn9bAVzG6Fp6BuvB41x7DkBZdo9YLMGcc success
Program PWDnx8LkjJUn9bAVzG6Fp6BuvB41x7DkBZdo9YLMGcc invoke [1]
Program log: process_instruction: PWDnx8LkjJUn9bAVzG6Fp6BuvB41x7DkBZdo9YLMGcc: 0 accounts, data=[1]
Program PWDnx8LkjJUn9bAVzG6Fp6BuvB41x7DkBZdo9YLMGcc consumed 12843 of 187157 compute units
Program PWDnx8LkjJUn9bAVzG6Fp6BuvB41x7DkBZdo9YLMGcc success[
เพราะ feature 'transaction wide compute cap' ถูกเปิดตามค่าตั้งต้นอยู่แล้ว ทำให้เราเห็นว่าแต่ละ instruction จะหัก CU จาก budget Transaction เริ่มต้นที่ 200_000 CU.
เลือกปิด features
- ในการ run ครั้งนี้ เราจะ run ให้ CU budget เท่ากับ devnetโดยใช้เครื่องมือที่อธิบายไว้ใน Feature Status เราจะแยก
transaction wide compute cap
public key และใช้--deactivate-feature
ในตอนเริ่ม test validator
solana-test-validator -l ./ledger --deactivate-feature 5ekBxc8itEnPv4NzGJtr8BVVQLNMQuLMNQQj7pHoLNZ9 --bpf-program target/deploy/PROGNAME.so --reset`
- เราจะเห็น logs ที่ instructions ของเราว่าตอนนี้มี 200_000 CU budget (มีปรับให้ดูง่าย) โดยจะมีบอกในทุกๆ clusters:
Program PWDnx8LkjJUn9bAVzG6Fp6BuvB41x7DkBZdo9YLMGcc invoke [1]
Program log: process_instruction: PWDnx8LkjJUn9bAVzG6Fp6BuvB41x7DkBZdo9YLMGcc: 0 accounts, data=[0]
Program PWDnx8LkjJUn9bAVzG6Fp6BuvB41x7DkBZdo9YLMGcc consumed 12843 of 200000 compute units
Program PWDnx8LkjJUn9bAVzG6Fp6BuvB41x7DkBZdo9YLMGcc success
Program PWDnx8LkjJUn9bAVzG6Fp6BuvB41x7DkBZdo9YLMGcc invoke [1]
Program log: process_instruction: PWDnx8LkjJUn9bAVzG6Fp6BuvB41x7DkBZdo9YLMGcc: 0 accounts, data=[0]
Program PWDnx8LkjJUn9bAVzG6Fp6BuvB41x7DkBZdo9YLMGcc consumed 12843 of 200000 compute units
Program PWDnx8LkjJUn9bAVzG6Fp6BuvB41x7DkBZdo9YLMGcc success
Feature Parity Testing
เราสามารถทดสอบให้เหมือนกันทั้งหมดได้ใน cluster ที่ต้องการโดยดูว่า feature ยังไม่ได้เปิด (activated) และเพิ่ม--deactivate-feature <FEATURE_PUBKEY>
สำหรับแต่ละ feature เมื่อเราเรียก solana-test-validator
:
solana-test-validator --deactivate-feature PUBKEY_1 --deactivate-feature PUBKEY_2 ...
ทางเลือกอื่นเช่น scfsd จะมีคำสั่งสร้าง output เพื่อปิด features โดยมันจะป้อน output นั้นเข้าไปตอนเริ่มใช้ solana-test-validator
:
solana-test-validator -l ./.ledger $(scfsd -c devnet -k -t)
ถ้าเราเปิดอีก terminal ขึ้นมาระหว่างที่ validator ยังทำงานอยู่ และเรียกใช้คำสั่ง solana feature status
คุณจะเห็น features ที่ถูก deactivatedเหมือนใน devnet เลย
Full Parity Testing Programmatically
สำหรับคนที่ต้องการควบคุมการทดสอบใน test code ก็สามารถเปิดปิด features ได้โดยใช้ function TestValidatorGenesis ใน Solana 1.9.6
ใน folder root ของ program ให้เราสร้าง folder ชื่อ tests
และเพิ่ม file parity_test.rs
เข้าไป ส่วนด้านล่างนี้คือ boiler-plate สำหรับใช้ในแต่ละ test
#[cfg(test)]
mod tests {
use std::{error, path::PathBuf, str::FromStr};
// Use gadget-scfs to get interegate feature lists from clusters
// must have `gadgets-scfs = "0.2.0" in Cargo.toml [dev-dependencies] to use
use gadgets_scfs::{ScfsCriteria, ScfsMatrix, SCFS_DEVNET};
use solana_client::rpc_client::RpcClient;
use solana_program::{instruction::Instruction, message::Message, pubkey::Pubkey};
use solana_sdk::{
// Added in Solana 1.9.2
compute_budget::ComputeBudgetInstruction,
pubkey,
signature::{Keypair, Signature},
signer::Signer,
transaction::Transaction,
};
// Extended in Solana 1.9.6
use solana_test_validator::{TestValidator, TestValidatorGenesis};
/// Location/Name of ProgramTestGenesis ledger
const LEDGER_PATH: &str = "./.ledger";
/// Path to BPF program (*.so) change if needed
const PROG_PATH: &str = "target/deploy/";
/// Program name from program Cargo.toml
/// FILL IN WITH YOUR PROGRAM_NAME
const PROG_NAME: &str = "PROGRAM_NAME";
/// Program public key
/// FILL IN WITH YOUR PROGRAM'S PUBLIC KEY str
const PROG_KEY: Pubkey = pubkey!("PROGRAMS_PUBLIC_KEY_BASE58_STRING");
/// 'transaction wide compute cap' public key
const TXWIDE_LIMITS: Pubkey = pubkey!("5ekBxc8itEnPv4NzGJtr8BVVQLNMQuLMNQQj7pHoLNZ9");
/// Setup the test validator passing features
/// you want to deactivate before running transactions
pub fn setup_validator(
invalidate_features: Vec<Pubkey>,
) -> Result<(TestValidator, Keypair), Box<dyn error::Error>> {
// Extend environment variable to include our program location
std::env::set_var("BPF_OUT_DIR", PROG_PATH);
// Instantiate the test validator
let mut test_validator = TestValidatorGenesis::default();
// Once instantiated, TestValidatorGenesis configuration functions follow
// a builder pattern enabling chaining of settings function calls
let (test_validator, kp) = test_validator
// Set the ledger path and name
// maps to `solana-test-validator --ledger <DIR>`
.ledger_path(LEDGER_PATH)
// Load our program. Ignored if reusing ledger
// maps to `solana-test-validator --bpf-program <ADDRESS_OR_PATH BPF_PROGRAM.SO>`
.add_program(PROG_NAME, PROG_KEY)
// Identify features to deactivate. Ignored if reusing ledger
// maps to `solana-test-validator --deactivate-feature <FEATURE_PUBKEY>`
.deactivate_features(&invalidate_features)
// Start the test validator
.start();
Ok((test_validator, kp))
}
/// Convenience function to remove existing ledger before TestValidatorGenesis setup
/// maps to `solana-test-validator ... --reset`
pub fn clean_ledger_setup_validator(
invalidate_features: Vec<Pubkey>,
) -> Result<(TestValidator, Keypair), Box<dyn error::Error>> {
if PathBuf::from_str(LEDGER_PATH).unwrap().exists() {
std::fs::remove_dir_all(LEDGER_PATH).unwrap();
}
setup_validator(invalidate_features)
}
/// Submits a transaction with programs instruction
/// Boiler plate
fn submit_transaction(
rpc_client: &RpcClient,
wallet_signer: &dyn Signer,
instructions: Vec<Instruction>,
) -> Result<Signature, Box<dyn std::error::Error>> {
let mut transaction =
Transaction::new_unsigned(Message::new(&instructions, Some(&wallet_signer.pubkey())));
let recent_blockhash = rpc_client
.get_latest_blockhash()
.map_err(|err| format!("error: unable to get recent blockhash: {}", err))?;
transaction
.try_sign(&vec![wallet_signer], recent_blockhash)
.map_err(|err| format!("error: failed to sign transaction: {}", err))?;
let signature = rpc_client
.send_and_confirm_transaction(&transaction)
.map_err(|err| format!("error: send transaction: {}", err))?;
Ok(signature)
}
// UNIT TEST FOLLOWS
}
/// Setup the test validator passing features
/// you want to deactivate before running transactions
pub fn setup_validator(
invalidate_features: Vec<Pubkey>,
) -> Result<(TestValidator, Keypair), Box<dyn error::Error>> {
// Extend environment variable to include our program location
std::env::set_var("BPF_OUT_DIR", PROG_PATH);
// Instantiate the test validator
let mut test_validator = TestValidatorGenesis::default();
// Once instantiated, TestValidatorGenesis configuration functions follow
// a builder pattern enabling chaining of settings function calls
let (test_validator, kp) = test_validator
// Set the ledger path and name
// maps to `solana-test-validator --ledger <DIR>`
.ledger_path(LEDGER_PATH)
// Load our program. Ignored if reusing ledger
// maps to `solana-test-validator --bpf-program <ADDRESS_OR_PATH BPF_PROGRAM.SO>`
.add_program(PROG_NAME, PROG_KEY)
// Identify features to deactivate. Ignored if reusing ledger
// maps to `solana-test-validator --deactivate-feature <FEATURE_PUBKEY>`
.deactivate_features(&invalidate_features)
// Start the test validator
.start();
Ok((test_validator, kp))
}
/// Convenience function to remove existing ledger before TestValidatorGenesis setup
/// maps to `solana-test-validator ... --reset`
pub fn clean_ledger_setup_validator(
invalidate_features: Vec<Pubkey>,
) -> Result<(TestValidator, Keypair), Box<dyn error::Error>> {
if PathBuf::from_str(LEDGER_PATH).unwrap().exists() {
std::fs::remove_dir_all(LEDGER_PATH).unwrap();
}
setup_validator(invalidate_features)
}
/// Submits a transaction with programs instruction
/// Boiler plate
fn submit_transaction(
rpc_client: &RpcClient,
wallet_signer: &dyn Signer,
instructions: Vec<Instruction>,
) -> Result<Signature, Box<dyn std::error::Error>> {
let mut transaction =
Transaction::new_unsigned(Message::new(&instructions, Some(&wallet_signer.pubkey())));
let recent_blockhash = rpc_client
.get_latest_blockhash()
.map_err(|err| format!("error: unable to get recent blockhash: {}", err))?;
transaction
.try_sign(&vec![wallet_signer], recent_blockhash)
.map_err(|err| format!("error: failed to sign transaction: {}", err))?;
let signature = rpc_client
.send_and_confirm_transaction(&transaction)
.map_err(|err| format!("error: send transaction: {}", err))?;
Ok(signature)
}
เราสามารถ now เพิ่ม test functions ใน mod test {...}
เพื่อทดสอบค่าตั้งต้นของ validator setup (เปิดใช้ทุก features) และค่อยปิก transaction wide compute cap
เหมือนตัวอย่างที่แล้วที่ใช้ solana-test-validator
จาก command line.
#[test]
fn test_base_pass() {
// Run with all features activated (default for TestValidatorGenesis)
let inv_feat = vec![];
// Start validator with clean (new) ledger
let (test_validator, main_payer) = clean_ledger_setup_validator(inv_feat).unwrap();
// Get the RpcClient
let connection = test_validator.get_rpc_client();
// Capture our programs log statements
solana_logger::setup_with_default("solana_runtime::message=debug");
// This example doesn't require sending any accounts to program
let accounts = &[];
// Build instruction array and submit transaction
let txn = submit_transaction(
&connection,
&main_payer,
// Add two (2) instructions to transaction to demonstrate
// that each instruction CU draws down from default Transaction CU (200_000)
// Replace with instructions that make sense for your program
[
Instruction::new_with_borsh(PROG_KEY, &0u8, accounts.to_vec()),
Instruction::new_with_borsh(PROG_KEY, &1u8, accounts.to_vec()),
]
.to_vec(),
);
assert!(txn.is_ok());
}
#[test]
fn test_deactivate_tx_cu_pass() {
// Run with all features activated except 'transaction wide compute cap'
let inv_feat = vec![TXWIDE_LIMITS];
// Start validator with clean (new) ledger
let (test_validator, main_payer) = clean_ledger_setup_validator(inv_feat).unwrap();
// Get the RpcClient
let connection = test_validator.get_rpc_client();
// Capture our programs log statements
solana_logger::setup_with_default("solana_runtime::message=debug");
// This example doesn't require sending any accounts to program
let accounts = &[];
// Build instruction array and submit transaction
let txn = submit_transaction(
&connection,
&main_payer,
[
// This instruction adds CU to transaction budget (1.9.2) but does nothing
// when we deactivate the 'transaction wide compute cap' feature
ComputeBudgetInstruction::request_units(400_000u32),
// Add two (2) instructions to transaction
// Replace with instructions that make sense for your program
// You will see that each instruction has the 1.8.x 200_000 CU per budget
Instruction::new_with_borsh(PROG_KEY, &0u8, accounts.to_vec()),
Instruction::new_with_borsh(PROG_KEY, &1u8, accounts.to_vec()),
]
.to_vec(),
);
assert!(txn.is_ok());
}
นอกจากนี้ scfs engine gadget ก็สามารถกำหนดให้ปิดทุก features ของ cluster โดยทำตามตัวอย่างด้านล่างนี้ โดยใช้ engine เพื่อหา features ที่ถูกปิดทั้งหมดของ devnet.
#[test]
fn test_devnet_parity_pass() {
// Use gadget-scfs to get all deactivated features from devnet
// must have `gadgets-scfs = "0.2.0" in Cargo.toml to use
// Here we setup for a run that samples features only
// from devnet
let mut my_matrix = ScfsMatrix::new(Some(ScfsCriteria {
clusters: Some(vec![SCFS_DEVNET.to_string()]),
..Default::default()
}))
.unwrap();
// Run the sampler matrix
assert!(my_matrix.run().is_ok());
// Get all deactivated features
let deactivated = my_matrix
.get_features(Some(&ScfsMatrix::any_inactive))
.unwrap();
// Confirm we have them
assert_ne!(deactivated.len(), 0);
// Setup test validator and logging while deactivating all
// features that are deactivated in devnet
let (test_validator, main_payer) = clean_ledger_setup_validator(deactivated).unwrap();
let connection = test_validator.get_rpc_client();
solana_logger::setup_with_default("solana_runtime::message=debug");
let accounts = &[];
let txn = submit_transaction(
&connection,
&main_payer,
[
// Add two (2) instructions to transaction
// Replace with instructions that make sense for your program
Instruction::new_with_borsh(PROG_KEY, &0u8, accounts.to_vec()),
Instruction::new_with_borsh(PROG_KEY, &1u8, accounts.to_vec()),
]
.to_vec(),
);
assert!(txn.is_ok());
}
ทดสอบกันให้สนุกนะ!