Solana程序测试指南

  • Helius
  • 发布于 2024-04-06 18:25
  • 阅读 13

本文探讨了在Solana区块链环境中进行自动化测试的必要性,重点介绍了单元测试、集成测试和端到端测试的概念与实施。通过展示如何在Rust和TypeScript中编写基本单元测试,并分析流行的Solana测试框架,文章为开发者提供了全面的测试策略,以确保程序的安全性和可靠性。

27分钟阅读

2024年4月4日

介绍

在区块链环境中进行测试超越了传统的软件测试范式,引入了独特的挑战和更高的后果。在 Solana 的高吞吐量、低延迟环境下,容错空间很小。自动化测试不仅仅成为最佳实践,更是确保在 Solana 动态且无情的环境中运行的程序可靠性和安全性的基本必要条件。

本文探讨了自动化测试的核心类型——单元测试、集成测试和端到端 (E2E) 测试。它还探讨了如何在 JavaScript/TypeScript 和 Rust 中编写基本的单元测试,然后分析流行的 Solana 测试框架。最后,本文以一个测试“夺山头”游戏程序的实际例子结束。

注意:

本文假设读者具备 Solana 的编程模型和程序开发的知识。本文不会讲述构建程序或任何 Solana 特定概念的过程——我们专注于学习如何测试 Solana 程序。

如果你是 Solana 新手,建议你首先阅读以下之前的博客文章:

本文也补充了我们之前关于 Solana 程序安全性的文章。建议同时阅读这两篇文章。

什么是测试?

手动测试和自动化测试

测试是为了验证一段代码或整个应用程序是否按预期工作。一般有两种测试类型:

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

手动测试是一个高度灵活的过程,与被测试的应用程序类型无关。它适合测试新特性、可用性和无障碍性。手动测试依赖于测试人员对应用程序应如何表现的直觉。然而,由于缺乏工具支持,手动测试本质上缓慢、容易出错、耗时且往往不完整(即,不覆盖所有场景)。

自动化测试旨在解决手动测试的缺点。例如,它通常更快,特别是当测试并行执行时。由于遵循预定义的脚本,自动化测试不太容易出错。它提高了测试覆盖率,因为能够有效处理大量测试用例,使其成为一种高度可扩展的解决方案。然而,由于其刚性客观性,对于依赖人类交互、判断或批判性推理的测试,自动化测试的准确性较低。

出于本文目的,我们集中于 Solana 程序的自动化测试,因为在主网手动测试是非常昂贵的,而在开发网手动测试又非常耗时。然而,手动和自动化测试都应该是将代码发布到生产环境之前测试过程的一部分。一个强大的测试过程可以通过在开发早期识别问题,尽量减少引入到生产环境中的漏洞数量。

有多种类型的自动化测试,即:

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

单元测试

单元测试是一个过程,通过测试代码的最小功能单位来确保它们正确工作。理想情况下,单位是程序中最小的构建块(例如,单个函数、模块),它们组合在一起形成最终产品。核心思想是,如果我们全面测试其构建块,整体程序应该可以按预期工作。

单元测试是 Solana 开发的基础,因为它可以确保给定程序的每个部分按预期行为运行。这些测试的可重用性确保新特性或更新遵循项目的规范和用户期望,因为它们在测试用例中定义。因此,单元测试本质上鼓励代码优化和重构,以便新改进不会对程序的功能产生不利影响。单元测试不仅保证每个代码段在各种测试条件下正确执行,还确保高效和安全的区块链交互。通过单元测试及早发现漏洞至关重要,因为这可以防止潜在的漏洞进入生产环境。

各种测试框架通过简化模拟网络条件和管理程序状态的过程,帮助简化单元测试,下面我们会进一步探讨。Solana 开发人员通过单元测试可以实现高度的代码可靠性和性能。

集成测试

集成测试超越单元测试,检查程序中不同单位如何协同工作。验证程序的功能和模块如何协同工作,对 Solana 开发至关重要,因为程序交互通常复杂且存在财务后果。集成测试旨在识别和解决在单独测试单位时不容易发现的问题,而这些问题在组件交互时出现。这些问题可能包括数据格式不匹配、类型不一致、程序依赖或第三方 API 的问题。

