Fracton テックブログ

FractonのEthereum周辺技術について発信しています

FoundryのFuzz testingを触ってみた

こんにちは、Fractonでリサーチエンジニアをしている池田です。今回は、FoundryのFuzz testingについて説明します。Fuzz testingとは、大量のランダムデータをテストに入力し、開発者が想定しづらいバグを発見するためのテストです。

FoundryでのFuzz testingのやり方

Foundryの公式docsに使い方の説明があります。Fuzz Testing

公式docsのサンプルを使って説明します。以下のようなテストがあるとします。このテストでは、withdraw()を持ったSafeコントラクトに1ETH送って、Safeから1ETHをwithdrawできるかをテストしています。

pragma solidity 0.8.10;

import "forge-std/Test.sol";

contract Safe {
    receive() external payable {}

    function withdraw() external {
        payable(msg.sender).transfer(address(this).balance);
    }
}

contract SafeTest is Test {
    Safe safe;

    // Needed so the test contract itself can receive ether
    // when withdrawing
    receive() external payable {}

    function setUp() public {
        safe = new Safe();
    }

    function testWithdraw() public {
        payable(address(safe)).transfer(1 ether);
        uint256 preBalance = address(this).balance;
        safe.withdraw();
        uint256 postBalance = address(this).balance;
        assertEq(preBalance + 1 ether, postBalance);
    }
}

これをforge testするとpassしますが、このテストでチェックしているのは、あくまで1ETHをtransferしてwithdrawしたときのみであり、他の金額を入力したときの動作は検証されていません。

Foundryには、テストに入力するデータをランダムに生成し、想定外のデータに対しての挙動を確認するFuzz testing機能があります。

使い方は簡単で、testWithdraw()にamountというparamを入力するだけです。

contract SafeTest is Test {
    // ...

    function testWithdraw(uint256 amount) public {
        payable(address(safe)).transfer(amount);
        uint256 preBalance = address(this).balance;
        safe.withdraw();
        uint256 postBalance = address(this).balance;
        assertEq(preBalance + amount, postBalance);
    }
}

これをforge testするとamountをuint256の範囲でランダムに生成し、様々なamountに対してテストを実行してくれます。デフォルトでは、乱数生成を256回行い、256通りのシナリオをテストできます。

実はこのテストは失敗し、実行結果は以下のようになります。

$ forge test
Compiling 1 files with 0.8.10
Compiler run successful

Running 1 test for test/Safe.t.sol:SafeTest
[FAIL. Counterexample: calldata=0x215a2f200000000000000000000000000000000000000001000000000000000000000000, args=[79228162514264337593543950336]] testWithdraw(uint256) (runs: 45, μ: 19554, ~: 19554)
Test result: FAILED. 0 passed; 1 failed; finished in 23.75ms

args=[79228162514264337593543950336]] testWithdraw(uint256) (runs: 45, μ: 19554, ~: 19554)というログが出ています。runs: 45、つまり45回目の試行で、arg(amount)=79228162514264337593543950336のときに、テストが失敗したことを示しています。

失敗している理由ですが、テストの1行目のpayable(address(safe)).transfer(amount);がruns=45のargに対して、残高不足で失敗しているのが原因です。 これはFoundryのTestコントラクトの仕様ですが、デフォルトではTestの初期残高は2**96、つまりuint96の最大値に設定されています。そのためuint256の範囲でランダムに値を取ると、uint96_MAXを超えた値が入ったときに、transferが失敗します。

上記のTestの仕様を踏まえると、以下のようにtestのparamをuint96にするとテストが成功します。

function testWithdraw(uint96 amount) public {

実行結果は以下のようになります。

$ forge test
Compiling 1 files with 0.8.10
Compiler run successful

Running 1 test for test/Safe.t.sol:SafeTest
[PASS] testWithdraw(uint96) (runs: 256, μ: 13268, ~: 19654)
Test result: ok. 1 passed; 0 failed; finished in 88.82ms

runs: 256となっており、256通りの試行に対して、testがpassしていることがわかります。

デフォルトの試行回数は256回ですが、configを変えることで、試行回数は変えられます。

このようにFoundryでは、test関数にparamsを入力してあげるだけで、簡単にFuzz testingが実行できます。

結論

Foundryでは、Fuzz tesing機能が備わっており、大量のランダムデータの作成とテストを自動でやってくれる。