FABRKNT
Foundry を極める — Rust 規律を Solidity へ移す
Test discipline
レッスン 2 / 7·CONTENT25 分50 XP
コース
Foundry を極める — Rust 規律を Solidity へ移す
レッスンの役割
CONTENT
順序
2 / 7

レッスン1 — forge testcargo test の Solidity 等価物

問い

forge init のプロジェクト形は cargo new --lib の Foundry 等価物。forge testcargo test と同じ位置の binary、first compile 後は sub-second feedback、同じ --match-* filtering。Solidity の test discovery + assertion + verbosity ladder + negative-path test (vm.expectRevert) を、Rust 規律と並べて理解する。

原理(最小モデル)

  • プロジェクト形マッピング. Cargo.tomlfoundry.toml / src/lib.rssrc/Counter.sol / tests/integration_test.rstest/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 testcargo 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 testcargo 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 は assertEqassert_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 -- --nocaptureprintln! 出力が見える)。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.solforge 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 つ。

  1. forge init my-project && cd my-project — 標準 Foundry プロジェクトレイアウトを作成する。何かを触る前に生成物を読む。
  2. src/Counter.soltest/Counter.t.sol をそのまま読む — どちらも forge init で同梱、慣例を実演している。1 行ずつ理解する。
  3. test/Counter.t.sol に test を 1 つ追加test_RevertWhen_DecrementBelowZero。新規の Counter (setUp()number = 0) に対して、constant-folding を回避するために uint256 zero = 0; を経由した zero - 1c.setNumber(...) に渡し、vm.expectRevert で revert を assert する。forge test -vvv で full trace を見ながら走らせる。