在 Solana 的上下文中,程序本质上与互通的程序、钱包和预言机进行交互,集成测试验证这些交互是否按预期发生。尽管每个单元功能正常,它们的组合可能会引入意想不到的行为或在这些模拟条件下的低效。开发人员可以使用不同的测试框架来模拟不同的事务流和程序交互,紧密模拟现实世界场景。例如,Bankrun 是一个健壮的轻量级测试框架,可以让开发人员在时间之间来回跳转并动态设置账目信息。这些特性在使用 solana-test-validator 时是做不到的。集成测试对于确保程序强健、可靠,能够满足 Solana 网络条件的需求至关重要。

端到端 (E2E) 测试

端到端 (E2E) 测试是测试过程的巅峰。它专注于评估程序的完整操作流程,就像在现实世界场景中一样。此测试方法不同于单元测试和集成测试,因为它从用户的角度检查程序——用户所遇到的所有可能流程和功能都应该按预期工作。

E2E 测试对于验证程序是否符合其功能要求并提供无缝的用户体验至关重要。此测试阶段有助于发现可能在单元或集成测试中未明显可见的问题,例如,交易处理延迟、持久状态的问题、计算单元优化或意想不到的网络条件。尽管 E2E 测试通常适用于测试整个 dApp,但对程序操作流程的测试以及验证用户的交易如何与各个功能和模块交互,对于构建成功且安全的程序至关重要。

结合这些测试方法

结合测试方法

采用分层测试策略并结合单元、集成和 E2E 测试在你的开发过程中至关重要。每种测试方法在开发生命周期中扮演着不同的角色,解决程序功能和性能的不同方面。

单元测试是分层测试方法的基础,使开发人员能够快速识别和解决代码层面的最小问题。虽然它在确保单个函数或模块的客观正确性方面表现出色,但它未考虑这些单元如何协同工作或它们如何融入用户体验。

集成测试填补了这个空白,通过评估不同单元之间的相互作用来揭示整合这些组件时出现的问题。然而,单靠这一步骤可能无法完全捕捉最终用户的体验或程序在现实条件下的行为。

E2E 测试通过模拟现实世界用户场景并整体测试应用程序来补充单元和集成测试。这种方法对于评估整体用户体验是非常有价值的,但它未能提供任何细粒度的洞察,以快速识别和解决具体问题。

通过整合这些方法,开发人员可以创建一个全面的测试框架,覆盖潜在问题的全谱。不仅全面的方法提高了程序的质量和安全性,还简化了开发过程。开发人员可以快速做出知情的决策和修正,确信他们的更改将在多个层面上进行检查。结合这些方法至关重要,以确保 Solana 程序在部署前在技术上是健壮的并符合现实条件下用户的期望。

编写良好的测试

编写有效的测试对于开发可靠且安全的 Solana 程序至关重要。良好测试的本质在于关注被测试的行为,而非所使用的框架或代码的实现细节。开发人员可以通过结合测试驱动开发 (TDD)、安排-行动-断言 (AAA) 模型和行业最佳实践,创建一种高效的测试策略,从而提高代码质量。

测试驱动开发 (TDD)

TDD 是一种强大的软件开发方法,指导方针是编写测试。即,主要的思想是在编写实际代码之前先编写测试。TDD 循环通常涉及三个步骤:

  • 编写一个失败的测试:开发过程应从编写一个测试开始,该测试是用于下一项功能的。因为它正在测试的功能还不存在,所以测试必然失败
  • 实现代码:开发人员应仅编写最少量的代码,以使测试通过。这里的目标是速度和简洁性
  • 重构:一旦测试通过,开发人员应重构代码以改善其结构和清晰度,而不改变其行为。通过的测试充当引入任何重大变更的安全网。这一步骤可以包括删除重复代码、将方法拆分为更小的单位、重新安排继承层次或使名称自我文档化。在 Solana 的背景下,这一步骤可能涉及优化给定交易请求的计算单元数量,减少 CPI 数量或简化交易流程。

虽然 TDD 对于构建 良好 Solana 程序并非必要,但开发人员应考虑其理念——它促进了构建程序的细致方法,其迭代方法培养了灵活适应的开发过程,并且它与在程序开发中对精确性、安全性和高效性的需求完美契合。TDD 鼓励开发人员编写更清晰、更专注的代码,这些代码针对 Solana 的独特网络和性能要求(例如,优化计算单元)进行了优化。

