Bankrun 是专为 Solana 程序设计的一个快速、强大且轻量级的测试框架,它能够解决开发者在测试过程中遇到的常见问题,节省时间。本文将指导读者了解 Bankrun 的功能和如何使用它来提升 Solana 本地开发。
- 原文链接:www.quicknode.com/guides...
- 译者:AI翻译官,校对:翻译小组
- 本文链接:learnblockchain.cn/article…
Bankrun 是一个快速、强大且轻量级的框架,用于在 NodeJS 中测试 Solana 程序。它解决了测试 Solana 程序时常见的开发者痛点,从而能够为你节省时间。让我们看看它能为你做些什么以及如何开始使用它!
依赖项 | 版本 |
---|---|
Solana cli | 1.18.8 |
Anchor CLI | 0.30.1 |
Node.js | 最新版 |
yarn | 最新版 |
ts-node | 最新版 |
typescript | 最新版 |
Rust | 最新版 |
Bankrun 是 Solana CLI 测试验证器(通过 solana-test-validator
运行)的替代方案,旨在提供更快和更灵活的测试。它提供了几个功能,使你能够更轻松地测试 Solana 程序,包括:
Bankrun 通过启动一个轻量级的 BanksServer 工作,这几乎就像一个轻量级的 RPC,并创建一个 BanksClient 来与服务器进行通信。
让我们创建一个简单的 Anchor 项目,以测试一些 Bankrun 的功能。在开始之前,请确保你已安装 Anchor 版本 0.30.1 或更高版本。你可以通过运行以下命令检查版本:
anchor --version
如果你还没有,可以按照 这里 的安装说明进行操作。
请在终端中创建一个新项目。从项目父目录中运行以下命令:
anchor init bankrun-test
然后切换到新目录:
cd bankrun-test
项目创建后,请构建程序以确保一切正常:
anchor build
初始构建在几分钟后应成功完成。如果你看到错误,请按照错误消息中的说明进行修复。
我们需要一些依赖项以使项目正常工作。让我们来安装它们:
首先,安装我们的 Node.js 依赖项:
yarn add solana-bankrun anchor-bankrun @solana/spl-token
这将允许我们在测试中使用 Bankrun 以及 Solana 代币程序。
接下来,让我们将 SPL 代币程序添加到我们的 Anchor 程序。导航到 programs/bankrun-test/Cargo.toml
并将 anchor-spl
添加到依赖项:
[dependencies]
anchor-lang = "0.30.1"
anchor-spl = "0.30.1"
由于我们使用的是 Anchor 0.30 以上版本,我们还需要更新 programs/bankrun-test/Cargo.toml
中的 idl-build
功能,以包含 anchor-spl
依赖项:
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
太好了!我们现在准备好开始构建我们的程序。
由于本指南的重点是使用 Bankrun 进行测试,我们不会花太多时间在 Anchor 程序本身上。相反,我们将重点放在编写 Bankrun 测试上。导航到 programs/bankrun-test/src/lib.rs
并将内容替换为以下内容(确保将程序 ID 替换为你自己的):
use anchor_lang::prelude::*;
use anchor_spl::token::TokenAccount;
use std::str::FromStr;
declare_id!("11111111111111111111111111111111"); // REPLACE WITH YOUR PROGRAM ID
const MINIMUM_SLOT: u64 = 100;
const TOKEN_MINIMUM_BALANCE: u64 = 100_000_000_000;
const USDC_MINT: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
#[program]
pub mod bankrun_test {
use super::*;
pub fn set_data(ctx: Context<SetData>) -> Result<()> {
let current_slot = Clock::get()?.slot;
msg!("Current slot: {}", current_slot);
require_gte!(current_slot, MINIMUM_SLOT, BankrunError::InvalidSlot);
ctx.accounts.data_account.new_data = ctx.accounts.new_data.key();
ctx.accounts.data_account.last_updated_slot = current_slot;
msg!("Set new data: {}", ctx.accounts.new_data.key());
Ok(())
}
pub fn check_spl_token(ctx: Context<CheckSplToken>) -> Result<()> {
let usdc_mint = Pubkey::from_str(USDC_MINT).unwrap();
let token_account = &ctx.accounts.token_account;
let token_balance = token_account.amount;
msg!("Token account: {} has a balance of {}", token_account.key(), token_balance);
require_keys_eq!(token_account.mint, usdc_mint, BankrunError::InvalidTokenMint);
require_gte!(token_balance, TOKEN_MINIMUM_BALANCE, BankrunError::InsufficientTokenBalance);
Ok(())
}
}
#[derive(Accounts)]
pub struct SetData<'info> {
#[account(mut)]
pub payer: Signer<'info>,
#[account(
init,
payer = payer,
space = 8 + DataAccount::INIT_SPACE
)]
pub data_account: Account<'info, DataAccount>,
pub new_data: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct CheckSplToken<'info> {
pub token_account: Account<'info, TokenAccount>,
}
#[account]
#[derive(InitSpace)]
pub struct DataAccount {
pub last_updated_slot: u64,
pub new_data: Pubkey
}
#[error_code]
pub enum BankrunError {
// Error code: 6000
#[msg("Invalid slot")]
InvalidSlot,
// Error code: 6001
#[msg("Insufficient token balance")]
InsufficientTokenBalance,
// Error code: 6002
#[msg("Invalid token mint")]
InvalidTokenMint,
}
你可以运行 anchor keys sync
来更新你的 declare_id!
宏,以匹配你程序的程序 ID。
让我们来看一下我们程序的每个指令:
set_data
:该指令将 DataAccount
的 last_updated_slot
字段设置为当前槽,并将 new_data
字段设置为签署交易的 new_data
账户的公钥。该指令检查当前槽是否大于或等于 MINIMUM_SLOT
常量。如果当前槽小于 MINIMUM_SLOT
常量,则该指令将失败并显示错误代码 InvalidSlot
。check_spl_token
:该指令检查 token_account
的余额是否大于或等于 TOKEN_MINIMUM_BALANCE
常量。它还检查 token_account
是否与 USDC_MINT
常量相关联。如果这些条件之一不满足,则该指令将失败并显示错误代码 InsufficientTokenBalance
或 InvalidTokenMint
。这应该足以帮助我们演示 Bankrun 的“时间旅行”能力,并向账户写入任意数据。
继续运行 anchor build
来构建程序,并确保它成功编译。你不应该看到任何错误,但如果你看到,请按照终端中的说明进行修复。
好的!让我们开始编写测试。导航到 tests/bankrun-test.ts
并删除现有内容。
让我们开始导入必要的依赖。在文件顶部添加以下内容:
import { setProvider, Program } from "@coral-xyz/anchor";
import { BankrunTest } from "../target/types/bankrun_test";
import {
AccountInfoBytes,
AddedAccount,
BanksClient,
BanksTransactionResultWithMeta,
ProgramTestContext,
startAnchor
} from "solana-bankrun";
import { BankrunProvider } from "anchor-bankrun";
import { expect } from "chai";
import {
PublicKey,
Transaction,
Keypair,
Connection,
clusterApiUrl,
TransactionInstruction
} from "@solana/web3.js";
import {
ACCOUNT_SIZE,
AccountLayout,
getAssociatedTokenAddressSync,
MintLayout,
TOKEN_PROGRAM_ID
} from "@solana/spl-token";
const IDL = require("../target/idl/bankrun_test.json");
这些内容中的大部分应该看起来很熟悉,但我们从 anchor-bankrun
和 solana-bankrun
中导入了一些新的依赖。我们将在设置函数中讨论这些依赖。
让我们创建几个常量来帮助我们进行测试。在导入下方添加以下内容:
// Constants
const PROJECT_DIRECTORY = ""; // Leave empty if using default anchor project
const USDC_DECIMALS = 6;
const USDC_MINT_ADDRESS = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
const MINIMUM_SLOT = 100;
const MINIMUM_USDC_BALANCE = 100_000_000_000; // 100k USDC
PROJECT_DIRECTORY
: 这是你的 Anchor 项目所在的目录(与 Anchor.toml
在同一目录)。如果你使用默认项目,可以将其留为空字符串。USDC_DECIMALS
: 这是 USDC 代币的小数位数。USDC_MINT_ADDRESS
: 这是 USDC 代币铸币的地址。MINIMUM_SLOT
: 这是我们在 Anchor 程序中定义的相同最小Slot。MINIMUM_USDC_BALANCE
: 这是我们在 Anchor 程序中定义的相同的 USDC 最低余额。接下来,让我们创建一些设置函数来帮助我们进行测试。在常量下方添加以下内容:
async function createAndProcessTransaction(
client: BanksClient,
payer: Keypair,
instruction: TransactionInstruction,
additionalSigners: Keypair[] = []
): Promise<BanksTransactionResultWithMeta> {
const tx = new Transaction();
const [latestBlockhash] = await client.getLatestBlockhash();
tx.recentBlockhash = latestBlockhash;
tx.add(instruction);
tx.feePayer = payer.publicKey;
tx.sign(payer, ...additionalSigners);
return await client.tryProcessTransaction(tx);
}
async function setupATA(
context: ProgramTestContext,
usdcMint: PublicKey,
owner: PublicKey,
amount: number
): Promise<PublicKey> {
const tokenAccData = Buffer.alloc(ACCOUNT_SIZE);
AccountLayout.encode(
{
mint: usdcMint,
owner,
amount: BigInt(amount),
delegateOption: 0,
delegate: PublicKey.default,
delegatedAmount: BigInt(0),
state: 1,
isNativeOption: 0,
isNative: BigInt(0),
closeAuthorityOption: 0,
closeAuthority: PublicKey.default,
},
tokenAccData,
);
const ata = getAssociatedTokenAddressSync(usdcMint, owner, true);
const ataAccountInfo = {
lamports: 1_000_000_000,
data: tokenAccData,
owner: TOKEN_PROGRAM_ID,
executable: false,
};
context.setAccount(ata, ataAccountInfo);
return ata;
}
让我们看看这些函数的每一个:
createAndProcessTransaction
接受一些基本交易信息和一个 BanksClient
,这是一个从任意验证者的角度表示账本状态的客户端(当我们使用 startAnchor
功能初始化测试时将创建它)。然后它创建一个新交易,将提供的指令添加到其中,用提供的付款人进行签名,并使用 BanksClient
的 tryProcessTransaction
函数处理它。这个函数对于测试很有用,因为它返回 BanksTransactionResultWithMeta
,其中包含交易日志、返回数据、使用的计算单位以及(如果适用)错误。setupATA
接受一个 ProgramTestContext
,它实际上是 BanksClient
的扩展,包含一些附加功能,包括 setAccount
函数,允许我们在上下文中设置一个账户。该函数将提供的账户数据编码到缓冲区,创建一个与提供的拥有者和铸币相对应的关联代币账户(ATA),并将 ATA 的数据设置为编码后的缓冲区。然后它将返回 ATA 的公钥。现在我们有了设置函数,可以开始编写测试。在支持函数下方描述测试套件:
describe("Bankrun Tests", () => {
const usdcMint = new PublicKey(USDC_MINT_ADDRESS);
let context: ProgramTestContext;
let client: BanksClient;
let payer: Keypair;
let provider: BankrunProvider;
let program: Program<BankrunTest>;
before(async () => {
const connection = new Connection(clusterApiUrl("mainnet-beta"));
const accountInfo = await connection.getAccountInfo(usdcMint);
const usdcAccount: AddedAccount = { address: usdcMint, info: accountInfo };
context = await startAnchor(PROJECT_DIRECTORY, [], [usdcAccount]);
client = context.banksClient;
payer = context.payer;
provider = new BankrunProvider(context);
setProvider(provider);
program = new Program<BankrunTest>(IDL, provider);
});
// TODO: Add Time Travel Tests Here
// TODO: Add Arbitrary Data Account Tests Here
});
在这里我们定义了一些将在测试中全局使用的变量:
usdcMint
: 这是 USDC 代币铸币的公钥。context
: 这是 ProgramTestContext
的一个实例,绕过 BanksClient
并包含附加功能。这个实例是通过 startAnchor
函数初始化的。请注意,我们传入了 usdcAccount
。这将初始化我们的测试环境,并初始化 USDC 铸造账户(我们稍后会编写测试来验证这一点)。client
: 这是将用于与账本状态互动的 BanksClient
。payer
: 这是将用于签署交易的付款人。provider
: 这是 BankrunProvider
的一个实例(实现了 Anchor 的 Provider
),它将包含附加的上下文和功能。program
: 这是 Anchor 的 Program<BankrunTest>
的一个实例,将像任何其他 Anchor 测试套件一样使用。让我们创建一些测试,帮助我们验证程序的功能。由于我们有一个指令,如果当前Slot小于 MINIMUM_SLOT
则会失败,所以在传统测试环境中这个测试可能会很麻烦。Bankrun 允许我们根据需要进行“时间旅行”,这可以通过两种方式实现:
provider.context.warpToSlot
函数(或对时间段使用 warpToEpoch
),或context.setClock
函数。在你的 "Bankrun Tests" 描述块内, 在 "before" 块 *之后,添加以下 "Time Travel Tests" 描述块:
describe("Time Travel Tests", () => {
const testCases = [
{ desc: "(too early)", slot: MINIMUM_SLOT - 1, shouldSucceed: false },
{ desc: "(at or above threshold)", slot: MINIMUM_SLOT, shouldSucceed: true },
]
testCases.forEach(({ desc, slot, shouldSucceed }) => {
describe(`When slot is ${slot} ${desc}`, () => {
let txResult: BanksTransactionResultWithMeta;
let newData: Keypair;
let dataAccount: Keypair;
before(async () => {
provider.context.warpToSlot(BigInt(slot));
newData = Keypair.generate();
dataAccount = Keypair.generate();
const ix = await program.methods
.setData()
.accounts({
payer: payer.publicKey,
newData: newData.publicKey,
dataAccount: dataAccount.publicKey,
})
.signers([newData, dataAccount])
.instruction();
txResult = await createAndProcessTransaction(client, payer, ix, [newData, dataAccount]);
});
if (!shouldSucceed) {
it("transaction should fail", () => {
expect(txResult.result).to.exist;
});
it("should contain specific error details in log", () => {
const errorLog = txResult.meta.logMessages.find(log =>
log.includes('AnchorError') &&
log.includes('InvalidSlot') &&
log.includes('6000') &&
log.includes('Error Message: Invalid slot')
);
expect(errorLog).to.exist;
});
it("last log message should indicate failure", () => {
expect(txResult.meta.logMessages[txResult.meta.logMessages.length - 1]).to.include('failed');
});
} else {
it("transaction should succeed", () => {
expect(txResult.result).to.be.null;
});
it("last log message should indicate success", () => {
expect(txResult.meta.logMessages[txResult.meta.logMessages.length - 1]).to.include('success');
});
it("should contain expected log message", () => {
const expectedLog = "Set new data: " + newData.publicKey.toString();
const foundLog = txResult.meta.logMessages.some(log => log.includes(expectedLog));
expect(foundLog).to.be.true;
});
it("should set new data correctly", async () => {
const onChainData = await program.account.dataAccount.fetch(dataAccount.publicKey);
expect(onChainData.newData.toString()).to.equal(newData.publicKey.toString());
});
}
});
});
});
让我们来分析一下测试:
MINIMUM_SLOT
常量的情况(预期失败),以及一个当前槽大于或等于 MINIMUM_SLOT
常量的情况(预期成功)。对于每个测试用例,我们创建一个 describe
块来描述测试用例。在 describe
块中,我们创建一个 before
块来设置测试环境。在创建和发送交易之前,我们使用 provider.context.warpToSlot
函数“时光旅行”到指定的槽。然后,根据指令是否期望成功或失败,我们在 describe
块内运行一系列测试。对于每个测试,我们创建一个 it
块来描述测试用例。在 it
块内,我们检查交易结果并对期望的行为进行断言。createAndProcessTransaction
函数只有在交易过程中发生错误时返回 result
属性,因此我们可以使用 expect
函数来检查结果是否为 null。此外,createAndProcessTransaction
函数将我们程序的日志消息作为 meta.logMessages
属性返回。我们创建了一些辅助函数来查找预期的 AnchorError 日志或预期的 success 或 failed 日志消息。fetch
方法从 dataAccount
账户中获取 newData
字段,并断言它与预期值匹配。现在我们应该能够运行测试。在终端中输入:
anchor test
你的测试应通过,但你应该注意到终端中包含大量调试信息:
When slot is 100 (at or above threshold)
[2024-07-17T21:57:31.175139000Z DEBUG solana_runtime::message_processor::stable_log] Program 4uqt4ZDm7WhG3BfQASEADZNi5TUyE21zwXwbqUb1Qjmk invoke [1]
[2024-07-17T21:57:31.175203000Z DEBUG solana_runtime::message_processor::stable_log] Program log: Instruction: SetData
[2024-07-17T21:57:31.175246000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111111 invoke [2]
[2024-07-17T21:57:31.175254000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111111 success
[2024-07-17T21:57:31.175290000Z DEBUG solana_runtime::message_processor::stable_log] Program log: Current slot: 100
[2024-07-17T21:57:31.175372000Z DEBUG solana_runtime::message_processor::stable_log] Program log: Set new data: 5go3aphd2yJPqRpQEN8Pg4Asvp2xkpNLJxXc6HErm4a5
[2024-07-17T21:57:31.175383000Z DEBUG solana_runtime::message_processor::stable_log] Program 4uqt4ZDm7WhG3BfQASEADZNi5TUyE21zwXwbqUb1Qjmk consumed 18600 of 200000 compute units
[2024-07-17T21:57:31.175391000Z DEBUG solana_runtime::message_processor::stable_log] Program 4uqt4ZDm7WhG3BfQASEADZNi5TUyE21zwXwbqUb1Qjmk success
✔ transaction should succeed
✔ last log message should indicate success
✔ should contain expected log message
✔ should set new data correctly
这又是使用 Bankrun 的一个好处,因为它允许我们更细致地调试测试。尽管这不是什么大问题,因为我们的测试是通过的,但在快速调试程序时,这可以非常有用!你应该能够浏览日志,准确查看你的程序如何以及在哪里记录错误或成功消息(正如我们在测试中识别的那样)。非常酷,对吧?
现在让我们看一些测试,以帮助我们探索 BankRun 的任意数据账户功能。我们将测试 check_spl_token
指令,该指令将检查代币账户的余额是否大于或等于 TOKEN_MINIMUM_BALANCE
常量。如果你还记得,我们的 Anchor 程序要求代币铸造必须是 USDC 铸造。运行此测试通常需要使用一个假的 USDC 代币,但 Bankrun 允许我们使用任意账户,这意味着我们可以将代币账户信息写入任何我们想要的账户。事实上,我们已经在我们的 startAnchor
函数中做到这一点——通过使用我们从 Solana 主网提取的账户信息来初始化 usdcMint
账户。让我们(1)编写一个测试以确保 USDC 铸造正确初始化,并(2)编写一组测试以验证具有足够余额的 ATA 是否已初始化。在你的 "Bankrun Tests" 描述块内, 在 "Time Travel Tests" 块后,添加以下 "Arbitrary Data Account Tests" 描述块:
describe("任意数据账户测试", () => {
const testCases = [
{ desc: "余额不足", amount: MINIMUM_USDC_BALANCE - 1_000_000, shouldSucceed: false },
{ desc: "余额充足", amount: MINIMUM_USDC_BALANCE, shouldSucceed: true },
];
describe("USDC 铸造初始化", () => {
let rawAccount: AccountInfoBytes;
before(async () => {
rawAccount = await client.getAccount(usdcMint);
});
it("应当已经初始化 USDC 铸造", () => {
expect(rawAccount).to.exist;
});
it("应当有正确的小数位数", () => {
const mintInfo = MintLayout.decode(rawAccount.data);
expect(mintInfo.decimals).to.equal(USDC_DECIMALS);
});
});
testCases.forEach(({ desc, amount, shouldSucceed }) => {
describe(`拥有 ${desc} USDC 余额的 ATA`, () => {
let ata: PublicKey;
let txResult: BanksTransactionResultWithMeta;
before(async () => {
let owner = Keypair.generate();
ata = await setupATA(context, usdcMint, owner.publicKey, amount);
const ix = await program.methods
.checkSplToken()
.accounts({
tokenAccount: ata,
})
.instruction();
txResult = await createAndProcessTransaction(client, payer, ix);
});
it("应当已经初始化 USDC ATA", async () => {
const rawAccount = await client.getAccount(ata);
expect(rawAccount).to.exist;
});
it("应当在 ATA 中有正确的余额", async () => {
const accountInfo = await client.getAccount(ata);
const tokenAccountInfo = AccountLayout.decode(accountInfo.data);
expect(tokenAccountInfo.amount).to.equal(BigInt(amount));
});
if (shouldSucceed) {
it("应当成功处理交易", () => {
expect(txResult.result).to.be.null;
});
it("最后的日志消息应指示成功", () => {
expect(txResult.meta.logMessages[txResult.meta.logMessages.length - 1]).to.include('success');
});
} else {
it("应当无法处理交易", () => {
expect(txResult.result).to.exist;
});
it("应在日志中包含特定错误细节", () => {
const errorLog = txResult.meta.logMessages.find(log =>
log.includes('AnchorError') &&
log.includes('InsufficientTokenBalance') &&
log.includes('6001') &&
log.includes('错误消息: 代币余额不足。')
);
expect(errorLog).to.exist;
});
it("最后的日志消息应指示失败", () => {
expect(txResult.meta.logMessages[txResult.meta.logMessages.length - 1]).to.include('failed');
});
}
});
});
});
测试分析:
BanksClient
的 getAccount
方法获取 USDC 铸造的原始账户信息。然后我们使用 expect
函数检查账户是否存在,以及小数位数是否与预期值匹配。对于每个测试用例,我们创建一个描述测试用例的 describe
块。在 describe
块内部,我们创建一个设置测试环境的 before
块。在创建和发送交易之前,我们使用 setupATA
函数创建一个具有指定余额的 ATA。然后我们使用 createAndProcessTransaction
函数将交易发送到账本状态。describe
块内部运行一系列测试。对于每个测试,我们创建一个描述测试用例的 it
块。在 it
块内部,我们检查交易结果,并对预期行为进行断言。这些测试在结构上与之前编写的测试相似,利用 BanksTransactionResultWithMeta
对象的 results
和 meta.logMessages
属性。在你的终端中输入:
anchor test
你的测试应通过:
18 passing (2s)
✨ Done in 3.02s.
哇!真快!尽管向集群发送了几笔交易,但我们的测试在几分之一秒内完成。这是使用 Bankrun 的一个巨大优势,因为它使我们能以更高效的方式运行测试!但是那些日志呢?不用担心——如果你不想看到它们,可以轻松删除它们。
打开 Anchor.toml
并将 [scripts]
部分更新为这样的格式:
[scripts]
test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
test_debug = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
现在,当你运行 anchor test
时,应该不会看到日志:
Bankrun Tests
Time Travel Tests
When slot is 99 (too early)
✔ transaction should fail
✔ should contain specific error details in log
✔ last log message should indicate failure
When slot is 100 (at or above threshold)
✔ transaction should succeed
✔ last log message should indicate success
✔ should contain expected log message
✔ should set new data correctly
Arbitrary Data Account Tests
USDC mint initialization
✔ should have initialized USDC mint
✔ should have correct decimals
ATA with insufficient USDC balance
✔ should have initialized USDC ATA
✔ should have correct balance in ATA
✔ should fail to process the transaction
✔ should contain specific error details in log
✔ last log message should indicate failure
ATA with sufficient USDC balance
✔ should have initialized USDC ATA
✔ should have correct balance in ATA
✔ should process the transaction successfully
✔ last log message should indicate success
18 passing (442ms)
✨ Done in 1.19s.
这太棒了!但是我们还添加了一个 test_debug
脚本,如果我们需要调试某些内容,它将显示日志。让我们重新运行测试,这次将使用 test_debug
脚本:
就这样!你的日志被显示出来。这是调试测试并确保它们按预期工作的好方法。
干得好!你现在在工具箱中拥有一些额外的工具,以加速 Solana 程序的测试和开发。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!