Scrypto Test
  • 17 May 2024
  • 10 Minutes to read
  • Dark
    Light
  • PDF

Scrypto Test

  • Dark
    Light
  • PDF

Article Summary

This document provides a description and guidance on writing tests using the scrypto-test library.

The library provides two frameworks to help you test your blueprint code:

  • TestEnvironment - Allows developers to call their Scrypto functions and methods directly and assert on the output, based on a test environment

  • SimulatedLedger - Allows developers to publish Scrypto code to a simulated ledger and run various test transactions to check the behaviors and outcomes

Both TestEnvironment and SimulatedLedger will be useful throughout your blueprint development journey. The former is useful for unit testing a specific function or method, without needing to think about costing or auth.  The latter is useful for integration testing your blueprint, under a close to real network environment, with all limits and restrictions applied.

Import scrypto-test into your project

Before you can use scrypto-test to test your blueprints and packages you must update your Cargo.toml file to ensure that:

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

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

An example of a well-configured Cargo.toml file can be seen below

[package]
name = "radiswap"
version = "0.1.0"
edition = "2021" # 2. Your package is using resolver = "2". Setting Edition 2021 defaults the resolver to 2

[dependencies]
sbor = { git = "https://github.com/radixdlt/radixdlt-scrypto", tag = "v1.2.0" }
scrypto = { git = "https://github.com/radixdlt/radixdlt-scrypto", tag = "v1.2.0" }

[dev-dependencies]
scrypto-test = { git = "https://github.com/radixdlt/radixdlt-scrypto", tag = "v1.2.0" }

[features]
default = []

[profile.release]
opt-level = 'z'        # Optimize for size.
lto = true             # Enable Link Time Optimization.
codegen-units = 1      # Reduce number of codegen units to increase optimizations.
panic = 'abort'        # Abort on panic.
strip = true           # Strip the symbols.
overflow-checks = true # Panic in the case of an overflow.

[lib]
crate-type = ["cdylib", "lib"]

[workspace]
# Set the package crate as its own empty workspace, to hide it from any potential ancestor workspace
# Remove this [workspace] section if you intend the package to be part of a Cargo workspace

Test Environment

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 functions, and everything else allowed for by the client sub-apis.

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 <blueprint_mod_name>_test.

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 of 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::radiswap_test::*;
use scrypto_test::prelude::pool::substates::two_resource_pool::*;
use scrypto_test::prelude::*;

#[test]
fn simple_radiswap_test() -> Result<(), RuntimeError> {
    // Arrange
    let mut env = TestEnvironment::new();
    let package_address = PackageFactory::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 (pool_units, _change) = radiswap.add_liquidity(bucket1, bucket2, &mut env)?;

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

#[test]
fn reading_and_asserting_against_radiswap_pool_state() -> Result<(), RuntimeError> {
    // Arrange
    let mut env = TestEnvironment::new();
    let package_address = PackageFactory::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)?;

    // Assert
    let pool_component = env
        .with_component_state::<RadiswapState, _, _, _>(radiswap, |substate, _| {
            substate.pool_component.clone()
        })?;
    let (amount1, amount2) = env.with_component_state::<VersionedTwoResourcePoolState, _, _, _>(
        pool_component,
        |VersionedTwoResourcePoolState::V1(TwoResourcePoolStateV1 {
             vaults: [(_, vault1), (_, vault2)],
             ..
         }),
         env| { (vault1.amount(env).unwrap(), vault2.amount(env).unwrap()) },
    )?;
    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.

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 = PackageFactory::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 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 */
    })
}

Simulated Ledger

The SimulatedLedger is an in-memory ledger simulator which you can interact with.  As a user, you are submitting transactions to the ledger and get receipts back.

Example

The following is an example that uses the simulated ledger to test some of the functionality of Hello blueprint, from the radixdlt/radixdlt-scrypto.

use scrypto_test::prelude::*;

#[test]
fn test_hello() {
    // Setup the environment
    let mut ledger = LedgerSimulatorBuilder::new().build();

    // Create an account
    let (public_key, _private_key, account) = ledger.new_allocated_account();

    // Publish package
    let package_address = ledger.compile_and_publish(this_package!());

    // Test the `instantiate_hello` function.
    let manifest = ManifestBuilder::new()
        .lock_fee_from_faucet()
        .call_function(
            package_address,
            "Hello",
            "instantiate_hello",
            manifest_args!(),
        )
        .build();
    let receipt = ledger.execute_manifest(
        manifest,
        vec![NonFungibleGlobalId::from_public_key(&public_key)],
    );
    println!("{:?}\n", receipt);
    let component = receipt.expect_commit(true).new_component_addresses()[0];

    // Test the `free_token` method.
    let manifest = ManifestBuilder::new()
        .lock_fee_from_faucet()
        .call_method(component, "free_token", manifest_args!())
        .call_method(
            account,
            "deposit_batch",
            manifest_args!(ManifestExpression::EntireWorktop),
        )
        .build();
    let receipt = ledger.execute_manifest(
        manifest,
        vec![NonFungibleGlobalId::from_public_key(&public_key)],
    );
    println!("{:?}\n", receipt);
    receipt.expect_commit_success();
}


Was this article helpful?