ブロックチェーン学習ロードマップ

スマートコントラクトのテスト手法:開発者が品質と安全性を確保するための技術詳細

Tags: スマートコントラクト, テスト, Hardhat, Foundry, Solidity

はじめに:スマートコントラクトにおけるテストの重要性

従来のソフトウェア開発において、テストはシステムの品質と信頼性を確保するための不可欠なプロセスです。これはブロックチェーン上のスマートコントラクト開発においても同様、あるいはそれ以上に重要となります。なぜなら、スマートコントラクトは一度デプロイされると原則として変更が難しく、バグやセキュリティ上の脆弱性が存在する場合には、重大な資産損失やシステム全体の停止といった致命的な結果を招く可能性があるからです。

スマートコントラクトは、ブロックチェーン上で特定の条件下で自動的に実行されるプログラムです。その性質上、非中央集権的で不変性が高いため、従来のサーバーサイドアプリケーションのようにデプロイ後に簡単にパッチを適用したり、データベースを修正したりすることが困難です。したがって、デプロイ前にコードが意図した通りに動作し、かつセキュリティ上の問題がないことを徹底的に確認する必要があります。この確認プロセスの中核を担うのが、スマートコントラクトのテストです。

従来のソフトウェア開発におけるテスト手法の多くは、スマートコントラクトにも適用できます。例えば、ユニットテスト、インテグレーションテスト、エンドツーエンドテストといった概念はそのまま活用可能です。しかし、ブロックチェーン特有の概念(Gas消費、ブロック生成、分散環境など)を考慮した、固有のテスト手法やツールが必要となります。

スマートコントラクトテストの種類と目的

スマートコントラクトのテストは、その粒度や目的に応じていくつかの種類に分けられます。主要なテスト手法とその目的を理解することは、効果的なテスト戦略を立てる上で重要です。

ユニットテスト

ユニットテストは、スマートコントラクト開発において最も基本となるテスト手法です。単一のスマートコントラクト、あるいはスマートコントラクト内の特定の関数やロジック単位(ユニット)が、単独で正しく機能するかどうかを確認することを目的とします。

このテストでは、対象となるユニットを他の依存関係から隔離し、様々な入力データや状態を模擬的に与えて、期待される出力や状態変化が得られるか検証します。例えば、コントラクトの特定の関数に特定の値を送金した場合に、送信者の残高が減少し、コントラクトの残高が増加し、イベントが正しく発行されるかなどを確認します。

ユニットテストの利点は、問題箇所を特定しやすいこと、テストの実行速度が速いことです。これにより、開発者はコードの変更が他の部分に悪影響を与えていないか(リグレッション)を迅速に確認できます。

インテグレーションテスト

インテグレーションテストは、複数のスマートコントラクトが連携して動作する場合に、それらの相互作用が正しく機能するかを確認するテストです。例えば、ERC-20トークンコントラクトと、そのトークンを扱うDeFiプロトコルコントラクトが連携する際の動作などが対象となります。

ユニットテストでは単一のコントラクトの内部ロジックを確認しますが、インテグレーションテストでは、コントラクト間の呼び出し、状態の共有、イベントの発行とそれに応じた処理などが意図通りに行われるか検証します。実際のシステムに近い環境でテストを行うことで、単体では問題なく動作するが、組み合わせることで発生する不具合を発見できます。

フォークテスト

フォークテストは、既存の公開されているブロックチェーン(例: Ethereum Mainnet, Polygon Mainnetなど)の特定のブロック時点の状態をローカル環境に複製(フォーク)し、その環境でスマートコントラクトをテストする手法です。

このテストの最大の目的は、実際のチェーン上で稼働している他のスマートコントラクトやデータ(例: 特定のERC-20トークンの残高、既存のDeFiプロトコルの状態など)と連携する際の挙動を、より現実に近い形で検証することです。例えば、特定のDEX(分散型取引所)コントラクトと連携するコントラクトを開発している場合、フォークテストを利用すれば、そのDEXの現在の流動性プールの状態などをローカルに再現してテストできます。

フォークテストはインテグレーションテストの一種とも言えますが、外部の実際のチェーンの状態を利用するという点で区別されます。より複雑なシナリオや、既存のシステムとの連携を検証する際に非常に強力な手法となります。

スマートコントラクトテストのためのツール

スマートコントラクト開発において、テストを実行するための環境やフレームワークは複数存在します。代表的なものにHardhatやFoundryがあります。これらのツールは、ローカルブロックチェーンネットワークの構築、コントラクトのコンパイル・デプロイ、テストコードの実行といった一連のプロセスを効率化するための機能を提供します。

Hardhat

