Overview
Actors are deterministic Python, which makes them very testable: the SDK can stub the entire PVM host API in-process, so handlers run as plain functions under pytest — no validator, no Docker, sub-second feedback.
This guide covers the three layers of the testing ladder:
- Unit tests with
SimulatedChain — state, events, timers, determinism
- Mocking cross-actor calls with
CallMock
- Integration runs against a real local devnet
1. Unit testing with SimulatedChain
cowboy_sdk.testing.SimulatedChain is an in-memory, single-actor harness built on cowboy_sdk.mock_host. Constructing one installs the mock host (so import pvm_host resolves to it), resets state, and gives you a driveable chain:
from cowboy_sdk.testing import SimulatedChain
import main # your actor module
def test_counter_flow():
chain = SimulatedChain(main.Counter())
chain.call("init")
chain.call("increment")
chain.call("increment")
# Assert persisted state directly (raw stored bytes: CBOR-encoded 2)
chain.assert_state("count", b"\x02")
# Or read it back
raw = chain.state("count")
# Events emitted via runtime.emit_event are captured
assert chain.events()[-1][0] == "counter.incremented"
What the harness gives you:
| Method | Purpose |
|---|
chain.call(handler, payload=b"") | Invoke a handler by name (or pass a callable) |
chain.state(key) / chain.assert_state(key, expected) | Read / assert raw stored bytes |
chain.snapshot() | Full state dict, for golden comparisons |
chain.events() | All (name, payload) events emitted so far |
chain.set_sender(addr) / chain.set_block_height(n) | Control execution context |
chain.advance_block(n) | Advance the clock, firing any due timers into their handlers |
chain.check_determinism(scenario) | Replay a scenario on two fresh chains and compare snapshots |
Testing timers
advance_block fires every timer whose height is due and routes it to the handler named in the timer payload — so scheduled behavior is testable without a validator:
def test_timer_ticks():
chain = SimulatedChain(main.TimerCounter())
chain.call("init", b'{"interval_blocks": 5, "max_ticks": 2}')
chain.call("start")
fired = chain.advance_block(5) # [(handler, result), ...]
assert fired and fired[0][0] == "on_timer"
Determinism checks
Same scenario, two fresh chains, identical final state — a cheap guard against accidentally nondeterministic code (iteration order, time, randomness):
def test_deterministic():
def scenario(chain):
chain.call("init")
chain.call("increment")
assert SimulatedChain(main.Counter()).check_determinism(scenario)
2. Mocking cross-actor calls
When your actor calls other actors, stub the responses with CallMock — a matcher-based mock for the host’s call operation. Note that cowboy_sdk.call() CBOR-encodes arguments and CBOR-decodes responses, so matchers and responses are expressed in encoded bytes:
from cowboy_sdk import call, codec, mock_host
from cowboy_sdk.testing import CallMock
mock = CallMock(default=codec.encode(None))
mock.when(method="get_price", args=codec.encode("ETH")).respond(codec.encode(3000))
mock_host.set_call_handler(mock)
price = call(b"\x11" * 20, "get_price", "ETH") # → 3000
# Afterwards, inspect what your actor called:
assert mock.calls[0][1] == "get_price"
Rules match on any combination of target, method, args (exact equality on the encoded bytes) or a custom predicate; unmatched calls get the default. Every call is recorded in mock.calls as (target, method, args, cycles_limit).
3. Wiring it into pytest
Install the mock host early — before any actor module is imported — so every pvm_host lookup resolves to it. SimulatedChain does this on construction; for suites that import actor modules at collection time, do it in conftest.py:
# conftest.py
import sys
from cowboy_sdk import mock_host
sys.modules["pvm_host"] = mock_host
import pytest
@pytest.fixture(autouse=True)
def fresh_host():
mock_host.reset()
yield
mock_host.reset()
Then run, with the SDK on your path. Expose only the cowboy_sdk package — node/pvm/Lib as a whole is the PVM’s Python standard library, and putting the entire directory on PYTHONPATH shadows CPython’s stdlib and breaks pytest:
mkdir -p .sdkpath && ln -s <monorepo>/node/pvm/Lib/cowboy_sdk .sdkpath/cowboy_sdk
PYTHONPATH=.sdkpath pytest actors/counter/
mock_host.reset() between tests is essential — state, timers, and context are module-level in the mock. The autouse fixture above keeps tests independent.
mock_host.set_context(...) lets you fake execution-context fields (sender, tx hash, …) when a handler branches on them; for the block clock, use set_block_height() (or SimulatedChain’s helpers), which is what timers and runtime.get_block_height() follow.
4. Integration: run against a real devnet
Unit tests don’t exercise CBOR boundaries with the real host, gas metering, or the scheduler’s actual dispatch. Once unit tests pass, run the actor on a local network:
# Boot a devnet (see Quickstart) and deploy
cowboy actor deploy --code actors/counter/main.py --salt 0x01
cowboy actor execute --actor <ADDR> --handler increment --payload 0x
cowboy actor logs --address <ADDR>
For repo examples there is a shared sweep harness that can spawn a temporary isolated validator per run:
cd examples
./run_examples.sh --spawn-local # temporary local validator
./run_examples.sh --server local 21-... # reuse a validator you already run
What to test at which layer
| Concern | SimulatedChain | Local devnet |
|---|
| Handler logic, state transitions | ✓ | |
| Events | ✓ | |
| Timer scheduling logic | ✓ | ✓ |
| Cross-actor protocols | mocked | ✓ |
| Gas (Cycles/Cells) behavior | | ✓ |
| Runner jobs / continuations | mocked | ✓ |
| Determinism | check_determinism | consensus itself |
Further reading