Zero to Hero in Foundry - Part 3: Testing Fundamentals

Abhiram AAbhiram A
5 min read

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 simple forge command to deploy our contract to our local chain. And finally used cast to interact with our deployed contract

Please 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?!

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 run forge 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! 😎

0
Subscribe to my newsletter

Read articles from Abhiram A directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Abhiram A
Abhiram A