新消息:Helius 收购 Light Protocol
Solana 程序测试指南
博客/开发

Solana 程序测试指南

Developer Experience EngineerX 上的 0xIchigoLinkedIn 上的 0xIchigoGitHub 上的 0xIchigo
阅读需 27 分钟

引言

区块链环境中的测试超越了传统软件测试范式,带来了独特的挑战和更严重的后果。在 Solana 高吞吐量、低延迟的环境中,容错空间很小。为了确保程序能够在 Solana 动态且严苛的环境中安全可靠地运行,自动化测试不仅是最佳实践,更是不可或缺的基本要求。

本文将探讨自动化测试的核心类型——单元测试、集成测试和端到端(E2E)测试。我们还会介绍如何使用 JavaScript/TypeScript 和 Rust 编写基本单元测试,并分析常用的 Solana 测试框架。最后,本文将通过一个测试“山丘之王”游戏程序的实用示例收尾。

如果你刚接触 Solana,建议先阅读以下往期博客文章:

本文也是对上一篇 Solana 程序安全文章的补充。建议结合阅读这两篇文章。

什么是测试?

测试用于验证一段代码或整个应用是否按预期运行。测试通常分为两类:

  • 手动测试:以人为中心的流程,由开发者、质量保证分析师、渗透测试人员或其他负责人执行测试用例
  • 自动化测试:以代码为中心的流程,通过编写脚本以编程方式执行预定义的测试用例

手动测试非常灵活,不受被测应用类型的限制。它适合测试新功能、易用性和无障碍体验。手动测试依赖测试人员对应用应如何运行的直觉判断。但由于测试过程中缺少工具,它本身速度慢、容易出错、耗时,而且往往不完整(即无法覆盖所有场景)。 

自动化测试旨在解决手动测试的不足。例如,它通常更快,尤其是在并行执行测试时。自动化测试按照预定义脚本运行,因此不易受到人为错误影响。它能够高效处理大量测试用例,提高测试覆盖率,是一种高度可扩展的方案。但由于其客观标准较为刻板,自动化测试不太适合依赖人工交互、判断或批判性推理的测试。

本文将重点介绍 Solana 程序的自动化测试,因为在主网上进行手动测试成本很高,而在开发网上进行手动测试又非常耗时。不过,在将代码发布到生产环境之前,手动测试和自动化测试都应纳入测试流程。完善的测试流程能够在开发早期发现缺陷,最大限度地减少进入生产环境的 bug。

自动化测试主要包括以下类型:

  • 单元测试
  • 集成测试
  • 端到端(E2E)测试

单元测试

单元测试是测试代码中最小功能单元以确保其正常运行的过程。理想情况下,单元是程序中最小的构建模块(例如单个函数或模块),它们组合起来构成最终产品。其核心理念是:如果充分测试每个构建模块,整个程序就应当能够按预期运行。 

单元测试是 Solana 开发的基础,因为它可以确保程序的每个部分都按预期运行。这些测试可以重复使用,从而确保新功能或更新符合测试用例中定义的项目规范和用户预期。因此,单元测试也会自然地推动代码优化和重构,避免新改进对程序功能造成不利影响。单元测试不仅可以保证每段代码在各种测试条件下正确运行,还能确保区块链交互高效且安全。通过单元测试尽早发现 bug 至关重要,因为这能防止潜在漏洞进入生产环境。 

各种测试框架可以通过简化网络条件模拟和程序状态管理来提升单元测试效率,本文稍后将对此展开介绍。Solana 开发者可以通过单元测试显著提高代码的可靠性和性能。

集成测试

集成测试在单元测试的基础上进一步检查程序的不同单元如何协同工作。在 Solana 开发中,验证程序的函数和模块能否协同运行至关重要,因为程序交互通常很复杂,并且涉及资金风险。集成测试旨在识别和解决单独测试各单元时不易发现、但在组件交互时会暴露的问题。这些问题可能包括数据格式不匹配、类型不一致、程序依赖或第三方 API 问题。

在 Solana 环境中,程序本身会与其他程序、钱包和预言机交互,而集成测试可以验证这些交互是否按预期进行。即使每个单元都能完美运行,它们组合后仍可能在模拟条件下出现意外行为或效率问题。开发者可以使用不同的测试框架模拟各种交易流程和程序交互,尽可能还原真实场景。例如,Bankrun 是一个强大且轻量的测试框架,允许开发者在时间线上前后跳转,并动态设置账户数据。使用 solana-test-validator 时无法实现这些操作。集成测试对于确保程序稳健、可靠并能应对 Solana 的网络条件至关重要。

端到端(E2E)测试

端到端(E2E)测试是测试流程的最后阶段。它专注于评估程序在真实场景下的完整运行流程。这种测试方法不同于单元测试和集成测试,因为它从用户视角检查程序——最终用户可能遇到的所有流程和功能都应按预期运行。 

E2E 测试对于验证程序是否满足功能要求并提供流畅的用户体验至关重要。这一测试阶段有助于发现单元测试或集成测试中可能未暴露的问题,例如交易处理延迟、状态持久化问题、计算单元优化或意外的网络状况。虽然 E2E 测试通常用于测试整个 dApp,但测试程序的运行流程,并验证用户交易如何与各个函数和模块交互,对于构建成功且安全的程序至关重要。

结合使用这些测试方法

