Smart Contract Testing: The Foundry Way

Ifeoluwaolubo
Web 3 Digitals
Published in
11 min readNov 4, 2023

In the previous article, we started out with a new tool called Foundry to aid our smart contract development life cycle. We wrote a Kickstarter smart contract that “sort of” solves the issue the current kickstarter project faces by moving it into a rather decentralized system. You can find the link to article below ⬇️:

Since the blockchain is designed to be immutable, it is very important that we thoroughly test the behaviour of our smart contract to see if it does as intended before we actually deploy it on the blockchain. Please note that, the fact that we need to do thorough testing on our smart contracts cannot be over emphasized. In fact, this is so important that companies hire smart contract security engineers and auditors to run through their software for potential bugs and necessary optimizations.

Foundry is a very beautiful tool when it comes to smart contract testing. We not only have the ability to write tests in our beloved solidity, but it also come packed with some cheat codes that makes writing tests much easier. With foundry, we can see how much tests we’ve written and the different areas of our code the test covers very easily. Although writing test that has a 100% test coverage is rather infeasible, we should at least try to make it a very high percentage.

In this article, we build upon our preexisting knowledge of foundry by extending our kickstarter smart contract from the previous article and writing extensive tests for it. I’ll be using VsCode as my editor. Let’s get right into it 🚀

Basic testing

In my test folder, I’ll create a new file called KickstarterTest.t.sol with the following code

Like all the smart contracts we’ve previously worked on, we have our usual declarations and version. On line 5 we are importing a Base contract called Test from forge. Then our KickstarterTest contract now “Inherits” From this test contract and that’s exactly what the code on line 7 is doing, we’re simply just inheriting from forge’s base Test contract so all the testing tools would be readily available to us.

Quite often, we’ll need to do some initial setup (like funding an account with some ethers) before we run our tests. In foundry, we can do this by using the setUp function as seen below.

setUp function

The setup function runs each time before any of our tests runs. Eg if we have a test to check the balance of an account and another test to transfer ethers, then our test suite would run something like this:

setUp() -> testBalance() -> setUp() -> testTransfer()

For demo purposes, let’s just write a simple test to get familiar with how we actually run tests in foundry.
NB: I’ll be removing this test once I’m done, it’s only for demo purposes.

assertion test

Here we are just testing that the amount is indeed 20 using the assertEq function. Run the test using this command

forge test

You should see an output like this:

What to test..

Now that we’ve seen some basic testing, we have to test our actual kickstarter contract. One thing to know before writing good tests for our contract (or any software really) is, what aspects of our code are we very interested in and whether or not these aspects should always behave appropriately.

So really, what are the aspects of our contract that we are so interested in? I’ve noted a few things that we might absolutely need to write test for, which includes (but not limited to):

  • The manager’s address is the right thing
  • The minimum contribution set by the manager upon contract creation is actually the right thing.
  • Users that are funding must not provide an amount less than the minimum contribution amount.
  • We are keeping track of the funders and how much they have funded so far.
  • Manager can create spend requests
  • Funders cannot create spend requests
  • Funders can approve spend requests
  • Manager cannot finalize the movement of money without the majority of the funders approving the spend request.
  • Manager can move money if majority of the funders has approved the spend request

By no means is this all we need to test for, this is just sufficient as of right now. Also to fully grasp what it is we’re planning to test, I’ll suggest you quickly have a refresher on the previous article where we worked on the kickstarter contract just in case you haven’t done that yet, again link is below ⬇

A little code refactor…

Before writing our test, there is a little modification we’ll need to do to our previous implementation. Foundry provides us with a way to write deployment script and these scripts simulates deployment to various blockchain network. So you guessed right, we’ll be creating our own deployment script. 😋

First let’s delete the Deploy.sol file that contains our DeployCampaign factory from the last article, then change the constructor of the Campaign contract to only accept the minimum contribution.

In the script folder, create a DeployKickstarter.s.sol file with the following content:

