Foundry Forge: Testing
Merely deploying contracts is not the end game. Many tools have offered this capability in the past. Forge sets itself apart by hosting a Cairo VM instance, enabling the sequential execution of tests. It employs Scarb for contract compilation.
To utilize Forge, define test functions and label them with test attributes. Users can either test standalone Cairo functions or integrate contracts, dispatchers, and test contract interactions on-chain.
snForge
Command-Line Usage
This section guides you through the Starknet Foundry snforge
command-line tool. Learn how to set up a new project, compile the code, and execute tests.
To start a new project with Starknet Foundry, use the --init
command and replace project_name
with your project's name.
snforge --init project_name
Once you've set up the project, inspect its layout:
cd project_name
tree . -L 1
The project structure is as follows:
.
├── README.md
├── Scarb.toml
├── src
└── tests
src/
holds your contract source code.tests/
is the location of your test files.Scarb.toml
is for project andsnforge
configurations.
Ensure the CASM code generation is active in the Scarb.toml
file:
# ...
[[target.starknet-contract]]
casm = true
# ...
To run tests using snforge
:
snforge
Collected 2 test(s) from the `test_name` package
Running 0 test(s) from `src/`
Running 2 test(s) from `tests/`
[PASS] tests::test_contract::test_increase_balance
[PASS] tests::test_contract::test_cannot_increase_balance_with_zero_value
Tests: 2 passed, 0 failed, 0 skipped
Integrating snforge
with Existing Scarb Projects
For those with an established Scarb project who wish to incorporate snforge
, ensure the snforge_std package
is declared as a dependency. Insert the line below in the [dependencies] section of your Scarb.toml
:
# ...
[dependencies]
snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry.git", tag = "[VERSION]" }
Ensure the tag version corresponds with your snforge
version. To verify your snforge
version:
snforge --version
Or, add this dependency using the scarb
command:
scarb add snforge_std --git https://github.com/foundry-rs/starknet-foundry.git --tag VERSION
With these steps, your existing Scarb project is now snforge
-ready.
Testing with snforge
Utilize Starknet Foundry's snforge
command to efficiently run tests.
Executing Tests
Navigate to the package directory and issue this command to run tests:
snforge
Sample output might resemble:
Collected 3 test(s) from `package_name` package
Running 3 test(s) from `src/`
[PASS] package_name::executing
[PASS] package_name::calling
[PASS] package_name::calling_another
Tests: 3 passed, 0 failed, 0 skipped
Example: Testing a Simple Contract
The example provided below demonstrates how to test a Starknet contract using snforge
.
#![allow(unused)] fn main() { #[starknet::interface] trait IHelloStarknet<TContractState> { fn increase_balance(ref self: TContractState, amount: felt252); fn get_balance(self: @TContractState) -> felt252; } #[starknet::contract] mod HelloStarknet { #[storage] struct Storage { balance: felt252, } #[external(v0)] impl HelloStarknetImpl of super::IHelloStarknet<ContractState> { // Increases the balance by the specified amount. fn increase_balance(ref self: ContractState, amount: felt252) { self.balance.write(self.balance.read() + amount); } // Returns the balance. fn get_balance(self: @ContractState) -> felt252 { self.balance.read() } } } }
Remember, the identifier following mod
signifies the contract name. Here, the contract name is HelloStarknet
.
Craft the Test
Below is a test for the HelloStarknet
contract. This test deploys HelloStarknet
and interacts with its functions:
#![allow(unused)] fn main() { use snforge_std::{ declare, ContractClassTrait }; #[test] fn call_and_invoke() { // Declare and deploy the contract let contract = declare('HelloStarknet'); let contract_address = contract.deploy(@ArrayTrait::new()).unwrap(); // Instantiate a Dispatcher object for contract interactions let dispatcher = IHelloStarknetDispatcher { contract_address }; // Invoke a contract's view function let balance = dispatcher.get_balance(); assert(balance == 0, 'balance == 0'); // Invoke another function to modify the storage state dispatcher.increase_balance(100); // Validate the transaction's effect let balance = dispatcher.get_balance(); assert(balance == 100, 'balance == 100'); } }
To run the test, execute the snforge
command. The expected output is:
Collected 1 test(s) from using_dispatchers package
Running 1 test(s) from src/
[PASS] using_dispatchers::call_and_invoke
Tests: 1 passed, 0 failed, 0 skipped
Example: Testing ERC20 Contract
There are several methods to test smart contracts, such as unit tests, integration tests, fuzz tests, fork tests, E2E tests, and using foundry cheatcodes. This section discusses testing an ERC20 example contract from the starknet-js
subchapter examples using unit and integration tests, filtering, foundry cheatcodes, and fuzz tests through the snforge
CLI.
ERC20 Contract Example
After setting up your foundry project, add the following dependency to your Scarb.toml
(in this case we are using version 0.7.0 of the OpenZeppelin Cairo contracts, but you can use any version you want):
openzeppelin = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v0.7.0" }
Here's a basic ERC20 contract:
#![allow(unused)] fn main() { use starknet::ContractAddress; #[starknet::interface] trait Ierc20<TContractState> { fn balance_of(self: @TContractState, account: ContractAddress) -> u256; fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool; } #[starknet::contract] mod erc20 { use starknet::ContractAddress; use openzeppelin::token::erc20::ERC20; #[storage] struct Storage {} #[constructor] fn constructor( ref self: ContractState, initial_supply: felt252, recipient: ContractAddress ) { let name = 'MyToken'; let symbol = 'MTK'; let mut unsafe_state = ERC20::unsafe_new_contract_state(); ERC20::InternalImpl::initializer(ref unsafe_state, name, symbol); ERC20::InternalImpl::_mint(ref unsafe_state, recipient, initial_supply.into()); } #[external(v0)] impl Ierc20Impl of super::Ierc20<ContractState> { fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { let unsafe_state = ERC20::unsafe_new_contract_state(); ERC20::ERC20Impl::balance_of(@unsafe_state, account) } fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool { let mut unsafe_state = ERC20::unsafe_new_contract_state(); ERC20::ERC20Impl::transfer(ref unsafe_state, recipient, amount) } } } }
This contract allows minting tokens to a recipient during deployment, checking balances, and transferring tokens, relying on the openzeppelin ERC20 library.
Test Preparation
Organize your test file and include the required imports:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use array::ArrayTrait; use result::ResultTrait; use option::OptionTrait; use traits::TryInto; use starknet::ContractAddress; use starknet::Felt252TryIntoContractAddress; use snforge_std::{declare, ContractClassTrait}; // Additional code here. } }
For testing, you'll need a helper function to deploy the contract instance. This function requires a supply
amount and recipient
address:
#![allow(unused)] fn main() { use snforge_std::{declare, ContractClassTrait}; fn deploy_contract(name: felt252) -> ContractAddress { let recipient = starknet::contract_address_const::<0x01>(); let supply: felt252 = 20000000; let contract = declare(name); let mut calldata = array![supply, recipient.into()]; contract.deploy(@calldata).unwrap() } // Additional code here. }
Use declare
and ContractClassTrait
from snforge_std
. Then, initialize the supply
and recipient
, declare the contract, compute the calldata, and deploy.
Writing the Test Cases
Verifying the Balance After Deployment
To begin, test the deployment helper function to confirm the recipient's balance:
#![allow(unused)] fn main() { // ... use erc20_contract::erc20::Ierc20SafeDispatcher; use erc20_contract::erc20::Ierc20SafeDispatcherTrait; #[test] #[available_gas(3000000000000000)] fn test_balance_of() { let contract_address = deploy_contract('erc20'); let safe_dispatcher = Ierc20SafeDispatcher { contract_address }; let recipient = starknet::contract_address_const::<0x01>(); let balance = safe_dispatcher.balance_of(recipient).unwrap(); assert(balance == 20000000, 'Invalid Balance'); } }
Execute snforge
to verify:
Collected 1 test from erc20_contract package
[PASS] tests::test_erc20::test_balance_of
Utilizing Foundry Cheat Codes
When testing smart contracts, simulating different conditions is essential. Foundry Cheat Codes
from the snforge_std
library offer these simulation capabilities for Starknet smart contracts.
These cheat codes consist of helper functions that adjust the smart contract's environment. They allow developers to modify parameters or conditions to examine contract behavior in specific scenarios.
Using snforge_std
's cheat codes, you can change elements like block numbers, timestamps, or even the caller of a function. This guide focuses on start_prank
and stop_prank
. You can find a reference to available cheat codes here
Below is a transfer test example:
#![allow(unused)] fn main() { use snforge_std::{declare, ContractClassTrait, start_prank, stop_prank}; #[test] #[available_gas(3000000000000000)] fn test_transfer() { let contract_address = deploy_contract('erc20'); let safe_dispatcher = Ierc20SafeDispatcher { contract_address }; let sender = starknet::contract_address_const::<0x01>(); let receiver = starknet::contract_address_const::<0x02>(); let amount : felt252 = 10000000; // Set the function's caller start_prank(contract_address, sender); safe_dispatcher.transfer(receiver.into(), amount.into()); let balance_after_transfer = safe_dispatcher.balance_of(receiver).unwrap(); assert(balance_after_transfer == 10000000, 'Incorrect Amount'); // End the prank stop_prank(contract_address); } }
Executing snforge
for the tests displays:
Collected 2 tests from erc20_contract package
[PASS] tests::test_erc20::test_balance_of
[PASS] tests::test_erc20::test_transfer
In this example, start_prank
determines the transfer function's caller, while stop_prank
concludes the prank.
Full `ERC20 test example` file
#[cfg(test)] mod tests { use array::ArrayTrait; use result::ResultTrait; use option::OptionTrait; use traits::TryInto; use starknet::ContractAddress; use starknet::Felt252TryIntoContractAddress; use snforge_std::{declare, ContractClassTrait, start_prank, stop_prank};
use erc20_contract::erc20::Ierc20SafeDispatcher;
use erc20_contract::erc20::Ierc20SafeDispatcherTrait;
fn deploy_contract(name: felt252) -> ContractAddress {
let recipient = starknet::contract_address_const::<0x01>();
let supply : felt252 = 20000000;
let contract = declare(name);
let mut calldata = array![supply, recipient.into()];
contract.deploy(@calldata).unwrap()
}
#[test]
#[available_gas(3000000000000000)]
fn test_balance_of() {
let contract_address = deploy_contract('erc20');
let safe_dispatcher = Ierc20SafeDispatcher { contract_address };
let recipient = starknet::contract_address_const::<0x01>();
let balance = safe_dispatcher.balance_of(recipient).unwrap();
assert(balance == 20000000, 'Invalid Balance');
}
#[test]
#[available_gas(3000000000000000)]
fn test_transfer() {
let contract_address = deploy_contract('erc20');
let safe_dispatcher = Ierc20SafeDispatcher { contract_address };
let sender = starknet::contract_address_const::<0x01>();
let receiver = starknet::contract_address_const::<0x02>();
let amount : felt252 = 10000000;
start_prank(contract_address, sender);
safe_dispatcher.transfer(receiver.into(), amount.into());
let balance_after_transfer = safe_dispatcher.balance_of(receiver).unwrap();
assert(balance_after_transfer == 10000000, 'Incorrect Amount');
stop_prank(contract_address);
}
}
Fuzz Testing
Fuzz testing introduces random inputs to the code to identify vulnerabilities, security issues, and unforeseen behaviors. While you can manually provide these inputs, automation is preferable when testing a broad set of values. See the example below in test_fuzz.cairo
:
#![allow(unused)] fn main() { fn mul(a: felt252, b: felt252) -> felt252 { return a * b; } #[test] fn test_fuzz_sum(x: felt252, y: felt252) { assert(mul(x, y) == x * y, 'incorrect'); } }
Running snforge
produces:
Collected 1 test(s) from erc20_contract package
Running 0 test(s) from src/
Running 1 test(s) from tests/
[PASS] tests::test_fuzz::test_fuzz_sum (fuzzer runs = 256)
Tests: 1 passed, 0 failed, 0 skipped
Fuzzer seed: 6375310854403272271
The fuzzer supports these types by November 2023:
- u8
- u16
- u32
- u64
- u128
- u256
- felt252
Fuzzer Configuration
You can set the number of runs and the seed for a test:
#![allow(unused)] fn main() { #[test] #[fuzzer(runs: 100, seed: 38)] fn test_fuzz_sum(x: felt252, y: felt252) { assert(mul(x, y) == x * y, 'incorrect'); } }
Or, use the command line:
$ snforge --fuzzer-runs 500 --fuzzer-seed 4656
Or in scarb.toml
:
# ...
[tool.snforge]
fuzzer_runs = 500
fuzzer_seed = 4656
# ...
For more insight on fuzz tests, you can view it here
Filter Tests
To execute specific tests, use a filter string with the snforge
command. Tests matching the filter based on their absolute module tree path will be executed.
For instance, to run all tests with the string 'test_' in their name:
snforge test_
Expected output:
Collected 3 test(s) from erc20_contract package
Running 0 test(s) from src/
Running 3 test(s) from tests/
[PASS] tests::test_erc20::tests::test_balance_of
[PASS] tests::test_erc20::tests::test_transfer
[PASS] tests::test_fuzz::test_fuzz_sum (fuzzer runs = 256)
Tests: 3 passed, 0 failed, 0 skipped
Fuzzer seed: 10426315620495146768
All the tests with the string 'test_' in their test name went through.
Another example: To filter and run test_fuzz_sum
we can partially match the test name with the string 'fuzz_sum' like this:
snforge test_fuzz_sum
To execute an exact test, combine the --exact
flag with a fully qualified test name:
snforge package_name::test_name --exact
To halt the test suite upon the first test failure, use the --exit-first
flag:
snforge --exit-first
If a test fails, the output will resemble:
Collected 3 test(s) from erc20_contract package
Running 0 test(s) from src/
Running 3 test(s) from tests/
[FAIL] tests::test_erc20::tests::test_balance_of
Failure data:
original value: [381278114803728420489684244530881381], converted to a string: [Invalid Balance]
[SKIP] tests::test_erc20::tests::test_transfer
[SKIP] tests::test_fuzz::test_fuzz_sum
Tests: 0 passed, 1 failed, 2 skipped
Failures:
tests::test_erc20::tests::test_balance_of
Conclusion
Starknet Foundry offers a notable step forward in Starknet contract development and testing. This toolset sharpens the process of creating, deploying, and testing Cairo contracts. Its main components, Forge and Cast, provide developers with robust tools for Cairo contract work.
Forge shines with its dual functionality: deploying and thoroughly testing Cairo contracts. It directly supports test writing in Cairo, removing the need for other languages and simplifying the task. Moreover, Forge seamlessly integrates with Scarb, emphasizing its adaptability, especially with existing Scarb projects.
The snforge
command-line tool makes initializing, setting up, and testing Starknet contracts straightforward.