Hardhatは、Ethereum開発に特化した開発環境およびテストフレームワークです。JavaScript/TypeScriptでテストコードを記述できるため、Webエンジニアにとって比較的馴染みやすいツールと言えます。組み込みのローカルネットワーク「Hardhat Network」を提供しており、高速なテスト実行やデバッグが可能です。また、プラグインシステムが豊富で、Ether.jsやWaffleといったライブラリと連携してテストを記述することが一般的です。

Foundry

Foundryは、Rustで記述されたスマートコントラクト開発ツールキットです。テストフレームワーク「Forge」を含んでおり、テストコードをSolidityで直接記述できる点が大きな特徴です。これは、コントラクトの記述言語とテスト言語が同じであるため、Solidity開発者にとっては直感的です。また、Rustで記述されているため非常に高速に動作します。Gasレポート生成やファジングといった高度なテスト機能も提供しています。

どちらのツールを選ぶかは、開発チームの技術スタックや好みに依存しますが、本質的なテストの概念は共通しています。

Hardhatを使ったユニットテストの例(JavaScript/TypeScript)

簡単なERC-20トークンコントラクトの一部をテストする例をHardhatとJavaScript/TypeScriptで記述してみましょう。

まず、テスト対象となるSolidityコントラクトを仮定します。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MyToken is ERC20 {
    constructor(uint256 initialSupply) ERC20("MyToken", "MYT") {
        _mint(msg.sender, initialSupply);
    }

    // 他のERC20関数など
}

次に、Hardhatプロジェクト内でこのコントラクトをテストするためのJavaScriptコードを記述します(testsディレクトリなど)。

// tests/MyToken.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("MyToken", function () {
  let MyToken;
  let myToken;
  let owner;
  let addr1;
  let addr2;
  const initialSupply = ethers.utils.parseEther("1000"); // 1000 MYT

  beforeEach(async function () {
    // テストごとに新しいコントラクトインスタンスとアカウントを作成
    MyToken = await ethers.getContractFactory("MyToken");
    [owner, addr1, addr2] = await ethers.getSigners();

    myToken = await MyToken.deploy(initialSupply);
    // await myToken.deployed(); // 古いHardhatバージョンで必要
  });

  it("Should deploy with the correct initial supply", async function () {
    // デプロイ後にownerの残高がinitialSupplyと等しいか確認
    expect(await myToken.balanceOf(owner.address)).to.equal(initialSupply);
  });

  it("Should transfer tokens between accounts", async function () {
    // ownerからaddr1へ50トークン転送
    const transferAmount = ethers.utils.parseEther("50");
    await myToken.transfer(addr1.address, transferAmount);
    expect(await myToken.balanceOf(owner.address)).to.equal(initialSupply.sub(transferAmount));
    expect(await myToken.balanceOf(addr1.address)).to.equal(transferAmount);

    // addr1からaddr2へ20トークン転送 (addr1はsignerを使ってmyTokenコントラクトを操作)
    const transferAmount2 = ethers.utils.parseEther("20");
    await myToken.connect(addr1).transfer(addr2.address, transferAmount2);
    expect(await myToken.balanceOf(addr1.address)).to.equal(transferAmount.sub(transferAmount2));
    expect(await myToken.balanceOf(addr2.address)).to.equal(transferAmount2);
  });

  it("Should fail if sender doesn’t have enough tokens", async function () {
    // ownerより多いトークンをaddr1に転送しようとする(失敗するはず)
    const transferAmount = ethers.utils.parseEther("2000"); // initialSupplyより多い
    await expect(myToken.transfer(addr1.address, transferAmount))
      .to.be.revertedWith("ERC20: transfer amount exceeds balance"); // 特定のエラーメッセージでrevertするか確認
  });

  // 他のアサーション...
});

このコードでは、beforeEachフックを使って各テストの前に新しいコントラクトインスタンスとテスト用アカウント(signer)を用意しています。itブロック内で個別のテストケースを定義し、expectアサーションライブラリ(ChaiとWaffleプラグイン経由)を使ってコントラクトの挙動を検証します。ethers.utils.parseEtherは、イーサ単位の数値をWei単位に変換するために使用しています(ERC-20トークンでも、通常は18桁の小数点以下を持つため同様のスケール変換が必要です)。

Foundryを使ったユニットテストの例(Solidity)

次に、FoundryとSolidityを使って同様のテストを記述する例を示します。Foundryでは、テストコントラクトはTestコントラクトを継承し、テスト関数はtestプリフィックスで始まる必要があります。

まず、テスト対象となるSolidityコントラクトは先ほどのHardhatの例と同じとします。

// src/MyToken.sol (Foundryプロジェクトのsrcディレクトリなど)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MyToken is ERC20 {
    constructor(uint256 initialSupply) ERC20("MyToken", "MYT") {
        _mint(msg.sender, initialSupply);
    }
}

