Testing using scrypto-test

This document provides a description and guidance on writing tests using the scrypto-test framework introduced with v0.12.0 of the Scrypto toolchain.

This testing framework is designed to allow developers to write Scrypto-like code to test their packages and blueprints and follows a different approach from the TestRunner class. The TestRunner is an in-memory ledger simulator which you can interact with as a user as if you are submitting transactions to the network and getting receipts back. The approach followed by this testing framework is different, instead of interacting with the ledger as an outside user who is submitting transactions, you are interacting with the Radix Engine as if you are a native blueprint. This change in abstractions and perspective is what allows this framework to do things which are difficult or impossible to do with the TestRunner.

Both the TestRunner and this testing framework will prove to be useful throughout your blueprint development journey. As an example, this testing framework allows you to disable some of kernel modules that may get in your way when writing tests so it may be an optimal framework to use to ensure that the "math checks out" in your blueprint code without needing to think about costing or auth. However, when you are reaching the final stages of developing a blueprint you may want tests that check that interactions with your blueprint will succeed in a simulated setting is as close as possible to the real network: this is when the TestRunner comes in.

Overall, we may put these two frameworks into two categories: This framework (named scrypto-test) is a framework for unit testing your blueprints and is a good framework to use to check that your DeFi logic is correct. The TestRunner is an integration testing or an end-to-end testing framework to test that your blueprints work in a simulated ledger with all limits and restrictions applied.

Before you can use scrypto-test to test your blueprints and packages you must update your Cargo.toml file with the instructions in 0.11.0 to 0.12.0 migration guide. More specifically, you must ensure that:

  1. Your package has default and test features.

  2. Your package is using resolver = "2".

  3. scrypto-test is added as a dev-dependency.

  4. Your package is added as a dev-dependency with the test feature enabled.

An example of a well-configured Cargo.toml file can be found here.

Introduction

At the heart of this testing framework is the TestEnvironment struct which encapsulates a self-contained instance of the Radix Engine (EncapsulatedRadixEngine). It is called a self-contained instance of the engine since it has the entire engine stack from substate database, scrypto and native vms, track, system config, and kernel.

Native blueprints are able to interact and make invocations to the engine through the ClientApi. To maintain the abstraction that a test in scrypto-test is a function in a native blueprint the TestEnvironment implements the ClientApi as well, allowing users of the TestEnvironment to get the current actor, read substate, call methods and function, and everything else allowed for by the client sub-apis.

Features

Blueprint Test Bindings

This framework generates test bindings through the #[blueprint] attribute macro that provide a higher-level interface to use in tests such that you are not writing raw and untyped method and function calls. There are three main parts generated as part of the test bindings:

  1. A ${BlueprintName}State struct: This is an automatically generated struct of the state that components of this blueprint have.

  2. A ${BlueprintName} struct: This is a wrapper around a NodeId and is the struct that has the implementation of all of the methods and function on your blueprint.

  3. The impl of the ${BlueprintName} struct: The implementation of the ${BlueprintName} struct is also autogenerated.

    • For each function that the blueprint has, there exists a function with the same name, arguments, and returns in the implementation of ${BlueprintName} but with two additional arguments of the address of the package and a mutable reference to the TestEnvironment.

    • For each method that the blueprint has, there exists a function with the same name, arguments, and returns in the implementation of ${BlueprintName} but with one additional argument which is mutable reference to the TestEnvironment.

All of what’s mentioned above can be found in a module called test_bindings in the same module as your blueprint. This module is gated behind a feature called test so that the test bindings do not increase the size of the built wasm but are still available from within tests.

The following is an example of the test bindings generated by the blueprint macro for an example blueprint:

  • Blueprint

  • Generated Test Bindings

#[blueprint]
mod test {
    struct Test {
        a: u32,
        admin: ResourceManager,
    }
    impl Test {
        pub fn x(&self, i: u32) -> u32 {
            i + self.a
        }
        pub fn y(i: u32) -> u32 {
            i * 2
        }
    }
}
/* Other bits of code generated by the blueprint macro */

