Skip to main content

Testing contracts with Stylus

Introduction

The Stylus SDK provides a testing framework that allows developers to write and run tests directly in Rust without deploying to a blockchain. This guide will walk you through the process of writing and running tests for Stylus contracts using the built-in testing framework.

The Stylus testing framework allows you to:

  • Simulate an Ethereum environment for your tests
  • Test storage operations
  • Mock transaction context and block information
  • Test contract-to-contract interactions
  • Verify contract logic without deployment costs

Prerequisites

Before you begin, make sure you have:

  • Rust installed (version 1.81.0 or later)
  • The Stylus SDK installed
  • Basic familiarity with Rust and smart contract development
  • Cargo configured for your project

Example Smart Contract

Let's look at an ERC-721 (NFT) implementation using the Stylus SDK. This example demonstrates the core functionality we'll test:

ERC-721
//! Implementation of the ERC-721 standard
//!
//! The eponymous [`Erc721`] type provides all the standard methods,
//! and is intended to be inherited by other contract types.
//!
//! You can configure the behavior of [`Erc721`] via the [`Erc721Params`] trait,
//! which allows specifying the name, symbol, and token uri.
//!
//! Note that this code is unaudited and not fit for production use.

use alloc::{string::String, vec, vec::Vec};
use alloy_primitives::{Address, U256, FixedBytes};
use alloy_sol_types::sol;
use core::{borrow::BorrowMut, marker::PhantomData};
use stylus_sdk::{
abi::Bytes,
evm,
msg,
prelude::*
};

pub trait Erc721Params {
/// Immutable NFT name.
const NAME: &'static str;

/// Immutable NFT symbol.
const SYMBOL: &'static str;

/// The NFT's Uniform Resource Identifier.
fn token_uri(token_id: U256) -> String;
}

sol_storage! {
/// Erc721 implements all ERC-721 methods
pub struct Erc721<T: Erc721Params> {
/// Token id to owner map
mapping(uint256 => address) owners;
/// User to balance map
mapping(address => uint256) balances;
/// Token id to approved user map
mapping(uint256 => address) token_approvals;
/// User to operator map (the operator can manage all NFTs of the owner)
mapping(address => mapping(address => bool)) operator_approvals;
/// Total supply
uint256 total_supply;
/// Used to allow [`Erc721Params`]
PhantomData<T> phantom;
}
}

// Declare events and Solidity error types
sol! {
event Transfer(address indexed from, address indexed to, uint256 indexed token_id);
event Approval(address indexed owner, address indexed approved, uint256 indexed token_id);
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);

// Token id has not been minted, or it has been burned
error InvalidTokenId(uint256 token_id);
// The specified address is not the owner of the specified token id
error NotOwner(address from, uint256 token_id, address real_owner);
// The specified address does not have allowance to spend the specified token id
error NotApproved(address owner, address spender, uint256 token_id);
// Attempt to transfer token id to the Zero address
error TransferToZero(uint256 token_id);
// The receiver address refused to receive the specified token id
error ReceiverRefused(address receiver, uint256 token_id, bytes4 returned);
}

/// Represents the ways methods may fail.
#[derive(SolidityError)]
pub enum Erc721Error {
InvalidTokenId(InvalidTokenId),
NotOwner(NotOwner),
NotApproved(NotApproved),
TransferToZero(TransferToZero),
ReceiverRefused(ReceiverRefused),
}

// External interfaces
sol_interface! {
/// Allows calls to the `onERC721Received` method of other contracts implementing `IERC721TokenReceiver`.
interface IERC721TokenReceiver {
function onERC721Received(address operator, address from, uint256 token_id, bytes data) external returns(bytes4);
}
}

/// Selector for `onERC721Received`, which is returned by contracts implementing `IERC721TokenReceiver`.
const ERC721_TOKEN_RECEIVER_ID: u32 = 0x150b7a02;

