新着:HeliusがLight Protocolを買収
Solanaプログラムのテストガイド
ブログ/開発

Solanaプログラムのテストガイド

Developer Experience EngineerXの0xIchigoLinkedInの0xIchigoGitHubの0xIchigo
読了時間:27分

はじめに

ブロックチェーン環境でのテストは、従来のソフトウェアテストの枠を超え、特有の課題とより重大な結果を伴います。Solanaは高スループットかつ低レイテンシの環境であるため、許容されるエラーの幅はわずかです。自動テストは単なるベストプラクティスではなく、動的で厳しいSolana環境で動作するプログラムの信頼性とセキュリティを確保するための不可欠な要件です。

この記事では、自動テストの主要な種類である単体テスト、統合テスト、エンドツーエンド(E2E)テストについて説明します。また、一般的なSolanaテストフレームワークを分析する前に、JavaScript/TypeScriptとRustで基本的な単体テストを作成する方法も解説します。最後に、「King of the Hill」ゲームプログラムをテストする実践的な例を紹介します。

Solanaを初めて使用する場合は、まず以下の過去の記事を読むことをおすすめします。

この記事は、Solanaプログラムのセキュリティに関する以前の記事を補完する内容でもあります。2つの記事を併せて読むことをおすすめします。

テストとは?

テストは、コードの一部またはアプリケーション全体が意図したとおりに動作することを確認するために行います。テストには、大きく分けて2つの種類があります。

  • 手動テスト:開発者、品質保証アナリスト、ペネトレーションテスター、その他の担当者がテストケースを実行する、人を中心としたプロセスです
  • 自動テスト:事前定義されたテストケースをプログラムで実行するスクリプトを作成する、コード中心のプロセスです

手動テストは、テスト対象のアプリケーションの種類に依存しない、柔軟性の高いプロセスです。新機能、ユーザビリティ、アクセシビリティのテストに適しています。手動テストでは、アプリケーションがどう動作すべきかについて、テスターの直感に頼ります。しかし、テストプロセスで使用できるツールが少ないため、本質的に遅く、ミスが生じやすく、時間がかかるうえ、多くの場合は不完全です(つまり、すべてのシナリオを網羅できません)。 

自動テストは、手動テストの欠点を解消することを目指します。たとえば、一般的にはより高速であり、特にテストを並列実行する場合に効果的です。自動テストは事前定義されたスクリプトに従うため、人為的ミスが起こりにくくなります。多数のテストケースを効率的に処理できるため、テストカバレッジが向上し、高いスケーラビリティを実現できます。ただし、客観的で柔軟性に欠けるため、人間の操作、判断、批判的思考に依存するテストでは、自動テストの精度が低くなります。

この記事では、Solanaプログラムの自動テストに焦点を当てます。mainnetで手動テストを行うと非常にコストがかかり、devnetでの手動テストにも多くの時間が必要になるためです。ただし、本番環境にコードをリリースする前のテストプロセスには、手動テストと自動テストの両方を含める必要があります。堅牢なテストプロセスを整備すれば、開発の早い段階でバグを特定し、本番環境に混入するバグを最小限に抑えられます。

自動テストには、主に以下の種類があります。

  • 単体テスト
  • 統合テスト
  • エンドツーエンド(E2E)テスト

単体テスト

単体テストは、コードを構成する最小の機能単位をテストし、正しく機能することを確認するプロセスです。理想的には、単位とはプログラムを構成する最小限の要素(個々の関数やモジュールなど)であり、それらを組み合わせることで完成品になります。その中核となる考え方は、構成要素を徹底的にテストすれば、プログラム全体も意図したとおりに動作するはずだというものです。 

単体テストは、特定のプログラムの各部分が意図したとおりに動作することを保証するため、Solana開発の基盤となります。テストを再利用することで、新機能やアップデートが、テストケースで定義されたプロジェクトの仕様とユーザーの期待に沿っていることを確認できます。そのため単体テストは、新しい改善によってプログラムの機能が損なわれないよう、コードの最適化とリファクタリングを自然に促します。単体テストは、各コードセグメントがさまざまなテスト条件下で正しく動作することだけでなく、ブロックチェーンとのやり取りが効率的かつ安全であることも保証します。単体テストによってバグを早期に検出することは、潜在的な脆弱性が本番環境に到達するのを防ぐうえで極めて重要です。 

