Zero to Hero in Foundry - Part 3: Testing Fundamentals


Recap
Welcome back folks! Part 2 took us through some pretty good stuff. We learnt how to use
anvil
to run a local simulation of a blockchain in our systems. Then we used a simpleforge
command to deploy our contract to our local chain. And finally usedcast
to interact with our deployed contractPlease read it if you haven't already before continuing
Foundry: Zero to Hero - Part 2
Learn Web3 through live and interactive challenges at Web3Compass
Today's Outcome
Topic of focus: Testing fundamentals
Write a SimpleBank.sol contract
Let users deposit & withdraw funds
Use function modifiers to restrict access to only owner of the deployed contract
And the most important bit - Write extensive tests for it!
SHALL WE?!
📝 Writing the Contract
By now we know how to initiatize our project using forge
. So, let’s jump right into the contract code
Here’s the SimpleBank.sol
contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
// Simple bank contract that allows users to deposit, withdraw, and check their balances.
contract SimpleBank {
address public owner; // Owner of the contract
mapping(address => uint256) private balances; // Mapping of user addresses to their balances
constructor(address _owner) {
owner = _owner;
}
// Set owner
function setOwner(address newOwner) onlyOwner public {
owner = newOwner;
}
// Deposit Ether into the bank
function deposit() public payable {
require(msg.value > 0, "Deposit amount must be greater than zero");
balances[msg.sender] += msg.value;
}
// Withdraw Ether from the bank, only owner can call
function withdraw(uint256 amount) onlyOwner public {
require(balances[owner] >= amount, "Insufficient balance");
balances[owner] -= amount;
payable(owner).transfer(amount);
}
// Get the balance of the owner
function getBalance() onlyOwner public view returns (uint256) {
return balances[owner];
}
// Modifier to restrict access to the owner
modifier onlyOwner() {
require(msg.sender == owner, "Not the contract owner");
_;
}
}
SimpleBank Contract Explained
Our contract lets a single owner safely deposit and withdraw Ether. Here’s how it works:
Owner: The contract is created with an owner address. Only this owner can withdraw funds or check the balance.
Deposit: Anyone can deposit Ether by calling the
deposit()
function. The contract keeps track of how much Ether each address has deposited.Withdraw: Only the owner can withdraw Ether using the
withdraw()
function. The contract checks that the owner has enough balance before allowing the withdrawal.Check Balance: The owner can check their balance with the
getBalance()
function.Change Owner: The owner can transfer ownership to another address using
setOwner()
.Security: The
onlyOwner
modifier makes sure that only the owner can perform sensitive actions like withdrawing or checking the balance.
Time to test!!
🧪 Testing the Contract
Let’s extensively test our SimpleBank.sol
contract.
SimpleBank.t.sol
test file
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test, console} from "forge-std/Test.sol";
import {SimpleBank} from "../src/SimpleBank.sol";
contract SimpleBankTest is Test {
SimpleBank public bank;
// Set up the contract
function setUp() public {
bank = new SimpleBank(msg.sender);
}
// Test setting a new owner
function testSetOwnerByOwner() public {
vm.startPrank(bank.owner());
bank.setOwner(address(0));
assertEq(bank.owner(), address(0));
vm.stopPrank();
}
// Test setting a new owner by non-owner
function testSetOwnerByNonOwner() public {
vm.expectRevert("Not the contract owner");
bank.setOwner(address(0));
}
// Test deposit by owner
function testDepositByOwner() public {
vm.startPrank(bank.owner());
uint256 initialBalance = bank.getBalance();
bank.deposit{value: 1 ether}();
uint256 newBalance = bank.getBalance();
assertEq(newBalance, initialBalance + 1 ether);
vm.stopPrank();
}
// Test deposit by non-owner
function testDepositByNonOwner() public {
vm.prank(bank.owner());
uint256 initialBalance = 0;
bank.deposit{value: 1 ether}();
vm.prank(bank.owner());
uint256 newBalance = bank.getBalance();
assertEq(newBalance, initialBalance + 1 ether);
}
// Test withdraw by owner
function testWithdrawByOwner() public {
vm.startPrank(bank.owner());
bank.deposit{value: 1 ether}();
uint256 initialBalance = bank.getBalance();
bank.withdraw(0.5 ether);
uint256 newBalance = bank.getBalance();
assertEq(newBalance, initialBalance - 0.5 ether);
vm.stopPrank();
}
// Test withdraw with insufficient balance
function testWithdrawNoBalance() public {
vm.startPrank(bank.owner());
bank.deposit{value: 1 ether}();
vm.expectRevert("Insufficient balance");
bank.withdraw(2 ether);
vm.stopPrank();
}
// Test withdraw by non-owner
function testWithdrawByNonOwner() public {
bank.deposit{value: 1 ether}();
vm.expectRevert("Not the contract owner");
bank.withdraw(0.5 ether);
}
// Test get balance by owner
function testGetBalanceByOwner() public {
vm.startPrank(bank.owner());
bank.deposit{value: 1 ether}();
uint256 balance = bank.getBalance();
assertEq(balance, 1 ether);
vm.stopPrank();
}
// Test get balance by non-owner
function testGetBalanceByNonOwner() public {
vm.expectRevert("Not the contract owner");
bank.getBalance();
}
// Fuzz test for withdraw
function testFuzz_Withdraw(uint8 amount) public {
vm.startPrank(bank.owner());
bank.deposit{value: 10000 ether}();
uint256 initialBalance = bank.getBalance();
bank.withdraw(amount);
uint256 newBalance = bank.getBalance();
assertEq(newBalance, initialBalance - amount);
vm.stopPrank();
}
}
SimpleBankTest Explained
Our test contract checks that the SimpleBank.sol
smart contract works as expected. Here’s what each part does:
Setup: Before each test, a new bank is created with the test contract as the owner.
Owner Functions: Tests that only the owner can change ownership, withdraw funds, and check the balance. If anyone else tries, the contract should revert with an error.
Deposits: Checks that both the owner and other users can deposit Ether, and that the balance updates correctly.
Withdrawals: Verifies that the owner can withdraw funds, but not more than their balance. Also checks that non-owners cannot withdraw.
Balance Checks: Ensures only the owner can see their balance.
Fuzz Testing: Randomly tests withdrawals with different amounts to make sure the contract handles all cases safely.
Now, instead of running the forge test
command, let’s try something else. Try running
forge coverage
We’ll see that the tests all run correctly, but we also see a coverage report of the entire test suite
Coverage measures how much of our smart contract code is actually tested by our test suite. It shows which lines of code were executed during testing and which lines were missed.
Why is it useful?
High coverage means our tests are checking most parts of your contract, making it less likely that bugs or vulnerabilities go unnoticed.How do we check coverage?
In Foundry, we can runforge coverage
to see a report. This report highlights which functions and lines were tested and gives us a percentage score.What should we aim for?
Aim for as close to 100% coverage as possible, but remember: coverage alone doesn’t guarantee your contract is bug-free. Good tests and thoughtful scenarios are just as important.
And that's a Wrap! (For now 😬)
By now we should have
Our SimpleBank.sol contract written
We have written proper & extensive tests for it
Checked our test coverage and got close to 100%
What's Next?
Part 4, we'll be
Writing a 1:1 token swap contract
Focusing majorly on writing Fuzz and Invariant tests for it
Don't miss it! I'll see you soon! 😎
Subscribe to my newsletter
Read articles from Abhiram A directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
