Testing Templates

Ootle / Guides / Testing Templates

Testing Templates

Covered in this guide

In this guide, you will:

  • Set up a test and create funded accounts to call your component with.
  • Set up the badges a transaction starts with, and tell them apart from real runtime proofs.
  • Override the current epoch and epoch hash for templates that read them.
  • Read component state directly between transactions.
  • Assert on the error message of a transaction you expect to be rejected.

tari_template_test_tooling compiles your template to WASM and runs transactions against it with the same engine the network uses, so a passing test means the engine accepted the transaction, not that a mock did. A template crate created with cargo generate already declares it; add it yourself if yours does not have it:

[dev-dependencies]tari_template_test_tooling = "0.39"

Tip

Template tests spend most of their time in the WASM JIT unless you optimize it in debug builds. The guessing game guide has the Cargo.toml profile settings that make these tests roughly 10x faster.

The examples below are the tests in crates/engine/tests/testing_tutorial.rs, which run in this repository’s CI against the guessing game template. They load the template by path — TemplateTest::new(CRATE_PATH, ["tests/templates/guessing_game"]) — because the template lives elsewhere in this repository. In your own template crate the template is the crate, so you use TemplateTest::my_crate() instead, and everything else on this page stays the same.

Basic setup and creating accounts

create_funded_account generates a fresh key pair, publishes an account component for it and funds it from the built-in XtrFaucet, all in one call. It returns the account address, an ownership proof and the secret key you seal that account’s transactions with:

crates/engine/tests/testing_tutorial.rs
let mut test = TemplateTest::new(CRATE_PATH, ["tests/templates/guessing_game"]);let template = test.get_template_address("GuessingGame");
// Creates a fresh key pair, publishes an account component for it and funds it from the// built-in XtrFaucet, which dispenses TARI.let (player, _player_proof, player_key) = test.create_funded_account();
let vaults = test.read_only_state_store().get_vaults_for_account(player).unwrap();assert_eq!(    vaults[&TARI_TOKEN].balance(),    Amount::from(TemplateTest::FUNDED_ACCOUNT_INITIAL_BALANCE));

Use create_empty_account when a test needs an account that holds nothing.

Note

Fees are off by default in TemplateTest, so a transaction does not need a fee payment instruction. Call test.enable_fees() when the behaviour you are testing depends on fees.

Proofs are the transaction’s initial auth scope

The proofs argument to execute_expect_success and execute_expect_failure supplies the transaction’s initial authorization scope: a set of virtual badges, each a NonFungibleAddress, that are in scope from the moment execution starts and that access rules are checked against.

On the network that scope has exactly one source: the engine derives one badge per transaction signer from the signer’s public key, and puts nothing else there. No other NFT ever lands in a real transaction’s initial scope — a badge you merely hold in a vault is not a virtual badge, and is presented by creating a real Proof from that vault instead. The harness’s proofs vector is the only thing that can put a non-signer NFT into an initial scope at all.

TemplateTest mirrors production when you leave the vector empty: it derives exactly the same signer badges, which is what you want whenever authorization is not what you are testing.

crates/engine/tests/testing_tutorial.rs
// `start_game` is owner-restricted, so the transaction is sealed by the key that owns the// component - the harness's own key, which created it in the same transaction.test.execute_expect_success(    test.transaction()        .allocate_component_address("game")        .call_function(template, "new", args![Workspace("game")])        .call_method("game", "start_game", args![NonFungibleId::from_string("💎")])        .build_and_seal(test.secret_key()),    vec![],);let (game, _) = test    .read_only_state_store()    .get_components_by_template_address(template)    .unwrap()    .remove(0);
// The player seals this one with their own key. `guess` is declared `rule![allow_all]`, so no// ownership proof is required: with an empty `proofs` argument the harness derives the proofs// from the transaction signers.test.execute_expect_success(    test.transaction()        .call_method(game, "guess", args![5u8, player])        .build_and_seal(&player_key),    vec![],);