さまざまなテストフレームワークを使うと、ネットワーク状態のシミュレーションやプログラム状態の管理が容易になり、単体テストを効率化できます。これについては、この記事の後半で説明します。Solana開発者は、単体テストを通じてコードの高い信頼性とパフォーマンスを実現できます。

統合テスト

統合テストは単体テストから一歩進み、プログラムの異なる単位がどのように連携するかを検証します。プログラム間のやり取りが複雑で、金銭的な影響を伴うことが多いSolana開発では、プログラムの関数やモジュールが連携して動作することを確認するのが不可欠です。統合テストは、単位を個別にテストしたときにはすぐに判明せず、コンポーネント同士を連携させたときに現れる問題を特定し、解決することを目的とします。こうした問題には、データ形式の不一致、型の不整合、プログラムの依存関係、サードパーティAPIの問題などがあります。

プログラムが本質的に他のプログラム、ウォレット、オラクルと連携するSolanaでは、統合テストによって、これらのやり取りが意図したとおりに行われることを確認します。各単位が問題なく機能していても、それらを組み合わせると、シミュレーション環境下で予期しない動作や非効率性が現れる可能性があります。開発者は各種テストフレームワークを使って、さまざまなトランザクションフローやプログラム間のやり取りをシミュレーションし、現実のシナリオを忠実に再現できます。たとえば、Bankrunは、時間を前後に移動し、アカウントデータを動的に設定できる、堅牢で軽量なテストフレームワークです。こうした操作は、solana-test-validatorでは実現できません。統合テストは、プログラムが堅牢かつ信頼でき、Solanaのネットワーク条件に対応できる状態であることを保証するうえで不可欠です。

エンドツーエンド(E2E)テスト

エンドツーエンド(E2E)テストは、テストプロセスの集大成です。現実のシナリオで発生するような、プログラムの完全な動作フローの評価に重点を置きます。このテスト手法は、ユーザーの視点からプログラムを検証する点で、単体テストや統合テストと異なります。エンドユーザーが遭遇する可能性のあるすべてのフローと機能が、意図したとおりに動作する必要があります。 

E2Eテストは、プログラムが機能要件を満たし、シームレスなユーザー体験を提供することを確認するために不可欠です。このテスト段階では、トランザクション処理の遅延、状態の永続化に関する問題、コンピュートユニットの最適化、予期しないネットワーク状態など、単体テストや統合テストでは明らかにならなかった問題を発見できます。E2Eテストは通常、dApp全体のテストに適用されますが、プログラムの動作フローをテストし、ユーザーのトランザクションが各種関数やモジュールとどのように連携するかを確認することは、成功する安全なプログラムの構築に欠かせません。

これらのテスト手法の組み合わせ

開発プロセスでは、階層化されたテスト戦略を採用し、単体テスト、統合テスト、E2Eテストを組み合わせることが重要です。各テスト手法は開発ライフサイクルで明確に異なる役割を担い、プログラムの機能とパフォーマンスのさまざまな側面に対応します。

単体テストは階層型テストアプローチの基盤であり、開発者が最も細かいコードレベルで問題をすばやく特定し、解決できるようにします。個々の関数やモジュールが客観的に正しいことを確認する点では優れていますが、それらの単位がどう連携するか、ユーザー体験にどう組み込まれるかまでは考慮できません。

統合テストは、異なる単位がどのように連携するかを評価し、コンポーネントの統合時に発生する問題を明らかにすることで、この隔たりを埋めます。ただし、統合テストだけでは、エンドユーザーの体験や現実の条件下でのプログラムの動作を完全には把握できない場合があります。

E2Eテストは、現実のユーザーシナリオをシミュレーションし、アプリケーション全体をテストすることで、単体テストと統合テストの両方を補完します。このアプローチはユーザー体験全体の評価に非常に有用ですが、特定の問題をすばやく特定して解決するために必要な詳細情報は得られません。