(答え: Cargo.tomlfoundry.tomlsrc/lib.rssrc/Counter.sol(あるいは main コントラクトとして付けた名前)、tests/integration_test.rstest/Counter.t.soltarget/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 つ。

  1. .t.sol.s.sol はファイル命名慣例であって、コンパイラが enforce するものではない。 Foundry は test/ 内のコントラクトで関数名が test で始まるものを test として扱う。.t.sol サフィックスは人間可読のための慣例で、*.t.sol で grep すれば全 test ファイルが見つかる。.s.sol で script ファイルも同様。Foundry は naming convention を使い、Rust は attribute を使う。規律は同じ。
  2. lib/forge-std は git submodule で、npm/cargo dep ではない。 forge initforge 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 で依存しているコードを正確に見られる。
  3. out/cache/ はデフォルトで gitignore されている。 out/ はコンパイル済み bytecode + ABI JSON を保持する(Rust の target/debug/ 相当)。cache/ は incremental compilation state を保持する。どちらも安全に消して再生成できる。そしてどちらも絶対に commit してはいけない。
  4. script/ は deployment script 用 (レッスン4で簡単に触れる)。 Foundry は testing と scripting を同じ forge binary 下に統合する。Hardhat は 2 つのツールに split する (hardhat test vs hardhat 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 つ。

  1. (gas: 31303) — すべての test が消費した gas を報告する。Hardhat はデフォルトで表示しない。Foundry は gas を first-class metric として扱う。(コンセンサス決定性に慣れた engineer 向けに補足: gas は EVM の「コンセンサスコスト」相当だ。同じ transaction に対してすべての validator が同じ gas を計算する。Tracking は規律の一部。)
  2. (runs: 256, μ: 31000, ~: 31161) — その test は fuzz test (理由は レッスン2で説明)。runs: 256 は 256 個のランダム入力で走らせたという意味。μ は mean gas、~ は median。Foundry は fuzz の統計を inline で表示する。
  3. 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 つ。

  1. pragma solidity ^0.8.35^ は caret style version 制約 (Cargo と同じ構文) で、「0.8.35 以上、ただし 0.9 未満」を意味する。Solidity 0.8 が規律の境界線: built-in overflow check が導入された (SafeMath 不要)。これこそが後の test_RevertWhen_DecrementBelowZero test を可能にしている。
  2. uint256 public numberpublic が getter 関数 (number()) を自動生成する。State variable 自体はコントラクト内部から直接書ける。外部からは自動生成された getter のみ呼び出せる。Solidity は let publet pub fn ...() を 1 つの宣言に collapse する。
  3. コンストラクタなし。 デフォルト初期化で number = 0。Rust の i64::default() と同じ default-zero semantics。
  4. setNumberincrementpublic — 誰でも呼べる。(onlyOwner のような制限 modifier は production ではここに入る。例は意図的に permissionless。)
  5. 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 つ。

  1. 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 だ。
  2. contract CounterTest is Test — test ファイル自体が forge-stdTest を継承するコントラクト。関数継承で assertEqvm.* への access を得る。Solidity の継承は tooling の API surface だ。Rust は trait + use を使う。
  3. function setUp() publicすべての test 関数の前に走る。Rust の #[test] ごとの init と同じ役割を 1 つの関数に集約したものだ。Test contract あたり setUp 1 つ。Per-test の setup が欲しければ、個別の test 関数内に wrap する。
  4. function test_Increment() public — 名前が test で始まり、public でマーク。それだけ、annotation なし。Foundry の test discovery は name-based「underscore-suffix-or-prefix が kind を名付ける」慣例は Solidity 版の Rust attribute システムだ。
  5. testFuzz_SetNumber(uint256 x) — 名前が testFuzz で始まり、かつ parameter を取る。Foundry はこれを fuzz test と解釈する (レッスン2でカバー)。setNumber(x) が 256 個のランダム uint256 値で呼ばれる。Assertion はそのすべてに対して成立する必要がある。
  6. assertEq(counter.number(), 1) — equality assertion。forge-stdTestassertEqすべての 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 つ。

  1. test_RevertWhen_<condition> は negative-path test の Foundry docs での命名慣例。 Test runner は強制しない (forge test はサフィックスを気にしない)。だが慣例が test 一覧を self-documenting にする。Tooling が構造を強制しないとき、命名慣例がドキュメントになる。

  2. vm.expectRevert() を引数なしで任意の revert reason に一致する。具体的な reason を気にしないときは引数なし形を使う。具体的な reason を assert したいときは vm.expectRevert(bytes) を使う (レッスン3で custom error と一緒に見る)。

  3. 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 を持つ。順序を尊重する。

  4. 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! が発生し、呼び出し境界を越えてスタックトレースが伝播してくる状態に相当する。このように、パニックがどのレイヤーで発生しているかをトレースから見極めることは、複雑な統合テストのデバッグにおいて不可欠なスキルとなる。

  5. コメントブロックが test の意図を step-by-step で walk する。 openhl-liquidation レッスン13の test と同じ math-walk in comments 規律だ。失敗を debug する将来の reader はコメントを読んで期待される挙動を再導出できる。Math-walk コメントが 1 つの test を、テスト対象の EVM 挙動の worked example に変える。

  6. Counter.soldecrement() を追加していない — 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 foundforge 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 foocargo test foo と同じ partial-name match だ。--match-* ファミリーはどの axis で filter しているかを明示的にする。Tooling が同じワークフローに収斂するとき、syntax も収斂する。

設計の振り返り

Foundry の forge test を形作った load-bearing な決定が 3 つ。

  1. 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 できる。

  2. Test discovery は attribute ではなく name で行う。 Foundry が @Test annotation を必要としないのは、Solidity に decorator がないからだ。test* で名付けられた関数が test。慣例は forge の test contract の関数リストへの grep で enforce される。Tooling 出力に documented された慣例は、人間 reader にとって attribute と等価だ。両方とも「これは test」signal を生む。

  3. 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.solCounter.t.sol の各行を読み、慣例を理解した
  • vm.expectRevert test を追加し、-vvv が何を見せるか確認した

レッスン 1〜5には in-repo の答え合わせがない。forge init の出力が答え合わせだからだ。同じ Foundry version なら、すべての reader に対して同じ出力が出る。レッスン6の capstone がこれを変える: レッスン6では rethlab/examples/foundry-capstone/ の特定の InsuranceFund.sol + test に対して作業する。

よくある質問

Q1: なぜ本コースが deployment を本格的にカバーしないのに forge initscript/ を作るのか?

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 がいる場所、つまり CounterTestCounter とは別の 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.expectRevert 3 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 signaturetestFuzz_* + 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 に現れず外部コントラクト内なら明示。