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 contract Test để 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ệnh forge 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ởi FundMeTest contract chứ không phải bởi msg.sender của FundMeTest contract. Do đó, việc kiểm tra fundMe.i_owner() == msg.sender trong hàm testOwnerIsMsgSender 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 and stopBroadcast]].

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án msg.sender thành một địa chỉ cụ thể trong các lần call tiếp theo, bao gồm cả staticcall.

  • startPrankstopPrank: cho phép gán msg.sender thành một địa chỉ cụ thể cho tất cả các call trước khi stopPrank được gọi. Tất cả các transaction xảy ra giữa startPrankstopPrank đề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ó skiprewind 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 prankdeal. 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 modifier funded, vốn sử dụng tài khoản của alice để 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 fulfillRandomWords0:

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