安排-行动-断言 (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"]);
    }
}

这个示例展示了我们的 tests 模块是如何用 #[cfg(test)] 属性注解的。在模块内,我们用 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] 属性,用于标记在特定条件下预计会恐慌的测试。这对于测试错误处理路径和指定预期的恐慌消息非常有用:

代码

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

与许多其他语言不同,Rust 允许直接测试私有函数。这对进行更详细的单元测试是有益的,因为代码的每个功能都可以通过单元测试来覆盖。

Rust 还支持更高级的测试组织技巧:

  • 嵌套模块:对于复杂的项目,测试可以组织成嵌套模块,允许创建清晰的层次结构以镜像项目的组织
  • 基于结果的测试:Rust 支持测试返回 Result<(), E> 类型的能力。这允许开发人员在测试中使用 ? 运算符,从而实现更具表现力的错误处理

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

TypeScript 已经成为测试程序的流行选择,特别是在 Anchor 完全主导了 Solana 上的 Rust 开发。使用 anchor init 命令,新的 Anchor 项目默认初始化了 Mocha 测试框架Chai 断言库

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

Chai 是一个可以与任何 JavaScript 测试框架(例如 Mocha)配对使用的断言库。它提供了一系列函数,使开发人员能够以可读的风格表达断言。Chai 的 expectshouldassert 接口允许开发人员编写全面的测试,这些测试在编写和阅读时都非常直观。它使用 语言链(即可链式调用的获取器),使断言的可读性得以提升。通过 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 开发人员可以确保程序按预期可靠地运行。

例如,假设你在开发一个程序,允许用户从保管库存入和提取 SOL。在这种情况下,使用 Mocha 和 Chai 编写的测试可能如下所示,以确保存入功能正常工作:

代码

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 函数,一个假定的工具函数,用于获取保管库当前的余额。

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

TypeScript 的静态类型系统有时可能使编写测试变得有些棘手,特别是在处理复杂或定义不明确类型时。使用类型断言来避免测试中的类型相关问题。然而,确保这些断言不会掩盖由于类型错误可能导致的潜在运行时错误。

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

Mocha 提供了 onlyskip 方法,以独占或跳过特定测试。尽管在开发过程中这很方便,但很容易意外将这些提交到生产中,导致测试运行不完整。在将测试发布到生产环境之前,始终检查测试中是否有 onlyskip。此外,在使用 Mocha 的Hook(即 beforeEachafterEachbeforeafter)处理异步代码时要小心。确保通过使用 async/ await 来正确处理 Promise,或者调用 done 回调方法,以避免未解决的的 Promise 或未调用的回调。

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

流行的 Solana 测试框架

Bankrun

一个 bank 负责跟踪客户端账户、管理程序执行并保持 Solana 账本的完整性与进展。它本质上是给定时间的账本快照,封装了由特定区块交易导致的状态。

Bankrun 是一个为 Solana 程序编写的轻量级、灵活的测试框架,使用 Node.js 开发。它强调易用性和速度,使开发人员能够快速编写和运行程序的测试。Bankrun 的真正价值在于 它是一个测试框架,赋予开发人员在受控、高效的环境中模拟和与 Solana 银行交互的能力。Bankrun 通过模拟 Solana 银行的动态,简化了测试过程,而不需要通常与设置这种环境相关的开销。

Bankrun 的设计基于轻量级的 BanksServer,该服务器模仿了 RPC 节点的行为,但性能和灵活性显著增强。开发人员可以通过 BanksClient 与此服务器进行交互。此客户端提供了一整套工具,其中包含用于检索账户余额、事务状态和模拟事务的方法。值得注意的是,tryProcessTransaction 方法允许在不抛出任何 JavaScript 错误的情况下处理预期会失败的事务。这使开发人员能够断言特定的失败模式或直接记录消息。

Meta-DAO 的 Futarchy GitHub 仓库 是使用 Bankrun 测试生产代码的一个很好的例子。

与 Anchor 集成

使用 startAnchor,将 Bankrun 与 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 类作为 AnchorProvider 的替代品来启动 Anchor 和 Bankrun。