// These methods aren't external, but are helpers used by external methods.
// Methods marked as "pub" here are usable outside of the erc721 module (i.e. they're callable from lib.rs).
impl<T: Erc721Params> Erc721<T> {
/// Requires that msg::sender() is authorized to spend a given token
fn require_authorized_to_spend(&self, from: Address, token_id: U256) -> Result<(), Erc721Error> {
// `from` must be the owner of the token_id
let owner = self.owner_of(token_id)?;
if from != owner {
return Err(Erc721Error::NotOwner(NotOwner {
from,
token_id,
real_owner: owner,
}));
}

// caller is the owner
if msg::sender() == owner {
return Ok(());
}

// caller is an operator for the owner (can manage their tokens)
if self.operator_approvals.getter(owner).get(msg::sender()) {
return Ok(());
}

// caller is approved to manage this token_id
if msg::sender() == self.token_approvals.get(token_id) {
return Ok(());
}

// otherwise, caller is not allowed to manage this token_id
Err(Erc721Error::NotApproved(NotApproved {
owner,
spender: msg::sender(),
token_id,
}))
}

/// Transfers `token_id` from `from` to `to`.
/// This function does check that `from` is the owner of the token, but it does not check
/// that `to` is not the zero address, as this function is usable for burning.
pub fn transfer(&mut self, token_id: U256, from: Address, to: Address) -> Result<(), Erc721Error> {
let mut owner = self.owners.setter(token_id);
let previous_owner = owner.get();
if previous_owner != from {
return Err(Erc721Error::NotOwner(NotOwner {
from,
token_id,
real_owner: previous_owner,
}));
}
owner.set(to);

// right now working with storage can be verbose, but this will change upcoming version of the Stylus SDK
let mut from_balance = self.balances.setter(from);
let balance = from_balance.get() - U256::from(1);
from_balance.set(balance);

let mut to_balance = self.balances.setter(to);
let balance = to_balance.get() + U256::from(1);
to_balance.set(balance);

// cleaning app the approved mapping for this token
self.token_approvals.delete(token_id);

evm::log(Transfer { from, to, token_id });
Ok(())
}

/// Calls `onERC721Received` on the `to` address if it is a contract.
/// Otherwise it does nothing
fn call_receiver<S: TopLevelStorage>(
storage: &mut S,
token_id: U256,
from: Address,
to: Address,
data: Vec<u8>,
) -> Result<(), Erc721Error> {
if to.has_code() {
let receiver = IERC721TokenReceiver::new(to);
let received = receiver
.on_erc_721_received(&mut *storage, msg::sender(), from, token_id, data.into())
.map_err(|_e| Erc721Error::ReceiverRefused(ReceiverRefused {
receiver: receiver.address,
token_id,
returned: alloy_primitives::FixedBytes(0_u32.to_be_bytes()),
}))?
.0;

if u32::from_be_bytes(received) != ERC721_TOKEN_RECEIVER_ID {
return Err(Erc721Error::ReceiverRefused(ReceiverRefused {
receiver: receiver.address,
token_id,
returned: alloy_primitives::FixedBytes(received),
}));
}
}
Ok(())
}

/// Transfers and calls `onERC721Received`
pub fn safe_transfer<S: TopLevelStorage + BorrowMut<Self>>(
storage: &mut S,
token_id: U256,
from: Address,
to: Address,
data: Vec<u8>,
) -> Result<(), Erc721Error> {
storage.borrow_mut().transfer(token_id, from, to)?;
Self::call_receiver(storage, token_id, from, to, data)
}

/// Mints a new token and transfers it to `to`
pub fn mint(&mut self, to: Address) -> Result<(), Erc721Error> {
let new_token_id = self.total_supply.get();
self.total_supply.set(new_token_id + U256::from(1u8));
self.transfer(new_token_id, Address::default(), to)?;
Ok(())
}

/// Burns the token `token_id` from `from`
/// Note that total_supply is not reduced since it's used to calculate the next token_id to mint
pub fn burn(&mut self, from: Address, token_id: U256) -> Result<(), Erc721Error> {
self.transfer(token_id, from, Address::default())?;
Ok(())
}
}

