Testing the contract
In this section, you test the Pod Racing contract from part 1 with Noir unit tests. The tests run in the TXE (Testing eXecution Environment), a local test runtime that the aztec CLI starts for you. No network is needed. Each code block below links to its source file.
Prerequisites
- The Pod Racing contract from part 1
- The Aztec CLI at version 6.0.0
Set up a test crate
Keep tests in their own type = "lib" crate next to the contract crate, as aztec new does (see Keep tests in the test crate). The test crate depends on the contract by path. In your project, put it beside contracts/:
[package]
name = "pod_racing_contract_test"
type = "lib"
[dependencies]
aztec = { git = "https://github.com/aztec-labs-eng/aztec-nr/", tag = "v6.0.0", directory = "aztec" }
pod_racing_contract = { path = "../contracts" }
Then list both crates in a workspace Nargo.toml at the project root:
[workspace]
members = ["contracts", "contracts_test"]
The contract module that defines Race is public (pub mod race; in main.nr), so the tests can read a game's state by field name.
Run the tests
From the project root:
aztec test
aztec test compiles the contracts, starts a TXE, and runs every #[test] in the workspace. Use aztec test rather than nargo test: the tests call the TXE's oracles.
Test setup
Each test deploys a fresh contract:
/// Deploys a fresh PodRacing contract and returns the environment, its address and the admin.
pub unconstrained fn setup() -> (TestEnvironment, AztecAddress, AztecAddress) {
let mut env = TestEnvironment::new();
let admin = env.create_light_account();
let initializer = PodRacing::interface().constructor(admin);
let contract_address =
env.deploy("@pod_racing_contract/PodRacing").with_public_initializer(admin, initializer);
(env, contract_address, admin)
}
Source code: docs/lines/v6/examples/contracts/pod_racing_contract_test/src/utils.nr#L7-L19
env.deploy("@pod_racing_contract/PodRacing") names the package and the contract, so the TXE loads the artifact that aztec compile wrote for that package. create_light_account makes a test account without deploying an account contract.
Public state is read inside env.public_context_at. Reading the whole Race struct, instead of single storage slots, keeps the tests working when the struct gains a field:
/// Reads a game's public `Race` struct by field name, so a new field does not break the tests.
pub unconstrained fn read_race(env: TestEnvironment, contract_address: AztecAddress, game_id: Field) -> Race {
env.public_context_at(contract_address, |context| {
context.storage_read(derive_storage_slot_in_map(PodRacing::storage_layout().races.slot, game_id))
})
}
Source code: docs/lines/v6/examples/contracts/pod_racing_contract_test/src/utils.nr#L21-L28
Helpers
Point allocations and multi-step setups live in helpers.nr, so each test states only what it checks:
pub unconstrained fn balanced_allocation() -> (u8, u8, u8, u8, u8) {
(2, 2, 2, 2, 1)
}
pub unconstrained fn aggressive_allocation() -> (u8, u8, u8, u8, u8) {
(3, 3, 3, 0, 0)
}
pub unconstrained fn defensive_allocation() -> (u8, u8, u8, u8, u8) {
(0, 0, 1, 4, 4)
}
Source code: docs/lines/v6/examples/contracts/pod_racing_contract_test/src/helpers.nr#L6-L18
pub unconstrained fn setup_two_player_game(
env: &mut TestEnvironment,
contract_address: AztecAddress,
player1: AztecAddress,
player2: AztecAddress,
game_id: Field,
) {
env.call_public(player1, PodRacing::at(contract_address).create_game(game_id));
env.call_public(player2, PodRacing::at(contract_address).join_game(game_id));
}
pub unconstrained fn play_round_with_allocation(
env: &mut TestEnvironment,
contract_address: AztecAddress,
player: AztecAddress,
game_id: Field,
round: u8,
allocation: (u8, u8, u8, u8, u8),
) {
let (t1, t2, t3, t4, t5) = allocation;
env.call_private(
player,
PodRacing::at(contract_address).play_round(game_id, round, t1, t2, t3, t4, t5),
);
}
pub unconstrained fn play_all_rounds_with_strategy(
env: &mut TestEnvironment,
contract_address: AztecAddress,
player: AztecAddress,
game_id: Field,
allocations: [(u8, u8, u8, u8, u8); 3],
) {
for i in 0..3 {
play_round_with_allocation(env, contract_address, player, game_id, (i + 1) as u8, allocations[i]);
}
}
Source code: docs/lines/v6/examples/contracts/pod_racing_contract_test/src/helpers.nr#L20-L58
env.call_public and env.call_private send a transaction from the given account, like vm.prank followed by a call in Foundry. call_private runs the private function and then its enqueued public calls.
Test patterns
A basic test checks state after setup:
#[test]
unconstrained fn test_initializer() {
let (env, contract_address, admin) = utils::setup();
let stored_admin: AztecAddress = env.public_context_at(contract_address, |context| {
context.storage_read(PodRacing::storage_layout().admin.slot)
});
assert_eq(stored_admin, admin);
}
Source code: docs/lines/v6/examples/contracts/pod_racing_contract_test/src/pod_racing.nr#L5-L15
A failure test names the error it expects. should_fail_with matches a substring of the assertion message, so the test cannot pass because of a different failure:
#[test(should_fail_with = "a round allows at most 9 points")]
unconstrained fn test_fail_play_round_too_many_points() {
let (mut env, contract_address, _) = utils::setup();
let player1 = env.create_light_account();
let player2 = env.create_light_account();
helpers::setup_two_player_game(&mut env, contract_address, player1, player2, GAME_ID);
env.call_private(player1, PodRacing::at(contract_address).play_round(GAME_ID, 1, 2, 2, 2, 2, 2));
}
Source code: docs/lines/v6/examples/contracts/pod_racing_contract_test/src/pod_racing.nr#L87-L97
The full game flow plays all rounds for both players, reveals both totals, and checks the stored scores:
#[test]
unconstrained fn test_full_game_flow() {
let (mut env, contract_address, _) = utils::setup();
let player1 = env.create_light_account();
let player2 = env.create_light_account();
helpers::play_full_game(&mut env, contract_address, player1, player2, true, true);
let race = utils::read_race(env, contract_address, GAME_ID);
assert_eq(
[
race.player1_track1_final, race.player1_track2_final, race.player1_track3_final,
race.player1_track4_final, race.player1_track5_final,
],
[7, 7, 7, 3, 3],
);
assert_eq(
[
race.player2_track1_final, race.player2_track2_final, race.player2_track3_final,
race.player2_track4_final, race.player2_track5_final,
],
[3, 3, 6, 7, 8],
);
}
Source code: docs/lines/v6/examples/contracts/pod_racing_contract_test/src/pod_racing.nr#L176-L201
Finalization guards
finalize_game runs only once per game, only after player 2 has joined, and only after both players have revealed. Each guard has its own failure test. These two tests check that the winner is credited once:
#[test]
unconstrained fn test_finalize_credits_the_winner_once() {
let (mut env, contract_address, _) = utils::setup();
let player1 = env.create_light_account();
let player2 = env.create_light_account();
helpers::play_full_game(&mut env, contract_address, player1, player2, true, true);
env.mine_block();
env.call_public(player1, PodRacing::at(contract_address).finalize_game(GAME_ID));
assert_eq(utils::wins(env, contract_address, player1), 1);
assert_eq(utils::wins(env, contract_address, player2), 0);
}
#[test(should_fail_with = "game already finalized")]
unconstrained fn test_fail_finalize_twice() {
let (mut env, contract_address, _) = utils::setup();
let player1 = env.create_light_account();
let player2 = env.create_light_account();
helpers::play_full_game(&mut env, contract_address, player1, player2, true, true);
env.mine_block();
env.call_public(player1, PodRacing::at(contract_address).finalize_game(GAME_ID));
env.call_public(player1, PodRacing::at(contract_address).finalize_game(GAME_ID));
}
Source code: docs/lines/v6/examples/contracts/pod_racing_contract_test/src/finalize.nr#L4-L30
env.mine_block() makes sure the chain is past the game's block deadline, which finalize_game checks before it compares the scores.
Known limits of the tutorial contract
The contract keeps the game simple, so two gaps remain:
- No reveal deadline. Finalization waits until both players reveal. A player who expects to lose can block the game forever by never calling
finish_game. A production game needs a deadline after which the revealed player wins. - A zero total reads as "not revealed". The contract tells a revealed player apart by a non-zero total. A player who put no points on any track reveals a total of zero, and the game cannot be finalized.
Exercise: add a forfeit function
Add a public forfeit_game(game_id) function that lets a player concede before the game ends:
- Check that player 2 has joined, that the caller is one of the two players, and that the game is not finalized.
- Credit the other player in
win_historyand mark the game as finalized. - Write a test that forfeits a game, then checks
win_historywithutils::wins. - Write
should_fail_withtests for a caller who is not a player and for a game player 2 has not joined.
Run aztec test until all tests pass.
Next steps
- Testing Aztec.nr contracts covers the full
TestEnvironmentAPI: authwits, time travel and oracle versions. - Testing with Aztec.js runs end-to-end tests against a local network.