在开发流程中采用分层测试策略,并结合单元测试、集成测试和 E2E 测试至关重要。每种测试方法在开发生命周期中都有不同作用,分别覆盖程序功能和性能的不同方面。

单元测试是分层测试方法的基础,让开发者能够在最细粒度的代码层面快速识别并解决问题。它擅长确保单个函数或模块在客观层面正确运行,但无法体现这些单元如何协同工作,也无法反映它们如何融入用户体验。

集成测试通过评估不同单元之间的交互来弥补这一缺口,并发现组件集成时出现的问题。然而,仅靠集成测试可能无法完整反映最终用户的体验,也无法充分体现程序在真实条件下的行为。

E2E 测试通过模拟真实用户场景并将应用作为整体进行测试,为单元测试和集成测试提供补充。这种方法对评估整体用户体验非常有价值,但无法提供快速识别和解决具体问题所需的细粒度洞察。

通过整合这些方法,开发者可以构建一个覆盖各类潜在问题的稳健测试框架。全面的测试方法不仅能提高程序的质量和安全性,还能简化开发流程。开发者可以快速做出明智的决策和修改,并确信变更会经过多个层面的检验。在部署之前,结合这些测试方法对于确保 Solana 程序技术可靠,并在真实条件下符合用户预期至关重要。

编写优质测试

编写有效的测试对于开发可靠、安全的 Solana 程序至关重要。一个优质测试的关键在于关注被测行为,而不是所用框架或代码实现细节。开发者可以结合测试驱动开发(TDD)、准备-执行-断言(AAA)模式和行业最佳实践,制定高效的测试策略并提升代码质量。

测试驱动开发(TDD)

TDD 是一种由测试编写驱动的稳健软件开发方法。也就是说,核心理念是在编写实际代码之前先编写测试。TDD 循环通常包含三个步骤:

  • 编写失败的测试:开发应从为开发者接下来要添加的功能编写测试开始。由于被测功能尚不存在,测试必然会失败
  • 实现代码:开发者应编写让测试通过所需的最少代码。这里的目标是快速、简单
  • 重构:测试通过后,开发者应在不改变代码行为的前提下进行重构,以改善代码结构和清晰度。通过的测试可以充当安全网,避免引入破坏性变更。此步骤可以包括删除重复代码、将方法拆分为更小的单元、重新组织继承层次结构,或采用见名知意的命名。在 Solana 环境中,这一步可能包括优化给定交易请求的 CU 数量、减少 CPI 数量或简化交易流程。

虽然构建优质 Solana 程序并非必须使用 TDD,但开发者应考虑其理念:它提倡严谨地构建程序,迭代式方法有助于形成灵活且适应性强的开发流程,并且与程序开发对精确性、安全性和效率的要求高度契合。TDD 鼓励开发者编写更简洁、更聚焦的代码,并针对 Solana 独特的网络和性能要求进行优化(例如优化 CU)。 

准备-执行-断言(AAA)模式

AAA 模式为编写清晰、简洁且有效的测试提供了一种简单而强大的结构。其核心是鼓励采用规范的测试编写方法,并将测试分为三个不同阶段:

  • 准备:首先设置测试环境并准备所有相关输入。这可能包括生成账户、模拟账户余额或准备指令。目标是创建一个受控场景,模拟被测行为发生时的条件
  • 执行:执行被测行为。这里重点关注触发目标行为的操作。例如,当我调用函数 x 并传入账户 y 时,会发生什么?
  • 断言:根据预期结果评估操作的实际结果。此步骤对于验证测试通过还是失败至关重要。例如,断言可以是简单的值检查,也可以是涉及多项状态变更的复杂验证。这些断言的具体实现最终取决于所使用的框架或协议。Lighthouse 是一个提供断言指令的程序,可以将这些指令添加到交易中,用于识别不良状态、伪造的模拟结果或超额支出等问题。我们将在另一篇文章中更深入地探讨 Lighthouse 的优势和复杂细节。

AAA 模式的优势在于适应性强,可用于单元测试、集成测试和 E2E 测试。例如:

  • 单元测试:针对某个函数的测试可以先设置程序状态作为准备阶段,再调用该函数作为执行阶段,最后通过检查函数返回值或产生的状态变更进行断言
  • 集成测试:测试多个程序之间的交互时,可以先部署程序并设置其初始状态,再执行相关交易,最后验证每个相关程序的最终状态
  • E2E 测试:程序的 E2E 测试可以先设置程序状态,再完成整个预期用户流程(例如创建账户、创建提案、对该提案投票、结束提案的投票阶段等),最后检查流程结果是否符合预期

AAA 模式对于程序开发至关重要。它要求测试以行为为中心,而这是验证程序能否按预期运行的必要条件。围绕 AAA 构建的测试更易于理解和维护,因为每个步骤都被明确划分为设置、操作和验证。此外,AAA 还促进开发独立、解耦的测试,让每项测试专注于特定行为或交互。

行业最佳实践

编写优质测试并非 Solana 开发独有。我们可以借鉴软件开发中的通用经验,将其用于测试 Solana 程序,重点测试预期行为,而不是纠结于实现细节。 

例如,单元测试通常应针对方法的公共接口,传入特定参数并验证结果是否符合预期。这样一来,只要行为保持一致,即使方法的内部实现发生变化,单元测试仍然有效。在 Solana 开发中,这意味着如果程序逻辑的变更不影响其外部行为,就不应要求重构测试。

