はじめに
DeFiプロトコルやNFTプロジェクトが相次いでハッキング被害を受ける中、スマートコントラクトの単体テスト(Unit Testing)は「開発者の保険」ではなく必須条件になりました。オンチェーンにデプロイしたコードは基本的に変更不可能であり、1行のバグが数百万ドルを失わせる事例も珍しくありません。Ethereum.orgの公式ドキュメントは、Hardhat・Truffle・Foundry・Brownieなど主要フレームワークを用いたテスト戦略を紹介しています。本記事ではその内容を深掘りし、「何を・どうテストすればよいか」を体系的にまとめます。
単体テストの目的と3つの分類
機能検証(Functional Tests)
- 関数の返値・状態変化が仕様どおりか
- リバート条件が適切か(
require
/revert
)
セキュリティ検証(Security Tests)
- 再入可能性・整数オーバーフロー・アクセス制御漏れ
- 想定外のキャッシュ汚染/ストレージ衝突
経済設計検証(Economic Tests)
- 金利計算・報酬分配・トークン供給が意図どおりか
- oracle操作やフラッシュローン攻撃耐性
Hardhatで学ぶJavaScript/TypeScriptテスト基礎
セットアップ
npm init -y
npm i --save-dev hardhat @nomiclabs/hardhat-ethers ethers chai
npx hardhat # 「JavaScriptプロジェクト」を選択
コントラクト(Lock.sol)
pragma solidity ^0.8.20;
contract Lock {
uint public unlockTime;
constructor(uint _unlockTime) payable {
unlockTime = _unlockTime;
}
function withdraw() external {
require(block.timestamp >= unlockTime, "locked");
payable(msg.sender).transfer(address(this).balance);
}
}
単体テスト(test/Lock.ts)
import { expect } from "chai";
import { ethers } from "hardhat";
describe("Lock", () => {
it("ロック解除前はrevert", async () => {
const [alice] = await ethers.getSigners();
const Lock = await ethers.getContractFactory("Lock");
const lock = await Lock.deploy((await ethers.provider.getBlock("latest")).timestamp + 60, { value: 1 });
await expect(lock.withdraw()).to.be.revertedWith("locked");
});
it("ロック解除後に送金", async () => {
const [alice] = await ethers.getSigners();
const Lock = await ethers.getContractFactory("Lock");
const unlockAt = (await ethers.provider.getBlock("latest")).timestamp + 60;
const lock = await Lock.deploy(unlockAt, { value: 1 });
await ethers.provider.send("evm_setNextBlockTimestamp", [unlockAt + 1]);
await lock.withdraw();
expect(await ethers.provider.getBalance(lock.address)).to.eq(0);
});
});
ポイント
evm_setNextBlockTimestamp
でブロック時間を操作し時間依存ロジックをテストchai
のexpect().to.be.revertedWith()
でリバート理由まで検証
Foundryで高速Fuzz&Invariantテスト
インストール
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge init foundry-demo && cd foundry-demo
Fuzzテスト例(Counter.t.sol)
pragma solidity ^0.8.23;
import "forge-std/Test.sol";
contract Counter {
uint256 public num;
function inc(uint256 x) external { num += x; }
}
contract CounterTest is Test {
Counter c = new Counter();
function testFuzz_Inc(uint256 x) public {
vm.assume(x < 1e18);
uint256 before = c.num();
c.inc(x);
assertEq(c.num(), before + x);
}
}
forge test -vvvv
は100件以上のランダム入力で検証vm.assume
で現実的な入力に制限し不要なガス浪費を抑制
Invariantテスト
contract Invariant is Test {
Counter c = new Counter();
function invariant_sumNotOverflow() public {
assertLe(c.num(), type(uint256).max / 2);
}
}
Foundryは状態を保存しながら数千回ランダムシーケンスを実行、インバリアント破壊ケースを自動検出します。
Brownie(Python)でのテスト&カバレッジ
インストール
pip install eth-brownie
brownie init
PyTest記法
import pytest
def test_withdraw(accounts, Lock):
alice = accounts[0]
lock = Lock.deploy(0, {'from': alice, 'value': '1 ether'})
with pytest.reverts("locked"):
lock.withdraw({'from': alice})
カバレッジ測定
brownie test --coverage
HTMLレポートで行単位の未テスト箇所を可視化。Solidityファイルが赤色ならテスト追加が必要です。
モック & スタブ技法
シナリオ | 手法 | フレームワーク |
---|---|---|
Oracle価格を固定 | MockAggregatorV3をデプロイ | Hardhat/Foundry |
ERC20転送成功/失敗 | FakeTokenでtransfer を戻り値操作 | Hardhat |
Chainlink VRF | vm.roll で乱数固定 | Foundry |
外部コントラクトをモック化することで、単体テストは純粋関数的になり失敗箇所特定が迅速になります。
CI/CD統合ベストプラクティス
- GitHub Actionsで
forge test
やnpx hardhat test
を実行 - カバレッジ閾値(例:90%)未満ならPRブロック
actions/cache
でFoundryキャッシュ・node_modulesを高速化- Solidity静的解析(Slither)・Lint(Solhint)を並列ジョブで追加
- テスト通過後のみ
hardhat-deploy
またはforge script
でTestnetへ自動デプロイ
よくある落とし穴と対策
問題 | 症状 | 対策 |
---|---|---|
ForkテストのRPC制限 | Alchemy/Etherscanエラー | ALCHEMY_MAX_CALL=30 に増枠プラン・--fork-block-number 固定 |
フラッシュローン攻撃検出漏れ | テストが単一Tx | Hardhat NetworkでautoMine=false にし複数呼び出し |
時間依存テストの不安定 | CIで偶発fail | ブロックタイムを固定&evm_increaseTime instead of sleep |
まとめ
スマートコントラクトの単体テストは機能・セキュリティ・経済設計の3軸を網羅する必要があります。Remixで概念を掴み、HardhatやFoundryで高速なローカルテストを構築し、BrownieやPythonの資産を活かして統計解析を行う――この多段構成が2025年現在の“最強パターン”です。
CIにテストと静的解析を組み込み、カバレッジとFuzzingで抜け漏れを潰せば、メインネットデプロイ後に重大バグが見つかるリスクを大幅に下げられます。この記事を参考に、まずはforge init
やnpx hardhat
を実行し、あなたのコントラクトを徹底的に叩き上げてみてください。
コメント