A non-empty vector replaces those derived signer badges rather than adding to them — the harness auto-derives only while the argument is empty — and test.disable_auto_add_proofs_from_signers() switches the derivation off altogether. So reach for the override to take something away, not to add something: it is how you run a transaction with a required badge deliberately missing and assert that the method is denied. Adding an extra NFT to the vector instead builds a scope the network cannot produce, and misrepresents a vault-held badge as something it never is on-chain. If you do pass a list and the signer’s own authorization is meant to stay, include that signer’s badge explicitly — test.owner_proof() for transactions sealed with test.secret_key(), or the proof each of create_funded_account and create_empty_account returns alongside the key it generates — otherwise it is silently dropped.

To exercise a badge that lives in a vault, do what a real transaction does rather than reaching for the vector: call create_proof_for_resource on the account holding it, pass the resulting Proof to the code that needs it, and have that code authorize with it — Proof::authorize, try_authorize or authorize_with. Authorizing adds the proof to the current call frame’s scope, and access rules match a held proof just as readily as a virtual badge.

That difference in reach is the last thing worth knowing: virtual badges are in scope for the top-level call only. A component reached through another template starts from an empty badge set, so a method that passes when called directly can be denied across a template boundary. it_permits_cross_template_calls_using_proofs in crates/engine/tests/access_rules.rs shows the denial, with the account’s own badge in scope the whole time — the cross-template call itself is a plain ComponentManager::get(address).call(...) inside the CrossTemplate fixture:

crates/engine/tests/access_rules.rs
// Try to take tokens without proof. Even though I'm the owner of the resource, the scope does not carry over// when cross-template calls are made.let reason = test.execute_expect_failure(    Transaction::builder_localnet(Epoch(1))        .call_function(cross_call_template, "call_component_with_args", args![            component_address,            "take_tokens",            invoke_args![10],        ])        .put_last_instruction_output_on_workspace("tokens")        .call_method(owner_account, "deposit", args![Workspace("tokens")])        .drop_all_proofs_in_workspace()        .build_and_seal(&owner_key),    vec![owner_proof.clone()],);
assert_access_denied_for_action(reason, ResourceAuthAction::Withdraw);

The rest of that test then does it the working way: mint the badge, deposit it into the account, create a Proof for its resource, and pass that proof across the boundary for the callee to authorize with.

Overriding the epoch and epoch hash

Templates read consensus values through Consensus::current_epoch() and Consensus::current_epoch_hash(). Both are virtual substates: values the engine injects into an execution rather than reading from the state store. TemplateTest seeds them with epoch 0 and a zero hash, and set_virtual_substate replaces either one for every transaction that follows.

The repository’s own consensus tests in crates/engine/tests/test.rs (module consensus) show both. The template being called here is a fixture whose functions return the two values unchanged, so the assertion observes exactly what the engine gave the template:

crates/engine/tests/test.rs
let mut template_test = TemplateTest::new(CRATE_PATH, vec!["tests/templates/consensus"]);
// the default value for a current epoch in the mocks is 0let result: u64 = template_test.call_function("TestConsensus", "current_epoch", args![], vec![]);assert_eq!(result, 0);
// set the value of current epoch to "1" and call the template function again to check that it reads the new// valuetemplate_test.set_virtual_substate(VirtualSubstateId::CurrentEpoch, VirtualSubstate::CurrentEpoch(1));let result: u64 = template_test.call_function("TestConsensus", "current_epoch", args![], vec![]);assert_eq!(result, 1);

The epoch hash is overridden the same way, with VirtualSubstateId::CurrentEpochHash:

crates/engine/tests/test.rs
let injected_hash = [0xABu8; 32];template_test.set_virtual_substate(    VirtualSubstateId::CurrentEpochHash,    VirtualSubstate::CurrentEpochHash(injected_hash.into()),);
let result: Hash32 = template_test.call_function("TestConsensus", "current_epoch_hash", args![], vec![]);assert_eq!(result.as_slice(), injected_hash);