// these methods are external to other contracts
#[public]
impl<T: Erc721Params> Erc721<T> {
/// Immutable NFT name.
pub fn name() -> Result<String, Erc721Error> {
Ok(T::NAME.into())
}

/// Immutable NFT symbol.
pub fn symbol() -> Result<String, Erc721Error> {
Ok(T::SYMBOL.into())
}

/// The NFT's Uniform Resource Identifier.
#[selector(name = "tokenURI")]
pub fn token_uri(&self, token_id: U256) -> Result<String, Erc721Error> {
self.owner_of(token_id)?; // require NFT exist
Ok(T::token_uri(token_id))
}

/// Gets the number of NFTs owned by an account.
pub fn balance_of(&self, owner: Address) -> Result<U256, Erc721Error> {
Ok(self.balances.get(owner))
}

/// Gets the owner of the NFT, if it exists.
pub fn owner_of(&self, token_id: U256) -> Result<Address, Erc721Error> {
let owner = self.owners.get(token_id);
if owner.is_zero() {
return Err(Erc721Error::InvalidTokenId(InvalidTokenId { token_id }));
}
Ok(owner)
}

/// Transfers an NFT, but only after checking the `to` address can receive the NFT.
/// It includes additional data for the receiver.
#[selector(name = "safeTransferFrom")]
pub fn safe_transfer_from_with_data<S: TopLevelStorage + BorrowMut<Self>>(
storage: &mut S,
from: Address,
to: Address,
token_id: U256,
data: Bytes,
) -> Result<(), Erc721Error> {
if to.is_zero() {
return Err(Erc721Error::TransferToZero(TransferToZero { token_id }));
}
storage
.borrow_mut()
.require_authorized_to_spend(from, token_id)?;

Self::safe_transfer(storage, token_id, from, to, data.0)
}

/// Equivalent to [`safe_transfer_from_with_data`], but without the additional data.
///
/// Note: because Rust doesn't allow multiple methods with the same name,
/// we use the `#[selector]` macro attribute to simulate solidity overloading.
#[selector(name = "safeTransferFrom")]
pub fn safe_transfer_from<S: TopLevelStorage + BorrowMut<Self>>(
storage: &mut S,
from: Address,
to: Address,
token_id: U256,
) -> Result<(), Erc721Error> {
Self::safe_transfer_from_with_data(storage, from, to, token_id, Bytes(vec![]))
}

/// Transfers the NFT.
pub fn transfer_from(&mut self, from: Address, to: Address, token_id: U256) -> Result<(), Erc721Error> {
if to.is_zero() {
return Err(Erc721Error::TransferToZero(TransferToZero { token_id }));
}
self.require_authorized_to_spend(from, token_id)?;
self.transfer(token_id, from, to)?;
Ok(())
}

/// Grants an account the ability to manage the sender's NFT.
pub fn approve(&mut self, approved: Address, token_id: U256) -> Result<(), Erc721Error> {
let owner = self.owner_of(token_id)?;

// require authorization
if msg::sender() != owner && !self.operator_approvals.getter(owner).get(msg::sender()) {
return Err(Erc721Error::NotApproved(NotApproved {
owner,
spender: msg::sender(),
token_id,
}));
}
self.token_approvals.insert(token_id, approved);

evm::log(Approval {
approved,
owner,
token_id,
});
Ok(())
}

/// Grants an account the ability to manage all of the sender's NFTs.
pub fn set_approval_for_all(&mut self, operator: Address, approved: bool) -> Result<(), Erc721Error> {
let owner = msg::sender();
self.operator_approvals
.setter(owner)
.insert(operator, approved);

evm::log(ApprovalForAll {
owner,
operator,
approved,
});
Ok(())
}

/// Gets the account managing an NFT, or zero if unmanaged.
pub fn get_approved(&mut self, token_id: U256) -> Result<Address, Erc721Error> {
Ok(self.token_approvals.get(token_id))
}

/// Determines if an account has been authorized to managing all of a user's NFTs.
pub fn is_approved_for_all(&mut self, owner: Address, operator: Address) -> Result<bool, Erc721Error> {
Ok(self.operator_approvals.getter(owner).get(operator))
}

/// Whether the NFT supports a given standard.
pub fn supports_interface(interface: FixedBytes<4>) -> Result<bool, Erc721Error> {
let interface_slice_array: [u8; 4] = interface.as_slice().try_into().unwrap();

if u32::from_be_bytes(interface_slice_array) == 0xffffffff {
// special cased in the ERC165 standard
return Ok(false);
}

const IERC165: u32 = 0x01ffc9a7;
const IERC721: u32 = 0x80ac58cd;
const IERC721_METADATA: u32 = 0x5b5e139f;

Ok(matches!(u32::from_be_bytes(interface_slice_array), IERC165 | IERC721 | IERC721_METADATA))
}
}

Writing Tests

The Stylus SDK testing framework is available through the stylus_sdk::testing module, which is re-exported when targeting native architectures. This allows you to write and run tests using Rust's standard testing infrastructure.

Setting Up Your Test Environment

To write tests for your contract, follow these steps:

  1. Create a test module in your contract file or in a separate file
  2. Import the testing framework
  3. Create a test VM environment
  4. Initialize your contract with the test VM
  5. Write your test assertions

Here's a complete example of how to test our NFT contract:

