Basics
Test được viết bằng Solidity và theo quy ước thì ta sẽ đặt nó trong thư mục test/
.
Ví dụ một file test:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import {Test, console} from "forge-std/Test.sol";
import {FundMe} from "../src/Fundme.sol";
contract FundMeTest is Test {
FundMe private fundMe;
function setUp() external {
fundMe = new FundMe();
console.log(fundMe.i_owner());
}
function testMinimumDollarIsFive() public view {
assertEq(fundMe.MINIMUM_USD(), 5e18);
}
function testOwnerIsMsgSender() public {
assertEq(fundMe.i_owner(), address(this));
}
}
Với:
import {Test, console} from "forge-std/Test.sol";
là để import thư viện test của Foundry. Ta sẽ kế thừa smart contractTest
để có thể sử dụng các hàm test của Foundry.console
là một function của Foundry giúp in ra khi chạy lệnhforge test
với flag-vv
.
Hàm setUp
là một optional function sẽ được thực thi mỗi khi chạy một hàm test. Trong hàm này, ta thường thực hiện những tác vụ cần thiết trước khi test chẳng hạn như deploy contract, khởi tạo giá trị cho các biến, …
Important
Cần chú ý rằng contract
FundMe
trong test trên sẽ được deploy mởiFundMeTest
contract chứ không phải bởimsg.sender
củaFundMeTest
contract. Do đó, việc kiểm trafundMe.i_owner() == msg.sender
trong hàmtestOwnerIsMsgSender
sẽ bị fail.
Chạy test với lệnh sau:
forge test
Lệnh này sẽ tìm kiếm tất cả các public/external function bắt đầu bằng tiền tố test
để thực thi.
Output của lệnh trên:
Ran 1 test for test/FundMeTest.t.sol:FundMeTest
[PASS] testDemo() (gas: 13660)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 690.40µs (144.90µs CPU time)
Ran 1 test suite in 11.25ms (690.40µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Seealso
Để chỉ thực thi một hàm test nào đó trong file test, ta sẽ specify tên hàm thông qua flag --mt
(match test):
forge test --mt testOwnerIsMsgSender
Xét hàm sau:
function getVersion() public view returns (uint256) {
AggregatorV3Interface priceFeed = AggregatorV3Interface(
0x694AA1769357215DE4FAC081bf1f309aDC325306
);
return priceFeed.version();
}
Running Tests On Chain Forks
Hàm này gán cứng địa chỉ của Sepolia testnet. Tất nhiên, khi chạy test sau ở trên Anvil local network thì chúng ta sẽ gặp lỗi:
function testPriceFeedVersionIsAccurate() public {
uint256 version = fundMe.getVersion();
assertEq(version, 4);
}
Để giải quyết vấn đề này, ta cần sử dụng thêm flag --fork-url
để chạy một Anvil instance có trạng thái được sao chép y hệt từ Sepolia thông qua RPC URL.
forge test --mt testPriceFeedVersionIsAccurate --fork-url $SEPOLIA_RPC_URL
Với $SEPOLIA_RPC_URL
là biến môi trường lưu RPC URL kèm API Key của NaaS node.
Important
Nếu có sử dụng
vm.startBroadcast()
khi chạy test trên chain fork thì nhớ truyền vào private key/public key của địa chỉ mà ta muốn dùng để deploy các contract. Xem thêm [[#Cheatcodes#startBroadcast
andstopBroadcast
]].
Coverage
Chúng ta có thể dùng subcommand coverage
để hiển thị phần nào trong code được test và phần nào không được test:
forge coverage
Ran 10 tests for test/unit/FundMeTest.t.sol:FundMeTest
[PASS] testAddsFunderToArrayOfFunders() (gas: 103866)
[PASS] testFundFailsWIthoutEnoughETH() (gas: 25124)
[PASS] testFundUpdatesFundDataStructure() (gas: 103190)
[PASS] testMinimumDollarIsFive() (gas: 8885)
[PASS] testOnlyOwnerCanWithdraw() (gas: 102824)
[PASS] testOwnerIsMsgSender() (gas: 9059)
[PASS] testPriceFeedVersionIsAccurate() (gas: 14409)
[PASS] testPrintStorageData() (gas: 18487)
[PASS] testWithdrawFromASingleFunder() (gas: 92703)
[PASS] testWithdrawFromMultipleFunders() (gas: 558620)
Suite result: ok. 10 passed; 0 failed; 0 skipped; finished in 3.31ms (12.39ms CPU time)
Ran 1 test suite in 14.33ms (3.31ms CPU time): 10 tests passed, 0 failed, 0 skipped (10 total tests)
╭---------------------------------+----------------+----------------+---------------+----------------╮
| File | % Lines | % Statements | % Branches | % Funcs |
+====================================================================================================+
| script/DeployFundMe.s.sol | 100.00% (7/7) | 100.00% (9/9) | 100.00% (0/0) | 100.00% (1/1) |
|---------------------------------+----------------+----------------+---------------+----------------|
| script/HelperConfig.s.sol | 66.67% (10/15) | 66.67% (8/12) | 33.33% (1/3) | 66.67% (2/3) |
|---------------------------------+----------------+----------------+---------------+----------------|
| src/FundMe.sol | 72.09% (31/43) | 68.57% (24/35) | 57.14% (4/7) | 83.33% (10/12) |
|---------------------------------+----------------+----------------+---------------+----------------|
| src/PriceConverter.sol | 100.00% (7/7) | 100.00% (8/8) | 100.00% (0/0) | 100.00% (2/2) |
|---------------------------------+----------------+----------------+---------------+----------------|
| test/mocks/MockV3Aggregator.sol | 52.17% (12/23) | 52.94% (9/17) | 100.00% (0/0) | 50.00% (3/6) |
|---------------------------------+----------------+----------------+---------------+----------------|
| Total | 70.53% (67/95) | 71.60% (58/81) | 50.00% (5/10) | 75.00% (18/24) |
╰---------------------------------+----------------+----------------+---------------+----------------╯
Tip
Việc kiểm tra coverage cho ta biết rằng ta cần viết thêm hàm test nào.
Chúng ta có thể thêm flag --report debug
để xuất báo cáo của lệnh forge coverage
giúp cho biết dòng nào trong các smart contract chưa được covered bởi test.
Cheatcodes
Common
Foundry hỗ trợ một số cheat codes dùng để hỗ trợ cho việc test:
-
prank
: cho phép gánmsg.sender
thành một địa chỉ cụ thể trong các lần call tiếp theo, bao gồm cảstaticcall
. -
startPrank
vàstopPrank
: cho phép gánmsg.sender
thành một địa chỉ cụ thể cho tất cả các call trước khistopPrank
được gọi. Tất cả các transaction xảy ra giữastartPrank
vàstopPrank
đều được ký bởi địa chỉ mà ta chỉ định. -
makeAddr
: tạo một địa chỉ mới với tên có dạng chuỗi.address alice = makeAddr("alice");
Ví dụ sử dụng các cheatcode trên:
function testFundUpdatesFundDataStructure() public { vm.prank(alice); fundMe.fund{value: SEND_VALUE}(); uint256 amountFunded = fundMe.getAddressToAmountFunded(alice); assertEq(amountFunded, SEND_VALUE); }
-
deal
: cho phép thiết lập balance của một user. Sử dụng như sau:vm.deal(alice, STARTING_BALANCE);
Nếu dùng nhiều cheat code liền kề nhau thì chúng sẽ không ảnh hưởng lên nhau mà chỉ ảnh hưởng đến transaction:
function testOnlyOwnerCanWithdraw() public {
vm.prank(alice);
fundMe.fund{value: SEND_VALUE}();
vm.expectRevert();
vm.prank(alice);
fundMe.withdraw();
}
Trong ví dụ trên, do alice
không phải là owner nên transaction sẽ bị revert và vm.expectRevert
sẽ chỉ ảnh hưởng đến fundMe.withdraw();
.
startBroadcast
And stopBroadcast
Chạy các operation trong một transaction mà có thể sign. Thường được dùng để thực hiện việc deploy contract.
Ví dụ:
vm.startBroadcast();
Raffle raffle = new Raffle(
entranceFee,
interval,
vrfCoordinator,
gasLane,
subscriptionId,
callbackGasLimit
);
vm.stopBroadcast();
Hàm vm.startBroadcast
có thể nhận vào một private key hoặc nhận vào một địa chỉ (public key) để chỉ định tài khoản gửi transaction. Đối với Anvil, ta có thể địa chỉ của default sender account ở đường dẫn forge-std/src/Base.sol
.
expectRevert
Đối với cheatcode expectRevert
, chúng ta có thể truyền vào selector của error để kiểm tra rằng revert xảy ra với đúng error mà ta mong muốn. Ví dụ ta có error và contract như sau:
error Contract_Error();
contract Contract {}
Sử dụng expectRevert
như sau:
vm.expectRevert(Contract.Contract_Error.selector);
Đối với trường hợp error có signature với tham số như sau:
error Raffle__UpkeepNotNeeded(
uint256 currentBalance,
uint256 numPlayers,
uint256 raffleState
);
Ta cần tạo ra selector để truyền vào vm.expectRevert
bằng cách sử dụng hàm abi.encodeWithSelector
để truyền các đối số vào error:
abi.encodeWithSelector(
Raffle.Raffle__UpkeepNotNeeded.selector,
currentBalance,
numPlayers,
raffleState
)
expectEmit
Để kiểm tra xem function có emit event hay không, ta sử dụng expectEmit
. Ví dụ:
function testEmitsEventOnEntrance() public {
// Arrange
vm.prank(PLAYER);
// Act / Assert
vm.expectEmit(true, false, false, false, address(raffle));
emit EnteredRaffle(PLAYER);
raffle.enterRaffle{value: entranceFee}();
}
Theo tài liệu, cheatcode này có các tham số như sau:
function expectEmit(
bool checkTopic1,
bool checkTopic2,
bool checkTopic3,
bool checkData,
address emitter
) external;
Với các tham số từ 1 đến 3 là để chứa các indexed parameter của event và checkData
là chứa các unindexed parameter của event. Tham số cuối cùng là địa chỉ emit event.
Sau khi gọi cheatcode, ta cần emit event một cách thủ công. Chúng ta có thể truy cập đến event thông qua contract hoặc tự định nghĩa event có cùng signature trong test contract.
warp
And roll
Để thay đổi các giá trị liên quan đến block chẳng hạn như block.timestamp
hay block.number
, ta có thể dùng vm.warp
hay vm.roll
. Ngoài ra, còn có skip
và rewind
dùng để skip hoặc rewind block.timestamp
một khoảng thời gian cụ thể.
Ví dụ sử dụng:
function testDontAllowPlayersToEnterWhileRaffleIsCalculating() public {
// Arrange
vm.prank(PLAYER);
raffle.enterRaffle{value: entranceFee}();
vm.warp(block.timestamp + interval + 1);
vm.roll(block.number + 1);
raffle.performUpkeep("");
// Act / Assert
vm.expectRevert(Raffle.Raffle__RaffleNotOpen.selector);
vm.prank(PLAYER);
raffle.enterRaffle{value: entranceFee}();
}
Hàm test trên là để đảm bảo không có một player nào khác được phép tham gia vào trò chơi của smart contract nếu như hàm performUpkeep
đang thực hiện và trạng thái của contract chưa thay đổi thành OPEN
. Do hàm performUpkeep
chỉ được thực thi khi đã trải qua khoảng thời gian interval
nhất định nên ta sử dụng vm.wrap
để tua thời gian. Ngoài ra, để giả lập việc blockchain được mined, ta sử dụng hàm vm.roll
để tăng block.number
.
Events
Chúng ta sẽ dùng cheatcode vm.recordLogs
để ghi lại tất cả các event được emit ra vào trong một mảng. Để truy cập, ta sử dụng cheatcode vm.getRecordedLogs
.
Do quá trình chạy smart contract có thể có nhiều event được emit ra nên ta sẽ không biết chính xác vị trí của event mà ta muốn kiểm tra ở trong mảng trả về của getRecordedLogs
(có kiểu Vm.Log[] memory
). Thông thường, ta sẽ sử dụng Debugger để tìm kiếm index của event. Sau khi biết index của event thì ta có thể lấy ra các topic cũng bằng cách sử dụng index. Ví dụ:
vm.recordLogs();
raffle.performUpkeep(""); // emits requestId
Vm.Log[] memory entries = vm.getRecordedLogs();
bytes32 requestId = entries[1].topics[1];
Lý do mà ta không sử dụng expectEmit
là vì cheatcode này kiểm tra xem có đối số truyền vào một tham số nào đó của event hay không chứ không cho phép kiểm tra xem đối số truyền vào đó có giá trị như thế nào. Mà trong trường hợp này ta lại cần lấy ra requestId
thuộc event đã được emit.
envUint
Hàm này dùng để nạp một biến môi trường từ file .env
. Ví dụ:
deployerKey: vm.envUint("SEPOLIA_PRIVATE_KEY")
readFile
Hàm này cho phép đọc nội dung của file và có signature như sau:
// Reads the entire content of file to string, (path) => (data)
function readFile(string calldata) external returns (string memory);
Tuy nhiên, để có thể đọc file ở một path nào đó, ta cần cấp quyền cho nó ở trong file cấu hình foundry.toml
:
fs_permissions = [
{ access = "read", path = "./broadcast" },
{ access = "read", path = "./reports" },
{ access = "read", path = "./img/" }
]
AAA Pattern
Khi viết test, ta sẽ follow theo AAA pattern: Arange-Act-Assert (cách làm này giống với Check-Effect-Interact pattern, xem thêm Smart Contracts - Security Patterns in the Ethereum Ecosystem and Solidity). Với:
- Arrange: khai báo các biến cần cho việc test
- Act: thực hiện việc test chẳng hạn như gọi hàm.
- Assert: kiểm tra các kết quả của việc test.
Ví dụ:
function testWithdrawFromASingleFunder() public funded {
// AAA Pattern
// Arange
uint256 startingFundMeBalance = address(fundMe).balance;
uint256 startingOwnerBalance = fundMe.getOwner().balance;
// Act
vm.startPrank(fundMe.getOwner());
fundMe.withdraw();
vm.stopPrank();
// Assert
uint256 endingFundMeBalance = address(fundMe).balance;
uint256 endingOwnerBalance = fundMe.getOwner().balance;
assertEq(endingFundMeBalance, 0);
assertEq(
startingFundMeBalance + startingOwnerBalance,
endingOwnerBalance
);
}
Hoax
Hàm sau đây kiểm tra xem owner của contract có thể rút ETH trong trường hợp có nhiều funders hay không:
function testWithdrawFromMultipleFunders() public funded {
uint160 numberOfFunders = 10;
uint160 startingFunderIndex = 1;
for (
uint160 i = startingFunderIndex;
i < numberOfFunders + startingFunderIndex;
i++
) {
// we get hoax from stdcheats
// prank + deal
hoax(address(i), SEND_VALUE);
fundMe.fund{value: SEND_VALUE}();
}
uint256 startingFundMeBalance = address(fundMe).balance;
uint256 startingOwnerBalance = fundMe.getOwner().balance;
vm.startPrank(fundMe.getOwner());
fundMe.withdraw();
vm.stopPrank();
assert(address(fundMe).balance == 0);
assert(
startingFundMeBalance + startingOwnerBalance ==
fundMe.getOwner().balance
);
assert(
(numberOfFunders + 1) * SEND_VALUE ==
fundMe.getOwner().balance - startingOwnerBalance
);
}
Do kiểu address
có kích thước 20 bytes - tương ứng với 160 bits nên ta sẽ dùng kiểu uint160
để vừa làm index vừa làm địa chỉ của các funders luôn.
Hàm hoax
được gọi trong vòng lặp là một sự kết hợp của prank
và deal
. Sau đó, hàm test thực hiện fund cho contract.
Ở bước act, ta thực hiện withdraw bằng tài khoản của owner.
Ở bước assert, ta kiểm tra rằng:
- Tài khoản của contract sau khi withdraw là 0.
- Tài khoản hiện tại của owner bằng với tài khoản trước đó của owner cộng với tài khoản của contract.
- Tài khoản của owner tăng lên bằng đúng với tích của số lượng funders nhân với
SEND_VALUE
. Lý do ta cộng thêm 1 là vì ta dùng modifierfunded
, vốn sử dụng tài khoản củaalice
để fund trước khi thực hiện hàm test.
Debugger
Chúng ta có thể chạy test với debugger bằng cách sử dụng flag --debug
. Debugger sẽ có giao diện dòng lệnh (TUI) mà có thể tương tác như sau:
Fuzzing
Để có thể gọi các hàm với nhiều đối số khác nhau, ta có thể thêm vào tham số cho hàm test.
Ví dụ, trong hàm bên dưới ta gán cứng cho đối số đầu tiên truyền vào fulfillRandomWords
là 0
:
function testFulfillRandomWordsCanOnlyBeCalledAfterPerformUpkeep()
public
raffleEntredAndTimePassed
{
// Arrange
// Act / Assert
vm.expectRevert((VRFCoordinatorV2_5Mock.InvalidRequest.selector);
// vm.mockCall could be used here...
VRFCoordinatorV2_5Mock(vrfCoordinator).fulfillRandomWords(
0,
address(raffle)
);
}
Để test với các giá trị khác, ta sẽ thêm vào tham số cho hàm testFulfillRandomWordsCanOnlyBeCalledAfterPerformUpkeep
và truyền tham số này vào hàm fulfillRandomWords
:
function testFulfillRandomWordsCanOnlyBeCalledAfterPerformUpkeep(uint256 randomRequestId)
public
raffleEntredAndTimePassed
{
// Arrange
// Act / Assert
vm.expectRevert(VRFCoordinatorV2_5Mock.InvalidRequest.selector);
// vm.mockCall could be used here...
VRFCoordinatorV2_5Mock(vrfCoordinator).fulfillRandomWords(
randomRequestId,
address(raffle)
);
}
Foundry sẽ tự động truyền vào tham số vừa thêm các giá trị random khi chạy test.
Số lần thực hiện fuzzing là random. Nếu ta muốn gán cứng thì có thể cấu hình như sau:
[fuzz]
runs = 1000
Resources
- Video: Foundry Fund Me - Writing tests for your Solidity smart contract - Foundry Fundamentals
- Video: Foundry Fund Me - Running tests on chains forks - Foundry Fundamentals
- Video: Foundry Fund Me - Foundry tests cheatcodes - Foundry Fundamentals
- Video: Foundry Fund Me - Adding more coverage to the tests - Foundry Fundamentals
- Video: Smart Contract Lottery - Intro to fuzz testing - Foundry Fundamentals