こんにちは、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機能が備わっており、大量のランダムデータの作成とテストを自動でやってくれる。