スマートコントラクトの信頼性を確保するための単体テスト作成ガイド
スマートコントラクトの開発において、テストは非常に重要なプロセスです。一度デプロイすると変更が難しいブロックチェーンの性質上、意図しない動作や脆弱性を事前に発見し、修正する必要があります。特にユーザーの資産を扱うコントラクトでは、その重要性はさらに高まります。
このステップでは、Hardhat環境で一般的に使用されるテストフレームワークMochaとアサーションライブラリChaiを使って、Solidityスマートコントラクトの単体テストを作成する方法を学びます。
テスト環境の概要
Hardhatプロジェクトでは、通常test
ディレクトリ内にテストファイルを配置します。テストファイルは.js
または.ts
(TypeScriptを使用する場合)拡張子を持ちます。Hardhatは、npx hardhat test
コマンドを実行すると、このディレクトリ内のテストファイルを自動的に検出して実行します。
テストの実行には、主に以下のツールが連携して動作します。
- Hardhat Network: 開発用に設計されたローカルEthereumネットワーク。テストを実行するための環境を提供します。
- Mocha: JavaScriptのテストフレームワーク。テストの構造(
describe
やit
ブロック)や実行フロー(beforeEach
など)を提供します。 - Chai: BDD/TDDスタイルのアサーションライブラリ。テスト結果が期待通りか検証(
expect
など)するために使用します。 - ethers.js: Ethereumとの対話を行うためのライブラリ。Hardhat環境ではグローバルスコープで利用可能になっており、コントラクトのデプロイや関数呼び出しに使用します。Hardhatはethers.jsを拡張した
hardhat-ethers
プラグインを提供しています。 - Hardhat Chai Matchers: Chaiを拡張し、スマートコントラクト特有のアサーション(イベントの発生、リバートなど)を簡単に行えるようにするプラグイン(
@nomicfoundation/hardhat-chai-matchers
)。
テストの基本構造
Mochaを使ったテストは、describe
とit
というブロックで構成されます。
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の
describe
とit
でテスト構造を定義します。beforeEach
などのフックやFixtureでセットアップを共通化できます。 - Chaiの
expect
とHardhat Chai Matchersを使って、関数の戻り値、イベントの発行、リバート、残高変更などを検証します。 ethers.js
を使ってコントラクトのデプロイや関数呼び出しを行います。
徹底的なテストは、安全で信頼性の高いスマートコントラクト開発に不可欠です。様々なケースを想定し、網羅的なテストスイートを作成することを心がけましょう。
次のステップでは、これらのテストを基盤として、デプロイスクリプトと異なるネットワークへの設定について学びます。
参考情報
- Hardhat Documentation – Testing Contracts: Hardhatでのテストに関する公式ドキュメント。
- Mocha: Mochaテストフレームワークの公式サイト。
- Chai Assertion Library: Chaiアサーションライブラリの公式サイト。
- Hardhat Chai Matchers Documentation: スマートコントラクト用カスタムマッチャーのドキュメント。
- ethers.js Documentation (v5): ethers.jsライブラリのドキュメント (Hardhatでよく使われるv5)。
- ethers.js Documentation (v6): ethers.jsライブラリの最新ドキュメント (v6)。
コメント