使用 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 的速度、灵活性和专业级功能使其成为大多数开发场景的首选,尤其是那些需要快速迭代或详细模拟的场景。然而,solana-test-validator 在需要真实验证者行为和使用 BanksServer 不支持的 RPC 方法的测试中仍然适用。

solana-program-test

solana-program-test crate 是为 Solana 程序设计的 Rust 基础测试框架。该框架围绕 BanksClient 构建。它模拟 Solana 银行的操作,使开发人员能够在与主网类似的测试条件下部署、交互和评估他们的程序行为,类似于 Bankrun。辅助 BanksClient 的是 ProgramTest 结构,这是用于初始化测试环境的工具。也就是说,它便于开发指定的程序和设置所需的账户。其他结构如 BanksTransactionResultWithMetadataProgramTestContext 提供丰富的交易处理的上下文和见解,增强整体调试和验证过程。

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

  • SPL Token(及其 2022 版本)
  • SPL Memo(1.0 和 3.0 版本)
  • SPL 关联代币账户

这些预加载程序提供了更快和更专注的测试设置,因为它们不需要手动设置这些常见程序。

Marginfi GitHub 仓库有多个 solana-program-test 的实现示例,其代码为生产就绪提供了支持。Bonfida 的开发指南则详细概述了使用 solana-program-test 框架编写集成测试

solana-test-framework

solana-test-frameworksolana-program-test 的扩展,旨在通过扩展 BanksClientRpcClientProgramTestProgramTestContext 提供多种便利方法。和 Bankrun 一样,ProgramTestContext 的扩展允许开发人员在高级测试场景中跳转到特定时间戳并更新预言机价格。

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

  • 事务管理:通过 transaction_from_instructions 简化事务的组装、签名和支付
  • 账户反序列化:通过 get_account_with_anchorget_account_with_borsh,方便检索和反序列化 Anchor 和 Borsh 账户
  • 账户创建和程序部署:开发人员可以通过 create_accountcreate_token_mintcreate_token_accountdeploy_program 函数,简化测试环境的设置。

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

示例测试场景

程序

以下程序为例:

代码

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<()> {
        // 如果第一个出手的人没有发送任何 SOL 作为初始奖金
        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,
        );
``````rust
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,
        );

        // 将新的国王的资金发送到奖池
        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(),\
            ],
        )?;

        // 将旧国王的资金退回
        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: 这是可以的 - 它是一个用于存储 SOL 的 PDA,不需要数据布局
    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: 这是可以的 - 它只接收 SOL,我们不需要任何其他访问
    pub king: UncheckedAccount<'info>,
    #[account(mut)]
    pub payer: Signer<'info>,
    #[account(\
        mut,\
        seeds = [b"prize_pool"],\
        bump,\
    )]
    /// CHECK: 这是可以的 - 它是一个用于存储 SOL 的 PDA,不需要数据布局
    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("初始奖金必须大于零")]
    NeedAnInitialPrize,
    #[msg("出价必须高于当前奖金")]
    BidTooLow,
    #[msg("无效的奖池账户")]
    InvalidPrizePoolAccount,
}

该程序实现了一个简单的“山顶之王”游戏在 Solana 上。该游戏允许用户通过向奖池发送超过当前国王所发送的 SOL 来成为“国王”。当新的国王取代他们时,先前国王发送的 SOL 会被转回给他们。