We imported our campaign, then created a DeployCampaign contract that inherits from the base Script from forge. We then created a run function that returns a campaign. The run function is the entry point of our deployment script. The vm.startBroadcast() and vm.stopBroadcast() are cheatcodes that comes with forge scripts, basically it broadcasts the transaction to deploy the contract on a blockchain.

We use the setAmount function to set the minimum amount then in between the start and stop broadcast we created our campaign and passed the minimum amount. Again this deploys the contract on-chain. Run the command:

forge script script/DeployKickstarter.s.sol

You should see outputs like:

The good thing about creating deployment scripts like this is that we not only use it for deployment but also for testing and we are sure that we have the same deployment configured in both scenarios.

We can now use this deployment script in our test setUp function like this:

test setUp

Nothing much here, we just created a constant to store the minimum amount that would be used during our test. In the setUp function, we created a deployCampiagn, set the amount to the minimum amount then ran the deployment and saved the returned campaign in our campaign storage.

Actual testing

Alright, we’ve finally reached the point where we can start writing tests for our campaign. First let’s test that our manager is the right thing.

You’re probably wondering why the address being used is the msg.sender and not the deployCampaign address. Another thing the start and stop broadcast does is that it sets the actual sender of the transaction as the sender and not the deployCampaign.

If all was done correctly, then you should see that the test passes like so

Great, so far so good. Lets move on to the next test 🚀

Next we test that the minimum amount is the correct value

Then run forge test again. You should see 2 tests passing.

Next we test a funder cannot contribute less than the minimum amount

The vm.expectRevert() means that the code below it is supposed to revert. Since we are passing an amount that is less than the minimum contribution amount we are simply just saying that we expect the lines below it to fail. Run the tests again and it should be passing as well.

We then test that we keep track of the funders and the amount they funded.

When writing tests, it can be quite difficult sometimes to know who’s sending a particular transaction, which is why foundry provides a cheatcode that lets us explicitly declare who it is that would be sending the transactions.

Before continuing with our tests, lets create a user that would be sending all of our transactions

Forge provides us with a makeAddr function that accepts a name and returns an address. This would be the address of the user sending our transactions.

I added a function to our campaign contract in the Kickstarter.sol file to get the amount funded by a funder.

We can then use this function in our test like so

Run this test with a -vvvv flag. You should see your tests failing 😅

Notice the EvmError at the bottom to be “Out of fund”. Meaning the user does not have enough ethers for the transaction and specifically has zero ethers. Now this is expected since we only created the user without actually providing some initial balance to their account. Luckily for us, forge also provides another cheatcode that gives our user some ethers. Now let’s magically produce ethers out of thin air 😎.

The -vvvv flag used is sort of a log level where the number of v’s ranges from 1 to 5 and each shows more logs for your test. The more the v, the more comprehensive logs you see.

In our setUp function, let’s give our user 50 ether.

vm.deal() cheatcode

Modify your test again like below, run it and they should now be passing.

vm.prank() cheatcode

The vm.prank(USER) means that the next immediate transaction is to be sent by the USER.

That was a lot of cheat codes I know 😂, but let’s continue with our test. Next we are to test that a manager can create spend request. I added the following code to the campaign contract, then used it in our test below.

function getSpendRequestCount() public view returns (uint) {
return s_numRequests;
}

We created a recipient and prefunded with 5 ether. This is the address the manager is trying to send the money for the spend request to. We added a user to the Campaign then created the spend request stating explicitly that the msg.sender (manager) is the sender of the transaction and asserted that we now have 1 spendRequest created.

Note that the vm.startPrank used above is essentially the same thing with vm.prank. vm.prank only specifies the next immediate transaction while vm.startPrank specifies everything in between the start and stop as being sent by the user.

We then test that manager cannot finalize a spend request if majority of the funders has not approved it.

We expect a revert when we call the finalizeRequest function for the first spend request created when it has not been approved.

