- 17 May 2024
- 10 Minutes to read
- DarkLight
- PDF
Scrypto Test
- Updated on 17 May 2024
- 10 Minutes to read
- DarkLight
- PDF
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 environmentSimulatedLedger
- 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:
Your package is using
resolver = "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:
A
${BlueprintName}State
struct: This is an automatically generated struct of the state that components of this blueprint have.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.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:
A method to enable the kernel module (e.g., TestEnvironment::enable_auth_module).
A method to disable the kernel module (e.g., TestEnvironment::disable_auth_module).
A method to enable the kernel module for some block of code and then reset the modules (e.g., TestEnvironment::with_auth_module_enabled).
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 | |
Disable | ||
Block-scope Enabled | ||
Block-scope Disabled | ||
Limits Module | Enable | |
Disable | ||
Block-scope Enabled | ||
Block-scope Disabled | ||
Transaction Runtime Module | Enable | |
Disable | ||
Block-scope Enabled | ||
Block-scope Disabled | ||
Costing Module | Enable | |
Disable | ||
Block-scope Enabled | ||
Block-scope Disabled | ||
Kernel Trace Module | Enable | |
Disable | ||
Block-scope Enabled | ||
Block-scope Disabled | ||
Execution Trace Module | Enable | |
Disable | ||
Block-scope Enabled | ||
Block-scope 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:
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…).
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(())
}
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.
Methods can be called on the Bucket in much of the same way as Scrypto.
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.
The object returned from the
Radiswap::new
call is aRadiswap
object which has all of the same functions and methods as the blueprint meaning that we can call methods on it likeadd_liquidity
.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.
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();
}