次に、テストコードをSolidityで記述します(testディレクトリなど)。

// test/MyToken.t.sol
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import "../src/MyToken.sol"; // テスト対象コントラクトへのパス

contract MyTokenTest is Test {
    MyToken public myToken;
    address public owner;
    address public addr1;
    address public addr2;
    uint256 public initialSupply = 1000 ether; // 1000 MYT (18 decimalsなのでetherを使用)

    function setUp() public {
        // 各テストの前に実行されるセットアップ関数
        owner = address(this); // デフォルトではテストコントラクト自体がownerになる
        addr1 = makeAddr("addr1"); // makeAddrでテスト用アドレスを生成
        addr2 = makeAddr("addr2");

        myToken = new MyToken(initialSupply);
    }

    function testInitialSupply() public {
        // ownerの残高がinitialSupplyと等しいか確認
        assertEq(myToken.balanceOf(owner), initialSupply, "Initial supply check failed");
    }

    function testTransfer() public {
        // ownerからaddr1へ50トークン転送
        uint256 transferAmount = 50 ether;
        myToken.transfer(addr1, transferAmount);
        assertEq(myToken.balanceOf(owner), initialSupply - transferAmount, "Owner balance after transfer");
        assertEq(myToken.balanceOf(addr1), transferAmount, "Addr1 balance after transfer");

        // addr1からaddr2へ20トークン転送
        uint256 transferAmount2 = 20 ether;
        // vm.startPrank/vm.stopPrankでmsg.senderを切り替える
        vm.startPrank(addr1);
        myToken.transfer(addr2, transferAmount2);
        vm.stopPrank();
        assertEq(myToken.balanceOf(addr1), transferAmount - transferAmount2, "Addr1 balance after second transfer");
        assertEq(myToken.balanceOf(addr2), transferAmount2, "Addr2 balance after second transfer");
    }

    function testFailTransferInsufficientBalance() public {
        // ownerより多いトークンをaddr1に転送しようとする(失敗するはず)
        uint256 transferAmount = 2000 ether; // initialSupplyより多い
        // vm.expectRevertでrevertと特定のエラーデータを期待する
        // ERC20標準のエラーメッセージは文字列だが、FoundryのexpectRevertではbytes4またはbytesでチェックすることが多い
        // OpenZeppelinのERC20はCustom Errorを使用しているため、bytes4でチェック
        // 例: bytes4(keccak256("ERC20InsufficientBalance(address,uint256,uint256)"))
        // あるいは、特定のメッセージ文字列でのrevertをチェックしたい場合は vm.expectRevert(bytes("エラーメッセージ")) を使う
        // ここでは単純にrevertすることを期待する例
        vm.expectRevert();
        myToken.transfer(addr1, transferAmount);
    }

    // 他のテスト関数...
}

Foundryでは、setUp関数が各テストケースの前に実行されます。testプリフィックスの関数がテストケースとして認識されます。forge-std/Test.solから提供されるassertEqのようなアサーション関数や、vm.startPrank/vm.stopPrankmsg.senderを偽装)、vm.expectRevert(Revertを期待)といったVMチートコードを使用してテストを記述します。FoundryはSolidityネイティブでテストを記述できるため、コントラクトロジックとテストロジックの間のコンテキストスイッチが少なく、開発効率が良いと感じる開発者もいます。

テストのベストプラクティスと注意点

効果的なスマートコントラクトテストを行うためには、いくつかのベストプラクティスを考慮する必要があります。

まとめ:テストの重要性と次のステップ

スマートコントラクト開発におけるテストは、単なる品質保証の一環ではなく、ユーザーの資産を守り、プロトコルの信頼性を維持するための最も重要な技術プロセスの一つです。ユニットテスト、インテグレーションテスト、フォークテストといった異なるレベルのテストを組み合わせ、HardhatやFoundryのような強力なツールを活用することで、より安全で信頼性の高いスマートコントラクトを開発できます。

スマートコントラクト開発者を目指すWebエンジニアの方々にとって、Solidityや他のコントラクト言語の習得はもちろんですが、同時にこれらのテスト手法とその実践方法を学ぶことは必須です。実際にHardhatやFoundryをインストールし、シンプルなコントラクトを記述してテストコードを書いてみることから始めることをお勧めします。

さらに学習を進める上では、より高度なテスト手法(プロパティベーステスト、ファジング)、セキュリティ監査の一般的な脆弱性パターン、そしてテスト自動化(CI/CDパイプラインへの組み込み)についても学んでいくと良いでしょう。これらの知識とスキルは、分散型アプリケーション(dApps)を開発する上で、あなたの技術力を一層強固なものにしてくれるはずです。