To do this for your own template, replace the fixture parts: the template name and the function or method you call, the return type you decode into, and — if the value is stored rather than returned — the state path you read it back from. What must not change is the shape of the check: the behaviour you assert on has to be one that actually reads the epoch or the hash. Overriding the epoch proves nothing about a method that never calls Consensus.

Two related calls are worth knowing:

  • test.current_epoch() returns the epoch the harness is executing in, which is also the epoch that test.transaction() stamps onto the transactions it builds.
  • remove_virtual_substate deletes the value entirely. A template that then reads it is rejected with Virtual substate not found: Virtual(CurrentEpochHash), which is what the repository’s test_epoch_hash_not_available_without_injection test asserts.

Examining component state directly

extract_component_value reads a single value out of a component’s state between transactions. State is encoded field by field, so a path segment is the index of the field in your template struct, not its name:

crates/engine/tests/testing_tutorial.rs
// Component state is encoded field by field, so a path segment is the *index* of the field in// the template struct. `GuessingGame` declares `prize_vault`, `guesses`, `round_number`, so// `$.2` is the round counter that `start_game` incremented.let round_number: u32 = test.extract_component_value(game, "$.2");assert_eq!(round_number, 1);
// The store also answers typed questions about a component. `start_game` minted the prize NFT// into the component's vault, so that vault now holds exactly one token.let vaults = test.read_only_state_store().get_vaults_for_component(game).unwrap();let prize_vault = vaults.values().next().expect("the game holds a prize vault");assert_eq!(prize_vault.balance(), Amount::from(1u64));

The index is the one thing you must translate when adapting this to your own template: $.0 is your struct’s first field, $.2 its third, and reordering your fields changes the paths. The type you decode into has to match the field’s own type, and extract_component_value panics if the path is missing or the value does not decode — which is what you want in a test.

For anything the path syntax cannot express, test.read_only_state_store() gives you the store itself: get_component, get_vaults_for_component, get_account, get_vault and get_resource all return typed values. test.print_state() dumps everything when you are working out what a transaction actually did.

Asserting on a rejected transaction

execute_expect_failure panics if the transaction succeeds and otherwise hands you the RejectReason. assert_reject_reason then checks that the reason contains the message you expect, so a panic! or a failed assert! in your template is assertable verbatim:

crates/engine/tests/testing_tutorial.rs
// A guess that the template accepts is recorded in `guesses`. Establishing that first is what// makes the rejection below meaningful: this is the observable a committed `guess` changes.let before_any_guess = test.read_only_state_store().get_component(game).unwrap();test.execute_expect_success(    test.transaction()        .call_method(game, "guess", args![5u8, player])        .build_and_seal(&player_key),    vec![],);let after_valid_guess = test.read_only_state_store().get_component(game).unwrap();assert_ne!(    after_valid_guess.state(),    before_any_guess.state(),    "an accepted guess must be visible in the component state");
// The template asserts `guess <= 10`, and the panic message reaches the test verbatim.let reason = test.execute_expect_failure(    test.transaction()        .call_method(game, "guess", args![100u8, cheat])        .build_and_seal(&cheat_key),    vec![],);assert_reject_reason(reason, "Template error: Guess must be from 0 to 10");
// A rejected transaction commits nothing: the same state the accepted guess produced.let after_rejection = test.read_only_state_store().get_component(game).unwrap();assert_eq!(    after_rejection.state(),    after_valid_guess.state(),    "a rejected transaction must not commit state");

Two things make this a real test rather than a green tick:

  • The rejection is for the reason you meant. A transaction rejected because it was sealed by the wrong key, or because the caller had no funds, also satisfies execute_expect_failure. Asserting on the message is what distinguishes “my rule fired” from “the setup was broken”.
  • The state comparison is against something that would have changed. The accepted guess above proves that guess writes to the component; comparing that same state after the rejection is therefore evidence that nothing committed. Comparing a field the method never touches proves nothing at all.

For access-rule failures, tari_template_test_tooling::support::assert_error also provides assert_access_denied_for_action and assert_insufficient_funds_for_action, which spell out the engine error rather than a template panic.

Next steps