test file
#[cfg(test)]
mod test {
use super::*;
use stylus_sdk::testing::*;
use alloy_primitives::{Address, U256};

#[test]
fn test_mint() {
// Create a test VM environment
let vm = TestVM::default();

// Set a specific sender address for the test
let sender = Address::from([0x1; 20]);
vm.set_sender(sender);

// Initialize the contract with the test VM
let mut contract = StylusTestNFT::from(&vm);

// Test initial state
assert_eq!(contract.total_supply().unwrap(), U256::ZERO);

// Test minting
contract.mint().unwrap();

// Verify the result
assert_eq!(contract.total_supply().unwrap(), U256::from(1));
assert_eq!(contract.erc721.balance_of(sender).unwrap(), U256::from(1));
}

#[test]
fn test_mint_to() {
// Create a test VM environment
let vm = TestVM::default();

// Initialize the contract with the test VM
let mut contract = StylusTestNFT::from(&vm);

// Set up recipient address
let recipient = Address::from([0x2; 20]);

// Test minting to a specific address
contract.mint_to(recipient).unwrap();

// Verify the result
assert_eq!(contract.total_supply().unwrap(), U256::from(1));
assert_eq!(contract.erc721.balance_of(recipient).unwrap(), U256::from(1));
}

#[test]
fn test_burn() {
// Create a test VM environment
let vm = TestVM::default();

// Set a specific sender address for the test
let sender = Address::from([0x1; 20]);
vm.set_sender(sender);

// Initialize the contract with the test VM
let mut contract = StylusTestNFT::from(&vm);

// Mint a token first
contract.mint().unwrap();
assert_eq!(contract.erc721.balance_of(sender).unwrap(), U256::from(1));

// Burn the token
contract.burn(U256::ZERO).unwrap();

// Verify the token was burned
// Note: total_supply doesn't decrease after burning
assert_eq!(contract.total_supply().unwrap(), U256::from(1));
assert_eq!(contract.erc721.balance_of(sender).unwrap(), U256::ZERO);
}
}

Advanced Testing Features

Customizing the Test Environment

You can customize your test environment using TestVMBuilder for more complex scenarios:

ERC-721
#[test]
fn test_with_custom_setup() {
let vm = TestVMBuilder::new()
.with_sender(Address::from([0x1; 20]))
.with_value(U256::from(100))
.with_contract_address(Address::from([0x3; 20]))
.build();

let contract = StylusTestNFT::from(&vm);
// Test logic here
}

Testing Contract Interactions

To test contract interactions, you can mock calls to other contracts:

#[test]
fn test_external_contract_interaction() {
let vm = TestVM::default();

// Address of an external contract
let external_contract = Address::from([0x5; 20]);

// Mock data and response
let call_data = vec![/* function selector and parameters */];
let expected_response = vec![/* expected return data */];

// Mock the call
vm.mock_call(external_contract, call_data.clone(), Ok(expected_response));

// Initialize your contract
let contract = StylusTestNFT::from(&vm);

// Test logic that involves calling the external contract
// ...
}

Testing Storage

The testing framework automatically handles persistent storage simulation. Storage operations in your tests will work exactly as they would on-chain, but in a controlled test environment.

#[test]
fn test_storage_persistence() {
let vm = TestVM::default();

// You can also set storage values directly
let key = U256::from(1);
let value = B256::from([0xff; 32]);
vm.set_storage(key, value);

// And retrieve them
assert_eq!(vm.get_storage(key), value);
}

Best Practices

  1. Test Organization

    • Keep tests in a separate module marked with #[cfg(test)]
    • Group related tests together
  2. Test Isolation

    • Create a new TestVM instance for each test
    • Don't rely on state from previous tests
  3. Comprehensive Testing

    • Test happy paths and error cases
    • Test edge cases and boundary conditions
    • Test access control and authorization
  4. Meaningful Assertions

    • Make assertions that verify the actual behavior you care about
    • Use descriptive error messages in assertions

Running Tests

Testing with cargo-stylus

When using the cargo-stylus CLI tool, you can run tests with:

cargo stylus test

You can also run specific tests by name:

cargo test test_mint

Conclusion

Testing is an essential part of smart contract development to ensure security, correctness, and reliability. The Stylus SDK provides powerful testing tools that allow you to thoroughly test your contracts before deployment.

The ability to test Rust contracts directly, without requiring a blockchain environment, makes the development cycle faster and more efficient.