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
- 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
, and cargo
from your preferred terminal application.
The Stylus Testing Framework
The Stylus SDK includes stylus_test
, 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 key 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
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::\*;
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));
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 comprehensive 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
Basic 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();
// let mut contract = VendingMachine::from(&vm);
// Test logic goes here...
}
Using the TestVM
The TestVM
simulates the execution environment for your Stylus 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 comprehensive test suite that covers all aspects of our contract.
Below is a more advanced example test file, we'll go over the code features one by one:
Comprehensive 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 = TestVMBuilder::new()
.sender(address!("dCE82b5f92C98F27F116F70491a487EFFDb6a2a9"))
.contract_address(address!("0x11b57fe348584f042e436c6bf7c3c3def171de49"))
.value(U256::from(1))
.build();
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)
);
}
}
Running Tests
To run your tests, you can use the standard Rust test command:
cargo test
Or with the cargo-stylus
CLI tool:
cargo stylus test
To run a specific test:
cargo test test_give_cupcake
To see test output:
cargo test -- --nocapture
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.