[Solidityのはじめ方] Part22: 単体テストの書き方(Mocha/Chai)

Solidity

スマートコントラクトの信頼性を確保するための単体テスト作成ガイド

スマートコントラクトの開発において、テストは非常に重要なプロセスです。一度デプロイすると変更が難しいブロックチェーンの性質上、意図しない動作や脆弱性を事前に発見し、修正する必要があります。特にユーザーの資産を扱うコントラクトでは、その重要性はさらに高まります。

このステップでは、Hardhat環境で一般的に使用されるテストフレームワークMochaとアサーションライブラリChaiを使って、Solidityスマートコントラクトの単体テストを作成する方法を学びます。

テスト環境の概要

Hardhatプロジェクトでは、通常testディレクトリ内にテストファイルを配置します。テストファイルは.jsまたは.ts(TypeScriptを使用する場合)拡張子を持ちます。Hardhatは、npx hardhat testコマンドを実行すると、このディレクトリ内のテストファイルを自動的に検出して実行します。

テストの実行には、主に以下のツールが連携して動作します。

  • Hardhat Network: 開発用に設計されたローカルEthereumネットワーク。テストを実行するための環境を提供します。
  • Mocha: JavaScriptのテストフレームワーク。テストの構造(describeitブロック)や実行フロー(beforeEachなど)を提供します。
  • Chai: BDD/TDDスタイルのアサーションライブラリ。テスト結果が期待通りか検証(expectなど)するために使用します。
  • ethers.js: Ethereumとの対話を行うためのライブラリ。Hardhat環境ではグローバルスコープで利用可能になっており、コントラクトのデプロイや関数呼び出しに使用します。Hardhatはethers.jsを拡張したhardhat-ethersプラグインを提供しています。
  • Hardhat Chai Matchers: Chaiを拡張し、スマートコントラクト特有のアサーション(イベントの発生、リバートなど)を簡単に行えるようにするプラグイン(@nomicfoundation/hardhat-chai-matchers)。

テストの基本構造

Mochaを使ったテストは、describeitというブロックで構成されます。

  • describe(description, callback): 関連するテストケースをグループ化します。descriptionにはテストスイート(テスト群)の名前を記述します。入れ子にすることも可能です。
  • it(description, callback): 個々のテストケースを定義します。descriptionには具体的なテスト内容を記述します。callback関数内にテストのロジックとアサーションを記述します。

テストコードは非同期処理を含むことが多いため、callback関数は通常async関数として定義し、非同期処理をawaitで待ちます。

以下は基本的なテストファイルの構造例です。


// ethersやChaiのexpectをインポート
const { expect } = require("chai");
const { ethers } = require("hardhat");

// テストスイートを開始
describe("Counterコントラクトのテスト", function () {
  // テスト間で共有する変数を定義
  let Counter;
  let counter;
  let owner;
  let addr1;

  // 各テストケースの実行前に実行されるフック
  beforeEach(async function () {
    // コントラクトファクトリを取得
    Counter = await ethers.getContractFactory("Counter");
    // テスト用のアカウント(Signer)を取得
    [owner, addr1] = await ethers.getSigners();
    // コントラクトをデプロイ
    counter = await Counter.deploy();
    // デプロイ完了を待つ(任意だが推奨)
    // await counter.deployed(); // ethers v6以降では不要または非推奨
  });

  // 個別のテストケース1: デプロイ時の初期値を確認
  it("初期カウントが0であるべき", async function () {
    // コントラクトのcount()関数を呼び出し、結果を検証
    expect(await counter.count()).to.equal(0);
  });

  // 個別のテストケース2: increment関数が正しく動作するか確認
  it("increment関数を呼び出すとカウントが1増えるべき", async function () {
    // increment関数を呼び出すトランザクションを実行
    const tx = await counter.increment();
    // トランザクションの完了を待つ
    await tx.wait();
    // 再度count()関数を呼び出し、結果を検証
    expect(await counter.count()).to.equal(1);
  });

  // 他のテストケース...
});
        

Mochaのフック

Mochaには、テストの特定のタイミングで処理を実行するためのフックが用意されています。

  • beforeEach(callback): 各itブロックの実行前に毎回実行されます。テスト前の状態設定(コントラクトのデプロイなど)によく使われます。
  • afterEach(callback): 各itブロックの実行後に毎回実行されます。テスト後のクリーンアップに使われます。
  • before(callback): 最初のitブロックの実行前に一度だけ実行されます。
  • after(callback): 最後のitブロックの実行後に一度だけ実行されます。

テストのセットアップを効率化するために、Fixtures (`@nomicfoundation/hardhat-network-helpers`の`loadFixture`) を使うことも推奨されています。Fixtureは初回のみセットアップを実行し、以降はネットワークの状態をリセットすることで高速化を図ります。


const { loadFixture } = require("@nomicfoundation/hardhat-toolbox/network-helpers");
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Counterコントラクトのテスト (Fixture使用)", function () {
  // Fixture関数を定義
  async function deployCounterFixture() {
    const Counter = await ethers.getContractFactory("Counter");
    const [owner, addr1] = await ethers.getSigners();
    const counter = await Counter.deploy();
    // await counter.deployed(); // ethers v6以降では不要または非推奨
    return { counter, owner, addr1 };
  }

  it("初期カウントが0であるべき", async function () {
    // Fixtureをロードして変数を受け取る
    const { counter } = await loadFixture(deployCounterFixture);
    expect(await counter.count()).to.equal(0);
  });

  it("increment関数を呼び出すとカウントが1増えるべき", async function () {
    const { counter } = await loadFixture(deployCounterFixture);
    const tx = await counter.increment();
    await tx.wait();
    expect(await counter.count()).to.equal(1);
  });
});
        

ChaiとHardhat Chai Matchersを使ったアサーション