此外,编写单元测试时的一个常见误区是过度依赖被测方法的内部工作机制,包括要求某些私有方法被调用特定次数或必须以特定方式编写。这类测试过于脆弱,任何代码重构都可能导致其失败,即使被测方法的实际行为完全没有变化。测试应转而关注从外部观察到的方法结果和副作用。代码覆盖率工具可以在此发挥作用,既能确保测试全面,又不会过度依赖内部机制。

在 Solana 开发中采用这些最佳实践,可以提高程序的稳健性和适应性。专注于测试行为而非实现细节,可以让代码更具韧性、更易维护;将代码部署到 Solana 这类动态网络环境时,这一点至关重要。采用这种方法可以确保程序逻辑发生变化时无需进行大规模重新测试,并确保程序做好部署准备。

现在,让我们开始编写测试。

编写基本单元测试

在 Rust 中进行单元测试

Rust 采用一种独特的单元测试方式,鼓励开发者将测试与代码放在同一文件中。这通过 tests 模块实现,并由 #[cfg(test)] 属性控制。该测试属性确保只有使用 cargo test 命令显式测试软件时,才会编译并运行这些测试(即使用 cargo build 命令时不会运行)。开发者还可以使用 #[ignore] 属性,将测试排除在常规测试运行之外。这适用于速度特别慢的测试,并且仍可通过 cargo test -- --ignored 命令显式调用这些测试。

以下面的 Rust 函数为例:

代码
pub fn bubble_sort<T: Ord>(array: &mut [T]) {
    if array.is_empty() {
        return;
    }

    for i in 0..array.len() {
        for j in 0..array.len() - 1 - i {
            if array[j] > array[j + 1] {
                array.swap(j, j + 1);
            }
        }
    }
}

冒泡排序是一种排序算法,它会反复遍历列表中的元素,将当前元素与后一个元素进行比较,并在必要时交换二者的值。如果要测试该函数是否按预期运行,可以编写以下测试:

代码
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_bubble_sort() {

        let mut test1 = vec![12, 39, 4, 36, 777];
        assert_eq!(bubble_sort(&mut test1), vec![4, 12, 36, 39, 777]);

        let mut test2 = vec![21, 55, 14, -123, 32, 0];
        assert_eq!(bubble_sort(&mut test2), vec![-123, 0, 14, 21, 32, 55]);

        let mut test3 = vec!["Orange", "Pear", "Apple", "Grape", "Banana"];
        assert_eq!(bubble_sort(&mut test3), vec!["Apple", "Banana", "Grape", "Orange", "Pear"]);
    }
}

此示例展示了如何使用 #[cfg(test)] 属性标注 tests 模块。在该模块中,我们使用 use super::*; 将父模块中的所有公共项导入当前测试模块的作用域。随后,我们通过多个测试用例断言排序后的向量应是什么样子。Rust 提供了多个断言宏,例如用于一般真值判断的 assert!、用于相等性检查的 assert_eq!,以及用于不等性检查的 assert_ne!。这些断言是 Rust 测试策略的基础,也是开始编写测试真正需要掌握的全部内容。 

来看一个非常基础的例子。假设你有一个函数,用于判断账户余额是否足以支付给定交易:

代码
pub fn has_sufficient_balance(account_balance: u64, transaction_fee: u64) -> bool {
    account_balance >= transaction_fee
}

此函数接收两个参数:给定账户的当前余额和预计交易费。如果账户余额足以支付交易费,则返回 true;否则返回 false。可以使用以下单元测试轻松测试它:

代码
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sufficient_funds_for_transaction() {
        let account_balance = 1_000_000;
        let transaction_fee = 5_000;

        assert!(has_sufficient_balance(account_balance, transaction_fee));
    }

    #[test]
    fn insufficient_funds_for_transaction() {
        let account_balance = 1_000;
        let transaction_fee = 5_000;

        assert!(!has_sufficient_balance(account_balance, transaction_fee));
    }
}

在第一个测试中,我们断言当账户余额远高于交易费时,has_sufficient_balance 返回 true,表示资金足以支付该交易。在第二个测试中,我们断言当账户余额低于交易费时,has_sufficient_funds 返回 false,表示资金不足以支付该交易。 

在 Rust 中进行测试的其他重要注意事项

Rust 提供 #[should_panic] 属性,用于标记预期在特定条件下发生 panic 的测试。这有助于测试错误处理路径并指定预期的 panic 消息:

代码
#[test]
#[should_panic(expected = "Divide-by-zero error")]
fn test_divide_by_zero() {
    divide_non_zero_result(0, 0);
}

与许多其他语言不同,Rust 允许直接测试私有函数。这样可以进行更细致的单元测试,以单元测试覆盖代码功能的各个方面。

Rust 还支持更高级的测试组织方式:

  • 嵌套模块:对于复杂项目,可以将测试组织到嵌套模块中,形成与项目组织方式相对应的清晰层次结构
  • 基于 Result 的测试:Rust 支持让测试返回 Result<(), E> 类型。开发者可以在测试中使用 ? 运算符,从而以更具表达力的方式处理错误

使用 Mocha 和 Chai 在 TypeScript 中进行单元测试

