testing について
Solidity 開発者向けのスマートコントラクトテストガイドです。Foundry のユニット、ファズ、フォーク、不変条件テストを扱い、境界条件や連携、経済的な性質の確認に重点を置きます。
主な機能
- 01Foundry のユニットテストとアサーション
- 02入力制約付きファズテスト
- 03デプロイ済みプロトコルでのフォークテスト
- 04ランダムな呼び出し列での不変条件検証
- 05デプロイ前のテスト・分析チェックリスト
活用シーン
- 01コントラクトの計算や入力の境界条件確認
- 02フォーク環境での外部プロトコル連携テスト
- 03Vault や AMM の状態特性の検証
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ austintgriffith/ ethskills --skill testingインストール後、Claude Code で/testingを入力して読み込まれたか確認します。
詳細
スマートコントラクトのテスト
おそらく間違っていること
ゲッターや単純な関数をテストしています。 name() が名前を返すことをテストしても意味がありません。エッジケース、失敗するケース、経済的な不変条件をテストしてください。壊れると損失につながるのは、そうした部分です。
ファジングをしていません。 forge test で見つかるのは、想定していたバグです。ファジングで見つかるのは、想定していなかったバグです。コントラクトで計算を行うなら、ファジングしてください。ユーザー入力を処理するなら、ファジングしてください。価値を移転するなら、必ずファジングしてください。
フォークテストをしていません。 コントラクトが Uniswap、Aave、その他の外部プロトコル(検証済みアドレス:addresses/SKILL.md)を呼び出す場合、フォーク上で実際にデプロイされたコントラクトを相手にテストしてください。モックでは、実際の状態でのみ発生する統合バグが隠れてしまいます。
実装をそのままなぞるテストを書いています。 deposit(100) によって balance[user] = 100 になることをテストするのは同語反復です。Solidity の代入が動作することをテストしているだけです。プロパティをテストしてください。「入金して出金した後、ユーザーがトークンを取り戻せる」。不変条件をテストしてください。「入金総額は常にコントラクト残高と等しい」。
状態を持つプロトコルでの不変条件テストを省略しています。 時間の経過とともに状態を変更する複数の関数が相互に作用するコントラクト(ボールト、AMM、レンディング)には、不変条件テストが必要です。ユニットテストは1つの経路を確認します。不変条件テストは、何千ものランダムな実行シーケンスにわたってプロパティが維持されることを確認します。
Foundry によるユニットテスト
テストファイルの構成
// test/MyContract.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";
contract MyTokenTest is Test {
MyToken public token;
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
function setUp() public {
token = new MyToken("Test", "TST", 1_000_000e18);
// Give alice some tokens for testing
token.transfer(alice, 10_000e18);
}
function test_TransferUpdatesBalances() public {
vm.prank(alice);
token.transfer(bob, 1_000e18);
assertEq(token.balanceOf(alice), 9_000e18);
assertEq(token.balanceOf(bob), 1_000e18);
}
function test_TransferEmitsEvent() public {
vm.expectEmit(true, true, false, true);
emit Transfer(alice, bob, 500e18);
vm.prank(alice);
token.transfer(bob, 500e18);
}
function test_RevertWhen_TransferExceedsBalance() public {
vm.prank(alice);
vm.expectRevert();
token.transfer(bob, 999_999e18); // More than alice has
}
function test_RevertWhen_TransferToZeroAddress() public {
vm.prank(alice);
vm.expectRevert();
token.transfer(address(0), 100e18);
}
}
主なアサーションパターン
// Equality
assertEq(actual, expected);
assertEq(actual, expected, "descriptive error message");
// Comparisons
assertGt(a, b); // a > b
assertGe(a, b); // a >= b
assertLt(a, b); // a < b
assertLe(a, b); // a <= b
// Approximate equality (for math with rounding)
assertApproxEqAbs(actual, expected, maxDelta);
assertApproxEqRel(actual, expected, maxPercentDelta); // in WAD (1e18 = 100%)
// Revert expectations
vm.expectRevert(); // Any revert
vm.expectRevert("Insufficient balance"); // Specific message
vm.expectRevert(MyContract.CustomError.selector); // Custom error
// Event expectations
vm.expectEmit(true, true, false, true); // (topic1, topic2, topic3, data)
emit MyEvent(expectedArg1, expectedArg2);
実際にテストすべきこと
// ✅ TEST: Edge cases that lose money
function test_TransferZeroAmount() public { /* ... */ }
function test_TransferEntireBalance() public { /* ... */ }
function test_TransferToSelf() public { /* ... */ }
function test_ApproveOverwrite() public { /* ... */ }
function test_TransferFromWithExactAllowance() public { /* ... */ }
// ✅ TEST: Access control
function test_RevertWhen_NonOwnerCallsAdminFunction() public { /* ... */ }
function test_OwnerCanPause() public { /* ... */ }
// ✅ TEST: Failure modes
function test_RevertWhen_DepositZero() public { /* ... */ }
function test_RevertWhen_WithdrawMoreThanDeposited() public { /* ... */ }
function test_RevertWhen_ContractPaused() public { /* ... */ }
// ❌ DON'T TEST: OpenZeppelin internals
// function test_NameReturnsName() — they already tested this
// function test_SymbolReturnsSymbol() — waste of time
// function test_DecimalsReturns18() — it does, trust it
ファジングテスト
Foundry は、パラメーターを持つテスト関数を自動的にファジングします。1つの値をテストする代わりに、何百ものランダムな値をテストします。
基本的なファジングテスト
// Foundry calls this with random amounts
function testFuzz_DepositWithdrawRoundtrip(uint256 amount) public {
// Bound input to valid range
amount = bound(amount, 1, token.balanceOf(alice));
uint256 balanceBefore = token.balanceOf(alice);
vm.startPrank(alice);
token.approve(address(vault), amount);
vault.deposit(amount, alice);
vault.withdraw(vault.balanceOf(alice), alice, alice);
vm.stopPrank();
// Property: user gets back what they deposited (minus any fees)
assertGe(token.balanceOf(alice), balanceBefore - 1); // Allow 1 wei rounding
}
入力値の制限
// bound() is preferred over vm.assume() — bound reshapes, assume discards
function testFuzz_Fee(uint256 amount, uint256 feeBps) public {
amount = bound(amount, 1e6, 1e30); // Reasonable token amounts
feeBps = bound(feeBps, 1, 10_000); // 0.01% to 100%
uint256 fee = (amount * feeBps) / 10_000;
uint256 afterFee = amount - fee;
// Property: fee + remainder always equals original
assertEq(fee + afterFee, amount);
}
// vm.assume() discards inputs — use sparingly
function testFuzz_Division(uint256 a, uint256 b) public {
vm.assume(b > 0); // Skip zero (would revert)
// ...
}
反復回数を増やして実行する
# Default: 256 runs
forge test
# More thorough: 10,000 runs
forge test --fuzz-runs 10000
# Set in foundry.toml for CI
# [fuzz]
# runs = 1000
フォークテスト
メインネットのフォーク上で、実際にデプロイされたプロトコルを相手にコントラクトをテストします。モックでは見つけられない統合バグを検出できます。
基本的なフォークテスト
contract SwapTest is Test {
// Real mainnet addresses — full verified list: addresses/SKILL.md
address constant UNISWAP_ROUTER = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45;
address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
function setUp() public {
// Fork mainnet at a specific block for reproducibility
vm.createSelectFork("mainnet", 19_000_000);
}
function test_SwapETHForUSDC() public {
address user = makeAddr("user");
vm.deal(user, 1 ether);
vm.startPrank(user);
// Build swap path
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter
.ExactInputSingleParams({
tokenIn: WETH,
tokenOut: USDC,
fee: 3000,
recipient: user,
amountIn: 0.1 ether,
amountOutMinimum: 0, // In production, NEVER set to 0
sqrtPriceLimitX96: 0
});
// Execute swap
uint256 amountOut = ISwapRouter(UNISWAP_ROUTER).exactInputSingle{value: 0.1 ether}(params);
vm.stopPrank();
// Verify we got USDC back
assertGt(amountOut, 0, "Should receive USDC");
assertGt(IERC20(USDC).balanceOf(user), 0);
}
}
フォークテストを行う場面
- 常に: 外部プロトコル(Uniswap、Aave、Chainlink)を呼び出すコントラクト
- 常に: 特殊な挙動をするトークン(USDT、送金手数料付き、リベース型)を扱うコントラクト
- 常に: オラクル価格を読み取るコントラクト
- 決して行わない: 外部呼び出しのない純粋なロジックコントラクト。ユニットテストを使用してください
フォークテストの実行
# Fork from RPC URL
forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY
# Fork at specific block (reproducible)
forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY --fork-block-number 19000000
# Set in foundry.toml to avoid CLI flags
# [rpc_endpoints]
# mainnet = "${MAINNET_RPC_URL}"
不変条件テスト
不変条件テストは、何千ものランダムな関数呼び出しシーケンスにわたってプロパティが維持されることを検証します。状態を持つプロトコルには欠かせません。
不変条件とは?
不変条件とは、ユーザーがどのような操作シーケンスを実行しても、常に真でなければならないプロパティです。
- 「総供給量はすべての残高の合計と等しい」(ERC-20)
- 「入金総額は、総シェア数とシェア価格の積と等しい」(ボールト)
- 「スワップ後も常に x * y >= k が成り立つ」(AMM)
- 「ユーザーは入金したものをいつでも出金できる」(エスクロー)
基本的な不変条件テスト
contract VaultInvariantTest is Test {
MyVault public vault;
IERC20 public token;
VaultHandler public handler;
function setUp() public {
token = new MockERC20("Test", "TST", 18);
vault = new MyVault(token);
handler = new VaultHandler(vault, token);
// Tell Foundry which contract to call randomly
targetContract(address(handler));
}
// This runs after every random sequence
function invariant_TotalAssetsMatchesBalance() public view {
assertEq(
vault.totalAssets(),
token.balanceOf(address(vault)),
"Total assets must equal actual balance"
);
}
function invariant_SharePriceNeverZero() public view {
if (vault.totalSupply() > 0) {
assertGt(vault.convertToAssets(1e18), 0, "Share price must never be zero");
}
}
}
// Handler: guided random actions
contract VaultHandler is Test {
MyVault public vault;
IERC20 public token;
constructor(MyVault _vault, IERC20 _token) {
vault = _vault;
token = _token;
}
function deposit(uint256 amount) public {
amount = bound(amount, 1, 1e24);
deal(address(token), msg.sender, amount);
vm.startPrank(msg.sender);
token.approve(address(vault), amount);
vault.deposit(amount, msg.sender);
vm.stopPrank();
}
function withdraw(uint256 shares) public {
uint256 maxShares = vault.balanceOf(msg.sender);
if (maxShares == 0) return;
shares = bound(shares, 1, maxShares);
vm.prank(msg.sender);
vault.redeem(shares, msg.sender, msg.sender);
}
}
不変条件テストの実行
# Default depth (15 calls per sequence, 256 sequences)
forge test
# Deeper exploration
forge test --fuzz-runs 1000
# Configure in foundry.toml
# [invariant]
# runs = 512
# depth = 50
テストしてはいけないこと
- OpenZeppelin の内部処理。
ERC20.transferが動作することをテストしないでください。これは多数の監査会社によって監査され、何千ものコントラクトで使用されています。その上に構築した独自のロジックをテストしてください。 - Solidity の言語機能。
requireがリバートすることや、mappingが値を保存することをテストしないでください。コンパイラーは正常に動作します。 - すべてのゲッター。
name()がコンストラクターに渡した名前を返すなら、それはテストではなく、同語反復です。 - 成功するケースだけ。 成功するケースはおそらく動作します。失敗するケースをテストしてください。ゼロや最大の uint を指定した場合は?権限のない呼び出し元は?再入は?
テストの労力は次の点に集中してください: 独自のビジネスロジック、数学的演算、外部プロトコルとの統合箇所、アクセス制御の境界、経済的なエッジケース。
デプロイ前テストチェックリスト
- すべてのカスタムロジックについて、エッジケースを含む単体テストがある
- ゼロの金額、最大 uint、空の配列、自己送金をテスト済み
- アクセス制御を検証済み — 権限のない呼び出しはリバートする
- すべての数学演算に対するファズテスト(最低 1000 回の実行)
- 外部プロトコルとの連携ごとにフォークテストを実施
- ステートフルなプロトコル(ボールト、AMM、レンディング)の不変条件テスト
-
expectEmitでイベントを検証済み - リグレッションを検出するため、
forge snapshotでガススナップショットを取得済み -
slither .で静的解析を実施 — 未対応の重大または中程度の指摘がない - すべてのテストに合格:
forge test -vvv