Scrypto Test
  • 27 Jun 2024
  • 9 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

  • SimulatedLedger - Allows developers to publish Scrypto code to a simulated ledger and run various test transactions to check the 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, with a close-to-production environment, where all limits and restrictions are 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 = { version = "1.2.0" }
scrypto = { version = "1.2.0" }

[dev-dependencies]
scrypto-test = { version = "1.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, consisting of 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. The TestEnvironment implements the ClientApi as well, allowing tests to get the current actor, read substate, call methods and functions, etc.

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 System Modules at Runtime

The Radix Engine kernel is designed to be modular with concepts such as auth, costing, limits, transaction runtime and others being system 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 system modules when the TestEnvironment is first instantiated.

System 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 system modules have four methods on the TestEnvironment struct:

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

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

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

  4. A method to disable the system 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 system modules, enable or disable the system module based on the method that’s been called, execute the callback, and then set the system 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 system 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(())
}

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)?
        /* system 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 system modules and also to doing operations with the system 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?