随着 Anchor 全面占据 Solana Rust 开发通用标准的地位,TypeScript 已成为测试程序的热门选择。使用 anchor init 命令时,新 Anchor 项目会默认初始化 Mocha 测试框架和 Chai 断言库。 

Mocha 是一个功能丰富、运行在 Node.js 上的 JavaScript 测试框架,因此异步测试非常简单。在 Solana 开发中,Mocha 主要用于测试 dApp 的客户端逻辑和其他区块链交互。 

Chai 是一个断言库,可以与 Mocha 等任何 JavaScript 测试框架搭配使用。它为开发者提供一系列函数,以易读的方式表达断言。Chai 的 expect、should 和 assert 接口让开发者能够编写易于阅读和编写的全面测试。它通过 expect 和 should 接口使用语言链(即可链式调用的 getter),提升断言的可读性。借助 Chai,expect({a: 1, b: 2}).to.not.have.any.keys(“c”, “d”); 是一条可读性很高且有效的断言。

例如,如果使用 anchor init hello_world 命令创建 hello_world 项目,则会在 hello_world/tests 目录中创建以下 hello_world.ts 测试文件:

代码
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { HelloWorld } from "../target/types/hello_world";

describe("hello_world", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.HelloWorld as Program<HelloWorld>;

  it("Is initialized!", async () => {
    // Add your test here.
    const tx = await program.methods.initialize().rpc();
    console.log("Your transaction signature", tx);
  });
});

下面逐一解释这些代码的含义。

Mocha 使用 describe 块对测试进行分组,并使用 it 函数定义测试用例。此示例遵循 AAA 模式,以结构化方式进行测试:

  • 准备:这里,anchor.setProvider(anchor.AnchorProvider.env()); 将 Anchor 客户端配置为使用环境的默认提供程序,该程序通常指向本地 Solana 测试验证节点。然后,program 常量声明会初始化被测程序的实例,让我们能够在测试中调用它的方法
  • 执行:在测试用例 “Is initialized!” 中,我们调用程序的 initialize 方法并发送交易
  • 断言:在此测试用例中,我们只记录交易签名,没有提供断言。通常,这个阶段会使用 Chai 编写断言。举一个非常基础的例子,我们可以使用 expect(tx).to.be.a(“string”); 这样的断言修改默认测试代码。更详细的测试可以在初始化后获取并检查程序状态,断言其是否与预期值一致

Mocha 与 Chai 的组合,加上 Anchor 项目默认配置的 AAA 模式,为程序测试提供了一个稳健的框架。Solana 开发者可以通过明确准备测试环境、调用程序方法执行操作并断言结果,确保程序的运行可预测且可靠。

例如,假设你正在开发一个程序,允许用户在 Solana 环境中向金库存入 SOL 或从中提取 SOL。为了确保存款功能按预期运行,使用 Mocha 和 Chai 在 TypeScript 中编写的测试可能如下所示:

代码
import { expect } from "chai";
import { PublicKey } from "@solana/web3.js";
import { depositSOL } from "../src/vault";

describe("Vault Program", function() {
    describe("Deposit functionality", function() {
        it("should correctly deposit SOL into the vault", async function() {
            const vaultPublicKey = new PublicKey(/* vault public key */);
            const userPublicKey = new PublicKey(/* user public key */);
            const depositAmount = 1; // 1 SOL

            const initialVaultBalance = await getVaultBalance(vaultPublicKey);
            await depositSOL(vaultPublicKey, userPublicKey, depositAmount);

            const finalVaultBalance = await getVaultBalance(vaultPublicKey);
            expect(finalVaultBalance).to.equal(initialVaultBalance + depositAmount);
        });
    });
});

此示例测试了一个假设的 depositSOL 函数,该函数负责将 SOL 存入金库。它断言存款后金库余额增加了正确的金额。我们使用 getVaultBalance 函数,这是一个假设的实用函数,用于获取金库的当前余额。

使用 Mocha 和 Chai 在 TypeScript 中进行测试的其他重要注意事项

TypeScript 的静态类型系统有时会增加测试编写的难度,尤其是在处理复杂或定义不明确的类型时。可以使用类型断言来避免测试中的类型相关问题。但要确保这些断言不会掩盖因类型不正确而可能产生的运行时错误。

在 TypeScript 中模拟对象或函数时,请确保模拟的实体遵循正确的类型。ts-sinon 或 ts-mockito 等库可以帮助创建类型安全的模拟,使测试保持准确并反映程序的真实行为。

Mocha 提供 only 和 skip 方法,用于仅运行或跳过特定测试。这些功能在开发期间很方便,但也很容易被意外提交到生产环境,导致测试运行不完整。在将测试推送到生产环境之前,务必检查是否存在 only 或 skip。此外,将 Mocha 的钩子(即 beforeEach、afterEach、before、after)与异步代码一起使用时要格外谨慎。请确保使用 async/await 正确处理 promise,或调用 done 回调方法,以避免 promise 未解决或回调未调用。

使用 Chai 的 expect().to.deep.equal() 时,请注意它在处理包含日期或随机值等动态生成属性的对象时的行为。这些属性可能导致预期深度相等的测试意外失败。在适用情况下,可以考虑使用 Chai 的 expect().to.include() 进行更有针对性的断言。

热门 Solana 测试框架

Bankrun

bank 负责跟踪客户端账户、管理程序执行,并维护 Solana 账本的完整性与推进。它本质上是账本在某个时间点的快照,封装了特定区块中的交易所产生的状态。 

