Testing Your Module
In this section, we'll walk you through writing tests for the ValueSetter
module you've been working on. The Sovereign SDK provides a powerful testing framework in the sov-test-utils
crate that allows you to test your module's logic in an isolated environment, without needing to run a full rollup.
Step 1: Setting Up the Test Environment
All module tests follow a similar pattern. First, we need to create a temporary, isolated runtime that includes our module. Then, for each test, we'll define the initial ("genesis") state and use a TestRunner
to execute transactions and make assertions.
Let's build a setup
helper function to handle this boilerplate.
a) Create a Test Runtime
The first thing we need is a runtime to test against. The generate_optimistic_runtime!
macro creates a temporary runtime that includes your ValueSetter
module alongside the core modules (like the Bank
) needed for a functioning rollup.
// Typically in tests/test_value_setter.rs
use sov_modules_api::Spec;
use sov_test_utils::{generate_optimistic_runtime, TestSpec};
use value_setter::{ValueSetter, ValueSetterConfig};
type S = TestSpec;
// This macro creates a temporary runtime for testing.
generate_optimistic_runtime!(
TestRuntime <=
value_setter: ValueSetter<S>
);
b) Create a setup
Helper
To avoid repeating code in every test, we'll create a setup
function. This function will be responsible for creating test users, configuring the initial state of the rollup (the genesis state), and initializing the TestRunner
that we'll use to drive the tests.
use sov_test_utils::runtime::genesis::optimistic::HighLevelOptimisticGenesisConfig;
use sov_test_utils::runtime::TestRunner;
use sov_test_utils::TestUser;
// A helper struct to hold our test users, for convenience.
pub struct TestData<S: Spec> {
pub admin: TestUser<S>,
pub regular_user: TestUser<S>,
}
pub fn setup() -> (TestData<S>, TestRunner<TestRuntime<S>, S>) {
// Create two users, the first of which will be our admin.
// (The `HighLevelOptimisticGenesisConfig` builder is a convenient way
// to set up the initial state for core modules.)
let genesis_config = HighLevelOptimisticGenesisConfig::generate()
.add_accounts_with_default_balance(2);
let mut users = genesis_config.additional_accounts().to_vec();
let regular_user = users.pop().unwrap();
let admin = users.pop().unwrap();
let test_data = TestData {
admin: admin.clone(),
regular_user,
};
// Configure the genesis state for our ValueSetter module.
let value_setter_config = ValueSetterConfig {
admin: admin.address(),
};
// Build the final genesis config by combining
// the core config with our module's specific config.
let genesis = GenesisConfig::from_minimal_config(
genesis_config.into(),
value_setter_config,
);
// Initialize the TestRunner with the genesis state.
// The runner gives us a simple way to execute transactions and query state.
let runner = TestRunner::new_with_genesis(
genesis.into_genesis_params(),
TestRuntime::default(),
);
(test_data, runner)
}
This setup
function now gives us a freshly initialized test environment for every test case, with our admin
and a regular_user
ready to go.
Step 2: Writing a "Happy Path" Test
Now, let's write our first test to ensure the admin can successfully set the value. We use a TransactionTestCase
to bundle the transaction input with a set of assertions to run after execution.
use sov_test_utils::{AsUser, TransactionTestCase};
use value_setter::{CallMessage, Event};
#[test]
fn test_admin_can_set_value() {
// 1. Setup
let (test_data, mut runner) = setup();
let admin = &test_data.admin;
let new_value = 42;
// 2. Execute the transaction
runner.execute_transaction(TransactionTestCase {
// The transaction input, created by the admin user.
input: admin.create_plain_message::<TestRuntime<S>, ValueSetter<S>>(
CallMessage::SetValue(new_value),
),
// The assertions to run after execution.
assert: Box::new(move |result, state| {
// 3. Assert the outcome
assert!(result.tx_receipt.is_successful());
// Assert that the correct event was emitted.
assert_eq!(result.events.len(), 1);
let event = &result.events[0];
// Note: The event enum name (`TestRuntimeEvent`) is auto-generated by our `generate_optimistic_runtime!` macro.
assert_eq!(
event,
&TestRuntimeEvent::ValueSetter(Event::ValueUpdated(new_value))
);
// Assert that the state was updated correctly by querying the module.
let value_setter = ValueSetter::<S>::default();
let current_value = value_setter.value.get(state).unwrap();
assert_eq!(current_value, Some(new_value));
}),
});
}
Step 3: Testing a Failure Case
It's equally important to test that our module fails when it should. Let's add a test to ensure a regular user cannot set the value.
#[test]
fn test_regular_user_cannot_set_value() {
// 1. Setup
let (test_data, mut runner) = setup();
let regular_user = &test_data.regular_user;
// 2. Execute the transaction from the non-admin user
runner.execute_transaction(TransactionTestCase {
// This time we're sending the transaction from the regular_user
input: regular_user.create_plain_message::<TestRuntime<S>, ValueSetter<S>>(
CallMessage::SetValue(99),
),
assert: Box::new(move |result, state| {
// 3. Assert that the transaction was reverted
assert!(result.tx_receipt.is_reverted());
// Optional: Check for the specific error message
if let sov_modules_api::TxEffect::Reverted(err) = result.tx_receipt {
assert!(err.reason.to_string().contains("Only the admin can set the value."));
}
// Assert that the state was NOT changed.
let value_setter = ValueSetter::<S>::default();
let current_value = value_setter.value.get(state).unwrap();
assert_eq!(current_value, None); // It should remain un-set.
}),
});
}
Step 4: Running Your Tests
Execute your tests from your module's root directory using the standard Cargo command:
cargo test
Additional Testing Capabilities
The TestRunner
provides methods for more advanced scenarios, all documented in the sov-test-utils
crate. Key capabilities include:
- Batch Execution: Execute and assert on a sequence of transactions with
runner.execute_batch(...)
. - Time Advancement: Test time-sensitive logic (like in
Hooks
) by advancing the slot count withrunner.advance_slots(...)
. - Historical Queries: Query state at a specific block height with
runner.query_state_at_height(...)
. - API Testing: Run an integrated REST API server for off-chain testing with
runner.query_api_response(...)
.
What's Next?
With a thoroughly tested module, you can be confident that your on-chain logic is correct. The next step is to understand how users will interact with it from the outside world.
In the next chapter, "Wallets and Accounts," we'll take a closer look at how users create accounts, sign transactions, and submit them to your rollup.