Testing Smart Contracts with Stylus
Introduction
The Stylus SDK provides a robust testing framework that allows developers to write and run tests for their contracts 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 a complete Ethereum environment for your tests without the need for running a test node
- Test contract storage operations and state transitions
- Mock transaction context and block information
- Test contract-to-contract interactions with mocked calls
- Verify contract logic without deployment costs or delays
- Simulate various user scenarios and edge cases
Prerequisites
Before you begin, make sure you have:
- Basic familiarity with Rust and smart contract development
- Understanding of unit testing concepts
- Rust toolchain: follow the instructions on Rust Lang's installation page to install a complete Rust toolchain (v1.81 or newer) on your system. After installation, ensure you can access the programs
rustup
,rustc
, andcargo
from your preferred terminal application.
The Stylus Testing Framework
The Stylus SDK includes testing
, a module that provides all the tools you need to test your contracts. This module includes:
- TestVM: A mock implementation of the Stylus VM that can simulate all host functions
- TestVMBuilder: A builder pattern to conveniently configure the test VM
- Built-in utilities for mocking calls, storage, and other EVM operations
Key Components
Here are the components you'll use when testing your Stylus contracts:
- TestVM: The core component that simulates the Stylus execution environment
- Storage accessors: For testing contract state changes
- Call mocking: For simulating interactions with other contracts
- Block context: For testing time-dependent logic
Example Smart Contract: Cupcake Vending Machine
Let's look at a Rust-based cupcake vending machine smart contract. This contract follows two simple rules:
- The vending machine will distribute a cupcake to anyone who hasn't received one in the last 5 seconds
- The vending machine tracks each user's cupcake balance
You can find all the code in this tutorial as a Rust workspace in the Quickstart repo
Cupcake Vending Machine Contract
#![cfg_attr(not(any(test, feature = "export-abi")), no_main)]
extern crate alloc;
/// Import items from the SDK. The prelude contains common traits and macros.
use stylus_sdk::alloy_primitives::{Address, U256};
use stylus_sdk::console;
use stylus_sdk::prelude::\*;
use alloy_sol_types::sol;
// Define the event using the sol! macro
sol! {
event CupcakeDistributed(address indexed recipient, uint256 indexed timestamp, uint256 new_balance);
}
sol_storage! { #[entrypoint]
pub struct VendingMachine {
mapping(address => uint256) cupcake_balances;
mapping(address => uint256) cupcake_distribution_times;
}
}
#[public]
impl VendingMachine {
pub fn give_cupcake_to(&mut self, user_address: Address) -> Result<bool, Vec<u8>> {
// Get the last distribution time for the user.
let last_distribution = self.cupcake_distribution_times.get(user_address);
// Calculate the earliest next time the user can receive a cupcake.
let five_seconds_from_last_distribution = last_distribution + U256::from(5);
// Get the current block timestamp using the VM pattern
let current_time = self.vm().block_timestamp();
// Check if the user can receive a cupcake.
let user_can_receive_cupcake =
five_seconds_from_last_distribution <= U256::from(current_time);
if user_can_receive_cupcake {
// Increment the user's cupcake balance.
let mut balance_accessor = self.cupcake_balances.setter(user_address);
let balance = balance_accessor.get() + U256::from(1);
balance_accessor.set(balance);
// Get current timestamp using the VM pattern BEFORE creating the mutable borrow
let new_distribution_time = self.vm().block_timestamp();
// Update the distribution time to the current time.
let mut time_accessor = self.cupcake_distribution_times.setter(user_address);
time_accessor.set(U256::from(new_distribution_time));
// Emit the CupcakeDistributed event
log(self.vm(), CupcakeDistributed {
recipient: user_address,
timestamp: U256::from(new_distribution_time),
new_balance: balance,
});
return Ok(true);
} else {
// User must wait before receiving another cupcake.
console!(
"HTTP 429: Too Many Cupcakes (you must wait at least 5 seconds between cupcakes)"
);
return Ok(false);
}
}
pub fn get_cupcake_balance_for(&self, user_address: Address) -> Result<U256, Vec<u8>> {
Ok(self.cupcake_balances.get(user_address))
}
}
Writing Tests for the Vending Machine
Now, let's write tests for our vending machine contract using the Stylus testing framework. We'll create tests that verify:
- Users can get an initial cupcake
- Users must wait 5 seconds between cupcakes
- Cupcake balances are tracked correctly
- The contract state updates properly
Test Structure
Create a test file using standard Rust test patterns. Here's the basic structure:
// Import necessary dependencies
#[cfg(test)]
mod test {
use super::*;
use alloy_primitives::address;
use stylus_sdk::testing::*;
#[test]
fn test_give_cupcake_to() {
// Set up test environment
let vm = TestVM::default();
// Initialize your contract
let mut contract = VendingMachine::from(&vm);
// Test logic goes here...
}
Using the TestVM
The TestVM
simulates the execution environment for your contract, removing the need to run your tests against a test node.
The TestVM
allows you to control aspects like:
- Block timestamp and number
- Account balances
- Transaction value and sender
- Storage state
Let's create a test suite that covers all aspects of our contract, we'll go over the code features one by one:
Test Vending Machine Contract
#[cfg(test)]
mod test {
use super::*;
use alloy_primitives::address;
use stylus_sdk::testing::*;
#[test]
fn test_give_cupcake_to() {
let vm = TestVM::default();
let mut contract = VendingMachine::from(&vm);
let user = address!("0xCDC41bff86a62716f050622325CC17a317f99404");
assert_eq!(contract.get_cupcake_balance_for(user).unwrap(), U256::ZERO);
vm.set_block_timestamp(vm.block_timestamp() + 6);
// Give a cupcake and verify it succeeds
assert!(contract.give_cupcake_to(user).unwrap());
// Check balance is now 1
assert_eq!(
contract.get_cupcake_balance_for(user).unwrap(),
U256::from(1)
);
// Try to give another cupcake immediately - should fail due to time restriction
assert!(!contract.give_cupcake_to(user).unwrap());
// Balance should still be 1
assert_eq!(
contract.get_cupcake_balance_for(user).unwrap(),
U256::from(1)
);
// Advance block timestamp by 6 seconds
vm.set_block_timestamp(vm.block_timestamp() + 6);
// Now giving a cupcake should succeed
assert!(contract.give_cupcake_to(user).unwrap());
// Balance should now be 2
assert_eq!(
contract.get_cupcake_balance_for(user).unwrap(),
U256::from(2)
);
}
}
TestVM: advanced use
This test shows how you can use advanced configuration and usage of the TestVM by creating and configuring a TestVM with custom parameters:
- Setting blockchain state (timestamps, block numbers)
- Interacting with contract methods
- Taking and inspecting VM state snapshots
- Mocking external contract calls
- Testing time-dependent contract behavior
- Testing logs
Advanced TestVM Configuration
1: TestVM Setup and Configuration
#[test]
fn test_advanced_testvm_configuration() {
// Create a TestVM with custom configuration using the builder pattern
// This approach allows for fluent, readable test setup
let vm: TestVM = TestVMBuilder::new()
// Set the transaction sender address (msg.sender in Solidity)
.sender(address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"))
// Set the address where our contract is deployed
.contract_address(address!("0x5FbDB2315678afecb367f032d93F642f64180aa3"))
// Set the ETH value sent with the transaction (msg.value in Solidity)
.value(U256::from(1))
.build();
// Configure additional blockchain state parameters directly on the VM instance
// This demonstrates how to set parameters after VM creation
vm.set_block_number(12345678);
// Note: The chain ID is set to 42161 (Arbitrum One) by default in the TestVM
// We don't need to set it explicitly as it's already configured in the VM state
2: Contract Initialization and User Setup
// Initialize our VendingMachine contract with the configured VM
// The `from` method connects our contract to the test environment
let mut contract = VendingMachine::from(&vm);
// Define a user address that will interact with our contract
// This represents an external user's Ethereum address
let user = address!("0xCDC41bff86a62716f050622325CC17a317f99404");
// 3: Initial State Verification
// ------------------------------------
// Verify the user starts with zero cupcakes
// This confirms our contract's initial state is as expected
assert_eq!(contract.get_cupcake_balance_for(user).unwrap(), U256::ZERO);
// Set the initial block timestamp by advancing it by 10 seconds
// This ensures we're past any time-based restrictions
vm.set_block_timestamp(vm.block_timestamp() + 10);
4: Contract Interaction
// Give a cupcake to the user and verify the operation succeeds
// The contract should return true when a cupcake is successfully given
assert!(contract.give_cupcake_to(user).unwrap());
// Verify the user now has exactly one cupcake
// This confirms our contract correctly updated its storage
assert_eq!(
contract.get_cupcake_balance_for(user).unwrap(),
U256::from(1)
);
5: VM State Inspection
// Take a snapshot of the current VM state for inspection
// This captures all storage, balances, and blockchain parameters
let snapshot = vm.snapshot();
// Inspect various aspects of the VM state to verify configuration
// Chain ID should be Arbitrum One (42161) which is the default
assert_eq!(snapshot.chain_id, 42161);
// Message value should match what we configured (1 wei)
assert_eq!(snapshot.msg_value, U256::from(1));
6: Mocking External Contract Calls
// Define an external contract we might want to interact with
let external_contract = address!("0x8626f6940E2eb28930eFb4CeF49B2d1F2C9C1199");
// Define example call data we would send to that contract
let call_data = vec![0xab, 0xcd, 0xef];
// Define the expected response from that contract
let expected_response = vec![0x12, 0x34, 0x56];
// Mock the external call so it returns our expected response
// This allows testing contract interactions without deploying external contracts
vm.mock_call(external_contract, call_data, Ok(expected_response));
7: Time-Dependent Behavior Testing
// Set a specific block timestamp
// This simulates the passage of time on the blockchain
vm.set_block_timestamp(1006);
// Try giving another cupcake after the time restriction has passed
// The contract should allow this since enough time has elapsed
assert!(contract.give_cupcake_to(user).unwrap());
// Verify the user now has two cupcakes
// This confirms our contract correctly handles time-based restrictions
assert_eq!(
contract.get_cupcake_balance_for(user).unwrap(),
U256::from(2)
);
8: Testing Event Logs
// Test that events are emitted when cupcakes are distributed
let vm_logs = TestVM::new();
let mut contract_logs = VendingMachine::from(&vm_logs);
let user_logs = address!("0xCDC41bff86a62716f050622325CC17a317f99404");
// Set initial timestamp to ensure we can give a cupcake
vm_logs.set_block_timestamp(100);
// Give a cupcake to the user - this should emit an event
let result = contract_logs.give_cupcake_to(user_logs).unwrap();
assert!(result, "Should successfully give first cupcake");
// Get all emitted logs from the VM
let logs = vm_logs.get_emitted_logs();
// Verify that exactly one event was emitted
assert_eq!(logs.len(), 1, "Should emit exactly one CupcakeDistributed event");
// Calculate the expected event signature for CupcakeDistributed
// Event signature: CupcakeDistributed(address indexed recipient, uint256 indexed timestamp, uint256 new_balance)
// The signature is calculated as: keccak256("CupcakeDistributed(address,uint256,uint256)")
use alloy_primitives::hex;
use stylus_sdk::alloy_primitives::B256;
let event_signature: B256 =
hex!("c12a96437276bfca30ffd7a90b5e9d233c71c97f759c3b76b886f29e87989bb2").into();
// Check the first topic (event signature)
assert_eq!(
logs[0].0[0],
event_signature,
"First topic should be the event signature"
);
// Extract the indexed recipient address from the second topic
let recipient_topic = logs[0].0[1];
let recipient_bytes: [u8; 32] = recipient_topic.into();
// Indexed addresses are padded to 32 bytes - extract the last 20 bytes
let mut recipient_address = [0u8; 20];
recipient_address.copy_from_slice(&recipient_bytes[12..32]);
// Verify the recipient address matches our user
assert_eq!(
Address::from(recipient_address),
user_logs,
"Event recipient should match the user who received the cupcake"
);
// The new_balance is not indexed, so it's in the data field
let log_data = &logs[0].1;
// For a single uint256, it should be 32 bytes
assert_eq!(log_data.len(), 32, "Event data should contain one uint256 (32 bytes)");
// Convert the data bytes to U256
let mut balance_bytes = [0u8; 32];
balance_bytes.copy_from_slice(&log_data[0..32]);
let new_balance = U256::from_be_bytes(balance_bytes);
// Verify the balance is 1 (first cupcake)
assert_eq!(
new_balance,
U256::from(1),
"Event should show new balance of 1 cupcake"
);
}
Here is a cargo.toml
file to add the required dependencies:
cargo.toml
[package]
name = "stylus-cupcake-example"
version = "0.1.7"
edition = "2021"
license = "MIT OR Apache-2.0"
keywords = ["arbitrum", "ethereum", "stylus", "alloy"]
[dependencies]
alloy-primitives = "=0.8.20"
alloy-sol-types = "=0.8.20"
mini-alloc = "0.8.4"
stylus-sdk = "0.8.4"
hex = "0.4.3"
dotenv = "0.15.0"
[dev-dependencies]
tokio = { version = "1.12.0", features = ["full"] }
ethers = "2.0"
eyre = "0.6.8"
stylus-sdk = { version = "0.8.4", features = ["stylus-test"] }
alloy-primitives = { version = "=0.8.20", features = ["sha3-keccak"] }
[features]
export-abi = ["stylus-sdk/export-abi"]
debug = ["stylus-sdk/debug"]
[[bin]]
name = "stylus-cupcake-example"
path = "src/main.rs"
[lib]
crate-type = ["lib", "cdylib"]
[profile.release]
codegen-units = 1
strip = true
lto = true
panic = "abort"
# If you need to reduce the binary size, it is advisable to try other
# optimization levels, such as "s" and "z"
opt-level = 3
You can find the example above in the stylus-quickstart-vending-machine git repository.
Running Tests
To run your tests, you can use the standard Rust test command:
cargo test
Or with the cargo-stylus
CLI tool:
To run a specific test:
cargo test test_give_cupcake
Testing Best Practices
-
Test Isolation
- Create a new
TestVM
instance for each test - Avoid relying on state from previous tests
- Create a new
-
Comprehensive Coverage
- Test both success and error conditions
- Test edge cases and boundary conditions
- Verify all public functions and important state transitions
-
Clear Assertions
- Use descriptive error messages in assertions
- Make assertions that verify the actual behavior you care about
-
Realistic Scenarios
- Test real-world usage patterns
- Include tests for authorization and access control
-
Gas and Resource Efficiency
- For complex contracts, consider testing gas usage patterns
- Look for storage optimization opportunities
Migrating from Global Accessors to VM Accessors
As of Stylus SDK 0.8.0, there's a shift away from global host function invocations to using the .vm()
method. This is a safer approach that makes testing easier. For example:
// Old style (deprecated)
let timestamp = block::timestamp();
// New style (preferred)
let timestamp = self.vm().block_timestamp();
To make your contracts more testable, make sure they access host methods through the HostAccess
trait with the .vm()
method.