#[cfg(feature = "test")] (1)
#[automatically_derived]
pub mod test_bindings {
    use scrypto::prelude::*;
    use super::*;
    use scrypto::prelude::MethodAccessibility::*;
    use scrypto::prelude::RoyaltyAmount::*;

    #[derive(:: scrypto :: prelude :: ScryptoSbor)]
    pub struct TestState { (2)
        pub a: u32,
        pub admin: ResourceManager
    }

    #[derive(Debug, Clone, Copy)]
    pub struct Test(pub NodeId); (3)

    impl<D: ::sbor::Decoder<::scrypto::prelude::ScryptoCustomValueKind>>
        ::scrypto::prelude::Decode<::scrypto::prelude::ScryptoCustomValueKind, D> for Test
    {
        #[inline]
        fn decode_body_with_value_kind(
            decoder: &mut D,
            value_kind: ::scrypto::prelude::ValueKind<::scrypto::prelude::ScryptoCustomValueKind,>,
        ) -> Result<Self, ::scrypto::prelude::DecodeError> {
            let node_id = match value_kind {
                ValueKind::Custom(::scrypto::prelude::ScryptoCustomValueKind::Reference) => {
                    <::scrypto::prelude::Reference as ::scrypto::prelude::Decode<
                        ::scrypto::prelude::ScryptoCustomValueKind,
                        D,
                    >>::decode_body_with_value_kind(decoder, value_kind)
                    .map(|reference| reference.0)
                }
                ValueKind::Custom(::scrypto::prelude::ScryptoCustomValueKind::Own) => {
                    <::scrypto::prelude::Own as ::scrypto::prelude::Decode<
                        ::scrypto::prelude::ScryptoCustomValueKind,
                        D,
                    >>::decode_body_with_value_kind(decoder, value_kind)
                    .map(|own| own.0)
                }
                _ => Err(::scrypto::prelude::DecodeError::InvalidCustomValue),
            }?;
            Ok(Self(node_id))
        }
    }

    impl ::core::convert::TryFrom<Test> for ::scrypto::prelude::ComponentAddress {
        type Error = ::scrypto::prelude::ParseComponentAddressError;
        fn try_from(value: Test) -> ::std::result::Result<Self, Self::Error> {
            ::scrypto::prelude::ComponentAddress::try_from(value.0)
        }
    }
    impl ::core::convert::TryFrom<Test> for ::scrypto::prelude::ResourceAddress {
        type Error = ::scrypto::prelude::ParseResourceAddressError;
        fn try_from(value: Test) -> ::std::result::Result<Self, Self::Error> {
            ::scrypto::prelude::ResourceAddress::try_from(value.0)
        }
    }
    impl ::core::convert::TryFrom<Test> for ::scrypto::prelude::PackageAddress {
        type Error = ::scrypto::prelude::ParsePackageAddressError;
        fn try_from(value: Test) -> ::std::result::Result<Self, Self::Error> {
            ::scrypto::prelude::PackageAddress::try_from(value.0)
        }
    }
    impl ::core::convert::TryFrom<Test> for ::scrypto::prelude::GlobalAddress {
        type Error = ::scrypto::prelude::ParseGlobalAddressError;
        fn try_from(value: Test) -> ::std::result::Result<Self, Self::Error> {
            ::scrypto::prelude::GlobalAddress::try_from(value.0)
        }
    }
    impl ::core::convert::TryFrom<Test> for ::scrypto::prelude::InternalAddress {
        type Error = ::scrypto::prelude::ParseInternalAddressError;
        fn try_from(value: Test) -> ::std::result::Result<Self, Self::Error> {
            ::scrypto::prelude::InternalAddress::try_from(value.0)
        }
    }
    impl ::core::convert::From<Test> for ::scrypto::prelude::Own {
        fn from(value: Test) -> Self {
            Self(value.0)
        }
    }
    impl ::core::convert::From<Test> for ::scrypto::prelude::Reference {
        fn from(value: Test) -> Self {
            Self(value.0)
        }
    }
    impl ::core::convert::From<Test> for ::scrypto::prelude::NodeId {
        fn from(value: Test) -> ::scrypto::prelude::NodeId {
            value.0
        }
    }