これらの手法を統合することで、開発者は潜在的な問題を幅広く網羅する堅牢なテストフレームワークを構築できます。包括的なアプローチは、プログラムの品質とセキュリティを高めるだけでなく、開発プロセスも効率化します。変更が複数のレベルで検証されるという確信を持って、開発者は情報に基づく判断と修正をすばやく行えます。デプロイ前にSolanaプログラムが技術的に健全であり、現実の条件下でユーザーの期待に沿うことを保証するには、これらの手法を組み合わせることが不可欠です。

優れたテストの書き方

効果的なテストの作成は、信頼性と安全性に優れたSolanaプログラムを開発するために欠かせません。優れたテストの本質は、使用するフレームワークやコードの実装詳細ではなく、テスト対象の動作に焦点を当てることにあります。テスト駆動開発(TDD)、Arrange-Act-Assert(AAA)パターン、業界のベストプラクティスを取り入れることで、コード品質を高める効率的なテスト戦略を構築できます。

テスト駆動開発(TDD)

TDDは、テストの作成を軸とする堅牢なソフトウェア開発手法です。つまり、実際のコードを作成する前にテストを書くことが基本的な考え方です。TDDのサイクルは通常、次の3つのステップで構成されます。

  • 失敗するテストを書く:開発者が次に追加したい機能のテストを書くことから開発を始めます。テスト対象の機能はまだ存在しないため、必然的にテストは失敗します
  • コードを実装する:テストを通過させるために必要な最小限のコードを作成します。ここでの目標は、スピードとシンプルさです
  • リファクタリングする:テストが通過したら、動作を変えずにコードの構造と明瞭さを改善します。通過したテストは、破壊的変更の混入を防ぐセーフティネットとして機能します。このステップには、重複コードの削除、メソッドの小さな単位への分割、継承階層の再編成、名前だけで目的が分かるようにすることなどが含まれます。Solanaの場合は、特定のトランザクションで要求するCU数の最適化、CPI数の削減、トランザクションフローの効率化などが該当します。

優れたSolanaプログラムを構築するうえでTDDは必須ではありませんが、その考え方は検討する価値があります。TDDはプログラム構築への綿密なアプローチを促進し、反復的な手法によって柔軟で適応性の高い開発プロセスを実現します。また、プログラム開発に求められる精度、セキュリティ、効率性にも完全に合致します。TDDは、Solana固有のネットワーク要件とパフォーマンス要件に合わせて最適化された、より簡潔で焦点の定まったコード(CUの最適化など)を開発者が作成するよう促します。 

Arrange-Act-Assert(AAA)パターン

AAAパターンは、明確で簡潔かつ効果的なテストを作成するための、シンプルでありながら強力な構造を提供します。その中核では、テスト作成を3つの明確なフェーズに分ける規律あるアプローチを推奨します。

  • Arrange(準備):まずテスト環境をセットアップし、関連する入力を準備します。これには、アカウントの生成、アカウント残高のシミュレーション、命令の準備などが含まれます。目的は、動作をテストする条件を再現した、制御可能なシナリオを作成することです
  • Act(実行):テスト対象の動作を実行します。ここでは、テストしたい動作を引き起こすアクションに焦点を当てます。たとえば、アカウントyを渡して関数xを呼び出すとどうなるでしょうか?
  • Assert(検証):アクションの結果を期待する結果と比較して評価します。このステップは、テストの成否を確認するうえで不可欠です。アサーションには、単純な値の確認から、複数の状態変更を伴う複雑な検証まであります。これらのアサーションをどう実装するかは、最終的には使用するフレームワークやプロトコルによって異なります。たとえば、Lighthouseは、望ましくない状態、偽装されたシミュレーション結果、過剰支出などを特定するためのアサーション命令をトランザクションに追加できるプログラムです。Lighthouseの利点と詳細については、別の記事でさらに詳しく説明します。

