How to make Assertions with Foundry - Cheatcode Guide
I guess you started your smart contract development journey by using Remix as your IDE for both development and testing. By now, you have discovered that you cannot carry out high-level testing with Remix - and here comes Foundry.
This is why I am creating this series; to teach Solidity developers how to use Foundry to test if there smart contracts work as planned. At the same time, smart contract security researchers will also benefit from this as they can learn how to test a smart contract against various attack vectors.
In this guide, I will be walking you through how to assert and manipulate while testing. I’ll do my best to make this very easy so everyone can follow.
Now, this is how the guide flows: I will teach you some cheatcodes substantively then we will write a contract to test what we have learned so far.
Are you ready? Let’s get into it
How to Make Assertions in Foundry: Examining the Cheatcodes for It
Foundry has a couple of assertion cheatcodes:
expectRevert
Imagine that we are both in a living room and I send you to go get me my airpod from my workstation. You obliged, got up, put on your slides, and head to the workstation adjacent to where you were sitting with me.
When you are coming back from the workstation, I will expect that you will have the airpod with you, right? That means I expected you to revert. This is similar to what the expectRevert
cheatcode does; it returns something.
Practically, we use this code to render error messages.
Think of a smart contract with this logic:
people must not deposit less than 1 ether per time
if they do, reject their deposit and say “deposit must not be less than 1 ether.”
In the above contract. we can the expectRevert
assertion to test if a deposit of 0.9 ether will be successful. Other things equal, the test should fail and the error message should be printed.
You can use this assertion to check whatever error declaration logic you prefer. In essence, you can use it for
require
:vm.expectRevert("deposit must not be less than 1 ether or whatever error message");
custom error messages: you have to use the error’s selector as in
vm.expectRevert(CustomError.selector);
You might wonder, how do you handle a function that has no pre-defined error message. In such cases, include an empty parenthesis with empty quotations as in expectRevert(bytes(""))
.
expectEmit
How sure are you that the events
you emitted in your contracts will actually emit in production? This is the work of expectEmit
- to assert or confirm events
emissions. If you don’t know what events
are, they are the logs we output [or emit] locally in a function and broadcast at the state [that is, above the functions].
Now, you need to know something: the number of arguments in your emit
's parameters in the test depends on the number you have in the contract; it must be exact in terms of number and data type. There will be errors if you mix this up.
Here is what I mean:
event Allowance (address indexed allower, address indexed allowee, uint256 amount, uint256 block.timestamp)
You can see that there are 4 arguments above. When you want to assert this event
in your test, don’t up the strict order of data types and the arguments you will test for must be up to four.
That said, this is the order of emitting events
:
write the cheatcode as in
vm.expectEmit()
emit the events - call the name of the contract
.
the name of the event(put the args here
)make the actual call
That said, some developers like to call their expectEmit
with the emitter address, while some don’t, and Foundry supports both. Generally, you might not need the emitter address, but you also need to know how to assert it in case you are performing edge-case smart contract security research.
for no emitter address -
vm.expectRevert()
for emitter address -
vm.expectRevert(address(contractName))
Have you ever read a codebase where events were tested this way:
vm.expectEmit(true, false, false, true)
I will explain why that can be the case.
So Solidity allows you to index maximum of three event arguments. If there's a fourth one, it will be an unindexable data.
Now, these arguments have a boolean type, meaning they are polar; either true or false.
You can specify the emission arguments you're interested in asserting by switching to the affirmative.
Alternatively, you can input a false
if you are not interested in checking if an emission argument is correct.
expectCall
In practice, we use this cheatcode to assert the success of a transfer made to an address. You might have written a smart contract that transfers yield to users, how can you be sure that the function will perform appropriately? This is where you need this assertion.
Ordinarily, you should call it and include three arguments as its parameters: from, to, amount. Such as this: vm.expectCall(address(owner), user, yieldProfit
But as you might be aware, this depends on how you wrote your transfer call. Let’s get into more interesting things: You can test your call based on the number of time it is made or supposed to be made.
- 0 Transfer Call
In the instant case, you are testing that no transfer was made:
vm.expectCall(address(theToken), abi.encodeCall(theToken.transfer, (John, 9)), 0);
token.transferFrom(John, address(0), 9);
It is the 0
at the end that specifies we don’t expect a transfer to be called.
- 1 Transfer Call
vm.expectCall(address(theToken), abi.encodeCall(theToken.transfer, (John, 9)));
token.transferFrom(John, address(0), 9);
When there you don’t specify the number, the default is one.
- 2 Transfer Calls
vm.expectCall(address(theToken), abi.encodeCall(theToken.transfer, (John, 9)), 2);
token.transferFrom(John, address(0), 9);
token.transferFrom(John, address(0), 9);
Notice two things here: we wrote 2
as the third argument and also repeated the transferFrom
function twice. This way, the vm.expectCall
is technically called 3 times.
Writing a Demo Contract
The first step for you to setting up your program with foundry init
.
Let’s write a smart contract that will facilitate mortgage transactions. Go the the src
path of your program and create a file named Mortgage.sol.
Then you can paste this contract (name it Mortgage.sol
):
Step 1: Writing the Child Contract
We want the main contract to make cross-contract calls to this child contract.
contract MustCall {
function communicate(uint256 message) external {}
}
This is a contract with a single function named communicate
with an argument called message.
Of course, it is marked external because we will want it to communicate with the parent contract.
Step 2: Inheritance and State Variables
We have to make the main contract inherit the first contract. Solidity allows this expression with the is
keyword. As in contract Mortgage is MustCall
.
Then we declared our variables such as _mortgagee
and mortgagor
. Now, bear in mind that a contract is not callable by its exact name, so we have to contextualize the MustCall
contract by renaming it to mustCall
.
In addition, we created a mapping to track addresses and the figures in them.
address public _mortgagee;
address public _mortgagor;
MustCall private mustCall;
mapping(address => uint256) public balances;
Step 3: Constructor
We have to initialize two important variables: mortgagee1
and _mustCall
. We set this in the constructor.
constructor(address mortgageel, address _mustCall) {
_mortgagee = mortgageel;
mustCall = MustCall(_mustCall);
}
Step 4: Admin-only Modifier
For security reasons, there are some functionalities that only the mortgagee and no one else should be able to call. This is where the *mortgageeOnly
* modifier is essential.
modifier mortgageeOnly() {
require(msg.sender == _mortgagee);
_;
}
Step 5: The Deposit Function
This is a function that the mortgagor
will call to deposit their funds.
function mortgagorDeposit() public payable {
require(msg.value >= 1 ether, "you cannot deposit less than 1 ether");
balances[msg.sender] += msg.value;
}
We added a requirement that no deposit should be less than 1 ether.
Step 6: The Balance Function
This is a return function that fetches us the balance of whatever address calls it.
function contractBalance() public view returns (uint) {
return address(this).balance;
}
If you have followed this tutorial diligently to this point, your full code should be like this:
// SPDX-License-Identifier: Unlicensed
pragma solidity 0.8.19;
contract MustCall {
function communicate(uint256 message) external {}
}
contract Mortgage is MustCall {
address public _mortgagee;
address public _mortgagor;
MustCall private mustCall;
mapping(address => uint256) public balances;
event Withdrawn(address indexed mortgagee, uint256 amount);
constructor(address mortgageel, address _mustCall) {
_mortgagee = mortgageel;
mustCall = MustCall(_mustCall);
}
function mortgagorDeposit() public payable {
require(msg.value >= 1 ether, "you cannot deposit less than 1 ether");
balances[msg.sender] += msg.value;
}
function withdrawal(uint256 _amount) external mortgageeOnly {
payable(msg.sender).transfer(_amount);
emit Withdrawn(msg.sender, _amount);
mustCall.communicate(_amount);
}
function contractBalance() public view returns (uint) {
return address(this).balance;
}
modifier mortgageeOnly() {
require(msg.sender == _mortgagee);
_;
}
}
Testing the Demo Contract with Foundry Assertion Cheatcode
Now that we have written the contract, it is time to test if our functions are operating as planned. Take these steps:
Step 1: Dependency Importation
There is a default dependency you must import to test with Foundry, and that is the forge-std/Test.sol
module. Then you must also import the contract you want to test.
import "forge-std/Test.sol";
import "../src/Mortgage.sol";
Step 2: Inheritance and Variable Declaration
This is a mistake new Foundry devs to make: not writing your contract name and import utility well.
There will be an error if you write
contract Mortgage is Test {}
, andcontract MortgeTest {}
It has to be in form of MortgageTest is Test
. That is, you have to add Test to the name of the contract you want to test. Then say is Test
so it can interface with the Test.sol
module in the Forge Standard Library.
Moving on, we created private variables.
Mortgage private mortgage;
MustCall private mustCall;
address payable private mortgagee = payable(address(0x123));
address payable private mortgagor = payable(address(this));
Step 3: setUp Function
This function works more like a somewhat constructor for your test. That is, you can initialize variables here.
function setUp() public {
mustCall = new MustCall();
mortgage = new Mortgage(address(mortgagee), address(mustCall));
// Fund the contract for testing purposes
vm.deal(address(mortgage), 10 ether);
}
Now, Foundry is very strict with argument-dependent lookup. Meaning for the new Mortgage
declaration for example, the arguments you provide must match the number and nature as you have created them in the constructor of the contract you are testing. Or else, you will hit errors.
We also sent 10 ether to the contract with the vm.deal
cheatcode.
Step 4: Testing Deposit
This function tests if a deposit will be successful:
function testMortgagorDeposit() public {
vm.prank(mortgagor);
mortgage.mortgagorDeposit{value: 1 ether}();
assertEq(mortgage.balances(mortgagor), 1 ether);
}
We pranked to change the address to the mortgagor since that is the address we expect to call this function.
Then we made a call to the mortgagorDeposit
function of the contract to deposit 1 ether.
Subsequently, we tried to assert if the balance of the mortgagor is now 1 ether.
Step 5: Testing Withdrawal
In this contract, we tested if cross-contract calls and withdrawal by the mortgagee will be successful.
function testWithdraw() public {
vm.expectCall(address(mustCall), abi.encodeWithSignature("communicate(uint256)", 0.5 ether));
// Expect event emission
vm.expectEmit(address(mortgage));
emit Withdrawn(mortgagee, 0.5 ether);
vm.startPrank(mortgagee);
mortgage.withdrawal(0.5 ether);
assertEq(address(mortgage).balance, 9.5 ether);
vm.stopPrank();
}
We used vm.expectCall
to test calling the mustCall
contract.
Right after that, we also used the vm.expectEmit
keyword to track when the mortgage
contract emits the Withdrawn
events to update its state.
Moving on, we pranked to make mortgagee
withdraw 0.5 ether from the mortgage
contract.
Then we tried asserting if the balance in the contract, when all is said and done, is 9.5 ether.
Step 6: Fallback
You should include fallback so the test contract can receive funds.
receive() external payable {}
At the end of the day, your test contract should appear like this:
// SPDX-License-Identifier: Unlicensed
pragma solidity 0.8.19;
import "forge-std/Test.sol";
import "../src/Mortgage.sol"; // Adjust the import path according to your directory structure
contract MortgageTest is Test {
Mortgage private mortgage;
MustCall private mustCall;
address payable private mortgagee = payable(address(0x123));
address payable private mortgagor = payable(address(this));
event Withdrawn(address indexed mortgagee, uint256 amount);
function setUp() public {
mustCall = new MustCall();
mortgage = new Mortgage(address(mortgagee), address(mustCall));
// Fund the contract for testing purposes
vm.deal(address(mortgage), 10 ether);
}
function testMortgagorDeposit() public {
vm.prank(mortgagor);
mortgage.mortgagorDeposit{value: 1 ether}();
assertEq(mortgage.balances(mortgagor), 1 ether);
}
function testWithdraw() public {
vm.expectCall(address(mustCall), abi.encodeWithSignature("communicate(uint256)", 0.5 ether));
// Expect event emission
vm.expectEmit(address(mortgage));
emit Withdrawn(mortgagee, 0.5 ether);
vm.startPrank(mortgagee);
mortgage.withdrawal(0.5 ether);
assertEq(address(mortgage).balance, 9.5 ether);
vm.stopPrank();
}
receive() external payable {}
}
Step 7: Running the Test with Forge CLI
First of all, ensure you have changed your CLI part to where you initialized foundry.
What I mean is to do something like cd hello_foundry
or cd whatever_name_you_used
.
Once you are on the right path, run forge test
. If you did everything correctly, you should see this on your CLI:
[⠃] Compiling...
No files changed, compilation skipped
Ran 2 tests for test/Mortgage.t.sol:MortgageTest
[PASS] testMortgagorDeposit() (gas: 40445)
[PASS] testWithdraw() (gas: 59174)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 2.49ms (1.28ms CPU time)
That means all our tests passed!
What next?
The tests we wrote in this contract is to test if our functions work the way we planned them. This is a mindset you must have in smart contract test: also test against attach vectors.
So beyond testing if your functions work, also test if they can be easily manipulated. This is not only for security researchers, but for smart devs who want to write smarter contracts.
If you have more questions, reach out to me on Twitter. I hope to keep more Foundry tutorials coming in too!
Shout-out
I hit a couple of bugs while building this tutorial, and some other good devs helped out. You can check them out under this tweet. Particularly, shout-out to WhyDeeHem, Paul, and The First Elder.
Subscribe to my newsletter
Read articles from John Fáwọlé directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
John Fáwọlé
John Fáwọlé
Web3 Technical Writer | Content Lead | Marketing Strategist | For Devs