    impl Test { (4)
        pub fn x<Y, E>(
            &self,
            i: u32,
            env: &mut Y (5)
        ) -> Result<u32, E>
        where
            Y: ::scrypto::api::ClientApi<E>,
            E: ::std::fmt::Debug
        {
            let rtn = env.call_method(
                &self.0,
                stringify!(x),
                ::scrypto::prelude::scrypto_encode(&(i,)).unwrap()
            )?;
            Ok(::scrypto::prelude::scrypto_decode(&rtn).unwrap())
        }

        pub fn y<Y, E>(
            i: u32,
            blueprint_package_address: ::scrypto::prelude::PackageAddress, (6)
            env: &mut Y (6)
        ) -> Result<u32, E>
        where
            Y: ::scrypto::api::ClientApi<E>,
            E: ::std::fmt::Debug
        {
            let rtn = env.call_function(
                blueprint_package_address,
                "Test",
                stringify!(y),
                ::scrypto::prelude::scrypto_encode(&(i,)).unwrap()
            )?;
            Ok(::scrypto::prelude::scrypto_decode(&rtn).unwrap())
        }
    }
}
1 The generated test_bindings module is feature gated behind a test feature. This means that this module wont increase the size of the package builds while still being available for testing.
2 Since the blueprint’s name is Test a TestState struct has been generated with the state that the Test components may have. This struct will prove useful when reading component state through TestEnvironment::read_component_state.
3 A wrapper is generated for the Test blueprint with the same name that it has. This wrapper has all of the methods and functions that the blueprint has.
4 The implementation of the Test struct is autogenerated as well. As mentioned above, all of the functions and methods that exist in the original blueprint are available here.
5 Methods have an additional argument called env which is a mutable reference to any object that implements the ClientApi. It is expected that in tests a mutable reference to the TestEnvironment would be passed here.
6 Functions have two additional arguments. The first is a PackageAddress of the package that contains this blueprint and the second is a mutable reference to any object that implements the ClientApi. For the second additional argument, it is expected that in tests a mutable reference to the TestEnvironment would be passed here.

Test bindings make it very easy for you to write tests without the need to worry about getting string function or method names right, the order or type of argument. It adds an additional layer of type-safety that makes such errors easy to catch at compile-time.

For cases where test bindings are not available, the TestEnvironment::call_method_typed and TestEnvironment::call_function_typed methods can be used.

Enabling and Disabling Kernel Modules at Runtime

The Radix Engine kernel is designed to be modular with concepts such as auth, costing, limits, transaction runtime and others being kernel modules that may be enabled or disabled during the test runtime without the need for a new kernel.

The modular design of the Radix Engine kernel proves to be useful when writing tests. As an example, you may want to not think about costing at all when writing tests and thus you may opt to disable the costing module entirely and continue your test without it. This can be done through the TestEnvironment::disable_costing_module method.

The following table describes the state of each of the kernel modules when the TestEnvironment is first instantiated.

Kernel Module Initial State

Auth Module

Enabled

Limits Module

Enabled

Transaction Runtime Module

Enabled

Costing Module

Disabled

Kernel Trace Module

Disabled

Execution Trace Module

Disabled

Each of the kernel modules have four methods on the TestEnvironment struct:

  1. A method to enable the kernel module (e.g., TestEnvironment::enable_auth_module).

  2. A method to disable the kernel module (e.g., TestEnvironment::disable_auth_module).

  3. A method to enable the kernel module for some block of code and then reset the modules (e.g., TestEnvironment::with_auth_module_enabled).

  4. A method to disable the kernel module for some block of code and then reset the modules (e.g., TestEnvironment::with_auth_module_disabled).