Bankrun 是一个使用 Node.js 编写的轻量、灵活的 Solana 程序测试框架。它注重易用性和速度,让开发者可以快速为程序编写并运行测试。Bankrun 的真正价值在于,它让开发者能够在受控且高效的环境中模拟 Solana bank 并与之交互。Bankrun 复现了 Solana bank 的运行机制,同时避免了搭建此类环境通常带来的额外开销,从而简化测试流程。

Bankrun 的设计基于轻量级 BanksServer。它模拟 RPC 节点的行为,同时显著提升性能和灵活性。开发者可以通过 BanksClient 与此服务器交互。该客户端提供完整的工具集,其中包含获取账户余额和交易状态以及模拟交易的方法。值得注意的是,tryProcessTransaction 方法可以处理预期失败的交易,而不会抛出任何 JavaScript 错误。因此,开发者可以直接断言特定的失败模式或日志消息。

Meta-DAO 的 Futarchy GitHub 仓库是使用 Bankrun 测试生产级代码的优秀示例。

与 Anchor 集成

将 Bankrun 与 Anchor 集成非常简单。使用 startAnchor,开发者可以自动将 Anchor 工作区中的所有程序部署到测试环境。这可确保测试在完整的 Solana 环境中准确复现程序的行为。Bankrun 文档提供了以下代码示例:

代码
import { startAnchor } from "solana-bankrun";
import { PublicKey } from "@solana/web3.js";

test("anchor", async () => {
	const context = await startAnchor("tests/anchor-example", [], []);
	const programId = new PublicKey(
		"Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS",
	);
	const executableAccount = await context.banksClient.getAccount(programId);
	expect(executableAccount).not.toBeNull();
	expect(executableAccount?.executable).toBe(true);
});

anchor-bankrun 包是一个强大的扩展,通过导出 BankrunProvider 类来支持 Anchor 和 Bankrun。测试期间,该类可直接替代 AnchorProvider。

使用 Bankrun 写入任意账户

Bankrun 的一项突出功能是能够写入任意账户数据。此功能突破了账户状态的限制,为开发者提供了前所未有的灵活性。例如,开发者无需持有 USDC 铸币密钥对,即可模拟一个持有大量 USDC 的账户。这对测试极具价值,因为它无需操作真实代币,从而简化了复杂场景的设置流程。 

Bankrun 文档提供了无限铸造 USDC 的代码示例,通过 start 函数展示了这项功能。start 函数会部署程序并按指定内容设置账户数据,为测试环境做好准备。

时间穿越

Bankrun 的另一项独立功能是时间穿越,即出于测试目的操控时间概念。通过操控时间,开发者可以让 Solana 集群时钟(即 Clock sysvar)快进或回退,从而即时模拟特定的时间条件。此功能对于测试基于时间逻辑运行的程序至关重要,包括归属计划、代币锁定,或任何在到达特定时间点时触发的功能。

借助 setClock 方法,时间穿越非常简单。开发者可使用此方法将集群的当前时间设为预定义的 Unix 时间戳,让整个测试环境转移到过去或未来的这一时刻。测试中的操作和交易会将指定时间视为当前时间继续执行,从而准确评估程序在相应条件下的行为。

以下是一个非常基础的 Bankrun 时间穿越示例:

代码
import { start } from "solana-bankrun";
import { PublicKey, Transaction, SystemProgram } from "@solana/web3.js";

async function simulateTimeTravel(context, secondsForward) {
    const newTimestamp = context.clock.unixTimestamp + secondsForward;
    context.adjustClock(newTimestamp);
}

test("One Year Later...", async () => {
    const context = await start([], []);
    const { banksClient, payer } = context;

    // Simulate setting the cluster clock forward by one year (in seconds)
    const oneYearInSeconds = 365 * 24 * 60 * 60;
    await simulateTimeTravel(context, oneYearInSeconds);

    // Proceed with tests assuming the future time
    const transaction = new Transaction().add(
        SystemProgram.transfer({
            fromPubkey: payer.publicKey,
            toPubkey: PublicKey.unique(),
            lamports: 100,
        }),
    );

    transaction.recentBlockhash = context.lastBlockhash;
    transaction.sign(payer);

    await banksClient.processTransaction(transaction);

    // Add assertions here to test expected future behavior
});

Bankrun 与 solana-test-validator 对比

选择 Bankrun 还是 solana-test-validator,主要取决于测试场景的具体要求。Bankrun 凭借速度、灵活性和专用功能,成为多数开发场景的首选,尤其适用于需要快速迭代或精细模拟的场景。不过,如果测试依赖真实的验证者行为,或需要使用 BanksServer 不支持的 RPC 方法,solana-test-validator 仍然适用。

solana-program-test

solana-program-test crate 提供一个专为 Solana 程序设计的 Rust 测试框架。该框架以 BanksClient 为核心。与 Bankrun 类似,它模拟 Solana bank 的运行,使开发者能够在仿照主网的测试条件下部署程序、与程序交互并评估其行为。作为 BanksClient 的补充,ProgramTest 结构体是用于初始化测试环境的工具。也就是说,它有助于开发指定程序并设置必要账户。BanksTransactionResultWithMetadata、InvokeContext 和 ProgramTestContext 等其他结构体为测试期间处理的交易提供丰富信息和上下文,增强了整体调试与验证流程。 

