Unit Test Pallets¶
Introduction¶
Unit testing in the Polkadot SDK helps ensure that the functions provided by a pallet behave as expected. It also confirms that data and events associated with a pallet are processed correctly during interactions. With your mock runtime in place from the previous guide, you can now write comprehensive tests that verify your pallet's behavior in isolation.
In this guide, you'll learn how to:
- Structure test modules effectively.
- Test dispatchable functions.
- Verify storage changes.
- Check event emission.
- Test error conditions.
- Use genesis configurations in tests.
Prerequisites¶
Before you begin, ensure you:
- Completed the Make a Custom Pallet guide.
- Completed the Mock Your Runtime guide.
- Configured custom counter pallet with mock runtime in
pallets/pallet-custom. - Understood the basics of Rust testing.
Understanding FRAME Testing Tools¶
FRAME (Framework for Runtime Aggregation of Modularized Entities) provides specialized testing macros and utilities that make pallet testing more efficient.
Assertion Macros¶
assert_ok!- Asserts that a dispatchable call succeeds.assert_noop!- Asserts that a call fails without changing state (no operation).assert_eq!- Standard Rust equality assertion.
assert_noop! Explained
Use assert_noop! to ensure the operation fails without any state changes. This is critical for testing error conditions - it verifies both that the error occurs AND that no storage was modified.
System Pallet Test Helpers¶
The frame_system pallet provides useful methods for testing:
System::events()- Returns all events emitted during the test.System::assert_last_event()- Asserts the last event matches expectations.System::set_block_number()- Sets the current block number.
Events and Block Number
Events are not emitted on block 0 (genesis block). If you need to test events, ensure you set the block number to at least 1 using System::set_block_number(1).
Origin Types¶
RuntimeOrigin::root()- Root/sudo origin for privileged operations.RuntimeOrigin::signed(account)- Signed origin from a specific account.RuntimeOrigin::none()- No origin (typically fails for most operations).
Learn more about origins in the FRAME Origin reference document.
Create the Tests Module¶
Create a new file for your tests within the pallet directory:
-
Navigate to your pallet directory:
-
Create a new file named
tests.rs: -
Open
src/lib.rsand add the tests module declaration after the mock module:
Set Up the Test Module¶
Open src/tests.rs and add the basic structure with necessary imports:
use crate::{mock::*, Error, Event};
use frame::deps::frame_support::{assert_noop, assert_ok};
use frame::deps::sp_runtime::DispatchError;
This setup imports:
- The mock runtime and test utilities from
mock.rs - Your pallet's
ErrorandEventtypes - FRAME's assertion macros via
frame::deps DispatchErrorfor testing origin checks
Complete Pallet Code Reference
Here's the complete pallet code that you'll be testing throughout this guide:
#![cfg_attr(not(feature = "std"), no_std)]
pub use pallet::*;
#[frame::pallet]
pub mod pallet {
use frame::prelude::*;
#[pallet::pallet]
pub struct Pallet<T>(_);
#[pallet::config]
pub trait Config: frame_system::Config {
type RuntimeEvent: From<Event<Self>> + IsType<<Self as frame_system::Config>::RuntimeEvent>;
#[pallet::constant]
type CounterMaxValue: Get<u32>;
}
#[pallet::event]
#[pallet::generate_deposit(pub(super) fn deposit_event)]
pub enum Event<T: Config> {
CounterValueSet {
new_value: u32,
},
CounterIncremented {
new_value: u32,
who: T::AccountId,
amount: u32,
},
CounterDecremented {
new_value: u32,
who: T::AccountId,
amount: u32,
},
}
#[pallet::error]
pub enum Error<T> {
NoneValue,
Overflow,
Underflow,
CounterMaxValueExceeded,
}
#[pallet::storage]
pub type CounterValue<T> = StorageValue<_, u32, ValueQuery>;
#[pallet::storage]
pub type UserInteractions<T: Config> = StorageMap<
_,
Blake2_128Concat,
T::AccountId,
u32,
ValueQuery
>;
#[pallet::genesis_config]
#[derive(DefaultNoBound)]
pub struct GenesisConfig<T: Config> {
pub initial_counter_value: u32,
pub initial_user_interactions: Vec<(T::AccountId, u32)>,
}
#[pallet::genesis_build]
impl<T: Config> BuildGenesisConfig for GenesisConfig<T> {
fn build(&self) {
CounterValue::<T>::put(self.initial_counter_value);
for (account, count) in &self.initial_user_interactions {
UserInteractions::<T>::insert(account, count);
}
}
}
#[pallet::call]
impl<T: Config> Pallet<T> {
#[pallet::call_index(0)]
#[pallet::weight(0)]
pub fn set_counter_value(origin: OriginFor<T>, new_value: u32) -> DispatchResult {
ensure_root(origin)?;
ensure!(new_value <= T::CounterMaxValue::get(), Error::<T>::CounterMaxValueExceeded);
CounterValue::<T>::put(new_value);
Self::deposit_event(Event::CounterValueSet { new_value });
Ok(())
}
#[pallet::call_index(1)]
#[pallet::weight(0)]
pub fn increment(origin: OriginFor<T>, amount: u32) -> DispatchResult {
let who = ensure_signed(origin)?;
let current_value = CounterValue::<T>::get();
let new_value = current_value.checked_add(amount).ok_or(Error::<T>::Overflow)?;
ensure!(new_value <= T::CounterMaxValue::get(), Error::<T>::CounterMaxValueExceeded);
CounterValue::<T>::put(new_value);
UserInteractions::<T>::mutate(&who, |count| {
*count = count.saturating_add(1);
});
Self::deposit_event(Event::CounterIncremented { new_value, who, amount });
Ok(())
}
#[pallet::call_index(2)]
#[pallet::weight(0)]
pub fn decrement(origin: OriginFor<T>, amount: u32) -> DispatchResult {
let who = ensure_signed(origin)?;
let current_value = CounterValue::<T>::get();
let new_value = current_value.checked_sub(amount).ok_or(Error::<T>::Underflow)?;
CounterValue::<T>::put(new_value);
UserInteractions::<T>::mutate(&who, |count| {
*count = count.saturating_add(1);
});
Self::deposit_event(Event::CounterDecremented { new_value, who, amount });
Ok(())
}
}
}
Write Your First Test¶
Let's start with a simple test to verify the increment function works correctly.
Test Basic Increment¶
Test that the increment function increases counter value and emits events.
#[test]
fn increment_works() {
new_test_ext().execute_with(|| {
// Set block number to 1 so events are registered
System::set_block_number(1);
let account = 1u64;
// Increment by 50
assert_ok!(CustomPallet::increment(RuntimeOrigin::signed(account), 50));
assert_eq!(crate::CounterValue::<Test>::get(), 50);
// Check event was emitted
System::assert_last_event(
Event::CounterIncremented {
new_value: 50,
who: account,
amount: 50,
}
.into(),
);
// Check user interactions were tracked
assert_eq!(crate::UserInteractions::<Test>::get(account), 1);
});
}
Run your first test:
You should see:
Congratulations! You've written and run your first pallet test.
Test Error Conditions¶
Now let's test that our pallet correctly handles errors. Error testing is crucial to ensure your pallet fails safely.
Test Overflow Protection¶
Test that incrementing at u32::MAX fails with Overflow error.
#[test]
fn increment_fails_on_overflow() {
new_test_ext_with_counter(u32::MAX).execute_with(|| {
// Attempt to increment when at max u32 should fail
assert_noop!(
CustomPallet::increment(RuntimeOrigin::signed(1), 1),
Error::<Test>::Overflow
);
});
}
Test overflow protection:
Test Underflow Protection¶
Test that decrementing below zero fails with Underflow error.
#[test]
fn decrement_fails_on_underflow() {
new_test_ext_with_counter(10).execute_with(|| {
// Attempt to decrement below zero should fail
assert_noop!(
CustomPallet::decrement(RuntimeOrigin::signed(1), 11),
Error::<Test>::Underflow
);
});
}
Verify underflow protection:
Test Access Control¶
Verify that origin checks work correctly and unauthorized access is prevented.
Test Root-Only Access¶
Test that set_counter_value requires root origin and rejects signed origins.
#[test]
fn set_counter_value_requires_root() {
new_test_ext().execute_with(|| {
let alice = 1u64;
// When: non-root user tries to set counter
// Then: should fail with BadOrigin
assert_noop!(
CustomPallet::set_counter_value(RuntimeOrigin::signed(alice), 100),
DispatchError::BadOrigin
);
// But root should succeed
assert_ok!(CustomPallet::set_counter_value(RuntimeOrigin::root(), 100));
assert_eq!(crate::CounterValue::<Test>::get(), 100);
});
}
Test access control:
Test Event Emission¶
Verify that events are emitted correctly with the right data.
Test Event Data¶
The increment_works test (shown earlier) already demonstrates event testing by:
- Setting the block number to 1 to enable event emission.
- Calling the dispatchable function.
- Using
System::assert_last_event()to verify the correct event was emitted with expected data.
This pattern applies to all dispatchables that emit events. For a dedicated event-only test focusing on the set_counter_value function:
Test that set_counter_value updates storage and emits correct event.
#[test]
fn set_counter_value_works() {
new_test_ext().execute_with(|| {
// Set block number to 1 so events are registered
System::set_block_number(1);
// Set counter to 100
assert_ok!(CustomPallet::set_counter_value(RuntimeOrigin::root(), 100));
assert_eq!(crate::CounterValue::<Test>::get(), 100);
// Check event was emitted
System::assert_last_event(Event::CounterValueSet { new_value: 100 }.into());
});
}
Run the event test:
Test Genesis Configuration¶
Verify that genesis configuration works correctly.
Test Genesis Setup¶
Test that genesis configuration correctly initializes counter and user interactions.
#[test]
fn genesis_config_works() {
new_test_ext_with_interactions(42, vec![(1, 5), (2, 10)]).execute_with(|| {
// Check initial counter value
assert_eq!(crate::CounterValue::<Test>::get(), 42);
// Check initial user interactions
assert_eq!(crate::UserInteractions::<Test>::get(1), 5);
assert_eq!(crate::UserInteractions::<Test>::get(2), 10);
});
}
Test genesis configuration:
Run All Tests¶
Now run all your tests together:
You should see all tests passing:
Mock Runtime Tests
You'll notice 2 additional tests from the mock module:
mock::__construct_runtime_integrity_test::runtime_integrity_tests- Auto-generated test that validates runtime constructionmock::test_genesis_config_builds- Validates that genesis configuration builds correctly
These tests are automatically generated from your mock runtime setup and help ensure the test environment itself is valid.
Congratulations! You have a well-tested pallet covering the essential testing patterns!
These tests demonstrate comprehensive coverage including basic operations, error conditions, access control, event emission, state management, and genesis configuration. As you build more complex pallets, you'll apply these same patterns to test additional functionality.
Full Test Suite Code
Here's the complete tests.rs file for quick reference:
use crate::{mock::*, Error, Event};
use frame::deps::frame_support::{assert_noop, assert_ok};
use frame::deps::sp_runtime::DispatchError;
#[test]
fn set_counter_value_works() {
new_test_ext().execute_with(|| {
// Set block number to 1 so events are registered
System::set_block_number(1);
// Set counter to 100
assert_ok!(CustomPallet::set_counter_value(RuntimeOrigin::root(), 100));
assert_eq!(crate::CounterValue::<Test>::get(), 100);
// Check event was emitted
System::assert_last_event(Event::CounterValueSet { new_value: 100 }.into());
});
}
#[test]
fn set_counter_value_requires_root() {
new_test_ext().execute_with(|| {
// Attempt to set counter with non-root origin should fail
assert_noop!(
CustomPallet::set_counter_value(RuntimeOrigin::signed(1), 100),
DispatchError::BadOrigin
);
});
}
#[test]
fn set_counter_value_respects_max_value() {
new_test_ext().execute_with(|| {
// Attempt to set counter above max value (1000) should fail
assert_noop!(
CustomPallet::set_counter_value(RuntimeOrigin::root(), 1001),
Error::<Test>::CounterMaxValueExceeded
);
// Setting to exactly max value should work
assert_ok!(CustomPallet::set_counter_value(RuntimeOrigin::root(), 1000));
assert_eq!(crate::CounterValue::<Test>::get(), 1000);
});
}
#[test]
fn increment_works() {
new_test_ext().execute_with(|| {
// Set block number to 1 so events are registered
System::set_block_number(1);
let account = 1u64;
// Increment by 50
assert_ok!(CustomPallet::increment(RuntimeOrigin::signed(account), 50));
assert_eq!(crate::CounterValue::<Test>::get(), 50);
// Check event was emitted
System::assert_last_event(
Event::CounterIncremented {
new_value: 50,
who: account,
amount: 50,
}
.into(),
);
// Check user interactions were tracked
assert_eq!(crate::UserInteractions::<Test>::get(account), 1);
});
}
#[test]
fn increment_tracks_multiple_interactions() {
new_test_ext().execute_with(|| {
let account = 1u64;
// Increment multiple times
assert_ok!(CustomPallet::increment(RuntimeOrigin::signed(account), 10));
assert_ok!(CustomPallet::increment(RuntimeOrigin::signed(account), 20));
assert_ok!(CustomPallet::increment(RuntimeOrigin::signed(account), 30));
// Check counter value
assert_eq!(crate::CounterValue::<Test>::get(), 60);
// Check user interactions were tracked (should be 3)
assert_eq!(crate::UserInteractions::<Test>::get(account), 3);
});
}
#[test]
fn increment_fails_on_overflow() {
new_test_ext_with_counter(u32::MAX).execute_with(|| {
// Attempt to increment when at max u32 should fail
assert_noop!(
CustomPallet::increment(RuntimeOrigin::signed(1), 1),
Error::<Test>::Overflow
);
});
}
#[test]
fn increment_respects_max_value() {
new_test_ext_with_counter(950).execute_with(|| {
// Incrementing past max value (1000) should fail
assert_noop!(
CustomPallet::increment(RuntimeOrigin::signed(1), 51),
Error::<Test>::CounterMaxValueExceeded
);
// Incrementing to exactly max value should work
assert_ok!(CustomPallet::increment(RuntimeOrigin::signed(1), 50));
assert_eq!(crate::CounterValue::<Test>::get(), 1000);
});
}
#[test]
fn decrement_works() {
new_test_ext_with_counter(100).execute_with(|| {
// Set block number to 1 so events are registered
System::set_block_number(1);
let account = 2u64;
// Decrement by 30
assert_ok!(CustomPallet::decrement(RuntimeOrigin::signed(account), 30));
assert_eq!(crate::CounterValue::<Test>::get(), 70);
// Check event was emitted
System::assert_last_event(
Event::CounterDecremented {
new_value: 70,
who: account,
amount: 30,
}
.into(),
);
// Check user interactions were tracked
assert_eq!(crate::UserInteractions::<Test>::get(account), 1);
});
}
#[test]
fn decrement_fails_on_underflow() {
new_test_ext_with_counter(10).execute_with(|| {
// Attempt to decrement below zero should fail
assert_noop!(
CustomPallet::decrement(RuntimeOrigin::signed(1), 11),
Error::<Test>::Underflow
);
});
}
#[test]
fn decrement_tracks_multiple_interactions() {
new_test_ext_with_counter(100).execute_with(|| {
let account = 3u64;
// Decrement multiple times
assert_ok!(CustomPallet::decrement(RuntimeOrigin::signed(account), 10));
assert_ok!(CustomPallet::decrement(RuntimeOrigin::signed(account), 20));
// Check counter value
assert_eq!(crate::CounterValue::<Test>::get(), 70);
// Check user interactions were tracked (should be 2)
assert_eq!(crate::UserInteractions::<Test>::get(account), 2);
});
}
#[test]
fn mixed_increment_and_decrement_works() {
new_test_ext_with_counter(50).execute_with(|| {
let account = 4u64;
// Mix of increment and decrement
assert_ok!(CustomPallet::increment(RuntimeOrigin::signed(account), 25));
assert_eq!(crate::CounterValue::<Test>::get(), 75);
assert_ok!(CustomPallet::decrement(RuntimeOrigin::signed(account), 15));
assert_eq!(crate::CounterValue::<Test>::get(), 60);
assert_ok!(CustomPallet::increment(RuntimeOrigin::signed(account), 10));
assert_eq!(crate::CounterValue::<Test>::get(), 70);
// Check user interactions were tracked (should be 3)
assert_eq!(crate::UserInteractions::<Test>::get(account), 3);
});
}
#[test]
fn different_users_tracked_separately() {
new_test_ext().execute_with(|| {
let account1 = 1u64;
let account2 = 2u64;
// User 1 increments
assert_ok!(CustomPallet::increment(RuntimeOrigin::signed(account1), 10));
assert_ok!(CustomPallet::increment(RuntimeOrigin::signed(account1), 10));
// User 2 decrements
assert_ok!(CustomPallet::decrement(RuntimeOrigin::signed(account2), 5));
// Check counter value (10 + 10 - 5 = 15)
assert_eq!(crate::CounterValue::<Test>::get(), 15);
// Check user interactions are tracked separately
assert_eq!(crate::UserInteractions::<Test>::get(account1), 2);
assert_eq!(crate::UserInteractions::<Test>::get(account2), 1);
});
}
#[test]
fn genesis_config_works() {
new_test_ext_with_interactions(42, vec![(1, 5), (2, 10)]).execute_with(|| {
// Check initial counter value
assert_eq!(crate::CounterValue::<Test>::get(), 42);
// Check initial user interactions
assert_eq!(crate::UserInteractions::<Test>::get(1), 5);
assert_eq!(crate::UserInteractions::<Test>::get(2), 10);
});
}
Where to Go Next¶
-
Guide Benchmark Your Pallet
Learn how to benchmark extrinsics in your custom pallet to generate precise weight calculations suitable for production use.
| Created: January 14, 2026