レッスン1 — forge test — cargo test の Solidity 等価物
問い
forge init のプロジェクト形は cargo new --lib の Foundry 等価物。forge test は cargo test と同じ位置の binary、first compile 後は sub-second feedback、同じ --match-* filtering。Solidity の test discovery + assertion + verbosity ladder + negative-path test (vm.expectRevert) を、Rust 規律と並べて理解する。
原理(最小モデル)
- プロジェクト形マッピング.
Cargo.toml→foundry.toml/src/lib.rs→src/Counter.sol/tests/integration_test.rs→test/Counter.t.sol/target/→out/+cache/。Foundry チーム自身が Rust 出身 = 意図的な familiar 感覚。 - Test discovery は attribute ではなく name で.
test*で名付けられた関数が test、testFuzz_*は fuzz test。Solidity に decorator がない → 命名慣例が Rust attribute の等価物。 - Assertion 3 primitives.
assertEq(value equality、全 Solidity primitive 型に overload)/assertTrue(boolean)/vm.expectRevert(negative-path、次の external call が特定 reason で revert することを assert)。 - Verbosity ladder.
-v(summary)/-vv(失敗メッセージ)/-vvv(stack trace、daily default)/-vvvv(pass/fail 両方の trace)/-vvvvv(opcode-level trace)。Rust のcargo test -- --nocapture等価。 vm.expectRevertの 1-call lifetime. Revert を期待する call の 直前に呼ぶ。間に挟まる call が arm を先に消費 → 紛らわしく失敗。- Constant-folding 回避.
uint256(0) - 1リテラルは Solc がコンパイル時に reject → ローカル変数経由で runtime SUB opcode に強制 → Solidity 0.8 の overflow check がPanic(0x11)を trigger。 - Panic 発生レイヤーの差. ローカル評価 panic(テストコントラクト内 = 外部 call はディスパッチされない、trace に現れない)vs 外部コントラクト内 panic(被呼び出し側で発生、revert データが bubble up、trace に明示)。
vm.expectRevertはどちらも捕捉する。
具体例 + ステップで組み立てる
レッスン 1 — forge test — cargo test の Solidity 等価物
ゴール
このレッスンで掴む概念:
forge initのプロジェクト形はcargo new --libの Foundry 等価物。 Rust がsrc//tests//target//Cargo.tomlで使うのと同じ規律あるディレクトリレイアウトを、Foundry はsrc//test//lib//foundry.tomlで再現する。Rust crate を書いたことがあれば、Foundry がコントラクトとテストをどう organize するかすでに知っている。名前は少し違うが、役割 は 1:1 で対応する。forge testはcargo testと同じ位置の binary。 First compile 後は sub-second feedback、同じ--match-*filtering、test 関数は名前で discover される慣例(Rust は#[test]attribute、Foundry は関数名がtestで始まること)。両ツールが意図的に同じワークフローに収斂している理由はひとつ。Foundry の作者が Rust ネイティブで、その筋肉記憶をそこで先に育てたからだ。assertEq/assertTrue/vm.expectRevertが unit test の 90% で使う 3 つの primitive。assertEqは value equality(あらゆる Solidity primitive 型へのオーバーロード付き)、assertTrueは boolean 条件、vm.expectRevertは negative-path test(次の呼び出しが特定の reason で revert することを assert)。Rust への cross-reference はassertEq↔assert_eq!、vm.expectRevert↔#[should_panic]あるいはassert!(matches!(result, Err(_)))。- Verbosity は
-v(silent) から-vvvvv(full call trace) まで段階的に上がる。-vvvが daily default — 失敗した test を full storage dump 付きで表示する。-vvvvvは変な revert を debug したいときの opcode trace 込みのモード。Rust への cross-reference はcargo test -- --nocapture(println!出力が見える)。Foundry の-vvvはその Solidity 等価物に加えて、各 step で EVM が見た storage diff まで見せてくれる。
確認:
forge test
…で forge init のデフォルト Counter コントラクトに付属する 2 つの test が走る。レッスン 1 完走後は negative-path test (vm.expectRevert) を 1 つ追加して合計 3 つ。レッスン終了時には 3 つすべて green。
具体的な変更:
src/Counter.sol—forge initデフォルトから変更なし(編集ではなく 読む 対象)。test/Counter.t.sol— 新しい test 関数 (test_RevertWhen_DecrementBelowZero) を 1 つ追加。Underflow-revert path を exercise する。Solidity 0.8 の built-in overflow check に対するvm.expectRevertのデモ。
合計で約 10 行の test code を追加。レッスン1の主題は ergonomics と test-discovery loop であって、賢い assertion ではない。
おさらい
レッスン0の後はこうなっている。
- コースの positioning は明確: 同じ定理、2 言語、rethlab の Rust 規律を Solidity に port する。
- ロードマップは 7 レッスン: orientation (レッスン0) → test discipline (レッスン 1〜3) → CLI + state-aware testing (レッスン 4〜5) → capstone (レッスン6)。
- Foundry を install 済み (
curl -L https://foundry.paradigm.xyz | bash && foundryup)、forge,cast,anvil,chiselの binary が$PATHに乗っている。
ここから レッスン 1 が test-discipline track を始める。最初の動詞は forge test。
計画
編集は 3 つ。
forge init my-project && cd my-project— 標準 Foundry プロジェクトレイアウトを作成する。何かを触る前に生成物を読む。src/Counter.solとtest/Counter.t.solをそのまま読む — どちらもforge initで同梱、慣例を実演している。1 行ずつ理解する。test/Counter.t.solに test を 1 つ追加 —test_RevertWhen_DecrementBelowZero。新規のCounter(setUp()でnumber = 0) に対して、constant-folding を回避するためにuint256 zero = 0;を経由したzero - 1をc.setNumber(...)に渡し、vm.expectRevertで revert を assert する。forge test -vvvで full trace を見ながら走らせる。
(答え: Cargo.toml → foundry.toml、src/lib.rs → src/Counter.sol(あるいは main コントラクトとして付けた名前)、tests/integration_test.rs → test/Counter.t.sol、target/ → out/ + cache/、Cargo.lock → 直接の等価物なし(Foundry は lib/ の git submodule を依存解決に使い、lib/forge-std は standard testing library として常に存在する)。マッピングは意図的。Foundry チーム自身が Rust 出身で、Rust 開発者に familiar な感覚を作るためにこう構築した。)
forge init プロジェクト形 — 1 ページツアー
my-project/
├── foundry.toml ← Cargo.toml 相当: profile config、deps、コンパイラフラグ
├── src/ ← src/ 相当: production コントラクトはここに住む
│ └── Counter.sol ← デフォルトの starter コントラクト
├── test/ ← tests/ 相当: integration test はここに住む
│ └── Counter.t.sol ← デフォルトの starter test (.t.sol 慣例に注目)
├── script/ ← Foundry 専用: deployment script はここに住む (レッスン4でカバー)
│ └── Counter.s.sol ← デフォルトの deploy script
├── lib/ ← Cargo の deps cache 相当、ただし git submodule
│ └── forge-std/ ← standard test library — 常に存在
├── README.md
└── .gitignore ← out/ と cache/ を ignore するよう事前設定済み
レイアウトで押さえる点が 4 つ。
.t.solと.s.solはファイル命名慣例であって、コンパイラが enforce するものではない。 Foundry はtest/内のコントラクトで関数名がtestで始まるものを test として扱う。.t.solサフィックスは人間可読のための慣例で、*.t.solで grep すれば全 test ファイルが見つかる。.s.solで script ファイルも同様。Foundry は naming convention を使い、Rust は attribute を使う。規律は同じ。lib/forge-stdは git submodule で、npm/cargo dep ではない。forge initがforge install foundry-rs/forge-stdを走らせ、lib/に clone する。Versioning は git tag か commit。Cargo/npm の依存解決の複雑性より genuinely simple だが、dep ごとに 1 つの git submodule が要る。Foundry の dep モデルは、semver の複雑性を git の透明性と引き換えにする —cd lib/forge-std && git logで依存しているコードを正確に見られる。out/とcache/はデフォルトで gitignore されている。out/はコンパイル済み bytecode + ABI JSON を保持する(Rust のtarget/debug/相当)。cache/は incremental compilation state を保持する。どちらも安全に消して再生成できる。そしてどちらも絶対に commit してはいけない。script/は deployment script 用 (レッスン4で簡単に触れる)。 Foundry は testing と scripting を同じforgebinary 下に統合する。Hardhat は 2 つのツールに split する (hardhat testvshardhat run)。統合は小さいが、1 日の context-switching コストを削減する。1 つの binary、1 つの config、1 つのメンタルモデル。
手を動かす walk-through
Step 1: forge init して見回す
forge init my-foundry-lab
cd my-foundry-lab
ls -la
前セクションのレイアウトが見えるはず。lib/forge-std/ が空なら(init 中のネットワーク問題)、forge install foundry-rs/forge-std を走らせて修正する。
forge test
期待される出力(短縮版):
[⠊] Compiling...
[⠒] Compiling 27 files with Solc 0.8.35
[⠢] Solc 0.8.35 finished in 1.49s
Compiler run successful!
Ran 2 tests for test/Counter.t.sol:CounterTest
[PASS] test_Increment() (gas: 31303)
[PASS] testFuzz_SetNumber(uint256) (runs: 256, μ: 31000, ~: 31161)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 5.67ms
Ran 1 test suite in 12.46ms (5.67ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 tests)
2 つの test、両方とも green。 First compile は数秒。その後の run は sub-second。
出力フォーマットで押さえる点が 3 つ。
(gas: 31303)— すべての test が消費した gas を報告する。Hardhat はデフォルトで表示しない。Foundry は gas を first-class metric として扱う。(コンセンサス決定性に慣れた engineer 向けに補足: gas は EVM の「コンセンサスコスト」相当だ。同じ transaction に対してすべての validator が同じ gas を計算する。Tracking は規律の一部。)(runs: 256, μ: 31000, ~: 31161)— その test は fuzz test (理由は レッスン2で説明)。runs: 256は 256 個のランダム入力で走らせたという意味。μは mean gas、~は median。Foundry は fuzz の統計を inline で表示する。5.67ms CPU time— Foundry は wall-clock と CPU time を別々に表示する。並列 test suite では CPU time が wall-clock を超える。2-test suite では同じになる。
Step 2: src/Counter.sol を読む
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.35;
contract Counter {
uint256 public number;
function setNumber(uint256 newNumber) public {
number = newNumber;
}
function increment() public {
number++;
}
}
押さえる点が 5 つ。
pragma solidity ^0.8.35—^は caret style version 制約 (Cargo と同じ構文) で、「0.8.35 以上、ただし 0.9 未満」を意味する。Solidity 0.8 が規律の境界線: built-in overflow check が導入された (SafeMath不要)。これこそが後のtest_RevertWhen_DecrementBelowZerotest を可能にしている。uint256 public number—publicが getter 関数 (number()) を自動生成する。State variable 自体はコントラクト内部から直接書ける。外部からは自動生成された getter のみ呼び出せる。Solidity はlet pubとlet pub fn ...()を 1 つの宣言に collapse する。- コンストラクタなし。 デフォルト初期化で
number = 0。Rust のi64::default()と同じ default-zero semantics。 setNumberとincrementはpublic— 誰でも呼べる。(onlyOwnerのような制限 modifier は production ではここに入る。例は意図的に permissionless。)decrement関数は存在しない。 これがヒント。新規 test は test ファイル内で decrement を加えず に underflow を直接トリガする (local 構築経由)。
Step 3: test/Counter.t.sol を読む
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.35;
import {Test, console} from "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";
contract CounterTest is Test {
Counter public counter;
function setUp() public {
counter = new Counter();
counter.setNumber(0);
}
function test_Increment() public {
counter.increment();
assertEq(counter.number(), 1);
}
function testFuzz_SetNumber(uint256 x) public {
counter.setNumber(x);
assertEq(counter.number(), x);
}
}
押さえる点が 6 つ。
import {Test, console} from "forge-std/Test.sol"—Testがすべての test が継承する base contract で、assertEq/assertTrue/vm.*cheatcodes 等を bundle する。console.logは Foundry のdbg!macro 相当 — 実際のコントラクト bytecode に影響しない print-debugging だ。contract CounterTest is Test— test ファイル自体がforge-stdのTestを継承するコントラクト。関数継承でassertEqとvm.*への access を得る。Solidity の継承は tooling の API surface だ。Rust は trait +useを使う。function setUp() public— すべての test 関数の前に走る。Rust の#[test]ごとの init と同じ役割を 1 つの関数に集約したものだ。Test contract あたりsetUp1 つ。Per-test の setup が欲しければ、個別の test 関数内に wrap する。function test_Increment() public— 名前がtestで始まり、publicでマーク。それだけ、annotation なし。Foundry の test discovery は name-based。「underscore-suffix-or-prefix が kind を名付ける」慣例は Solidity 版の Rust attribute システムだ。testFuzz_SetNumber(uint256 x)— 名前がtestFuzzで始まり、かつ parameter を取る。Foundry はこれを fuzz test と解釈する (レッスン2でカバー)。setNumber(x)が 256 個のランダムuint256値で呼ばれる。Assertion はそのすべてに対して成立する必要がある。assertEq(counter.number(), 1)— equality assertion。forge-stdのTestはassertEqを すべての Solidity primitive 型 (uint,int,bool,address,bytes,string,bytes32, ...) に対して overload する。型付きの variant を選ぶ必要はない。引数の型から正しい overload が選ばれる。1 行 assertion、Rust のlet x = ...; let y = ...; assert_eq!(x, y);の cascade はなし。
Step 4: vm.expectRevert で negative-path test を追加
Counter コントラクトには increment はあるが decrement はない。Solidity 0.8 は built-in overflow check を持つので、uint256(0) からの減算は Panic(uint256) で revert する (underflow の panic code は 0x11)。これを exercise する test を、underflow を test 内に inline で trigger しながら書く。
test/Counter.t.sol に追記:
function test_RevertWhen_DecrementBelowZero() public {
// Counter starts at 0 from setUp(). Decrementing should revert
// with the Solidity 0.8 built-in arithmetic-panic (overflow code 0x11).
// forge-std's `Test` exposes `vm.expectRevert(bytes)` for matching
// arbitrary revert reasons.
vm.expectRevert();
// Trick: writing `uint256(0) - 1` as a literal would be constant-
// folded by Solc and rejected at *compile time*. We want the
// underflow at *runtime* so vm.expectRevert can catch it. Storing
// the zero in a local variable defeats the constant folder — the
// subtraction becomes a runtime SUB opcode, which Solidity 0.8
// wraps with the overflow check that triggers Panic(0x11).
//
// Important: `zero - 1` evaluates *in this test contract* — the
// argument to setNumber must be computed before the external call
// is made. So the panic fires here, in the test contract, and the
// call to `counter.setNumber` is never actually dispatched. A trace
// (`forge test -vvvv`) shows no call into `counter`. vm.expectRevert
// still catches it because it intercepts any revert that occurs
// between arming and the next external-call site.
uint256 zero = 0;
counter.setNumber(zero - 1);
}
押さえる点が 6 つ。
-
test_RevertWhen_<condition>は negative-path test の Foundry docs での命名慣例。 Test runner は強制しない (forge testはサフィックスを気にしない)。だが慣例が test 一覧を self-documenting にする。Tooling が構造を強制しないとき、命名慣例がドキュメントになる。 -
vm.expectRevert()を引数なしで — 任意の revert reason に一致する。具体的な reason を気にしないときは引数なし形を使う。具体的な reason を assert したいときはvm.expectRevert(bytes)を使う (レッスン3で custom error と一緒に見る)。 -
vm.expectRevertは revert を期待する call の 直前 に呼ばなければならない。 Wrapper ではなく、次の external call を arm する one-shot cheatcode だ。expectRevertと target の間に何か別の呼び出しを挟むと、cheatcode は wrong call で trigger し、test が紛らわしく失敗する。Lifetime はちょうど 1 call ぶん:時間 ──► ┌──────────────────────────────────────────────────────────┐ │ 正しい — vm.expectRevert が *次の* external call を arm │ ├──────────────────────────────────────────────────────────┤ │ vm.expectRevert(); ←─── 罠を arm する │ │ counter.setNumber(...); ←─── 罠が発火、revert を期待 │ │ ✓ call が revert すれば test 成功 │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ 誤り — 間に挟まる call が arm を先に消費してしまう │ ├──────────────────────────────────────────────────────────┤ │ vm.expectRevert(); ←─── 罠を arm する │ │ counter.number(); ←─── 罠はここで発火するが、 │ │ ✗ revert しない → arm 消費 │ │ counter.setNumber(...); ←─── unarmed で走り、real な │ │ revert は test に捕まらない │ └──────────────────────────────────────────────────────────┘vm.expectRevertは 1-call の lifetime を持つ。順序を尊重する。 -
uint256 zero = 0; zero - 1パターンは constant-folding 回避策。uint256(0) - 1をリテラル式として書くと見た目は同じだが、コンパイルが通らない — Solc 0.8 はリテラル算術をコンパイル時に評価し、underflow を検出してソースを reject する。ゼロをローカル変数に格納すると、constant folder の目を欺ける: SUB opcode が runtime で走り、Solidity 0.8 がunchecked {}の外のあらゆる算術 op に挿入する runtime overflow check がPanic(0x11)を trigger する。Compile-time と runtime の overflow check は別の layer に住む。書き方がどちらを発火させるかを決める。ここで極めて重要なのは、「パニック(Revert)が発生する実行レイヤー(コンテキスト)の所在」 を理解することだ:
- テスト契約内のローカル評価パニック(今回):
zero - 1の減算は、counter.setNumber(...)への引数を構築する過程、すなわち このテストコントラクト自身のコンテキスト(テストランナーの実行フレーム)内 で評価され、そこでPanic(0x11)がトリガーされる。このため、ターゲットコントラクトであるcounterへの外部呼び出し(external call)自体はディスパッチすらされない。-vvvvトレースにcounterへのコールが一切記録されないのはこのためだ。それでもテストがパスするのは、vm.expectRevert()が「次の外部コールが呼び出されるまでの間に、同一実行フレーム(テストランナーコンテキスト)で発生した revert」も捕捉する仕様になっているからである。 - 外部コントラクト内でのパニック(一般的なケース): 対照的に、もし
Counterコントラクト側にdecrement()関数が存在し、その内部でnumber - 1を実行してアンダーフローさせた場合、パニックは 外部のCounterコントラクトのコンテキスト(EVM の別実行フレーム) で発生し、呼び出し元であるテストコントラクトへ revert データ(Panic(0x11))がバブルアップする。この場合、-vvvvトレースには明示的にCounter::decrement()へのコールと、それがPanic(0x11)で失敗したログが残る。
Rust のメンタルモデルで例えるなら、前者は関数の実引数を評価する際(呼び出し側)に
panic!が発生する状態であり、後者は呼び出された関数(被呼び出し側)の内部でpanic!が発生し、呼び出し境界を越えてスタックトレースが伝播してくる状態に相当する。このように、パニックがどのレイヤーで発生しているかをトレースから見極めることは、複雑な統合テストのデバッグにおいて不可欠なスキルとなる。 - テスト契約内のローカル評価パニック(今回):
-
コメントブロックが test の意図を step-by-step で walk する。 openhl-liquidation レッスン13の test と同じ
math-walk in comments規律だ。失敗を debug する将来の reader はコメントを読んで期待される挙動を再導出できる。Math-walk コメントが 1 つの test を、テスト対象の EVM 挙動の worked example に変える。 -
Counter.solにdecrement()を追加していない — underflow を test 内部で直接 trigger した。Production contract を変更せずに挙動を exercise できるという意味だ。Real なdecrementメソッドがある production contract では、test はcounter.decrement()を直接呼ぶ。Test は contract を変更せずに minimal シナリオを構築できる。
Step 5: forge test -vvv で走らせる
forge test -vvv
期待される出力(短縮版):
Ran 3 tests for test/Counter.t.sol:CounterTest
[PASS] testFuzz_SetNumber(uint256) (runs: 256, μ: 31000, ~: 31161)
[PASS] test_Increment() (gas: 31303)
[PASS] test_RevertWhen_DecrementBelowZero() (gas: 8957)
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 7.12ms
3 つの test pass。 test_RevertWhen_DecrementBelowZero の gas コスト (~9k) が他 (~31k) よりはるかに低いことに注目。EVM が call の途中で revert しているからだ。Revert は成功より安い。成功 path で起きるはずの SSTORE が gas を食う。
意図的に test を壊して -vvv が失敗時に何を見せるか確認するには、test を一時的にこう変える。
vm.expectRevert();
counter.setNumber(42); // これは revert しない。Test は失敗するはず。
forge test -vvv を再実行:
[FAIL: call did not revert as expected] test_RevertWhen_DecrementBelowZero() (gas: ...)
-vvv は storage trace を追加し、-vvvvv は opcode-level trace を追加する。Verbosity こそが debug tool だ。
続行する前に test を元の pass 版に戻す。
エラー時にありがちなパターン。
Source "forge-std/Test.sol" not found—forge install foundry-rs/forge-stdを実行しておらず、lib/forge-std/が空。今走らせる。(forge initが通常やってくれるが、ネットワーク不具合で skip されることがある。)Error: test_RevertWhen_DecrementBelowZero() FAILED. Reason: call did not revert as expected— Solidity version が 0.8.x ではなく、built-in overflow check を欠いている。Counter.solの冒頭でpragma solidity ^0.8.35を確認する。compile error: not found: Counter— import path が wrong。Test ファイルはimport {Counter} from "../src/Counter.sol"と書く。相対 path を再確認する。
Step 6: --match-test で test を filter
forge test --match-test test_Increment -vvv
test_Increment test のみを走らせ、fuzz test と revert test を無視する。1 つの test を反復するときに便利だ。Foundry の compile cache のおかげで、単一 test の subsequent run は ~50ms で済む。
他の便利な filter。
--match-contract CounterTest— 特定 contract の全 test を走らせる--match-path 'test/Counter.t.sol'— 特定 file の全 test を走らせる--no-match-test testFuzz_SetNumber— 特定 test を skip (!-style negation)
Rust への cross-reference。 forge test --match-test foo は cargo test foo と同じ partial-name match だ。--match-* ファミリーはどの axis で filter しているかを明示的にする。Tooling が同じワークフローに収斂するとき、syntax も収斂する。
設計の振り返り
Foundry の forge test を形作った load-bearing な決定が 3 つ。
-
Test は JavaScript ではなく Solidity に住む。 Hardhat の test は JS ファイルで、ethers.js 経由でコントラクトを呼び出す。Foundry の test は コントラクトそのもの — production code と同じ言語、同じコンパイラ、同じ bytecode。これが context-switching を 1 層丸ごと collapse する。Test code と production code がコンパイラを共有すれば、
import {Counter} from "../src/Counter.sol"で test surface 全体を statically type-check できる。 -
Test discovery は attribute ではなく name で行う。 Foundry が
@Testannotation を必要としないのは、Solidity に decorator がないからだ。test*で名付けられた関数が test。慣例はforgeの test contract の関数リストへの grep で enforce される。Tooling 出力に documented された慣例は、人間 reader にとって attribute と等価だ。両方とも「これは test」signal を生む。 -
vm.*cheatcodes は precompile であって、JS 側 wrapper ではない。 Hardhat のevm_snapshotは RPC method。Foundry のvm.expectRevertは precompile call。Cheatcode はアドレス0x7109709ECfa91a80626fF3989D68f67F5b1DD12Dに住み、Foundry の REVM fork がそのアドレスへの call を intercept する。まさに openhl Stage 9 の precompile-as-EVM-superpower パターン。レッスン1ではvm.expectRevertだけを使った。レッスン2と レッスン3でさらに cheatcode が登場する。それぞれが precompile だ。
答え合わせ
このレッスンの「答え合わせ」は forge init が生成するもの + あなたが追加した 1 つの test 関数。ディレクトリ構造はこうなる。
my-foundry-lab/
├── foundry.toml (init から変更なし)
├── src/Counter.sol (init から変更なし)
├── script/Counter.s.sol (init から変更なし)
├── test/Counter.t.sol (新規 test 関数で +10 行)
└── lib/forge-std/ (git submodule、変更なし)
レッスン1の後:
forge testが 3 つの test を pass するCounter.solとCounter.t.solの各行を読み、慣例を理解したvm.expectReverttest を追加し、-vvvが何を見せるか確認した
レッスン 1〜5には in-repo の答え合わせがない。forge init の出力が答え合わせだからだ。同じ Foundry version なら、すべての reader に対して同じ出力が出る。レッスン6の capstone がこれを変える: レッスン6では rethlab/examples/foundry-capstone/ の特定の InsuranceFund.sol + test に対して作業する。
よくある質問
Q1: なぜ本コースが deployment を本格的にカバーしないのに forge init が script/ を作るのか?
forge が testing と deployment scripting の両方を扱う 1 つの binary だから。プロジェクトレイアウトには両方のための slot がある。片方しか使わなくても、だ。script/ は レッスン4で軽く触れる (cast send + forge script 経由のシンプルな deploy)。Full な deployment ワークフローはそれ自体が別コース (スコープ外、レッスン0の「含まれないもの」リスト通り)。
Q2: なぜ setUp() が全 test の前に走り、contract 全体に 1 度ではないのか?
Foundry の test isolation が各 test を fresh な EVM state に対して走らせるからだ。どの test も state を別の test に leak できない。setUp() は per-test initializer。一度だけの global init (heavy fixture など) が欲しければ、test contract のコンストラクタに setup する。それは test contract がデプロイされたときに 1 度走る。Per-test isolation がデフォルトなのは、cross-test の state leak がどんな test runner でも flaky test の #1 source だからだ。
Q3: なぜ assertEq(counter.number(), 1) が number() を関数として呼び、field として読まないのか?
uint256 public number がその名前の getter 関数を自動生成するから。同じ contract 内部からは number と書ける。外部からは — test がいる場所、つまり CounterTest は Counter とは別の contract だ — counter.number() を呼ぶ必要がある。Public state variable は Solidity の get* 関数の syntactic sugar。Call-site 構文が裏で生成された関数を反映する。
Q4: -vvv が default 出力に何を追加するのか?
段階はこう。
-v/ フラグなし: pass/fail summary-vv: 失敗 test がエラーメッセージを得る-vvv: 失敗 test が stack trace を得る (どの関数がどれを呼んだか)-vvvv: 失敗・通過の両方が stack trace を得る-vvvvv: opcode-level の実行 trace (最も深い debug モード)
実務的には日常開発に -vvv (速い、失敗時にだけ興味深いものが見える)、変な revert で詰まったときだけ -vvvvv。
Q5: test/ の外の別ファイルで test を書けるか?
書ける。foundry.toml を設定して他の test path を追加できる。だが default の test/ ディレクトリが慣例で、tooling integration (IDE plugin、CI matrix) はそれを前提にする。Real な理由がない限り (各 contract チームが独自 test/ subdir を欲しがる巨大 monorepo など) default のままで。慣例は default が sane であるとき configuration に勝つ。
Q6: なぜ Solidity は Rust の [package] edition = "2024" のような形ではなく pragma solidity ^0.8.35 を持つのか?
言語進化モデルが違うから。Rust の edition は epoch で、古い構文を壊さずに default を変える (例: 2024 が新しいキーワード予約を有効化)。Solidity の pragma はどの compiler version が file をビルドできるかを制約する。Solidity では compiler bug が一般的で、コンセンサス決定性が mid-deploy version 不一致を catastrophic にする。だからこちらのほうが重い。Solidity の pragma は Cargo.toml の edition = "2024" よりも rust-version = "1.85" に近い。
次のレッスン(レッスン2) — forge fuzz — Solidity の proptest!
レッスン 2 は testFuzz_SetNumber test を property-based testing の real な working example に変える。openhl-liquidation の レッスン 9 が "proptest" と呼ぶものだ。学ぶこと:
- Default の 256-iteration fuzz cycle と
foundry.toml経由でのバンプ vm.assume(condition)—prop_assume!の Solidity 等価物、precondition に違反する入力を filter する- Shrinking — Foundry が 32-byte counterexample を、failure を trigger する最小
uint256まで reduce する仕組み - Corpus persistence —
cache/fuzz/が失敗入力を保存し、re-run が同じ counterexample を即座に replay する
レッスン2後、Solidity で最初の保存則 fuzz test を書き終えている。openhl-liquidation レッスン8の balance_never_negative proptest に 1:1 で map するものだ。
合格基準
- プロジェクト形マッピング(
Cargo.toml/src/lib.rs/tests//target/)の Foundry 等価物を即答できる。 - Test discovery が naming convention(
test*/testFuzz_*)で行われる理由を即答できる。 assertEq/assertTrue/vm.expectRevert3 primitives の役割を即答できる。- Verbosity ladder(
-vから-vvvvv)と日常使う-vvvの理由を即答できる。 vm.expectRevertの 1-call lifetime と arm 消費パターンを 1 文で説明できる。- Constant-folding 回避(ローカル変数経由)の仕組みを 1 文で説明できる。
- ローカル panic vs 外部 panic の trace 上の現れ方の違いを即答できる。
まとめ(3行)
forge test=cargo testの Solidity 等価物、in-process REVM で sub-second feedback、--match-test/--match-contract/--match-pathで filter、-vvvが daily default。- Test discovery は attribute ではなく name + parameter signature、
testFuzz_*+ parameter = fuzz test、test_RevertWhen_*= negative-path 慣例、Solidity の decorator 不在を name で代替。 vm.expectRevertは 1-call lifetime で次の external call を arm、constant-folding 回避はローカル変数経由で runtime SUB opcode 強制、panic はテストコントラクト内なら trace に現れず外部コントラクト内なら明示。