AAAパターンの強みは適応性にあり、単体テスト、統合テスト、E2Eテストのすべてに役立ちます。たとえば、次のように使用できます。

  • 単体テスト:特定の関数のテストでは、プログラムの状態をセットアップして準備し、関数を呼び出して実行し、関数の戻り値または結果として生じる状態変更を確認して検証します
  • 統合テスト:複数のプログラム間のやり取りをテストする場合、プログラムをデプロイして初期状態を設定することで準備し、関連するトランザクションを実行し、関係する各プログラムの最終状態を確認して検証します
  • E2Eテスト:プログラムのE2Eテストでは、プログラムの状態をセットアップして準備し、想定されるユーザーフロー全体(アカウントの作成、提案の作成、その提案への投票、提案の投票フェーズの終了など)を進めて実行し、フローの結果を期待する結果と比較して検証します

AAAパターンはプログラム開発に不可欠です。プログラムが意図したとおりに動作するかをテストするために必要な、動作中心のアプローチを徹底できます。AAAに沿って構成されたテストは、各ステップがセットアップ、アクション、検証に明確に分かれているため、理解しやすく保守も容易です。さらにAAAは、特定の動作ややり取りに焦点を当てた、独立性が高く疎結合なテストの作成を促進します。

業界のベストプラクティス

優れたテストの作成は、Solana開発に特有のものではありません。ソフトウェア開発全般から得られた知見をSolanaプログラムのテストに応用し、実装の詳細にとらわれるのではなく、意図した動作のテストに集中できます。 

たとえば、単体テストでは通常、メソッドの公開インターフェースを対象にし、特定の引数を渡して、期待どおりの結果になることを確認します。このアプローチでは、動作が一貫している限り、メソッドの内部実装が変わっても単体テストは有効なままです。Solana開発では、プログラムの外部動作に影響を与えないロジック変更であれば、テストのリファクタリングは不要であることを意味します。

また、単体テスト作成時によくある落とし穴は、テスト対象メソッドの内部動作に過度に依存させることです。たとえば、特定のプライベートメソッドが決まった回数呼び出されることや、特定の方法で実装されていることを前提にする場合です。このようなテストは非常に脆弱で、テスト対象メソッドの実際の動作が変わっていなくても、コードをリファクタリングするだけで失敗しやすくなります。代わりに、外部から観察できるメソッドの結果と副作用に焦点を当てる必要があります。ここでは、内部メカニズムに過度に依存せず、テストの包括性を確保するためにコードカバレッジツールを活用できます。

Solana開発にこれらのベストプラクティスを取り入れると、プログラムの堅牢性と適応性が向上します。実装の詳細ではなく動作のテストに焦点を当てることで、より耐障害性が高く保守しやすいコードを作成できます。これは、Solanaのような動的なネットワーク環境にコードをデプロイする際に不可欠です。このアプローチにより、プログラムロジックを変更しても大規模な再テストが不要になり、プログラムをデプロイ可能な状態に整えられます。

それでは、実際にテストを書いてみましょう。

基本的な単体テストの作成

Rustでの単体テスト

Rustは単体テストに独自のアプローチを採用しており、コードと同じファイル内にテストを配置することを推奨しています。これはtestsモジュールを通じて行い、#[cfg(test)]属性によって制御します。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
}

この関数は、特定のアカウントの現在残高と、予想されるトランザクション手数料の2つの引数を受け取ります。アカウント残高がトランザクション手数料を支払うのに十分であれば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を返すことをアサートします。2つ目のテストでは、アカウント残高がトランザクション手数料より少なく、トランザクションを支払うための資金が不足している場合に、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は、より高度なテスト整理手法にも対応しています。

  • ネストされたモジュール:複雑なプロジェクトでは、テストをネストされたモジュールとして整理し、プロジェクトの構成を反映した明確な階層構造を作成できます
  • Resultベースのテスト:Rustでは、テストからResult<(), E>型を返せます。これにより、開発者はテスト内で?演算子を使用し、より表現力の高いエラー処理を実現できます

MochaとChaiを使用したTypeScriptでの単体テスト

