Skip to content

Testing Your Template

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"

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.

create_funded_account generates a fresh key pair, publishes an account component for it and funds it from the built-in XTR faucet, 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 XTR faucet.
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.

Proofs are the transaction’s initial auth scope

Section titled “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.

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 0
let 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
// value
template_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.

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.

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, "Panic! 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.