为简化本地开发和测试,solana-program-test 会自动预加载多个程序:

  • SPL Token(及其 2022 版本)
  • SPL Memo(1.0 和 3.0 版本)
  • SPL Associated Token Account

这些预加载程序无需手动设置常用程序,因此可以更快、更专注地搭建测试环境。

Marginfi 的 GitHub 仓库提供了多个在生产级代码中实现 solana-program-test 的优秀示例。Bonfida 的开发指南也详细演示了如何使用 solana-program-test 框架编写集成测试。

solana-test-framework

solana-test-framework 是 Halborn 开发的 solana-program-test 扩展。它通过为 BanksClient、RpcClient、ProgramTest 和 ProgramTestContext 添加多种便捷方法,进一步丰富测试环境。例如,与 Bankrun 类似,针对 ProgramTestContext 的扩展支持高级测试场景,让开发者可以跳转到特定时间戳并更新预言机价格。

这些扩展提供以下增强功能:

  • 交易管理:通过 transaction_from_instructions 简化交易的组装、签名和支付
  • 账户反序列化:通过 get_account_with_anchor and get_account_with_borsh 分别轻松获取 Anchor 和 Borsh 账户并进行反序列化
  • 账户创建和程序部署:开发者可使用 create_account、create_token_mint、create_token_account 和 deploy_program 等函数高效设置测试环境。

solana-test-framework  同时支持外部集群和模拟运行时。它兼容多个 Solana 和 Anchor 版本,包括 Solana 1.9 至 1.14 版,以及分别对应 1.9、1.10 和 1.14 的 Anchor 版本。 

测试场景示例

程序

以下面的程序为例:

代码
use anchor_lang::prelude::*;
use anchor_lang::solana_program::system_instruction;
use solana_program::program::invoke;

declare_id!("3vMZa7r3CpHGejvXYbUpPXmm54FxCDPF1QAYnnzL88J9");

#[program]
pub mod king_of_the_hill {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>, initial_prize: u64) -> Result<()> {
        // In case the person who went first didn't send any SOL as the initial prize
        require!(initial_prize > 0, ErrorCode::NeedAnInitialPrize);

        let game_state = &mut ctx.accounts.game_state;

        game_state.king = ctx.accounts.initial_king.key();
        game_state.prize = initial_prize;

        let transfer_instruction = system_instruction::transfer(
            &ctx.accounts.initial_king.key(),
            &ctx.accounts.prize_pool.key(),
            initial_prize,
        );

        invoke(
            &transfer_instruction,
            &[
                ctx.accounts.initial_king.to_account_info(),
                ctx.accounts.prize_pool.to_account_info(),
                ctx.accounts.system_program.to_account_info(),
            ],
        )?;

        Ok(())
    }

    pub fn become_king(ctx: Context<BecomeKing>, new_prize: u64) -> Result<()> {
        require!(
            new_prize > ctx.accounts.game_state.prize,
            ErrorCode::BidTooLow
        );

        let transfer_to_pool_instruction = system_instruction::transfer(
            &ctx.accounts.payer.key(),
            &ctx.accounts.prize_pool.key(),
            new_prize,
        );

        // Send the new king's funds to the pool
        invoke(
            &transfer_to_pool_instruction,
            &[
                ctx.accounts.payer.to_account_info(),
                ctx.accounts.prize_pool.to_account_info(),
                ctx.accounts.system_program.to_account_info(),
            ],
        )?;

        // Send the old king's funds back
        ctx.accounts.prize_pool.sub_lamports(ctx.accounts.game_state.prize);
        ctx.accounts.king.add_lamports(ctx.accounts.game_state.prize);

        ctx.accounts.game_state.king = ctx.accounts.payer.key();
        ctx.accounts.game_state.prize = new_prize;

        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = initial_king,
        space = 8 + 32 + 8 + 1,
        seeds = [b"game_state"],
        bump,
    )]
    pub game_state: Account<'info, GameState>,
    #[account(mut)]
    pub initial_king: Signer<'info>,
    #[account(
        init,
        payer = initial_king,
        space = 8 + 8,
        seeds = [b"prize_pool"],
        bump,
    )]
    /// CHECK: This is okay - it's a PDA to store SOL and doesn't need a data layout
    pub prize_pool: UncheckedAccount<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct BecomeKing<'info> {
    #[account(
        mut,
        has_one = king,
    )]
    pub game_state: Account<'info, GameState>,
    #[account(mut)]
    /// CHECK: This is okay - it's only receiving SOL and we don't need any other access
    pub king: UncheckedAccount<'info>,
    #[account(mut)]
    pub payer: Signer<'info>,
    #[account(
        mut,
        seeds = [b"prize_pool"],
        bump,
    )]
    /// CHECK: This is okay - it's a PDA to store SOL and doesn't need a data layout
    pub prize_pool: UncheckedAccount<'info>,
    pub system_program: Program<'info, System>,
}

#[account]
pub struct GameState {
    pub king: Pubkey,
    pub prize: u64,
    pub prize_pool_bump: u8,
}

#[error_code]
pub enum ErrorCode {
    #[msg("The initial prize must be greater than zero")]
    NeedAnInitialPrize,
    #[msg("The bid must be higher than the current prize")]
    BidTooLow,
    #[msg("Invalid prize pool account")]
    InvalidPrizePoolAccount,
}

