- 27 Jun 2024
- 9 Minutes to read
- DarkLight
- PDF
Scrypto Test
- Updated on 27 Jun 2024
- 9 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 outputSimulatedLedger
- 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:
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 = { 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:
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 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:
A method to enable the system module (e.g., TestEnvironment::enable_auth_module).
A method to disable the system module (e.g., TestEnvironment::disable_auth_module).
A method to enable the system module for some block of code and then reset the modules (e.g., TestEnvironment::with_auth_module_enabled).
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 | |
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(())
}
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();
}