Mastering Foundry: Episode 2 Testing in Foundry

Mastering Foundry: Episode 2 Testing in Foundry


Check out the Github page if you're looking for an example.

If you don't want to read, but want to watch the video, check it out here.

Are you ready to transform your smart contract from a fragile glass house to an impenetrable fortress? Welcome to the world of Foundry testing. Let's dive into how you can ensure your EtherWallet smart contract isn't just good, but exceptional.

ps. Foundry has a super power when it comes to testing. Stick around I’ll show ya.

Start with the End in Mind: Your Contract

Before we even touch a line of test code, let’s clarify what we’re dealing with. This contract handles Ether transactions like a pro – receiving, storing, and allowing withdrawals only by the chosen one (the owner)… The key functions are:

  • Constructor: Sets the contract deployer as the owner.

  • Receive Function: Allows the contract to receive Ether.

  • Withdraw Function: Allows the owner to withdraw a specified amount of Ether.

  • Get Balance Function: Returns the contract's Ether balance.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract EtherWallet {
    address payable public owner;

    constructor() {
        owner = payable(msg.sender);
    }

    receive() external payable {}

    function withdraw(uint _amount) external {
        require(msg.sender == owner, "caller is not owner");
        payable(msg.sender).transfer(_amount);
    }

    function getBalance() external view returns (uint) {
        return address(this).balance;
    }
}

Credit: Solidity by Example

Setting the Stage: EtherWallet.t.sol

Create a file with the name EtherWallet.t.sol inside the test folder. Now let’s set up our test file.

Importing Dependencies

We start by importing Foundry's Test contract and our EtherWallet contract. These imports are essential for writing our tests.

import "forge-std/Test.sol";
import "../src/EtherWallet.sol"; // Adjust this path based on your project structure

Test Contract Setup

Our test contract, EtherWalletTest, inherits from Foundry's Test contract. We define an EtherWallet instance, an owner address and the receive() function ensures the contract is able to receive ether. The setUp function initializes our contract before each test.

contract EtherWalletTest is Test {
    EtherWallet etherWallet;
    address owner;
        receive() external payable {}

    function setUp() public {
        owner = address(this);
        etherWallet = new EtherWallet();
    }
}

Crafting Your Test Plan: The Blueprint

Now, let’s break down EtherWallet.t.sol - your blueprint for a bulletproof contract.

1. Ownership Test: Who's the Boss?

Just like knowing who’s in charge in any operation, this test confirms if the contract correctly recognizes its master.

function testDeployment() public {
    // Checks if the owner set in the EtherWallet contract is the same as the 'owner' variable in the test.
    // The 'owner' in the test context is typically the address that deployed the contract.
    assertEq(etherWallet.owner(), owner, "Owner should be set correctly on deployment");
}

2. Receiving Ether: Open for Business

This is where your contract proves its ability to fatten its wallet without a hiccup.

function testReceiveEther() public {
    // Sends 1 ether to the EtherWallet contract.
    payable(address(etherWallet)).transfer(1 ether);

    // Verifies if the EtherWallet contract's balance is correctly updated to 1 ether.
    assertEq(etherWallet.getBalance(), 1 ether, "Balance should be 1 ether after receiving");
}

3. Withdrawal Test: The Owner’s Privilege

Here, we ensure that the power to withdraw is exclusive to the owner, just like a VIP pass.

function testWithdrawEtherAsOwner() public {
    // Sends 1 ether to the EtherWallet contract to set up the test condition.
    payable(address(etherWallet)).transfer(1 ether);

    // Records the initial balance of the test contract (this is acting as the owner in the test).
    uint initialBalance = address(this).balance;

    // Specifies the amount to withdraw.
    uint withdrawAmount = 0.5 ether;

    // Simulates the transaction as if it were being done by the owner.
    vm.startPrank(owner);
    etherWallet.withdraw(withdrawAmount);
    vm.stopPrank();

    // Records the final balance of the test contract after withdrawal.
    uint finalBalance = address(this).balance;

    // Checks if the balance of the EtherWallet contract is reduced correctly by the withdrawal amount.
    assertEq(etherWallet.getBalance(), 0.5 ether, "Contract balance should be 0.5 ether after withdrawal");

    // Verifies that the balance of the owner (test contract) has increased by the withdrawn amount.
    assertEq(finalBalance, initialBalance + withdrawAmount, "Owner balance should increase by the withdraw amount");
}

4. Non-Owner Rejection: The Bouncer Test