该程序在 Solana 上实现了一个简单的“山丘之王”游戏。用户发送比当前国王更多的 SOL 到奖池,即可成为新“国王”。新国王取代旧国王时,旧国王此前发送的 SOL 会退还给他们。

该程序的功能如下:

  • 初始化:此函数会设置游戏的初始国王(即第一个初始化游戏的玩家)和初始奖励金额。初始奖励必须大于零。随后,该函数会将初始奖励从初始国王转入奖池
  • 成为国王:此函数允许新玩家通过出价高于当前奖励的 SOL 成为国王。它会将当前奖励转给即将卸任的国王,并用新国王的出价更新奖池,使出价者成为新国王。该出价必须高于当前奖励

编写测试

我们可以使用以下代码成功测试“山丘之王”游戏:

代码
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KingOfTheHill } from "../target/types/king_of_the_hill";

import { assert } from "chai";

const web3 = require("@solana/web3.js");

describe("King of the Hill Tests", () => {
  // Configure the client to use the local cluster.
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.KingOfTheHill
as Program<KingOfTheHill>;

  let initialKing, newKing;
  let gameStatePDA, prizePoolPDA;

  // Utility function for airdrops
  async function fundWallet(account, amount) {
    const publicKey = account.publicKey ? account.publicKey : account;

    await provider.connection.confirmTransaction(
      await provider.connection.requestAirdrop(publicKey, amount),
      "confirmed"
    );
  }

  before(async () => {
    initialKing = web3.Keypair.generate();
    newKing = web3.Keypair.generate();

    await fundWallet(initialKing, 25 * web3.LAMPORTS_PER_SOL);
    await fundWallet(newKing, 30 * web3.LAMPORTS_PER_SOL);

    [gameStatePDA] = web3.PublicKey.findProgramAddressSync(
      [Buffer.from("game_state")],
      program.programId
    );

    [prizePoolPDA] = web3.PublicKey.findProgramAddressSync(
      [Buffer.from("prize_pool")],
      program.programId
    );
  });

  it("Initializes the game correctly", async () => {
    // Arrange
    await fundWallet(gameStatePDA, 1 * web3.LAMPORTS_PER_SOL);
    await fundWallet(prizePoolPDA, 1 * web3.LAMPORTS_PER_SOL);

    let initialPrize = new anchor.BN(1 * web3.LAMPORTS_PER_SOL);

    // Act
    const tx = await program.methods
      .initialize(initialPrize)
      .accounts({
        gameState: gameStatePDA,
        initialKing: initialKing.publicKey,
        prizePool: prizePoolPDA,
        systemProgram: web3.SystemProgram.programId,
      })
      .signers([initialKing])
      .rpc();

    // Assert
    let gameState: any = await program.account.gameState.fetch(gameStatePDA);
    assert.equal(gameState.king.toBase58(), initialKing.publicKey.toBase58());
    assert.equal(
      gameState.prize.toString(),
      new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toString()
    );
  });

  it("Changes the king correctly", async () => {
    // Arrange
    const initialKingBalanceBefore = await provider.connection.getBalance(initialKing.publicKey);
    let newPrize = new anchor.BN(2 * web3.LAMPORTS_PER_SOL);

    // Act
    const becomeKingTx = await program.methods.becomeKing(newPrize)
      .accounts({
          gameState: gameStatePDA,
          king: initialKing.publicKey, // Correct usage of current king
          payer: newKing.publicKey, // New king who pays and becomes the king
          prizePool: prizePoolPDA,
          systemProgram: web3.SystemProgram.programId,
      })
      .signers([newKing]) // Signing by newKing
      .rpc();

    // Assert
    const initialKingBalanceAfter = await provider.connection.getBalance(initialKing.publicKey);

    const expectedBalance = initialKingBalanceBefore + new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toNumber();
    assert.ok(initialKingBalanceAfter >= expectedBalance, "Old king did not receive the funds back correctly");

    // Fetch the updated game state.
    const updatedGameState = await program.account.gameState.fetch(gameStatePDA);

    // Assertions to confirm the state has updated as expected.
    assert.equal(updatedGameState.king.toBase58(), newKing.publicKey.toBase58(), "King should be updated to newKing.");
    assert.equal(updatedGameState.prize.toString(), newPrize.toString(), "Prize should be updated to newPrize.");
  })
});

下面逐步解析所有内容。

首先,我们导入依赖并在 Anchor 中设置测试环境。本例使用 Mocha 和 Chai,通过 TypeScript 在 localhost 上进行测试:

代码
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KingOfTheHill } from "../target/types/king_of_the_hill";

import { assert } from "chai";

const web3 = require("@solana/web3.js");

我们使用 describe 对测试用例分组。我们还将客户端设置为使用本地集群,正确设置程序,依次初始化初始国王、新国王、游戏状态 PDA 和奖池 PDA 的变量,并创建一个实用函数来简化空投:

代码
describe("King of the Hill Tests", () => {
  // Configure the client to use the local cluster.
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.KingOfTheHill as Program<KingOfTheHill>;

  let initialKing, newKing;
  let gameStatePDA, prizePoolPDA;

  // Utility function for airdrops
  async function fundWallet(account, amount) {
    const publicKey = account.publicKey ? account.publicKey : account;

    await provider.connection.confirmTransaction(
      await provider.connection.requestAirdrop(publicKey, amount),
      "confirmed"
    );
  }

// Other code

});

