To say Foundry is an up and comer in the Solidity framework space would be an understatement. It is new but has a strong community backing and solid development. Moreover, it has now been described with one of the most overused words in the IT industry – “blazingly fast”.
If you are starting out with Foundry, you might have already come across the fact that it is made in Rust and has been adopted from DappTools. But the main hype behind it is that it helps you write your tests in Solidity.
Until so long, the de facto testing framework was Chai/Mocha. That didn’t change as the industry weight started shifting from Truffle to Hardhat. The only drawback one might consider was that it needed a Solidity Developer who also knew Javascript. Granted most already do but the question raised by Foundry is that if you are writing your contracts in Solidity then why not the tests as well.
In this article we will focus on developing contracts with Foundry and testing them (using solidity). Since the focus is not on contract build but on the framework itself, we will use the boring NFT contract we developed in my Chainlink VRF series. We will replicate that stuff with Foundry in this article.
Please note: This article assumes that you already have familiarity with Solidity and that you have already set up Foundry on your system.
I am a Windows user (sorry…) so I will be using WSL in my case. The process is same on WSL as well. The thing you might need to know is that when you open the distro you have installed, it provides an option to load all other partition drives into the distro at /mnt
directory. Since I am using Ubuntu 22.04 LTS distro as my WSL, it automatically does that.
You can follow the awesome articles on how to install Rust in WSL and then install Foundry with the foundryup
tool. The people at paradigm are really going in-character – the logo of Foundry is a smith and it comes with two tools called forge
and cast
.
These tools are used for “forging” the contract (development, testing and deploying) and “casting” the contract (interacting with the deployed contract). I wonder how long until the terms forging and casting smart contracts catch up with everyone (personally, I think it would be funny if 2 years down the line some releases a video or article where they say “forging a smart contract in hardhat” XD).
Also, there is this really awesome Foundry book you might want to check out if you want to learn more. Apparently that trend is catching up and new languages, tools and frameworks publishing their docs as “books” with – Rust Book, Anchor Book, Nutanix Bible and so on already being available to learners.
Housekeeping
In this section we will initiate a new Foundry project and fetch and install our dependencies. Since we won’t be discussing the contract OurNFTContract.sol
in this article, this section just sets up the housekeeping for our mini-project in Foundry.
Let’s start with running forge init
first. This is kind of like npx hardhat init
and sets up the files required. Given that Foundry has this vibrant and active community, this project structure might change a bit as time passes. However, here are the basic rules:
- You write the contract you want to develop/deploy in the
src
directory (browse to project root folder). - You write your tests inside the
test
directory. The tests are written inside a file in that directory. The file naming convention is<YOUR CONTRACT NAME>.t.sol
where the t in-between denotes test for the respective contract in the src folder. - The
.gitmodules
file like thenode_modules
file. The thing about Foundry is that it fetches all the modules you might want to use directly from GitHub. So, this file maintains those links. - The
foundry.toml
file is thepackage.jso
n counterpart and we define things like re-mappings (we will come to those) here.
We will place OurNFTContract.sol
inside the src
folder and our Mock VRF contract inside a directory called mocks
inside the src
folder itself. You won’t have the mocks
folder when you initialize a new project. So, make it yourself. You can compile the contract by running forge build. Try running it now and you might face an error as shown below.
Now if you have been following my articles, you should know by now that OurNFTContract.sol
has two dependencies – Openzeppelin and Chainlink. It imports contracts from both projects. Normally, running a npm install
in Hardhat or Truffle might have sufficed. But this is Foundry and it brings in its nuances.
To install the respective modules, we will need to first fetch the Github repo names to Openzepplin and Chainlink (click on these hyperlinks). We will enter this when installing. To add it into this Foundry Project, run:
-
forge install Openzeppelin/openzeppelin-contracts
-
forge install smartcontractkit/chainlink
If it says that there are changes in the Project that needs to be commited, just run the above again by adding a –no-commit
like the images shown below.
This will install Openzeppelin and Chainlink respectively. You might think that this should be enough. Run forge compile to check it. You will see it still errors out. You may go through the stack trace of the error before reading along.
Basically, what happens is that when the above two commands are run, forge fetches those repos, adds them to the lib
directory. It then creates what is called a remapping. Whenever your import these contracts, it will map those import paths to the modules installed in the folders inside lib
. Running forge remappings
will show you those assumed re-mappings.
However, the there is a difference between node-based imports and imports in Foundry. Node-based imports start with an “@”. Foundry ones do not by default. So, there are two options in-front of you now. You could either change those node-based imports in OurNFTContract.sol
from something like these:
to the ones shown below where the “@” are removed as shown in the image below. In fact, if you run forge re-mappings you would notice that forge assumes these re-mappings when you install a module.
Now let’s say you do not want to do the above. You could go with the second option. Open the foundry.toml
file in your project root and add a re-mappings key along with the remapping you want as shown below:
Now, running forge build
should work. It should compile all the contracts and give a similar output to the one shown in the picture below:
This concludes our housekeeping. Now that the contracts compile, we know that the re-mappings work. We can now move onto the bit that makes Foundry so interesting – writing tests. There are 2 USPs here:
- You can write tests in pure Solidity. So, bye bye ChaiJS.
- Foundry comes with a built-in Fuzzer.
Fuzzing is basically when you supply all kinds of values to your contract to check which one breaks it. With Hardhat or Truffle you do not get the feature. You need to separately use Echidna to fuzz test your contracts. And while the starter kit from Chainlink does make it easy by bringing the scripts under one package.json
, you need Docker to run it. Fuzzing with Foundry is as simple as passing in an argument to the test functions and Foundry handles the rest by passing a range of values when running those functions.
Contract Testing
As you may know by now, the general convention of creating a test file is by naming the file <CONTRACT TO BE TESTED>.t.sol
which in our case turns out to be OurNFTContract.t.sol
(placed inside the test
directory).
The tests will be written in this folder and the contract name follows the convention <CONTRACT TO BE TESTED>Test
. It will inherit from either Test
or DSTest
as shown below. Test.sol
is to be imported from forge-std
while DSTest
from ds-test
. You don’t need to use both since Test.sol
already imports DSTest.sol
and the Test
Contract inherits from the same. The Test
contract additionally contains what are known as “Cheatcodes” in Foundry. These are use to alter the EVM State. This is made possible as Test
inherits from Script
abstract contract that instantiates the **vm**
object. The VM object helps us do things that can be done in hardhat through ethers.provider.send(“evm_increaseTime”, <Time Period>)
and so on. The code below shows the initial portion of the Testing Contract in our case.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
// import "ds-test/test.sol";
import "forge-std/Test.sol";
import "../src/OurNFTContract.sol";
import "../src/mocks/VRFCoordinatorV2Mock.sol";
contract OurNFTContractTest is Test {
OurNFTContract nftContract;
VRFCoordinatorV2Mock vrfContract;
event SubscriptionCreated(uint64 indexed subId, address owner);
event SubscriptionFunded(
uint64 indexed subId,
uint256 oldBalance,
uint256 newBalance
);
event ConsumerAdded(uint64 indexed subId, address consumer);
event RandomWordsRequested(
bytes32 indexed keyHash,
uint256 requestId,
uint256 preSeed,
uint64 indexed subId,
uint16 minimumRequestConfirmations,
uint32 callbackGasLimit,
uint32 numWords,
address indexed sender
);
event RandomWordsFulfilled(
uint256 indexed requestId,
uint256 outputSeed,
uint96 payment,
bool success
);
event ReceivedRandomness(uint256 reqId, uint256 n1, uint256 n2);
event RequestedRandomness(uint256 reqId, address invoker, string name);
function setUp() public {
vrfContract = new VRFCoordinatorV2Mock(0, 0);
vrfContract.createSubscription();
nftContract = new OurNFTContract(
1,
address(vrfContract),
bytes32(
0x4b09e658ed251bcafeebbc69400383d49f344ace09b9576fe248bb02c003fe9f
)
);
}
/*
* Our Tests
*/
}
The Testing contract contains a lot of events as shown above. This is because there is no .to.emit
clause from ChaiJS that can be used here. There is a specific syntax for catching and checking events which we will come to later in this article that requires such explicit event declarations.
Notice how in the above code there is no constructor. That’s because a Testing Contract needs to contain a setUp()
function which does that job essentially. It’s like the beforeEach
Hook of Chai. Foundry runs the setUp()
function for every test function written in the Testing contract. Thus, every test function gets a clean slate to execute. This is a boon and a bane as we will come to see.
In our case, the setUp()
function consist of deploying the Mock VRF contract, creating a subscription in it and then initializing the our NFT Contract with the Subscription ID, Mock VRF address and the Keyhash. The testSubscriptionCreate()
function below shows how a subscription creation emits an Event that can then be caught for testing purposes.
function testSubscriptionCreate() public {
vm.expectEmit(true, true, false, false);
emit SubscriptionCreated(2, address(this));
vrfContract.createSubscription();
(uint96 balance, uint64 reqCount, address owner, ) = vrfContract
.getSubscription(2);
// emit log_uint(balance);
// emit log_uint(reqCount);
// emit log_address(owner);
assertEq(balance, 0);
assertEq(reqCount, 0);
assertEq(owner, address(this));
}
You may know by now that events can have at most 3 indexed parameters. The rest is the data in the event. The vm.expectEmit()
in the code above has been set to (true, true, false, false)
. The first 3 args passed to vm.expectEmit()
can be thought of positional args which enable the check for the 3 indexes in the events. This tells that Foundry needs to check that the 3 indexes should match between the event that we emit in the next line and the event that is emitted in the line after that on Mock VRF contract function invocation. The fourth/last arg to vm.expectEmit()
tell it if it needs to also check the data in the events as well (non-indexed parameters).
In our case, SubscriptionCreated
event emitted from the Mock VRF (when a subscription is created) has 2 parameters both of which are indexed. So we set the first 2 args to vm.expectEmit()
to true
. There is no 3rd parameter in the event. In fact, there is not data parameter either if you think about it. So, we set them both to false
.
In the line following vm.expectEmit()
, we emit the event from the TestContract
. This tells Foundry the event topic it needs to watch out for along with the parameters. After that we invoke vrfContract.createSubscription()
which creates a sub and emits the event.
We then check if the subscription was really created by invoking the getSubscription()
function and asserting that the values match out expectations. The 3 commented out events in-between which start with **log_**
act as a console.log (Javascript) and can be viewed when the verbosity is set on forge test
.
In the code below, we try to successfully fund the subscription. Note that there is no subscription with Id 2 by the time this function is executed. This is because the state from the previous function execution is wiped clean. We still have a subscription with ID 1 since that is created in the setUp()
function that runs before every test function.
function testSubscriptionFund() public {
vm.expectEmit(true, true, true, false);
emit SubscriptionFunded(1, 0, 2 ether);
vrfContract.fundSubscription(1, 2 ether);
}
Notice, how we again try an event-based testing in the code above. We could also for the alternative where after executing vrfContract.fundSubsciption()
we execute vrfContract.getSubscription()
passing in the ID 1. We could then assert that the balance was increased as we expected. This presents a nice choice and you can go for both. Here, it’s a personal choice to check using the SubscriptionFunded
event emitted when vrfContract.fundSubscription()
is executed.
After this, we check the consumer add procedure also in an event-based manner. Keep in mind that by the time this test is executed, the subscription with ID 1 has again reverted back to the 0-balance state from the previous state where we funded it with 2 ether-precision of fake LINK tokens.
function testAddConsumer() public {
vm.expectEmit(true, false, false, true);
emit ConsumerAdded(1, address(nftContract));
vrfContract.addConsumer(1, address(nftContract));
}
The vrfContract.fundSubscription()
and vrfContract.addConsumer()
will be executed in every test here on out so I wanted to elaborate on them right here before we get into the crux. As with the article testing the Chainlink VRF functionality, we will not check for everything but just that the communication between our NFT contract and the Mock VRF contract is functional.
NFT Contract sends request successfully
The first phase in the cycle is when our NFT contract requests a random number. This is done when the safeMint()
function is invoked. I won’t explain the safeMint()
function. For that I will refer you to my Chainlink VRF series once again.
function testRequestIsSent() public {
vrfContract.fundSubscription(1, 2 ether);
vrfContract.addConsumer(1, address(nftContract));
// vm.prank(address(1));
vm.expectEmit(false, false, false, true);
emit RequestedRandomness(1, address(this), "Halley");
nftContract.safeMint("Halley");
}
Bottom line, the safeMint()
function emits a RequestedRandomness
function which we try to detect in the test above. Note how the EVM is told to ignore all the index parameters and just check the data parameters in the event 1:1 to the event we emit from this function. This is because I didn’t put any indexed parameters in the event when I was developing the NFT contract (What? Were you expecting a long-winded discussion on best practices and stuff? Sorry to disappoint XD).
There is also a commented out vm.prank()
which we will come to in the later sections in this article.
If the test detects the event mentioned above, we can be sure that the NFT contract has indeed requested for Random Numbers from the VRF Oracle.
Mock VRF Coordinator Receives request successfully
Next, we check if the Mock VRF Oracle (vrfContract
) receives the requests from our NFT contract. The code section below should appear identical to the one above albeit one change. The event is different. The RandomWordsRequested
event is emitted from the Mock VRF when it receives a request for Random Numbers.
function testRequestIsReceived() public {
vrfContract.fundSubscription(1, 2 ether);
vrfContract.addConsumer(1, address(nftContract));
// vm.prank(address(1));
vm.expectEmit(true, true, true, false);
emit RandomWordsRequested(
bytes32(
0x4b09e658ed251bcafeebbc69400383d49f344ace09b9576fe248bb02c003fe9f
),
1,
100,
1,
0,
0,
0,
address(nftContract)
);
nftContract.safeMint("Halley");
}
Note how in the code section above, we tell the EVM to test if the event from the Mock VRF contract matches the event we emit in this test with respect to all the indexed parameters but it can ignore other event parameters. This is done by vm.expectEmit(true, true, true, false)
. The RandomWordsRequested
event has key hash, subscription Id and the address of the contract which sends the random number request as indexed parameters (check in the code section above where we defined all the events). These are duly matched.
There is a difference of how the events are detected. If you were using ChaiJs, you would have to pass in the contract name followed by the event name to the .to.emit
clause. Well…
Mock VRF Coordinator Sends back response successfully
Now that our NFT Contract has successfully sent the request for Random Numbers to Mock VRF and the Mock VRF has received the same, we need to check if the Mock VRF processes that request and sends back the response successfully to the NFT contract. The test given below does that.
function testRequestIsProcessed() public {
vrfContract.fundSubscription(1, 2 ether);
vrfContract.addConsumer(1, address(nftContract));
// IMPORTANT: Test Will fail with "ERC721: transfer to non ERC721Receiver implementer" otherwise
vm.prank(address(1));
nftContract.safeMint("Halley");
vm.expectEmit(true, false, false, true);
emit RandomWordsFulfilled(1, 1, 0, true);
vrfContract.fulfillRandomWords(1, address(nftContract));
}
Among the things to note here is how we can finally uncommented the vm.prank(address(1))
line. This indicates that the next contract/function call will happen from the address passed to vm.prank()
function. The need for this is explored in my very first article – NFT Marketplace Code Along. Basically, we will need to implement the IERC721Receiver interface or inherit from ERC721Holder.
You could try to run the tests but you would encounter an error similar to the one shown below.
Every function call to contracts inside the Testing contracts takes place from the address of the Testing Contract. When we invoke nftContract.safeMint()
, the NFT contract receives a mint request from this testing contract address. The thing is safe minting or transferring to contract addresses is not allowed unless it implements the aforementioned interface.
So we use vm.prank()
here to spoof the safeMint()
call such that it does not come from contract address. This is equivalent to using .connect()
on a deployed address in Hardhat while testing to invoke a contract function call from a different address.
Now the question arises why didn’t we need it in the earlier two tests? That’s because this kind of a check is put inside the _mint()
function of ERC721.sol
which we inherit. We do not invoke that from safeMint()
but from fulfillRandomWords()
function of our NFT contract.
The execution of the fulfillRandomWords()
from the Mock VRF contract results in the emission of the RandomWordsFulfilled
event which we check in the test above.
NFT Contract receives the response successfully
Lastly, the NFT contract needs to receive the random numbers from the Mock VRF. In our NFT contract, on execution of the fulfillRandomWords()
function, we emit the ReceivedRandomness
event. You may know by now that the Mock VRF is responsible for the execution of fulfillRandomWords() in NFT contract.
function testResponseIsReceived() public {
vrfContract.fundSubscription(1, 2 ether);
vrfContract.addConsumer(1, address(nftContract));
// IMPORTANT: Test Will fail with "ERC721: transfer to non ERC721Receiver implementer" otherwise
vm.prank(address(1));
nftContract.safeMint("Halley");
vm.expectEmit(false, false, false, true);
emit ReceivedRandomness(
1,
uint256(keccak256(abi.encode(1, 0))),
uint256(keccak256(abi.encode(1, 1)))
);
vrfContract.fulfillRandomWords(1, address(nftContract));
(address currentOwner, , string memory name, , , ) = nftContract
.getCharacter(0);
assertEq(currentOwner, address(1));
assertEq(name, "Halley");
}
So, as shown above, if we detect the emission of ReceivedRandomness
event on execution of fulfillRandomWords()
in the Mock VRF contract then we can be sure that the NFT contract has received the random words. That is precisely what we check. Furthermore, if we invoke getCharacter()
of the NFT contract by passing in the Token ID, then we can be doubly sure that the process runs full circle. That is the extra mile we go in the above test.
A simple question may be “How do we know the exact Random numbers received?”. When querying and actual Chainlink VRF Oracle, we won’t know it. But in the Mock VRF contract, to make things easy, the process is deterministic. It is done by encoding the request ID and the index of the number in the request. The encoded value’s Keccak hash is obtained and then typecasted to uint256
. Kind of like the picture below. Since we know the request ID and this logic in the Mock VRF, we can predict those numbers.
This concludes the initial goal of replicating those ChaiJS tests from Hardhat in Foundry.
Extra: Mocking VRF with Foundry’s Fuzzer
For everything we did in this article, we have not tested out one of the crucial offerings of Foundry – its fuzzing ability. There’s no extra fuss for fuzzing your contract. All you need to do is write a test that accepts a parameter. Foundry will take care of the rest. It will pass in values to check if any of those break your contract functionality.
Where might we be able to use that now?
Turns out we can emulate the Mock VRF to fuzz test our NFT contract. The code below demonstrates how. At this point you need to be aware of the fact that the Mock VRF contract calls the rawFulfillRandomWords()
function on the contract requesting randomness. This function is inherited from VRFConsumerBaseV2
(all contracts using Chainlink’s VRF must inherit from this contract). Only the VRF Coordinator is allowed to call the rawFulfillRandomWords()
function.
function testFuzzOnNftContract(uint n1, uint n2) public {
// vm.assume(n1 > 0 && n2 > 0);
vrfContract.fundSubscription(1, 2 ether);
vrfContract.addConsumer(1, address(nftContract));
// IMPORTANT: Test Will fail with "ERC721: transfer to non ERC721Receiver implementer" otherwise
vm.prank(address(1));
nftContract.safeMint("Halley");
uint[] memory randomNumbers = new uint256[](2);
randomNumbers[0] = n1;
randomNumbers[1] = n2;
vm.prank(address(vrfContract));
nftContract.rawFulfillRandomWords(1, randomNumbers);
(address currentOwner, , string memory name, , , ) = nftContract
.getCharacter(0);
assertEq(currentOwner, address(1));
assertEq(name, "Halley");
}
Remember how vm.prank()
helps us spoof the msg.sender
address for the next contract call? We use that in the above test. We trick our NFT contract into thinking that the Mock VRF contract is indeed the one calling the rawFulfillRandomWords()
inside our NFT contract. Through this, we pass in the fuzz values to the function in an uint256
array (just like VRF Oracle would send it).
If you do not, the below error would be thrown:
If you use the -vvvvv
flag with forge test
you will get to see the stack trace of the error which will pin out the following:
You could also use vm.assume()
to skip for certain fuzz values. It is kind of like a require statement but at the EVM level. If the condition inside vm.assume()
evaluates to false, the fuzz values passes to the testing function in the current iteration are skipped.
Lastly, we check if the character was indeed minted as we wanted by calling on the getCharacter()
method in our NFT Contract.
You could actually go a step further and replace the Mock VRF Contract completely by using vm.mockCalls()
. This would be stepping into the Black-box kind of test setup. In my opinion it would defeat the purpose here because we won’t be fully using Chainlink’s VRF process even though we have access to it via the Mock VRF Contract.
Running forge test
now would run the whole batch of tests we went through in this article. Rest assured, it should show the below output:
You can see the gas consumed by different tests shown beside each test. That's another advantage of using Foundry. No need to install a plugin manually like Hardhat.
Contract Deployment
We are nearing the end. Lastly, we need to deploy the contract now that it has been tested. You can use forge for this purpose. In the code repo for this blog, I have provided the shell script which sets a few environment variables needed by Foundry for contract deployment and verification.
Given the length of this article, we will deploy and verify in one go. Run the env.sh
script in the code repo after putting in your respective values. The following command deploys the contract to the network of your choice (obtained from the environment variables).
forge create --rpc-url $ETH_RPC_URL --constructor-args 0 0 --private-key $PRIVATE_KEY OurNFTContract --verify
In our case, that is Polygon’s Mumbai Testnet. The –verify
flag at the end of the command tells forge to verify the contract as well. This is a departure from the two-step process we generally see with Hardhat. Of course, you can verify separately as well.
As you can see above, our contract gets deployed and verified.
That’s it for this one, folks!
I believe in being classy. I am not crass enough to put forward comparison like "Who is the best footballer - Messi or Ronaldo?" (of course its Iker Casillas, period). Even though I have given a comparison between Foundry and Hardhat, I would not say outright that one is better than the other. They both have their advantages. Having used Hardhat for so long, I would say it has grown on me. But Foundry too has its appeal. I will leave it up to you as to which one to use.
This concludes our journey at this time. We started from initializing a Foundry project all the way to deploying it after testing. If you were able to follow it in one go, kudos to you. The code for this blog has been open-sourced at this repo.
If you have any feedback or would like to have a discussion regarding this article, feel free to leave them below. If you have any topic recommendations for me, I can be contacted via my email and social handles. Until the next article, keep building awesome stuff on Web3 and WAGMI!
Top comments (0)