デプロイしたコントラクトは直せません。リリース後にパッチを当てるという選択肢がない開発では、テストの位置づけが変わります。「品質を上げる活動」ではなく、「出す前にしかできない唯一の検証」です。

本書のコードは 67 テストが 0.1 秒未満で完走します。この速さは、書く量そのものを変えます。

4 つの層

書いているテストを分類するとこうなります。

何を確かめるか
単体期待どおりに状態が変わるか参加すると status が Active になる
失敗経路正しく失敗するか他人の手番で着手すると revert する
fuzzランダム入力でも不変条件が保たれるか払い出し合計 = ポット
全探索性質そのものを証明するHard な CPU は絶対に負けない

Web 開発と一番違うのは 2 番目の比重です。コントラクトのコードの半分は「やってはいけないことを止める」ためにあります。だからテストの半分も、止まることの確認になります。

失敗経路を書く

function test_Play_RejectsOutOfTurn() public {
    uint256 gameId = _joined();
    vm.prank(bob);
    vm.expectRevert(abi.encodeWithSelector(TicTacToe.NotYourTurn.selector, bob, alice));
    game.play(gameId, 0);
}

vm.expectRevertカスタムエラーとその引数まで指定しているのがポイントです。「何かで失敗した」ではなく「NotYourTurn(bob, alice) で失敗した」を検証しています。

これができるのは、require の文字列ではなくカスタムエラーを使っているからです。

error NotYourTurn(address caller, address expected);
...
if (msg.sender != expected) revert NotYourTurn(msg.sender, expected);

カスタムエラーには利点が 3 つあります。

  1. 安い — エラーメッセージの文字列をバイトコードに埋め込まない。4 バイトのセレクタと引数だけ
  2. 型がある — テストで引数まで検証できる。フロントエンドでも構造化して扱える
  3. 翻訳できる — フロントで NotYourTurn というエラー名を受け取り、多言語のメッセージに変換できる

3 番目は実際にやっています。本書のフロントは viem が復元したエラー名を i18n のキーとして使い、「あなたの手番ではありません」と表示します。エラーの表現が、コントラクトからフロントまで型のある形でつながっています。

fuzz テスト

引数を取るテスト関数は、自動的に fuzz になります。

function testFuzz_PotConservation(uint96 stake, uint16 feeBps) public {
    stake = uint96(bound(stake, 0, 10 ether));
    feeBps = uint16(bound(feeBps, 0, game.MAX_FEE_BPS()));

    vm.prank(owner);
    game.setFeeBps(feeBps);
    // ... 1 局を最後まで進める ...

    uint256 pot = uint256(stake) * 2;
    assertEq(game.pending(alice) + game.pending(owner), pot);
    assertEq(address(game).balance, pot);
}

bound で入力を有効な範囲に写像します。vm.assume で弾く方法もありますが、bound のほうが無駄打ちが出ませんassume は範囲外の入力を捨てて引き直すので、範囲が狭いと実行回数が減ってしまいます)。

検証しているのは不変条件です。「賭け金がいくらでも、手数料率がいくつでも、払い出しの合計はポットに一致し、コントラクトの残高もポットに一致する」。1 wei も生まれず、消えていない。

もう 1 つ、入力を信じない fuzz も書いています。

function testFuzz_NeverOverpays(uint8 a, uint8 b, uint8 c, uint8 d, uint8 e) public {
    uint256 gameId = _joined();
    uint8[5] memory attempts = [a % 9, b % 9, c % 9, d % 9, e % 9];

    for (uint256 i = 0; i < 5; ++i) {
        TicTacToe.Game memory g = game.getGame(gameId);
        if (g.status != TicTacToe.Status.Active) break;
        vm.prank(g.moves % 2 == 0 ? alice : bob);
        try game.play(gameId, attempts[i]) {} catch {}
    }

    assertLe(game.pending(alice) + game.pending(bob) + game.pending(owner), STAKE * 2);
    assertEq(address(game).balance, STAKE * 2);
}

try / catch で失敗を握りつぶしているのがミソです。ランダムな手順(既に埋まったマスへの着手を含む)を投げつけて、それでも払い出しがポットを超えないことを確認します。何が起きても金は湧かない、という主張です。

実行回数は foundry.toml で増やせます。

[profile.default.fuzz]
runs = 512

全探索で証明する

まるばつは状態空間が小さい。だから「たぶん強い」ではなく「絶対に負けない」を検証できます。

CPU の Hard は決定論的です。局面が同じなら必ず同じ手を返します。つまり分岐するのは人間の手番だけです。

  • 人間が先手なら、選択肢は 9 → 7 → 5 → 3 → 1 で 945 通り
  • CPU が先手なら 8 → 6 → 4 → 2 で 384 通り

合計 1,300 通り強。全部やれます。