接下来,我们使用 before 钩子设置初始国王和新国王的密钥对并为其注资,同时派生游戏状态和奖池的 PDA。此代码块会在测试用例前运行一次,让我们能更清晰地将每个用例简化为 AAA 模式:

代码
before(async () => {
    initialKing = web3.Keypair.generate();
    newKing = web3.Keypair.generate();

    await fundWallet(initialKing, 25 * web3.LAMPORTS_PER_SOL);
    await fundWallet(newKing, 30 * web3.LAMPORTS_PER_SOL);

    [gameStatePDA] = web3.PublicKey.findProgramAddressSync(
      [Buffer.from("game_state")],
      program.programId
    );

    [prizePoolPDA] = web3.PublicKey.findProgramAddressSync(
      [Buffer.from("prize_pool")],
      program.programId
    );
});

第一个测试用例非常简单——我们检查游戏能否正确初始化。我们先为游戏状态和奖池 PDA 注资,以便稍后与之交互,并将初始奖励设置为 1 SOL。然后调用 initialize 方法并传入 initialPrize。对于账户,我们传入游戏状态 PDA、初始国王、奖池 PDA 和系统程序。初始国王是此操作的签名者。最后,我们断言游戏状态中的国王和奖励是否已正确更新:

代码
it("Initializes the game correctly", async () => {
    // Arrange
    await fundWallet(gameStatePDA, 1 * web3.LAMPORTS_PER_SOL);
    await fundWallet(prizePoolPDA, 1 * web3.LAMPORTS_PER_SOL);

    let initialPrize = new anchor.BN(1 * web3.LAMPORTS_PER_SOL);

    // Act
    const tx = await program.methods
      .initialize(initialPrize)
      .accounts({
        gameState: gameStatePDA,
        initialKing: initialKing.publicKey,
        prizePool: prizePoolPDA,
        systemProgram: web3.SystemProgram.programId,
      })
      .signers([initialKing])
      .rpc();

    // Assert
    let gameState: any = await program.account.gameState.fetch(gameStatePDA);
    assert.equal(gameState.king.toBase58(), initialKing.publicKey.toBase58());
    assert.equal(
      gameState.prize.toString(),
      new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toString()
    );
});

下一个测试用例确保其他人可以成为国王。首先获取国王的初始余额,并将新奖励设置为 2 SOL。然后调用 becomeKing 函数并传入最新奖励金额。对于账户,我们传入游戏状态 PDA、当前国王的公钥、作为付款方的新国王、奖池 PDA 和系统程序。新国王被设为签名者。最后检查国王是否收回了此前为成为国王而贡献的初始 SOL,以及游戏状态是否已正确更新:

代码
it("Changes the king correctly", async () => {
    // Arrange
    const initialKingBalanceBefore = await provider.connection.getBalance(initialKing.publicKey);
    let newPrize = new anchor.BN(2 * web3.LAMPORTS_PER_SOL);

    // Act
    const becomeKingTx = await program.methods.becomeKing(newPrize)
      .accounts({
          gameState: gameStatePDA,
          king: initialKing.publicKey, // Correct usage of current king
          payer: newKing.publicKey, // New king who pays and becomes the king
          prizePool: prizePoolPDA,
          systemProgram: web3.SystemProgram.programId,
      })
      .signers([newKing]) // Signing by newKing
      .rpc();

    // Assert
    const initialKingBalanceAfter = await provider.connection.getBalance(initialKing.publicKey);

    const expectedBalance = initialKingBalanceBefore + new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toNumber();
    assert.ok(initialKingBalanceAfter >= expectedBalance, "Old king did not receive the funds back correctly");

    // Fetch the updated game state.
    const updatedGameState = await program.account.gameState.fetch(gameStatePDA);

    // Assertions to confirm the state has updated as expected.
    assert.equal(updatedGameState.king.toBase58(), newKing.publicKey.toBase58(), "King should be updated to newKing.");
    assert.equal(updatedGameState.prize.toString(), newPrize.toString(), "Prize should be updated to newPrize.");
})

在此测试场景中,我们检查了“山丘之王”程序的核心功能。通过单元测试,我们从细粒度层面验证了程序逻辑的完整性。通过测试国王能否正确变更,我们进一步确认了程序在模拟的真实条件下能够按预期运行。这些测试凸显了全面测试策略对于确保“山丘之王”程序质量和功能的重要性。 

总结

测试是开发安全、可靠且高效的 Solana 程序的基石。本文探讨了结合单元测试、集成测试和 E2E 测试的重要性,以覆盖程序开发生命周期的各个环节。通过整合这些方法,并利用 Bankrun、solana-program-test 和 solana-test-framework 等强大的测试框架,开发者可以显著提升 Solana 程序的质量。在你继续 Solana 开发之旅时,可以参考本文介绍的原则、实践和示例,构建稳健、高效且安全的程序。

如果你读到了这里,感谢你,匿名朋友!别忘了在下方输入邮箱地址,这样就不会错过 Solana 的任何最新动态。准备深入探索了吗?立即阅读 Helius 博客上的最新文章,继续你的 Solana 之旅。

其他资源

订阅 Helius

及时了解 Solana 开发的最新动态,并在我们发布新内容时收到更新

放大图片