5/5
## Setting Up Your Foundry Test Environment for Rebase Tokens To begin testing your Solidity smart contracts, particularly a `RebaseToken` and `Vault` system, we'll use the Foundry testing framework. Foundry tests are written in Solidity files that conventionally end with `.t.sol`. First, create your test file, for instance, `test/RebaseToken.t.sol`. Every Solidity file should start with an SPDX license identifier and the pragma directive specifying the compiler version: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; ``` Next, import the necessary components. For our rebase token tests, we need: * `Test` and `console` from `forge-std/Test.sol`: These provide Foundry's core testing utilities and a console logging feature. * `RebaseToken` and `Vault`: The smart contracts we intend to test, imported from their respective locations (e.g., `../src/RebaseToken.sol`). * `IRebaseToken`: An interface for our `RebaseToken`. This is crucial because our `Vault` contract's constructor will expect an `IRebaseToken` type. Your import block will look something like this: ```solidity import {Test, console} from "forge-std/Test.sol"; import {RebaseToken} from "../src/RebaseToken.sol"; import {Vault} from "../src/Vault.sol"; import {IRebaseToken} from "../src/interfaces/IRebaseToken.sol"; ``` ## Defining Your Test Contract and Initial State Foundry tests are structured within a contract that inherits from the imported `Test` contract. Inside this test contract, we'll declare state variables to hold instances of our deployed contracts and define standard addresses for actors like an owner and a user. ```solidity contract RebaseTokenTest is Test { RebaseToken private rebaseToken; Vault private vault; address public owner = makeAddr("owner"); address public user = makeAddr("user"); // Setup function and test functions will follow } ``` Here, `rebaseToken` and `vault` will store the deployed contract instances. We use Foundry's `makeAddr(string)` cheatcode to create deterministic addresses labeled "owner" and "user". This is useful for consistently setting up test scenarios. ## The `setup` Function: Deploying Contracts and Initial Configuration Foundry provides a special function named `setup`, which is executed before each test function (any function prefixed with `test...`). This is the ideal place to deploy your contracts and perform any initial configurations required for your tests. Our `setup` function will handle: 1. Deploying the `RebaseToken`. 2. Deploying the `Vault`, ensuring correct type casting for its constructor argument. 3. Granting the `Vault` contract the necessary permissions to mint and burn `RebaseToken`s. 4. Simulating initial collateral or rewards by sending ETH to the `Vault`. ```solidity function setup() public { // Impersonate the 'owner' address for deployments and role granting vm.startPrank(owner); rebaseToken = new RebaseToken(); // Deploy Vault: requires IRebaseToken. // Direct casting (IRebaseToken(rebaseToken)) is invalid. // Correct way: cast rebaseToken to address, then to IRebaseToken. vault = new Vault(IRebaseToken(address(rebaseToken))); // Grant the MINT_AND_BURN_ROLE to the Vault contract. // The grantMintAndBurnRole function expects an address. rebaseToken.grantMintAndBurnRole(address(vault)); // Send 1 ETH to the Vault to simulate initial funds. // The target address must be cast to 'payable'. (bool success, ) = payable(address(vault)).call{value: 1 ether}(""); // It's good practice to handle the success flag, though omitted for brevity here. // Stop impersonating the 'owner' vm.stopPrank(); } ``` **Key Points on Type Casting and Interactions:** * **Interface Casting:** When the `Vault` constructor expects an `IRebaseToken` and you have a `RebaseToken` instance, you must first cast the instance to its `address` and then wrap it with the interface: `IRebaseToken(address(rebaseToken))`. * **Address Casting:** When a function (like `grantMintAndBurnRole`) expects an `address` and you have a contract instance (`vault`), cast it using `address(vault)`. * **Payable Casting for Sending ETH:** To send ETH using a low-level `.call`, the recipient address (e.g., `address(vault)`) must be cast to `payable`. * **Pranking:** `vm.startPrank(address)` makes all subsequent contract calls originate from the specified address. `vm.stopPrank()` reverts to the default test contract address as the caller. This is essential for testing access control. ## Writing Your First Fuzz Test: Verifying Linear Interest Accrual With the setup complete, we can start writing test cases. We'll begin by testing if the `RebaseToken` accrues interest linearly over time after a user deposits. This test will also introduce **fuzz testing**. Fuzz testing involves calling a function with a wide range of automatically generated inputs to discover edge cases or unexpected behavior. In Foundry, you enable fuzzing by adding parameters to your test function. ```solidity // Test if interest accrues linearly after a deposit. // 'amount' will be a fuzzed input. function testDepositLinear(uint256 amount) public { // Constrain the fuzzed 'amount' to a practical range. // Min: 0.00001 ETH (1e5 wei), Max: type(uint96).max to avoid overflows. amount = bound(amount, 1e5, type(uint96).max); // 1. User deposits 'amount' ETH vm.startPrank(user); // Actions performed as 'user' vm.deal(user, amount); // Give 'user' the 'amount' of ETH to deposit // TODO: Implement deposit logic: // vault.deposit{value: amount}(); // Example // 2. TODO: Check initial rebase token balance for 'user' // uint256 initialBalance = rebaseToken.balanceOf(user); // 3. TODO: Warp time forward and check balance again // uint256 timeDelta = 1 days; // Example // vm.warp(block.timestamp + timeDelta); // uint256 balanceAfterFirstWarp = rebaseToken.balanceOf(user); // uint256 interestFirstPeriod = balanceAfterFirstWarp - initialBalance; // 4. TODO: Warp time forward by the same amount and check balance again // vm.warp(block.timestamp + timeDelta); // Warp by another 'timeDelta' // uint256 balanceAfterSecondWarp = rebaseToken.balanceOf(user); // uint256 interestSecondPeriod = balanceAfterSecondWarp - balanceAfterFirstWarp; // TODO: Assert that interestFirstPeriod == interestSecondPeriod for linear accrual. // assertEq(interestFirstPeriod, interestSecondPeriod, "Interest accrual is not linear"); vm.stopPrank(); // Stop impersonating 'user' } ``` **Explanation of Fuzzing Setup:** * **Fuzzed Parameter:** `uint256 amount` in the function signature tells Foundry to run this test multiple times with different random values for `amount`. * **`bound(variable, min, max)`:** This Foundry cheatcode constrains the fuzzed `amount` to a meaningful range (between 100,000 wei and `uint96.max`). This is more effective than `vm.assume` for setting input boundaries as it guides the fuzzer. * **User Actions:** * `vm.startPrank(user)`: Simulates the `user` performing the deposit. * `vm.deal(user, amount)`: A cheatcode to give the `user` address the specified `amount` of ETH, ensuring they have funds for the fuzzed deposit. * **Test Logic Outline:** The comments outline the steps: deposit, check balance, advance time (using `vm.warp`), check balance again, advance time by the same interval, and finally, assert that the interest accrued in both periods is equal for linear growth. ## Key Foundry Cheatcodes and Best Practices Encountered Throughout this initial setup, we've utilized several powerful Foundry cheatcodes and encountered important Solidity practices: **Foundry Cheatcodes Used:** * `vm.startPrank(address)`: Execute subsequent calls as if originating from `address`. * `vm.stopPrank()`: Revert `msg.sender` to the test contract's address. * `makeAddr(string)`: Creates a deterministic, labeled address for testing. * `vm.deal(address, amount)`: Sets the ETH balance of `address` to `amount`. * `bound(variable, min, max)`: Constrains a fuzzed input `variable` to the range `[min, max]`. * Low-level `.call{value: ethAmount}("")`: A way to send ETH to an address, especially to trigger `receive` or `fallback` functions. **Important Solidity and Testing Practices:** * **Type Casting for Interoperability:** * When a contract instance needs to be passed as an interface type: `InterfaceType(address(contractInstance))`. * When a contract instance needs to be passed as an address: `address(contractInstance)`. * When sending ETH via low-level calls, the target address must be cast to `payable`: `payable(address)`. * **Handling Low-Level Call Returns:** Functions like `.call`, `.send`, and `.transfer` return a boolean indicating success. It's crucial to handle this return value, at least by assigning it to a variable to avoid compiler warnings. In production code, you should explicitly check this boolean. ```solidity (bool success, bytes memory data) = target.call{value: amount}(""); require(success, "ETH transfer failed"); ``` * **Levels of Testing:** While this tutorial combines unit and fuzz testing for efficiency, remember that comprehensive testing often involves: * **Unit Tests:** Testing individual functions in isolation. * **Fuzz Tests:** Testing functions with a wide range of inputs. * **Integration Tests:** Testing how multiple contracts interact, or how contracts interact with off-chain scripts. By following these setup steps and understanding these core concepts, you're well on your way to writing robust tests for your `RebaseToken` system using Foundry. The next steps will involve completing the `testDepositLinear` logic and adding more test cases for other functionalities.
To begin testing your Solidity smart contracts, particularly a RebaseToken
and Vault
system, we'll use the Foundry testing framework. Foundry tests are written in Solidity files that conventionally end with .t.sol
.
First, create your test file, for instance, test/RebaseToken.t.sol
. Every Solidity file should start with an SPDX license identifier and the pragma directive specifying the compiler version:
Next, import the necessary components. For our rebase token tests, we need:
Test
and console
from forge-std/Test.sol
: These provide Foundry's core testing utilities and a console logging feature.
RebaseToken
and Vault
: The smart contracts we intend to test, imported from their respective locations (e.g., ../src/RebaseToken.sol
).
IRebaseToken
: An interface for our RebaseToken
. This is crucial because our Vault
contract's constructor will expect an IRebaseToken
type.
Your import block will look something like this:
Foundry tests are structured within a contract that inherits from the imported Test
contract. Inside this test contract, we'll declare state variables to hold instances of our deployed contracts and define standard addresses for actors like an owner and a user.
Here, rebaseToken
and vault
will store the deployed contract instances. We use Foundry's makeAddr(string)
cheatcode to create deterministic addresses labeled "owner" and "user". This is useful for consistently setting up test scenarios.
setup
Function: Deploying Contracts and Initial ConfigurationFoundry provides a special function named setup
, which is executed before each test function (any function prefixed with test...
). This is the ideal place to deploy your contracts and perform any initial configurations required for your tests.
Our setup
function will handle:
Deploying the RebaseToken
.
Deploying the Vault
, ensuring correct type casting for its constructor argument.
Granting the Vault
contract the necessary permissions to mint and burn RebaseToken
s.
Simulating initial collateral or rewards by sending ETH to the Vault
.
Key Points on Type Casting and Interactions:
Interface Casting: When the Vault
constructor expects an IRebaseToken
and you have a RebaseToken
instance, you must first cast the instance to its address
and then wrap it with the interface: IRebaseToken(address(rebaseToken))
.
Address Casting: When a function (like grantMintAndBurnRole
) expects an address
and you have a contract instance (vault
), cast it using address(vault)
.
Payable Casting for Sending ETH: To send ETH using a low-level .call
, the recipient address (e.g., address(vault)
) must be cast to payable
.
Pranking: vm.startPrank(address)
makes all subsequent contract calls originate from the specified address. vm.stopPrank()
reverts to the default test contract address as the caller. This is essential for testing access control.
With the setup complete, we can start writing test cases. We'll begin by testing if the RebaseToken
accrues interest linearly over time after a user deposits. This test will also introduce fuzz testing.
Fuzz testing involves calling a function with a wide range of automatically generated inputs to discover edge cases or unexpected behavior. In Foundry, you enable fuzzing by adding parameters to your test function.
Explanation of Fuzzing Setup:
Fuzzed Parameter: uint256 amount
in the function signature tells Foundry to run this test multiple times with different random values for amount
.
bound(variable, min, max)
: This Foundry cheatcode constrains the fuzzed amount
to a meaningful range (between 100,000 wei and uint96.max
). This is more effective than vm.assume
for setting input boundaries as it guides the fuzzer.
User Actions:
vm.startPrank(user)
: Simulates the user
performing the deposit.
vm.deal(user, amount)
: A cheatcode to give the user
address the specified amount
of ETH, ensuring they have funds for the fuzzed deposit.
Test Logic Outline: The comments outline the steps: deposit, check balance, advance time (using vm.warp
), check balance again, advance time by the same interval, and finally, assert that the interest accrued in both periods is equal for linear growth.
Throughout this initial setup, we've utilized several powerful Foundry cheatcodes and encountered important Solidity practices:
Foundry Cheatcodes Used:
vm.startPrank(address)
: Execute subsequent calls as if originating from address
.
vm.stopPrank()
: Revert msg.sender
to the test contract's address.
makeAddr(string)
: Creates a deterministic, labeled address for testing.
vm.deal(address, amount)
: Sets the ETH balance of address
to amount
.
bound(variable, min, max)
: Constrains a fuzzed input variable
to the range [min, max]
.
Low-level .call{value: ethAmount}("")
: A way to send ETH to an address, especially to trigger receive
or fallback
functions.
Important Solidity and Testing Practices:
Type Casting for Interoperability:
When a contract instance needs to be passed as an interface type: InterfaceType(address(contractInstance))
.
When a contract instance needs to be passed as an address: address(contractInstance)
.
When sending ETH via low-level calls, the target address must be cast to payable
: payable(address)
.
Handling Low-Level Call Returns: Functions like .call
, .send
, and .transfer
return a boolean indicating success. It's crucial to handle this return value, at least by assigning it to a variable to avoid compiler warnings. In production code, you should explicitly check this boolean.
Levels of Testing: While this tutorial combines unit and fuzz testing for efficiency, remember that comprehensive testing often involves:
Unit Tests: Testing individual functions in isolation.
Fuzz Tests: Testing functions with a wide range of inputs.
Integration Tests: Testing how multiple contracts interact, or how contracts interact with off-chain scripts.
By following these setup steps and understanding these core concepts, you're well on your way to writing robust tests for your RebaseToken
system using Foundry. The next steps will involve completing the testDepositLinear
logic and adding more test cases for other functionalities.
A practical walkthrough to Foundry Test Environment Setup for Rebase Tokens - Configure your Foundry test environment to test `RebaseToken` and `Vault` contracts, focusing on the `setup` function for deployments, type casting, and role assignments using `vm.prank`. You'll also begin writing a fuzz test for linear interest, utilizing `bound` for inputs and `vm.deal` for user balances.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on July 22, 2025
Duration: 36min
Duration: 3h 06min
Duration: 5h 02min
Duration: 6h 02min
Duration: 2h 47min
Duration: 1h 23min
Duration: 4h 28min
Duration: 1h 19min
Duration: 1h 10min
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on July 22, 2025