程序的功能如下:

  • Initialize:该函数设置游戏的初始国王(即第一个初始化游戏的玩家)和初始奖金数额。初始奖金必须大于零。然后将初始奖金从初始国王转移到奖池。
  • Become King:该函数允许新玩家通过出价高于当前奖金的 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("山顶之王测试", () => {
  // 配置客户端以使用本地集群。
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

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

  let initialKing, newKing;
  let gameStatePDA, prizePoolPDA;

  // 用于空投的工具函数
  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("正确初始化游戏", async () => {
    // 安排
    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);

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

    // 断言
    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("正确更改国王", async () => {
    // 安排
    const initialKingBalanceBefore = await provider.connection.getBalance(initialKing.publicKey);
    let newPrize = new anchor.BN(2 * web3.LAMPORTS_PER_SOL);

    // 行动
    const becomeKingTx = await program.methods.becomeKing(newPrize)
      .accounts({
          gameState: gameStatePDA,
          king: initialKing.publicKey, // 正确使用当前国王
          payer: newKing.publicKey, // 支付并成为国王的新国王
          prizePool: prizePoolPDA,
          systemProgram: web3.SystemProgram.programId,
      })
      .signers([newKing]) // 由 newKing 签名
      .rpc();

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

    const expectedBalance = initialKingBalanceBefore + new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toNumber();
    assert.ok(initialKingBalanceAfter >= expectedBalance, "旧国王没有正确收到资金退回");

    // 获取更新后的游戏状态。
    const updatedGameState = await program.account.gameState.fetch(gameStatePDA);

    // 断言以确认状态已按预期更新。
    assert.equal(updatedGameState.king.toBase58(), newKing.publicKey.toBase58(), "国王应该更新为新国王。");
    assert.equal(updatedGameState.prize.toString(), newPrize.toString(), "奖金应该更新为新奖金。");
  })
});

让我们将所有内容分解。

首先,我们从导入和在 Anchor 中设置测试环境开始。对于这个例子,我在本地主机上使用 TypeScript 测试 Mocha 和 Chai:

代码

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("山顶之王测试", () => {
  // 配置客户端以使用本地集群。
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

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

  let initialKing, newKing;
  let gameStatePDA, prizePoolPDA;

  // 用于空投的工具函数
  async function fundWallet(account, amount) {
    const publicKey = account.publicKey ? account.publicKey : account;

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

// 其他代码

});

接下来,我们使用 before Hook 来设置和为初始国王和新国王密钥对提供资金,以及推导游戏状态和奖池的 PDAs。此块将在测试用例之前运行,使我们能够更清晰地将每个用例简化为 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("正确初始化游戏", async () => {
    // 安排
    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);

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

    // 断言
    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("正确更改国王", async () => {
    // 安排
    const initialKingBalanceBefore = await provider.connection.getBalance(initialKing.publicKey);
    let newPrize = new anchor.BN(2 * web3.LAMPORTS_PER_SOL);

    // 行动
    const becomeKingTx = await program.methods.becomeKing(newPrize)
      .accounts({
          gameState: gameStatePDA,
          king: initialKing.publicKey, // 正确使用当前国王
          payer: newKing.publicKey, // 支付并成为国王的新国王
          prizePool: prizePoolPDA,
          systemProgram: web3.SystemProgram.programId,
      })
      .signers([newKing]) // 由 newKing 签名
      .rpc();

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

    const expectedBalance = initialKingBalanceBefore + new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toNumber();
    assert.ok(initialKingBalanceAfter >= expectedBalance, "旧国王没有正确收到资金退回");

    // 获取更新后的游戏状态。
    const updatedGameState = await program.account.gameState.fetch(gameStatePDA);

    // 断言以确认状态已按预期更新。
    assert.equal(updatedGameState.king.toBase58(), newKing.publicKey.toBase58(), "国王应该更新为新国王。");
    assert.equal(updatedGameState.prize.toString(), newPrize.toString(), "奖金应该更新为新奖金。");
})

在这个测试场景中,我们检查了“山顶之王”程序的核心功能。通过单元测试,我们验证了程序逻辑的完整性。通过验证国王更改的准确性,我们进一步确认程序在模拟的现实条件下按预期运行。这些测试强调了在确保“山顶之王”程序的质量和功能时全面测试策略的重要性。

结论

测试是开发安全、可靠和高效的 Solana 程序的基石。在本文中,我们探讨了结合单元测试、集成测试和 E2E 测试的重要性,以覆盖程序开发生命周期中的所有方面。通过整合这些方法并利用强大的测试框架,如 Bankrun、solana-program-testsolana-test-framework,开发人员可以显著提升他们的 Solana 程序的质量。在继续你的 Solana 开发者旅程时,让本文中讨论的原则、实践和示例指导你创建稳健、高效和安全的程序。

如果你已经阅读到这里,感谢你,匿名人士!请确保在下面输入你的电子邮件地址,以便你不会错过有关 Solana 的新动态。准备深入了解吗?探索 Helius 博客 上的最新文章,继续你的 Solana 之旅,今天就开始吧。

其他资源

  • 原文链接: helius.dev/blog/a-guide-...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Helius
Helius
https://www.helius.dev/