function test_HardNeverLoses() public view {
    _explore(0, 0, true);                                  // 人間が先手
    uint8 opening = solo.previewCpuMove(0, 0, HARD, 0);
    _explore(Board.bit(opening), 0, true);                 // CPU が先手
}

function _explore(uint16 cpu, uint16 human, bool humanToMove) private view {
    uint16 empty = Board.emptyMask(cpu, human);
    if (empty == 0) return;                                // 満局。引き分けなので OK

    if (humanToMove) {
        for (uint8 cell = 0; cell < 9; ++cell) {
            if (!Board.contains(empty, cell)) continue;
            uint16 next = human | Board.bit(cell);
            assertFalse(Board.isWin(next), "the Hard strategy allowed a loss");
            _explore(cpu, next, false);
        }
        return;
    }

    uint8 reply = solo.previewCpuMove(cpu, human, HARD, 0);
    uint16 nextCpu = cpu | Board.bit(reply);
    if (Board.isWin(nextCpu)) return;                      // CPU の勝ち。終端
    _explore(nextCpu, human, true);
}

結果は 1 行です。

[PASS] test_HardNeverLoses() (gas: 22627723)

2,262 万ガス使っていますが、テストなので実際に払うわけではありません。ローカルの EVM が計算しているだけです。

このテストの価値は、コメントに「この戦略は完璧です」と書くのとは質的に違います。戦略のロジックを 1 行いじれば、その瞬間にテストが落ちます。 後から手を入れる人(半年後の自分を含む)が、うっかり強さを壊せなくなります。

戦略を検査可能にするために、previewCpuMove という pure な関数を外部に公開しました。

function previewCpuMove(uint16 boardCpu, uint16 boardPlayer, Difficulty difficulty, uint256 seed)
    external pure returns (uint8)

ストレージに触らないので、任意の局面を渡して手を問い合わせられます。テストのために設計を少し開いたわけですが、フロントエンドが「ヒント」機能を作るのにも使えるので、無駄にはなっていません。

弱い CPU が弱いこともテストする

「強いこと」だけでなく「意図した弱さがあること」も検証します。

function test_NormalLosesToAFork() public {
    // 対角の 2 隅 + 3 つ目の隅で二重の狙いを作る。Normal はフォークを見ないので
    // 片方しか防げない。
    uint256 gameId = _newGame(NORMAL, false);
    _play(gameId, 0);
    _play(gameId, 8);
    _play(gameId, 6);
    _play(gameId, 7);

    assertEq(uint8(_result(gameId)), uint8(SoloTicTacToe.Result.PlayerWon));
}

function test_HardBlocksTheSameFork() public {
    // Normal を破る同じ手順が、Hard には通じない。
    uint256 gameId = _newGame(HARD, false);
    _play(gameId, 0);
    _play(gameId, 8);

    // Hard は二重の狙いを先読みして、こちらに手を強いる場所を取る。
    // フォークが完成しないので、勝負はまだ続いている。
    SoloTicTacToe.Solo memory g = solo.getGame(gameId);
    assertEq(uint8(g.result), uint8(SoloTicTacToe.Result.InProgress));
    assertFalse(Board.isWin(g.boardPlayer));
}

同じ手順を両方に投げて、結果が違うことを見る。 難易度の差が実在することの証明になります。「Normal を強くしすぎた」というリグレッションが検出できます。

カバレッジと落とし穴

forge coverage

行カバレッジが出ます。ただしカバレッジ 100% は「バグがない」を意味しません。特にコントラクトでは、危険なのは実行されなかった行ではなく想定しなかった呼び出し順序です。

だから本書では、カバレッジより次を重視しています。

  • 資金が出ていく経路を全部列挙する(第 11 章の表)
  • 不変条件を fuzz で叩く
  • 決定論的なロジックは全探索する

テストで使ったチートコード

やること
vm.prank(addr)次の呼び出しの msg.sender を差し替える
vm.deal(addr, n)残高を配る
vm.warp(ts)時刻を進める。締切のテストに必須
vm.roll(n)ブロック番号を進める。blockhash を使うなら要る
vm.expectRevert(...)失敗の型と引数まで検証する
vm.expectEmit(...)イベントの発火を検証する
makeAddr("alice")名前から決定論的にアドレスを作る。トレースに名前で出る

makeAddr は地味に効きます。トレースに 0x328809Bc… ではなく alice と出るので、-vvvv の出力が格段に読みやすくなります。

まとめ

  • デプロイ後に直せないので、テストは唯一の検証機会
  • 失敗経路を、エラーの型と引数まで検証する。カスタムエラーがそれを可能にする
  • fuzz で不変条件を叩くbound で範囲を写像する
  • 状態空間が小さいなら、全探索して性質を証明する
  • 意図した弱さもテストする
  • カバレッジより、資金の経路と不変条件

次章では、その CPU の中身 — ゲーム AI を Solidity で書く話をします。