Chaiはテストの結果が期待通りかを検証するためのアサーションライブラリです。expect構文が一般的に使われます。


// 例: 値が期待通りか
expect(await counter.count()).to.equal(5);
// 例: 値がnullでないか
expect(result).to.not.be.null;
// 例: 配列の長さが期待通りか
expect(items).to.have.lengthOf(3);
        

さらに、@nomicfoundation/hardhat-chai-matchersプラグインは、スマートコントラクトのテストに特化した便利なマッチャーを提供します。

イベントのテスト

コントラクトが特定のイベントを正しい引数で発行したかをテストできます。


// Counterコントラクトに `event CountIncremented(address indexed who, uint256 newCount);` があるとする
it("increment関数がCountIncrementedイベントを発行すべき", async function () {
  const { counter, owner } = await loadFixture(deployCounterFixture);

  // increment()の呼び出しが "CountIncremented" イベントを counter コントラクトから発行し、
  // その引数が owner.address と 1 であることを期待する
  await expect(counter.increment())
    .to.emit(counter, "CountIncremented")
    .withArgs(owner.address, 1); // 引数を検証
});
        

withArgsでイベントの引数を検証します。特定の引数を気にしない場合は、anyValueプレディケートを使用できます。


const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs");

// ...

await expect(counter.increment())
  .to.emit(counter, "CountIncremented")
  .withArgs(anyValue, 1); // 最初の引数(アドレス)は何でも良いが、2番目の引数(新しいカウント)は1であることを期待
        

リバート(エラー)のテスト

特定の条件下でトランザクションが正しくリバート(失敗)するかをテストします。require文によるリバートメッセージや、カスタムエラーによるリバートを検証できます。


// Counterコントラクトに `require(msg.sender == owner, "Only owner can decrement");` があるとする
it("オーナー以外がdecrementを呼び出すとリバートすべき", async function () {
  const { counter, addr1 } = await loadFixture(deployCounterFixture);

  // addr1がdecrement()を呼び出すと、"Only owner can decrement" という理由でリバートすることを期待
  await expect(counter.connect(addr1).decrement())
    .to.be.revertedWith("Only owner can decrement");
});

// Counterコントラクトに `error InsufficientCount(uint256 currentCount);` があり、
// `if (count == 0) { revert InsufficientCount(count); }` のようなコードがあるとする
it("カウントが0の時にdecrementを呼び出すとInsufficientCountエラーでリバートすべき", async function () {
  const { counter } = await loadFixture(deployCounterFixture);

  // decrement()を呼び出すと、counterコントラクトで定義された "InsufficientCount" カスタムエラーでリバートし、
  // その引数が 0 であることを期待する
  await expect(counter.decrement())
    .to.be.revertedWithCustomError(counter, "InsufficientCount")
    .withArgs(0);
});

// 単純にリバートするかどうかだけを確認する場合
it("特定の条件下でリバートすることを確認", async function() {
  // ... セットアップ ...
  await expect(contract.someFunctionThatShouldRevert()).to.be.reverted;
});
        

revertedWithはリバート理由の文字列を検証し、revertedWithCustomErrorはカスタムエラーとその引数を検証します。connect(signer)を使うことで、デフォルト以外の特定のアカウント(Signer)から関数を呼び出すことができます。

残高変更のテスト

EtherやERC20トークンの残高がトランザクションによって期待通りに変化したかをテストできます。


// Ether残高の変更をテスト
it("withdraw関数がオーナーに正しい額のEtherを送金すべき", async function() {
  // ... コントラクトにEtherを送金するセットアップ ...
  const { contract, owner } = await loadFixture(deployFixture);
  const contractBalance = await ethers.provider.getBalance(contract.address);

  await expect(contract.withdraw())
    .to.changeEtherBalance(owner, contractBalance); // オーナーのEther残高がcontractBalanceだけ増えることを期待
});

// ERC20トークン残高の変更をテスト (事前にトークンコントラクトのインスタンス `token` が必要)
it("transfer関数がトークン残高を正しく変更すべき", async function() {
  const { token, owner, addr1 } = await loadFixture(deployTokenFixture);
  const transferAmount = 100;

  await expect(token.transfer(addr1.address, transferAmount))
    .to.changeTokenBalances(token, [owner, addr1], [-transferAmount, transferAmount]);
    // ownerの残高がtransferAmount減り、addr1の残高がtransferAmount増えることを期待
});
        

テストの実行

テストを実行するには、プロジェクトのルートディレクトリで以下のコマンドを実行します。


npx hardhat test
        

特定のテストファイルのみを実行したい場合は、ファイルパスを指定します。


npx hardhat test ./test/Counter.test.js
        

Hardhatはテスト実行前にコントラクトを自動的にコンパイルします(変更があった場合)。

まとめ 🚀

このステップでは、Hardhat環境でMochaとChai、そしてethers.jsとHardhat Chai Matchersを使ってSolidityスマートコントラクトの単体テストを作成する基本的な方法を学びました。

  • テストはtestディレクトリに配置し、npx hardhat testで実行します。
  • Mochaのdescribeitでテスト構造を定義します。beforeEachなどのフックやFixtureでセットアップを共通化できます。
  • ChaiのexpectとHardhat Chai Matchersを使って、関数の戻り値、イベントの発行、リバート、残高変更などを検証します。
  • ethers.jsを使ってコントラクトのデプロイや関数呼び出しを行います。

徹底的なテストは、安全で信頼性の高いスマートコントラクト開発に不可欠です。様々なケースを想定し、網羅的なテストスイートを作成することを心がけましょう。

次のステップでは、これらのテストを基盤として、デプロイスクリプトと異なるネットワークへの設定について学びます。

参考情報

コメント

タイトルとURLをコピーしました