Skip to content

Contract Testing

vm-test-harness provides an in-memory host that lets you execute contracts locally without running a node. It’s ideal for unit-style tests, storage fixtures, event assertions, and cross-contract call flows.

Key pieces:

  • TestHost: In-memory storage, logs, balances, block context
  • ContractHarness: Load and execute Zig/Rust contract ELFs
  • TestAccounts: Deterministic test addresses (alice, bob, carol, dave, eve, treasury)
  • Helpers: Calldata builders, result parsers, event assertions, storage fixtures, snapshots

The crate lives in the workspace:

use vm_test_harness::{
ContractHarness, TestHost, TestAccounts, StorageFixture,
build_calldata, build_calldata_no_args,
parse_result, assert_call_ok,
assert_event_emitted, assert_event_count,
assert_storage_u128,
};

You must build contract ELFs before loading them in tests:

Terminal window
# Zig
cd contracts/my_zig && zig build -Doptimize=ReleaseSmall
# Rust
just contract-build --manifest-path contracts/my_rust/Cargo.toml
# All contracts
just contract-build-all
#[test]
fn my_token_initializes() {
// Skip gracefully if ELF not built yet
let Some(harness) = ContractHarness::from_zig_artifact_or_skip(
"contracts/my_token/zig-out/bin/my_token",
"contracts/my_token",
).unwrap() else { return };
let mut host = TestHost::new();
let accounts = TestAccounts::new();
host.seed_balances(&accounts, 100_000);
// Build calldata: 4-byte selector + borsh-encoded args
let calldata = build_calldata(SELECTOR_INITIALIZE, &InitArgs { fee_bps: 30 });
let result = harness.call(
&mut host,
accounts.alice, // origin
b"my_token", // contract address
&calldata,
1_000_000, // gas limit
);
assert_call_ok(&result, "init should succeed");
assert_event_emitted(&host, "Initialized()");
assert_storage_u128(&host, b"my_token", b"fee_bps", 30);
}

TestAccounts provides six deterministic addresses derived from keccak256(label):

let accounts = TestAccounts::new();
// accounts.alice, accounts.bob, accounts.carol,
// accounts.dave, accounts.eve, accounts.treasury

Calldata format: selector (4 bytes) || borsh(args).

// With arguments
let calldata = build_calldata(SELECTOR_TRANSFER, &TransferArgs { to, amount });
// Without arguments
let calldata = build_calldata_no_args(SELECTOR_TOTAL_SUPPLY);

Parse return data:

let result = harness.call(&mut host, origin, contract, &calldata, gas);
assert_call_ok(&result, "call should succeed");
// Typed result parsing
let supply: u128 = parse_result(&result.outcome.return_data)?;
use vm_test_harness::{TestHost, StorageFixture, assert_storage_u128, assert_storage_empty};
let mut host = TestHost::new();
StorageFixture::new(&mut host)
.contract(b"amm_pool")
.set_u128(b"reserve0", 1_000_000)
.set_u128(b"reserve1", 500_000)
.set_u64(b"swap_fee_bps", 30)
.done();
// ... execute swap ...
assert_storage_u128(&host, b"amm_pool", b"reserve0", 900_000);
assert_storage_empty(&host, b"amm_pool", b"pending_admin");
use vm_test_harness::{TestHost, assert_event_emitted, assert_event_with_topics, assert_event_count};
use vm_test_harness::topic_from_u128;
let host = TestHost::new();
// ... execute contract ...
assert_event_emitted(&host, "Transfer(address,address,uint256)");
let from = [1u8; 32];
let to = [2u8; 32];
let amount = topic_from_u128(1000);
assert_event_with_topics(&host, "Transfer(address,address,uint256)", &[from, to, amount]);
assert_event_count(&host, 1);

Register multiple programs with the host and call by address:

let mut host = TestHost::new();
let a = ContractHarness::from_zig_artifact("contracts/a/zig-out/bin/a").unwrap();
let b = ContractHarness::from_zig_artifact("contracts/b/zig-out/bin/b").unwrap();
host.register_program(b"contract_a", a.program().clone());
host.register_program(b"contract_b", b.program().clone());

Then use normal ContractHarness::call flows to exercise call graphs.

let mut host = TestHost::new();
let snapshot = host.snapshot();
// mutate state ...
host.restore_snapshot(snapshot);

Advance the host’s block context between calls:

host.advance_block(); // increments block_number, updates timestamp

The Ashen SDK includes a Zig testing module for unit tests that run inside zig test (no Rust harness needed):

const sdk = @import("ashen-sdk");
const testing = sdk.testing;
test "balance updates correctly" {
testing.assertStorageU128("balance", 1000);
testing.assertEventEmitted("Transfer(address,address,uint256)");
testing.assertEqU128(actual, expected);
}

The Zig testing_host provides an in-memory storage backend with mock syscalls, callable via testing_host.init() / testing_host.deinit().

Terminal window
# All contract integration tests
cargo test -p vm-runtime
# Single contract
cargo test -p vm-runtime --test amm_pool_v1
# Single test function
cargo test -p vm-runtime --test amm_pool_v1 -- amm_pool_initial_liquidity
# With output
cargo test -p vm-runtime --test amm_pool_v1 -- --nocapture

Tests that use from_zig_artifact_or_skip will print a build hint and skip gracefully if the ELF artifact is missing.

  • Use from_zig_artifact_or_skip() instead of from_zig_artifact() to avoid test failures when artifacts are not built.
  • Use ResourceLimits to simulate production caps.
  • Use snapshot() / restore_snapshot() to test rollback behavior.
  • Prefer deterministic fixtures for reproducible tests.
  • Treat harness tests as fast unit tests, and use devnet tests for end-to-end.