For the block scoped methods, they cache the state of the kernel modules, enable or disable the kernel module based on the method that’s been called, execute the callback, and then set the kernel modules to what has been cached before the execution of the callback. An example of how the block-scoped methods are used can be found here.

The following is a complete list of the methods used to manipulate the kernel modules.

Auth Module

Enable

TestEnvironment::enable_auth_module

Disable

TestEnvironment::disable_auth_module

Block-scope Enabled

TestEnvironment::with_auth_module_enabled

Block-scope Disabled

TestEnvironment::with_auth_module_disabled

Limits Module

Enable

TestEnvironment::enable_limits_module

Disable

TestEnvironment::disable_limits_module

Block-scope Enabled

TestEnvironment::with_limits_module_enabled

Block-scope Disabled

TestEnvironment::with_limits_module_disabled

Transaction Runtime Module

Enable

TestEnvironment::enable_transaction_runtime_module

Disable

TestEnvironment::disable_transaction_runtime_module

Block-scope Enabled

TestEnvironment::with_transaction_runtime_module_enabled

Block-scope Disabled

TestEnvironment::with_transaction_runtime_module_disabled

Costing Module

Enable

TestEnvironment::enable_costing_module

Disable

TestEnvironment::disable_costing_module

Block-scope Enabled

TestEnvironment::with_costing_module_enabled

Block-scope Disabled

TestEnvironment::with_costing_module_disabled

Kernel Trace Module

Enable

TestEnvironment::enable_kernel_trace_module

Disable

TestEnvironment::disable_kernel_trace_module

Block-scope Enabled

TestEnvironment::with_kernel_trace_module_enabled

Block-scope Disabled

TestEnvironment::with_kernel_trace_module_disabled

Execution Trace Module

Enable

TestEnvironment::enable_execution_trace_module

Disable

TestEnvironment::disable_execution_trace_module

Block-scope Enabled

TestEnvironment::with_execution_trace_module_enabled

Block-scope Disabled

TestEnvironment::with_execution_trace_module_disabled

Creation Buckets and Proofs

The BucketFactory and ProofFactory are a part of this testing framework and they aim to provide an easy way for buckets and proofs to be created within tests, the strategy used for their creation is specified through a CreationStrategy.

Currently, there are two supported creation strategies:

  1. CreationStrategy::DisableAuthAndMint: This creation strategy disables the auth module, mints the amount required by the developer, and then reenables the auth module. Since the only thing done is the disabling of the auth module, this strategy respects all of the rules and checks of the resource before the minting takes place (e.g., NFTs match the NFT schema, they’re not created at tombstones, etc…​).

  2. CreationStrategy::Mock: (Also known as creation out of thin air) This creation strategy does not go through the normal means of creating buckets and proofs, it creates a node with the expected substates as buckets or proofs and then hands it over to the caller. An advantage of this approach is that it doesn’t increase the total supply of the resource which may be useful when testing DeFi logic without wanting to worry increasing the total supply of the resource. However, this approach can be fragile in some cases.

When mocking Buckets and Proofs the factory does not perform an exhaustive list of checks which means that you can get to some bad state if used incorrectly. Only use mocking if you understand the risks involved.

The following is an example of the BucketFactory being used to create a bucket of XRD out of thin air (through CreationStrategy::Mock) and without increasing the total supply.

use scrypto_unit::prelude::*;

#[test]
fn creation_of_mock_fungible_buckets_succeeds() -> Result<(), RuntimeError> {
    // Arrange
    let mut env = TestEnvironment::new();

    // Act
    let bucket = BucketFactory::create_fungible_bucket(
        XRD,
        10.into(),
        Mock,
        &mut env
    )?;

    // Assert
    let amount = bucket.amount(&mut env)?;
    assert_eq!(amount, dec!("10"));

    Ok(())
}

