Skip to main content

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:

  1. The vending machine will distribute a cupcake to anyone who hasn't received one in the last 5 seconds
  2. 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:

  1. Users can get an initial cupcake
  2. Users must wait 5 seconds between cupcakes
  3. Cupcake balances are tracked correctly
  4. 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

  1. Test Isolation

    • Create a new TestVM instance for each test
    • Avoid relying on state from previous tests
  2. Comprehensive Coverage

    • Test both success and error conditions
    • Test edge cases and boundary conditions
    • Verify all public functions and important state transitions
  3. Clear Assertions

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

    • Test real-world usage patterns
    • Include tests for authorization and access control
  5. 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.