Solana上のRust開発においてAnchorが事実上の共通言語として圧倒的に普及したことに伴い、プログラムのテストには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パターンに従っています。

  • Arrange(準備):ここでは、anchor.setProvider(anchor.AnchorProvider.env());が、環境のデフォルトプロバイダー(通常はローカルのSolanaテストvalidator)を使用するようAnchorクライアントを設定します。次に、programのconst宣言によってテスト対象プログラムのインスタンスを初期化し、テスト内でそのメソッドを呼び出せるようにします
  • Act(実行):テストケース“Is initialized!”では、プログラムのinitializeメソッドを呼び出し、トランザクションを送信します
  • Assert(検証):このテストケースでは、アサーションを行わずにトランザクション署名をログに記録しています。通常は、この段階でChaiによるアサーションを追加します。非常に基本的な例として、デフォルトのテストコードにexpect(tx).to.be.a(“string”);のようなアサーションを追加できます。より詳細なテストでは、初期化後にプログラムの状態を取得して調べ、期待値と一致することをアサートします

MochaとChaiの組み合わせに、Anchorプロジェクトでデフォルト設定されるAAAパターンを加えることで、プログラムをテストするための堅牢なフレームワークが実現します。Solana開発者は、テスト環境を明確に準備し、プログラムメソッドを呼び出して実行し、結果を検証することで、プログラムが予測可能かつ確実に機能することを確認できます。

たとえば、Solana上の保管庫に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);
        });
    });
});

この例では、保管庫へのSOLの入金を処理する仮想的なdepositSOL関数をテストします。入金後に保管庫の残高が正しい金額だけ増加することをアサートしています。保管庫の現在残高を取得するユーティリティ関数として、getVaultBalanceを使用しています。

MochaとChaiを使用したTypeScriptでのテストに関するその他の重要な注意点

TypeScriptの静的型システムにより、特に複雑な型や明確に定義されていない型を扱う場合、テストの作成が少し難しくなることがあります。テストで型に関する問題を回避するには、型アサーションを使用します。ただし、不正な型によって発生し得る実行時エラーを、これらのアサーションで覆い隠さないようにしてください。

TypeScriptでオブジェクトや関数をモックする場合は、モックした要素が正しい型に準拠していることを確認してください。ts-sinonやts-mockitoなどのライブラリを使うと、型安全なモックを作成でき、テストの正確性を保ちながらプログラムの実際の動作を反映できます。

Mochaには、特定のテストだけを実行したりスキップしたりするためのonlyメソッドとskipメソッドがあります。開発中には便利ですが、誤って本番環境向けのコードにコミットし、不完全なテスト実行につながる可能性があります。本番環境にテストをプッシュする前に、onlyまたはskipがないか必ず確認してください。また、Mochaのフック(beforeEach、afterEach、before、after)を非同期コードで使用する場合は注意が必要です。未解決のPromiseや呼び出されないコールバックを避けるため、async/awaitを使ってPromiseを正しく処理するか、doneコールバックメソッドを呼び出してください。

Chaiのexpect().to.deep.equal()を使用する際は、日付やランダム値など、動的に生成されるプロパティを含むオブジェクトに対する動作に注意してください。これらのプロパティにより、深い等価性を期待するテストが予期せず失敗することがあります。適用できる場合は、より対象を絞ったアサーションとしてChaiのexpect().to.include()を使用することを検討してください。

人気のSolanaテストフレームワーク

Bankrun

バンクは、クライアントアカウントの追跡、プログラム実行の管理、Solana台帳の整合性と進行の維持を担います。基本的には、特定時点における台帳のスナップショットであり、特定のブロックのトランザクションから生じた状態を保持します。 

Bankrunは、Solanaプログラム向けにNode.jsで記述された軽量で柔軟なテストフレームワークです。使いやすさと速度を重視しており、開発者はプログラムのテストを迅速に記述して実行できます。Bankrunの真の価値は、開発者が制御された効率的な環境でSolanaバンクをシミュレーションし、操作できるテストフレームワークであることです。Bankrunは、こうした環境の構築に通常伴うオーバーヘッドを発生させずにSolanaバンクの動作を再現し、テストプロセスを効率化します。

Bankrunの設計は、RPCノードの動作を模倣しながら、パフォーマンスと柔軟性を大幅に高めた軽量なBanksServerを基盤としています。開発者は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パッケージは、テスト時にAnchorProviderの代替としてそのまま使用できるBankrunProviderクラスをエクスポートし、AnchorとBankrunの連携を可能にする強力な拡張機能です。