Example

The following is an example that uses this testing framework to test some of the functionality of Radiswap and the underlying pool. This example comes from the radixdlt/radixdlt-scrypto here.

use radiswap::test_bindings::*;
use scrypto::*;
use scrypto_test::prelude::*;

#[test]
fn simple_radiswap_test() -> Result<(), RuntimeError> {
    // Arrange
    let mut env = TestEnvironment::new();
    let package_address = Package::compile_and_publish(this_package!(), &mut env)?;

    let bucket1 = ResourceBuilder::new_fungible(OwnerRole::None) (1)
        .divisibility(18)
        .mint_initial_supply(100, &mut env)?;
    let bucket2 = ResourceBuilder::new_fungible(OwnerRole::None) (1)
        .divisibility(18)
        .mint_initial_supply(100, &mut env)?;

    let resource_address1 = bucket1.resource_address(&mut env)?; (2)
    let resource_address2 = bucket2.resource_address(&mut env)?; (2)

    let mut radiswap = Radiswap::new( (3)
        OwnerRole::None,
        resource_address1,
        resource_address2,
        package_address,
        &mut env,
    )?;

    // Act
    let (pool_units, _change) = radiswap.add_liquidity(bucket1, bucket2, &mut env)?; (4)

    // Assert
    assert_eq!(pool_units.amount(&mut env)?, dec!("100")); (5)
    Ok(())
}

#[test]
fn reading_and_asserting_against_radiswap_pool_state() -> Result<(), RuntimeError> {
    // Arrange
    let mut env = TestEnvironment::new();
    let package_address = Package::compile_and_publish(this_package!(), &mut env)?;

    let bucket1 = ResourceBuilder::new_fungible(OwnerRole::None)
        .divisibility(18)
        .mint_initial_supply(100, &mut env)?;
    let bucket2 = ResourceBuilder::new_fungible(OwnerRole::None)
        .divisibility(18)
        .mint_initial_supply(100, &mut env)?;

    let resource_address1 = bucket1.resource_address(&mut env)?;
    let resource_address2 = bucket2.resource_address(&mut env)?;

    let mut radiswap = Radiswap::new(
        OwnerRole::None,
        resource_address1,
        resource_address2,
        package_address,
        &mut env,
    )?;

    // Act
    let _ = radiswap.add_liquidity(bucket1, bucket2, &mut env)?;
    let radiswap_state = env.read_component_state::<RadiswapState, _>(radiswap)?; (6)

    let VersionedTwoResourcePoolState::V1(TwoResourcePoolSubstate {
        vaults: [(_, vault1), (_, vault2)],
        ..
    }) = env.read_component_state(radiswap_state.pool_component)?;

    // Assert
    let amount1 = vault1.amount(&mut env)?;
    let amount2 = vault2.amount(&mut env)?;
    assert_eq!(amount1, dec!("100"));
    assert_eq!(amount2, dec!("100"));

    Ok(())
}
1 The ResourceBuilder can be used within tests to create resources in a similar manner to how they’re created in Scrypto. Notice that we get back a bucket which we can use throughout the test.
2 Methods can be called on the Bucket in much of the same way as Scrypto.
3 This is part of the test bindings generated by the #[blueprint] macro for the Radiswap blueprint. As discussed in the Blueprint Test Bindings section, functions have two additional arguments: the address of the blueprint’s package and a mutable reference to an instance of the TestEnvironment.
4 The object returned from the Radiswap::new call is a Radiswap object which has all of the same functions and methods as the blueprint meaning that we can call methods on it like add_liquidity.
5 The buckets returned from the invocation are actual buckets and not just bucket manifest references. This means that the amount of resources in the bucket can be queried and assertions could be run against it.
6 The state of the Radiswap component can be read. When reading the state, it’s SBOR decoded as the RadiswapState struct, which (as mentioned in the Blueprint Test Bindings section) is one of the structs automatically generated by the #[blueprint] macro as part of the generated test bindings.