Finally, we’ll test that a spend request that has been approved by majority can be finalized by the manager. I added another getter function to the Campaign contract that looks like below and used in my test

function getSpendRequestCompletedStatus(
uint index
) public view returns (bool) {
return s_spendRequests[index].complete;
}

We once again create a campaign, asserted that it has not been completed, then our funder approved it, the manager finalized it which sent the ethers to the desired address, then we asserted that the spend request has been completed and that the recipient balance has increased.

Great, so far we’ve written very extensive tests for our kickstarter contract, and there are still some areas to test, but I’ll leave that up to you to extend the existing test suites. We can actually see how much of our code the test covers by running the command

forge coverage

Wow, we were able to write test that covers about 96% of our Kickstarter contract 🤩🥳. That’s quite good for right now.

You can find the code for the complete test suite below

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.21;

import {Test, console} from "forge-std/Test.sol";
import {DeployCampaign} from "../script/DeployKickstarter.s.sol";
import {Campaign} from "../src/Kickstarter.sol";

contract KickstarterTest is Test {
uint constant MINIMUM_AMOUNT = 50;
Campaign campaign;
address USER = makeAddr("ifeoluwa");
uint constant INITIAL_BALANCE = 50 ether;

function setUp() external {
DeployCampaign deployCampign = new DeployCampaign();
deployCampign.setAmount(MINIMUM_AMOUNT);
campaign = deployCampign.run();
vm.deal(USER, INITIAL_BALANCE);
}

function testManagerAddress() public {
assertEq(campaign.getManager(), msg.sender);
}

function testMinimumAmountIs20() public {
assertEq(campaign.getMinimumContribution(), MINIMUM_AMOUNT);
}

function testCannotContributeLessThanMinimumAmount() public {
vm.expectRevert();
campaign.contribute{value: 20}();
}

function testFundersAndAmountFunded() public {
vm.prank(USER);
campaign.contribute{value: 500}();
assertEq(campaign.getFunderAmount(USER), 500);

vm.prank(USER);
campaign.contribute{value: 200}();
assertEq(campaign.getFunderAmount(USER), 700);
}

function testManagersCanCreateSpendRequest() public {
address RECIPIENT = makeAddr("recipient");
vm.deal(RECIPIENT, 5 ether);

assertEq(campaign.getSpendRequestCount(), 0);

vm.startPrank(USER);
campaign.contribute{value: 200000}();
vm.stopPrank();

vm.prank(msg.sender);
campaign.createSpendRequest(
"some description",
10000,
payable(RECIPIENT)
);
assertEq(campaign.getSpendRequestCount(), 1);
}

function testCannotFinalizeIfNotApproved() public {
address RECIPIENT = makeAddr("recipient");

vm.startPrank(USER);
campaign.contribute{value: 200000}();
vm.stopPrank();

vm.prank(msg.sender);
campaign.createSpendRequest(
"some description",
10000,
payable(RECIPIENT)
);

vm.expectRevert();
vm.prank(msg.sender);
campaign.finalizeRequest(0);
}

function testCanFinalizeIfApproved() public {
address RECIPIENT = makeAddr("recipient");

vm.startPrank(USER);
campaign.contribute{value: 200000}();
vm.stopPrank();

vm.startPrank(msg.sender);
campaign.createSpendRequest(
"some description",
10000,
payable(RECIPIENT)
);
vm.stopPrank();

assertEq(campaign.getSpendRequestCompletedStatus(0), false);

vm.startPrank(USER);
campaign.approveRequest(0);
vm.stopPrank();

vm.prank(msg.sender);
campaign.finalizeRequest(0);

assertEq(campaign.getSpendRequestCompletedStatus(0), true);
assertEq(RECIPIENT.balance, 10000);
}
}

That would be all for this article and I hope you found it helpful as it explains the basics of testing with foundry and some of the tools and cheatcodes that are provided when writing tests. Please like, share, comment and follow. I’ll see you in the next, bye for now.👍

You can find the next step below.

--

--