Bankrunによる任意のアカウントへの書き込み

Bankrunの際立った機能の1つは、任意のアカウントデータを書き込めることです。この機能は前例のない柔軟性を提供し、開発者がアカウント状態の制約を回避できるようにします。たとえば、USDCのmintキーペアを所有していなくても、大量のUSDCを保有するアカウントをシミュレーションできます。実際のトークンを操作する必要がなくなり、複雑なシナリオのセットアップが効率化されるため、テストに極めて有用です。 

Bankrunのドキュメントでは、start関数を通じてこの機能を紹介する無限にUSDCをmintするコード例が提供されています。start関数は、プログラムをデプロイし、指定どおりにアカウントデータを設定してテスト環境を準備します。

タイムトラベル

もう1つの独立した機能は、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クレートは、Solanaプログラム専用に設計されたRustベースのテストフレームワークです。このフレームワークはBanksClientを中心に構成されています。Bankrunと同様にSolanaバンクの動作をシミュレーションし、開発者がメインネットを模倣したテスト条件下でプログラムをデプロイ、操作、評価できるようにします。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 1.9から1.14までのバージョンと、それぞれに対応するAnchor 1.9、1.10、1.14など、複数のSolanaおよび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上にシンプルな「King of the Hill」ゲームを実装します。ユーザーは、現在のキングよりも多くのSOLを賞金プールに送ることで、新しい「キング」になれます。新しいキングが登場すると、前のキングが送ったSOLはその本人に返還されます。

プログラムの機能は次のとおりです。

  • 初期化:この関数は、最初のキング(ゲームを最初に初期化したプレイヤー)と初期賞金額を設定します。初期賞金はゼロより大きい必要があります。その後、初期キングから賞金プールへ初期賞金を転送します
  • キングになる:この関数を使用すると、新しいプレイヤーは現在の賞金よりも多くのSOLを入札してキングになれます。現在の賞金を退任するキングに転送し、新しいキングの入札額で賞金プールを更新して、そのプレイヤーを新しいキングにします。この入札額は現在の賞金よりも高い必要があります

テストの記述

次のコードを使用して、King of the Hillゲームを正常にテストできます。

コード
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でテスト環境をセットアップします。この例では、localhost上でMochaとChaiを使用し、TypeScriptでテストしています。

コード
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の変数をそれぞれ初期化し、airdropを簡単に行うためのユーティリティ関数を作成します。

コード
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を導出します。このブロックはテストケースの前に1回実行されるため、各ケースを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に設定します。次に、initialPrizeを指定してinitializeメソッドを呼び出します。アカウントには、ゲーム状態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.");
})

このテストシナリオでは、King of the Hillプログラムの中核機能を検証しました。ユニットテストを通じて、プログラムロジックの整合性を詳細なレベルで検証しました。キングが正しく交代することをテストし、現実に近い条件をシミュレーションした環境でプログラムが意図どおりに動作することも確認しました。これらのテストは、King of the Hillプログラムの品質と機能を確保するうえで、包括的なテスト戦略が重要であることを示しています。 

まとめ

テストは、安全で信頼性が高く、効率的なSolanaプログラムを開発するための基盤です。この記事では、プログラム開発ライフサイクルのすべてを網羅するために、ユニットテスト、統合テスト、E2Eテストを組み合わせる重要性を解説しました。これらの手法を統合し、Bankrun、solana-program-test、solana-test-frameworkなどの強力なテストフレームワークを活用することで、開発者はSolanaプログラムの品質を大幅に向上できます。Solana開発者として歩み続けるなかで、ここで紹介した原則、実践方法、例を参考に、堅牢で効率的かつ安全なプログラムを構築してください。

ここまでお読みいただき、ありがとうございます!以下にメールアドレスを入力して、Solanaの最新情報を見逃さないようにしてください。さらに詳しく知りたいですか?今すぐHeliusブログの最新記事を読み、Solanaの旅を続けましょう。

その他のリソース

Heliusを購読

Solana開発の最新情報や新しい記事の公開通知を受け取れます

拡大画像