# Foundry NFT實作教學/介紹 (3)Unit Test # 前言 上篇: [Foundry NFT實作教學/介紹 (2)開發並部署合約](https://hackmd.io/@RainFox/B1UlEhQzn) 到上篇為止,我們已經完成開發並部署,同時也用了anvil cast的部分功能,這篇我們就會介紹Foundry最受他人喜歡的功能,**Test** 對於Test的功能本人我也還沒完全探索完成,因此這篇文章算是拋磚引玉之用,期待有更多人能夠分享相關的使用方法,若想仔細了解其結構推薦閱讀官方文檔 # 開始 ### 1. 建立測試檔案 先在test資料夾中創建`NFT.t.sol`這個檔案,並將以下的程式碼複製進去 ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "../src/NFT.sol"; contract NFTTest is Test{ } ``` 上面import的部分除了原本的contract NFT以外,還需要多import一個Test.sol,Test當中包含很多提供測試用的function,晚點我們一一介紹 ### 2. 寫測試 接下來我們就開始在`NFTTest`這個合約裡面寫測試案例 首先我們必須先宣告一個`function setUp() external {}`,這個`setUp`的funciton是Foundry的規定,作用跟`beforeEach`相同,每執行一個test就會執行`setUp` ```solidity contract NFTTest is Test{ NFT nft; function setUp() external { nft = new NFT(); //每執行一個test,我們就去建立一個新的contract instance } } ``` 接著寫我們的第一個test,讓這個test確認symbol()跟name()是否符合我們預期 ```solidity function testNameAndSymbol() external{ assertEq(nft.name(), "NFT"); assertEq(nft.symbol(), "NFT"); } ``` > 這邊test function 都需要宣告為public或external供外部調用,且function 的名字都需要以`test`做開頭 ### 3. 執行測試 輸入以下指令 ``` forge test ``` Output: ``` [⠔] Compiling... [⠘] Compiling 1 files with 0.8.18 [⠃] Solc 0.8.18 finished in 1.26s Compiler run successful Running 1 test for test/NFT.t.sol:NFTTest [PASS] testInfo() (gas: 14573) Test result: ok. 1 passed; 0 failed; finished in 3.51ms Running 2 tests for test/Counter.t.sol:CounterTest [PASS] testIncrement() (gas: 28334) [PASS] testSetNumber(uint256) (runs: 256, μ: 27553, ~: 28409) Test result: ok. 2 passed; 0 failed; finished in 12.33ms ``` 你會發現,除了NFT.t.sol執行測試以外,預設的Counter.t.sol也執行了測試(如果你沒有把他刪掉的話) 因此若要指定執行的測試合約,需要將指令改為 ``` forge test --match-contract NFTTest ``` 如此一來就只會執行你指定的測試合約去執行了 到目前為止,整個test的流程算是差不多了,接下來就會介紹幾個test常用的功能 # Cheatcode 在test裡面,我們常常需要去做一些外部狀態的更改,例如更換別人來調用function,偵測event等等,這時我們就會需要cheatcode,這邊我們用測試mint來做示範 ```solidity function testMint() external { //arrange uint256 userPrivateKey = vm.envUint("USER_PRIVATE_KEY"); // 取得.env檔案中的USER_PRIVATE_KEY address user = vm.addr(userPrivateKey); //將得到private key轉化為address,以便等等當作參數使用 vm.broadcast(userPrivateKey); // 宣告下一個transaction的sender是誰,這邊我放入user的private key //act nft.mint(user, 1); //執行mint,因為剛剛有使用broadcast,所以這邊transaction的sender是user //assert assertEq(nft.ownerOf(1), user); assertEq(nft.balanceOf(user), 1); } ``` 當中的`envUint()` `addr()` `broadcast()`都是一個cheatcode,並且存放在vm這個合約中,若想要知道有什麼語法可以參考 [Cheatcodes Reference](https://book.getfoundry.sh/cheatcodes/) 以及 [Ceatcodes](https://book.getfoundry.sh/forge/cheatcodes) 接著我們再做進一步的測試,我們需要去偵測event有沒有被觸發 在ERC721中執行`_mint`時,會觸發一個`Tranfer(address from, address to, uint256 tokenId)`的event ```solidity= function testMint() external { //arrange uint256 userPrivateKey = vm.envUint("USER_PRIVATE_KEY"); // 取得.env檔案中的USER_PRIVATE_KEY address user = vm.addr(userPrivateKey); //將得到private key轉化為address,以便等等當作參數使用 vm.broadcast(userPrivateKey); // 宣告下一個transaction的sender是誰,這邊我放入user的private key //新增event偵測 vm.expectEmit(true, true, true, true); //指定要偵測的event emit Transfer(address(0), user, 1); //act nft.mint(user, 1); //執行mint,因為剛剛有使用broadcast,所以這邊transaction的sender是user //assert assertEq(nft.ownerOf(1), user); assertEq(nft.balanceOf(user), 1); } ``` `vm.expectEmit(true, true, true, true)`,前三個true是設定前三個event 中`indexed`的參數需不需要去做比對,最後一個true則是設定除了三個`indexed`以外的參數需不需要去做比對 我們可以做個測試來更明白這個功能,將test event的部分改為 ```solidity= vm.expectEmit(true, true, true, true); //更改Transfer tokenId的參數部分 emit Transfer(address(0), user, 100) ``` 接著去跑測試,理論上結果應該不會過,因為你實際mint的tokenId是1不是100 測試結果: ``` [⠘] Compiling... [⠔] Compiling 1 files with 0.8.18 [⠒] Solc 0.8.18 finished in 1.66s Compiler run successful Running 2 tests for test/NFT.t.sol:NFTTest [PASS] testInfo() (gas: 14596) [FAIL. Reason: Log != expected log] testMint() (gas: 60682) Test result: FAILED. 1 passed; 1 failed; finished in 5.57ms Failing tests: Encountered 1 failing test in test/NFT.t.sol:NFTTest [FAIL. Reason: Log != expected log] testMint() (gas: 60682) ``` 但是這時候若我們把`vm.expectEmit(true, true, true, true)`改為`vm.expectEmit(true, true, false, true)`,**意指第三個`indexed`參數不需要去做比對** 再跑一次測試,這時候會發現測試就會過了,即便你mint的tokenId不是100而是1 ``` [⠑] Compiling... [⠔] Compiling 1 files with 0.8.18 [⠑] Solc 0.8.18 finished in 1.83s Compiler run successful Running 2 tests for test/NFT.t.sol:NFTTest [PASS] testInfo() (gas: 14596) [PASS] testMint() (gas: 60679) Test result: ok. 2 passed; 0 failed; finished in 5.39ms ``` # Trace code 在上面的test中,你可以選擇讓test顯示出更多的資訊,使用`-vvvv` ``` forge test -vvvv --match-contract NFTTest ``` Output: ``` [⠒] Compiling... No files changed, compilation skipped Running 2 tests for test/NFT.t.sol:NFTTest [PASS] testInfo() (gas: 14596) Traces: [14596] NFTTest::testInfo() ├─ [3285] NFT::name() [staticcall] │ └─ ← NFT ├─ [3306] NFT::symbol() [staticcall] │ └─ ← NFT └─ ← () [PASS] testMint() (gas: 60682) Traces: [60682] NFTTest::testMint() ├─ [0] VM::envUint(USER_PRIVATE_KEY) [staticcall] │ └─ ← <env var value> ├─ [0] VM::addr(<pk>) [staticcall] │ └─ ← 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 ├─ [0] VM::expectEmit(true, true, true, true) │ └─ ← () ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, tokenId: 1) ├─ [0] VM::broadcast(<pk>) │ └─ ← () ├─ [47186] NFT::mint(0x70997970C51812dc3A010C7d01b50e0d17dc79C8, 1) │ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8, tokenId: 1) │ └─ ← () ├─ [536] NFT::ownerOf(1) [staticcall] │ └─ ← 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 ├─ [634] NFT::balanceOf(0x70997970C51812dc3A010C7d01b50e0d17dc79C8) [staticcall] │ └─ ← 1 └─ ← () Test result: ok. 2 passed; 0 failed; finished in 5.80ms ``` 這邊可以看到他顯示,他call了哪些function,參數是什麼,以及return什麼,如果有錯誤的話,他會告訴你錯誤是發生在哪裡 剛剛我們採用的是四個v的模式(-vvvv),他也有多種模式可以選擇,從-vv 到-vvvvv ,越多v代表會顯示越詳細的訊息 官方說明 [forge-test](https://book.getfoundry.sh/reference/forge/forge-test) ``` -v --verbosity Verbosity of the EVM. Pass multiple times to increase the verbosity (e.g. -v, -vv, -vvv). Verbosity levels: - 2: Print logs for all tests - 3: Print execution traces for failing tests - 4: Print execution traces for all tests, and setup traces for failing tests - 5: Print execution and setup traces for all tests ``` 知道了這些後,我們可以就可以用`console.log`來顯示某些資訊了 ```solidity function testMint() external { uint256 userPrivateKey = vm.envUint("USER_PRIVATE_KEY"); address user = vm.addr(userPrivateKey); //Add console.log console.log(user); vm.expectEmit(true, true, true, true); emit Transfer(address(0), user, 1); vm.broadcast(userPrivateKey); nft.mint(user, 1); assertEq(nft.ownerOf(1), user); assertEq(nft.balanceOf(user), 1); } ``` 如果這邊在做test不指定v(verbosity),那麼`console.log`的資訊是不會顯示出來的,因此我們這邊將test的command改為 ``` forge test -vv --match-contract NFTTest ``` Output: ``` [⠔] Compiling... No files changed, compilation skipped Running 2 tests for test/NFT.t.sol:NFTTest [PASS] testInfo() (gas: 14596) [PASS] testMint() (gas: 63524) Logs: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 Test result: ok. 2 passed; 0 failed; finished in 956.08µs ``` 可以看到我們在Logs的欄位顯示出user這個address # Fuzz test Fuzz test個人認為是一個還蠻有趣的功能,簡單來說,就是將要測試的參數都用亂數去測,來檢測這個function是不是在各種情況下都能正常運作 實際使用的方式是在test function當中傳入參數,讓我們將`function testMint()`改為`function testMint(uint256 tokenId)`,同時也需要把測試當中原本tokenId的部分都從1改為tokenId,具體程式碼如下: ```solidity function testMint(uint256 tokenId) external { //arrange uint256 userPrivateKey = vm.envUint("USER_PRIVATE_KEY"); address user = vm.addr(userPrivateKey); console.log(user); vm.expectEmit(true, true, true, true); emit Transfer(address(0), user, tokenId); //1 --> tokenId vm.broadcast(userPrivateKey); //act nft.mint(user, tokenId); //1 --> tokenId //assert assertEq(nft.ownerOf(tokenId), user); //1 --> tokenId assertEq(nft.balanceOf(user), 1); } ``` 接著執行測試,理論上應該會過,並且會發現後面多了(runs: 256, μ: 63587, ~: 63587),數字略有不同的地方是正常的 - runs是代表這個test經過多少次fuzz test - "μ"代表所有測試花費gas的平均數(mean) - "~"代表所有測試花費gas的中位數(median) 我們再稍微改一下合約來更了解fuzz test ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {ERC721} from "openzeppelin-contracts/contracts/token/ERC721/ERC721.sol"; contract NFT is ERC721 { uint256 public totalSupply = 100; constructor() ERC721("NFT", "NFT") {} function mint(address to, uint256 tokenId) public { require(tokenId <= totalSupply, "NFT: tokenId is out of range"); _mint(to, tokenId); } } ``` 我們新增了totalSupply,並且設定require,tokenId必須小於totalSupply 接著再執行一次test Output: ``` [⠔] Compiling... [⠒] Compiling 2 files with 0.8.18 [⠆] Solc 0.8.18 finished in 1.52s Compiler run successful Running 2 tests for test/NFT.t.sol:NFTTest [PASS] testInfo() (gas: 14640) [FAIL. Reason: Log != expected log Counterexample: calldata=0x2f3f94ae0000000000000000000000000000000000000000000000000000000000000065, args=[101]] testMint(uint256) (runs: 4, μ: 65779, ~: 65779) Test result: FAILED. 1 passed; 1 failed; finished in 55.12ms Failing tests: Encountered 1 failing test in test/NFT.t.sol:NFTTest [FAIL. Reason: Log != expected log Counterexample: calldata=0x2f3f94ae0000000000000000000000000000000000000000000000000000000000000065, args=[101]] testMint(uint256) (runs: 4, μ: 65779, ~: 65779) Encountered a total of 1 failing tests, 1 tests succeeded ``` 會發現test沒辦法過了,而且還可以知道,**是當你的tokenId是101的時候發生錯誤的** 那麼問題來了,若我希望傳入的tokenId不要超過totalSupply的話,應該怎麼做? 這邊採用`assume`來規定fuzz test參數的範圍 ```solidity function testMint(uint256 tokenId) external { //arrange uint256 totalSupply = nft.totalSupply(); //設定vm.assume,括號當中的condition必須為true才會執行後面的test vm.assume(tokenId <= totalSupply); uint256 userPrivateKey = vm.envUint("USER_PRIVATE_KEY"); address user = vm.addr(userPrivateKey); console.log(user); vm.expectEmit(true, true, true, true); emit Transfer(address(0), user, tokenId); vm.broadcast(userPrivateKey); //act nft.mint(user, tokenId); //assert assertEq(nft.ownerOf(tokenId), user); assertEq(nft.balanceOf(user), 1); } ``` 接著這邊再去做測試,就會發現測試都可以通過了 詳細內容可以參考官方文件[Fuzz test](https://book.getfoundry.sh/forge/fuzz-testing) # Revert test 接下來我們需要測試,當錯誤情況發生時,會不會revert 有兩種方法可以採用 1. 將function的名稱設為`testFail`開頭 2. 程式碼中加入`vm.expectRevert()`來確認revert message是不是跟所想的一樣 我們這時候另寫一個function來測試當tokenId大於totalSupply時,會不會都revert ```solidity function testFail_WhenTokenIdGreaterThanTotalSupply(uint256 tokenId) external{ //arrange uint256 totalSupply = nft.totalSupply(); //將tokenId的條件都設為必須大於totalSupply vm.assume(tokenId > totalSupply); uint256 userPrivateKey = vm.envUint("USER_PRIVATE_KEY"); address user = vm.addr(userPrivateKey); vm.broadcast(userPrivateKey); //act nft.mint(user, tokenId); } ``` 接著執行測試,並發現所有測試都通過了 但是這樣的測試可能不夠準確,畢竟我們這個測試的目的是要測試`mint()`當中`require`有沒有發揮作用,但是testFail只能確定`nft.mint()`是失敗的,但卻不能確定是因為何種原因而失敗 因此這邊我們可以用第二種方法,來比對revert message是否確實是我們想要的 ```solidity function testRevert_WhenTokenIdGreaterThanTotalSupply(uint256 tokenId) external{ //arrange uint256 totalSupply = nft.totalSupply(); vm.assume(tokenId > totalSupply); uint256 userPrivateKey = vm.envUint("USER_PRIVATE_KEY"); address user = vm.addr(userPrivateKey); vm.broadcast(userPrivateKey); //assert //這邊預期Revert的message為NFT: tokenId is out of range vm.expectRevert("NFT: tokenId is out of range"); //act nft.mint(user, tokenId); } ``` > 若用這種方法,function的名稱除了需要以test為開頭外,並無特別的要求 # 結尾 到這裡為止,簡單的Foundry入門就完成了 此系列文章還有許多功能還沒提到,例如: Invariant test、hardhat兼容等等,有部分的功能我也還沒探索 目前用完的感想是,Foundry的想法很新且有趣,對test功能部分有特別加強,整體的語言都用solidity寫,寫起來會較為順暢,不需要去處理跨語言之間的型別轉換問題 但考量到整體環境ethers還是有相當重要的地位,且現在在智能合約開發上了解ethers也是必須的,因此熟悉hardhat的開發者我認為沒有迫切的必要遷移至Foundry,但是值得了解一下 後續若我有繼續探索Foundry也會繼續寫成文章分享出來,若其他人有其他建議跟分享,也歡迎告訴我 若在製作上有問題,可以參考原始碼: [hello-foundry](https://github.com/LI-YONG-QI/hello-foundry)