Skip to content

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:

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:

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

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:

  1. Navigate to your pallet directory:

    cd pallets/pallet-custom/src
    
  2. Create a new file named tests.rs:

    touch tests.rs
    
  3. Open src/lib.rs and add the tests module declaration after the mock module:

    src/lib.rs
    #![cfg_attr(not(feature = "std"), no_std)]
    
    pub use pallet::*;
    
    #[cfg(test)]
    mod mock;
    
    #[cfg(test)]
    mod tests;
    
    #[frame::pallet]
    pub mod pallet {
        // ... existing pallet code
    }
    

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 Error and Event types
  • FRAME's assertion macros via frame::deps
  • DispatchError for 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:

cargo test --package pallet-custom increment_works

You should see:

running 1 test
test tests::increment_works ... ok

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:

cargo test --package pallet-custom increment_fails_on_overflow

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:

cargo test --package pallet-custom decrement_fails_on_underflow

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:

cargo test --package pallet-custom set_counter_value_requires_root

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:

  1. Setting the block number to 1 to enable event emission.
  2. Calling the dispatchable function.
  3. 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:

cargo test --package pallet-custom set_counter_value_works

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:

cargo test --package pallet-custom genesis_config_works

Run All Tests

Now run all your tests together:

cargo test --package pallet-custom

You should see all tests passing:

$ cargo test --package pallet-custom running 15 tests test mock::__construct_runtime_integrity_test::runtime_integrity_tests ... ok test mock::test_genesis_config_builds ... ok test tests::decrement_fails_on_underflow ... ok test tests::decrement_tracks_multiple_interactions ... ok test tests::decrement_works ... ok test tests::different_users_tracked_separately ... ok test tests::genesis_config_works ... ok test tests::increment_fails_on_overflow ... ok test tests::increment_respects_max_value ... ok test tests::increment_tracks_multiple_interactions ... ok test tests::increment_works ... ok test tests::mixed_increment_and_decrement_works ... ok test tests::set_counter_value_requires_root ... ok test tests::set_counter_value_respects_max_value ... ok test tests::set_counter_value_works ... ok test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

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 construction
  • mock::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.

    Integrate

Last update: January 14, 2026
| Created: January 14, 2026