How To

This section provides smaller sized examples and instructions on how to achieve some of the things you may be looking to do in scrypto-test.

How to publish a package?
use scrypto_test::prelude::*;

#[test]
fn simple_package_can_be_published() -> Result<(), RuntimeError> {
    // Arrange
    let mut env = TestEnvironment::new();

    // Act & Assert
    let package_address = Package::compile_and_publish(this_package!(), &mut env)?;

    Ok(())
}
How to create a new resource?
use scrypto_test::prelude::*;

#[test]
fn simple_resources_can_be_created_successfully() -> Result<(), RuntimeError> {
    // Arrange
    let mut env = TestEnvironment::new();

    // Act & Assert
    let resource_address = ResourceBuilder::new_fungible(OwnerRole::None)
        .withdraw_roles(withdraw_roles! {
            withdrawer => rule!(require(resource_address));
            withdrawer_updater => rule!(deny_all);
        })
        .no_initial_supply(&mut env)?

    Ok(())
}
How to do some operation with the auth module disabled?
use scrypto_test::prelude::*;

fn xrd_can_be_minted_when_auth_module_is_disabled() -> Result<(), RuntimeError> {
    // Arrange
    let mut env = TestEnvironment::new();

    // Act
    let bucket = env.with_auth_module_disabled(|env| {
        /* Auth Module is disabled just before this point */
        ResourceManager(XRD).mint_fungible(100.into(), env)?
        /* Kernel modules are reset just after this point. */
    });

    // Assert
    let amount = bucket.amount(&mut env)?;
    assert_eq!(amount, dec!("100"));

    Ok(())
}

The approach described here also applies to all other kernel modules and also to doing operations with the kernel modules enabled or disabled.

How read component state?
use scrypto_test::prelude::*;

#[test]
fn can_invoke_owned_nodes_read_from_state() {
    // Arrange
    let mut env = TestEnvironment::new();

    // Act
    let (vault, _) = env
        .read_component_state::<(Vault, Own), _>(FAUCET)
        .expect("Should succeed");

    // Assert
    vault
        .amount(&mut env)
        .expect("Failed to get the vault amount");
}
How to have common arranges or teardowns?

There are cases where you may have many tests that all share a large portion of some arrange or teardown logic. While this framework does not specifically provide solutions for sharing code across tests, there are many useful Rust patterns that may be employed here to allow you to do this: the simplest and the most elegant is probably by using callback functions.

Imagine this, you are building a Dex and many of the tests you write require you to have two resources with a very large supply so you can write your tests with. One way to not have to write this bit of code in all of your tests is by having a function that creates the test environment, initializes it in the way you expect, calls a callback function provided by you, and then performs the teardown logic.

Here is an example of the above:

use scrypto_test::prelude::*;

pub fn two_resource_environment<F>(func: F)
where
    F: FnOnce(TestEnvironment, Bucket, Bucket),
{
    let mut env = TestEnvironment::new();
    let bucket1 = ResourceBuilder::new_fungible(OwnerRole::None)
        .mint_initial_supply(dec!("100000000000"), &mut env)
        .unwrap();
    let bucket2 = ResourceBuilder::new_fungible(OwnerRole::None)
        .mint_initial_supply(dec!("100000000000"), &mut env)
        .unwrap();

    func(env, bucket1, bucket2)

    /* Potential teardown happens here */
}

#[test]
fn contribution_provides_expected_amount_of_pool_units() {
    two_resource_environment(|mut env, bucket1, bucket2| {
        /* Your test goes here */
    })
}

The two_resource_environment creates and initializes the environment with the common arrange steps you want to have, calls the callback that you pass to it in tests, and then can perform any teardown steps that you would like it to. This is not the only way to do this, another way be by having factory and destructor methods for the TestEnvironment.