In this test, your contract acts like a club bouncer, rejecting anyone who isn’t on the list.

function testWithdrawEtherAsNonOwnerShouldFail() public {
    // Sends 1 ether to the EtherWallet contract to set up the test condition.
    payable(address(etherWallet)).transfer(1 ether);

    // Defines a non-owner address.
    address nonOwner = address(0x1);

    // Simulates a transaction from the non-owner address.
    vm.prank(nonOwner);

    // Flag to check if the transaction was reverted as expected.
    bool didRevert = false;

    // Attempts to withdraw from the EtherWallet as a non-owner.
    try etherWallet.withdraw(0.5 ether) {
        // This block should not execute as the transaction should revert.
    } catch {
        // Sets the flag to true if the transaction reverts, indicating the test passed.
        didRevert = true;
    }

    // Asserts that the transaction was indeed reverted.
    assertTrue(didRevert, "Withdrawal by non-owner should revert");
}

5. Balance Check: Counting the Cash

Finally, we verify if the contract knows how much it’s holding because in business, knowing your numbers is key.

function testGetBalance() public {
    // Sends 2 ether to the EtherWallet contract to set up the test condition.
    payable(address(etherWallet)).transfer(2 ether);

    // Checks if the getBalance function of the EtherWallet contract returns the correct amount (2 ether).
    assertEq(etherWallet.getBalance(), 2 ether, "Balance should be 2 ether");
}

Execution: Local testing

With your tests written, it’s time to hit the gym. Run forge test in your terminal and watch each test sculpt your contract into a masterpiece of reliability.

As for the super power, we can run a test with verbose output (forge test -vv) to get more detailed information about what's happening during the test execution.

Try it…it’s amazing.

This is specifically handy when your tests are failing and you need to debug your code. It tells you exactly where in your code the error occurred through traces.

Execution: Local Simulation

Now this contract pretty much operates in isolation. What I mean is you’re not interacting with another contract deployed on let’s say a price feed contract on Scroll. But to showcase how freaking easy it is for anvil to simulate a blockchain network like Scroll, we can pass the following command.

forge test --fork-url https://sepolia-rpc.scroll.io/

In the next episode we’ll actually take a look at how this is super convenient when you interact with contracts deployed on Scroll.

Practise

What matters is what sticks. If you want to practise the code yourself, checkout these templates with instructions to help you write tests.

1. Test Deployment: Verifying Contract Ownership

function testDeployment() public {
    // This test should check if the contract's owner is set correctly upon deployment.
    // Use assertEq to compare the owner set in the contract with the expected owner.
    // The expected owner is typically the address that deployed the contract.

2. Receiving Ether: Testing Ether Reception Capability

function testReceiveEther() public {
    // This test should send Ether to the contract and check if the balance updates correctly.
    // First, send a specific amount of Ether to the contract using `transfer`.
    // Then, use assertEq to verify that the contract's balance reflects this transaction.
}

3. Withdrawal Test: Ensuring Only Owner Can Withdraw

function testWithdrawEtherAsOwner() public {
    // This test should ensure that the owner can withdraw Ether from the contract.
    // Start by sending some Ether to the contract to set up the test condition.
    // Record the initial balance of the owner (the test contract itself in this case).
    // Execute the withdrawal action using the contract's withdraw function.
    // Record the final balance of the owner.
    // Use assertEq to check if the contract's balance decreased and the owner's balance increased as expected.
}

4. Non-Owner Rejection Test: Ensuring Security Against Unauthorized Withdrawals

function testWithdrawEtherAsNonOwnerShouldFail() public {
    // This test should attempt to withdraw Ether from the contract as a non-owner and expect failure.
    // First, send some Ether to the contract.
    // Define a non-owner address.
    // Simulate a transaction from this non-owner address.
    // Attempt to withdraw Ether from the contract.
    // The transaction should fail, and you should catch this revert to assert the test's success.
}

5. Balance Check Test: Verifying Contract's Balance Reporting

function testGetBalance() public {
    // This test should verify that the contract reports its balance correctly.
    // Send a specific amount of Ether to the contract.
    // Use assertEq to check if the contract's getBalance function returns the correct balance.
}

Wrapping Up: The Unbreakable Contract

Remember, in the world of smart contracts, “good enough” isn’t good enough. With Foundry you’re not just coding; you’re crafting a secure contract. This tutorial covered the basics, but Foundry offers much more, including advanced simulation and debugging features.