Aleo testing and debugging
1. Overview
Leo 4.x has two testing layers. leo test runs @test functions against the real VM, covering both circuit logic and finalization. leo devnode and leo devnet run a local chain for full deploy-and-execute integration testing.
If you are working from older material, note that Leo 4.0 removed the script keyword, the AST interpreter, and leo debug. The interpreter evaluated the AST directly rather than running compiled bytecode, so tests could pass there and behave differently on-chain. Tests now run through the VM, which is slower and honest. leo debug is a separate plugin and is not installed by default.
Version and canonical syntax
Target: Leo compiler >= 4.4.0.
Test files live under tests/ and wrap their tests in a program test_NAME.aleo { } block with a constructor. Tests are @test fn, not @test script. Cross-program references use ::, so an external type is my_project.aleo::Token rather than my_project.aleo/Token.
2. Key concepts
@testmarks a function thatleo testruns.@should_failmarks a test expected to fail through an assertion, an overflow, or a rejected finalization.@test(private_key = "...")runs the test as a specific account, which sets whatstd::ctx::signer()returns and determines record ownership.- A test that only reads return values needs no
finalblock. - A test that calls an entry point returning
Finalmust itself returnFinaland run the callee inside its ownfinalblock. That is also where mapping reads are legal.
3. Test file layout
my_project/
├── src/
│ └── main.leo
├── tests/
│ └── test_my_project.leo
└── program.json
4. Testing circuit logic
// tests/test_my_project.leo
import my_project.aleo;
program test_my_project.aleo {
@noupgrade
constructor() {}
@test
fn test_addition() {
let result: u32 = my_project.aleo::sum_values(1u32, 2u32);
assert_eq(result, 3u32);
}
// Run as a specific account: this sets std::ctx::signer()
@test(private_key = "APrivateKey1zkp8CZNn3yeCseEtxuVPbDCwSyhGW6yZKUYKfgXmcpoGPWH")
fn test_signer_identity() {
let who: address = my_project.aleo::whoami();
assert_eq(who, aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px);
}
// External record types need the program prefix
@test
fn test_record_creation() {
let token: my_project.aleo::Token = my_project.aleo::mint(
aleo1axszqqjhuqpzz4duv2slw8j4d6w3fmqczpycqkkvfct57xa8jypqazjg20,
100u64
);
assert_eq(token.amount, 100u64);
}
@test
@should_fail
fn test_unauthorized_caller() {
my_project.aleo::admin_only_function();
}
}
5. Testing finalization and mappings
Calling an entry point that returns Final makes the test itself stateful. snarkVM enforces this directly:
Function 'test_my_project.aleo/test_x' must contain a finalize block,
since it calls 'my_project.aleo/increment'.
Give the test a Final return type, call .run() on the returned handle inside the test's own final block, and assert on mapping state there.
import my_project.aleo;
program test_my_project.aleo {
@noupgrade
constructor() {}
@test
fn test_mapping_update() -> Final {
let who: address = aleo1qnr4dkkvkgfqph0vzc3y6z2eu975wnpz2925ntjccd5cfqxtyu8s7pyjh9;
let f: Final = my_project.aleo::mint_public(who, 1000u64);
return final {
// Run the callee's finalization first
f.run();
// Now the mapping reflects the change
let balance: u64 = my_project.aleo::account.get(who);
assert_eq(balance, 1000u64);
};
}
@test
@should_fail
fn test_insufficient_balance() -> Final {
let f: Final = my_project.aleo::transfer_public(
aleo19y2eyc2cycvdqmycqam60l6uexvfj468xcet65jnnzc5pn8g9ufqg2clp2,
9999999u64
);
return final {
f.run();
};
}
}
Ordering matters inside the final block. Reading a mapping before calling .run() gives you the pre-call value, which is occasionally what you want and usually a bug.
ChaCha::rand_* is available inside a test's final block, same as in production finalization. Tests using it are not reproducible, so assert on invariants rather than specific values.
6. Running tests
leo test # run everything
leo test test_mapping # run tests whose name matches
leo test --prove # run with full proof generation (slow, closest to production)
leo test --build-tests # compile tests without running them
leo test --json-output # machine-readable results for CI
leo test --checksums # print program and function checksums
Output looks like this:
Running 3 tests
PASS test_my_project.leo::test_addition
PASS test_my_project.leo::test_mapping_update
PASS test_my_project.leo::test_insufficient_balance
────────────────────────
Summary 3 tests run: 3 passed, 0 failed
leo test skips proof generation by default, which is what makes it fast enough to run on every edit. Use --prove before deploying, since proof generation surfaces constraint-count problems that the fast path hides.
7. Local network testing
leo devnode runs a single-node chain, which starts in seconds and is the right default for iterating on deployment.
leo devnode start
leo devnode advance --blocks 10 # advance the ledger
leo devnet runs a multi-validator network when you need consensus behaviour:
leo devnet --num-validators 4 --num-clients 2
A full local cycle:
leo devnode start
leo deploy --broadcast --network testnet --endpoint http://localhost:3030
leo execute mint_public "aleo1..." 100u64 --broadcast --endpoint http://localhost:3030
leo query program my_project.aleo --mapping-value account "aleo1..."
leo query transaction <tx_id>
The devnode key APrivateKey1zkp8CZNn3yeCseEtxuVPbDCwSyhGW6yZKUYKfgXmcpoGPWH is published in Leo's own CLI help. It is fine for local work and must never appear in anything that touches a real network.
8. Debugging without a debugger
leo debug is a plugin and is not part of the default install, so most debugging now happens through assertions and output inspection.
Narrow a failure by adding assertions above the suspect line; a failing assert_eq tells you the value indirectly by which comparison broke. Run leo run <fn> <inputs> to see the return value of a pure entry point. Use leo query program ... --mapping-value against a devnode to inspect on-chain state after execution. Read build/main.aleo when you want to see what your Leo actually compiled to, which is often clarifying for finalization ordering and for cross-program calls.
leo abi generates the ABI from compiled bytecode, which is the fastest way to confirm the input and output types a caller will see.
9. Common test failures
| Failure | Cause | Fix |
|---|---|---|
| "must contain a finalize block, since it calls ..." | test calls a Final entry point without its own final block | Give the test -> Final and call .run() inside return final { } |
ETYC0372067 "can only be used in a final fn, a final block" | .run() or a mapping read in the proof half of a test | Move it inside the test's final block |
ETYC0372034 | Mapping::get outside finalization | Same fix: move it into final { } |
EPAR0370047 "expected 'fn' or 'constructor'" | script test | Rewrite as @test fn; script was removed |
EPAR0370053 | identifier starting with _ | Rename it to start with a letter |
ETYC0372084 | test program has no constructor | Add @noupgrade constructor() {} |
| Unresolved external type | my_project.aleo/Token | Use my_project.aleo::Token |
| Record ownership mismatch | test account does not own the record | Set the caller with @test(private_key = "...") |
| Assertion failure in finalization | get on a missing key | Use get_or_use with a default |
| Import not found | missing import in the test file | Add import my_project.aleo; |
| Arithmetic overflow | checked arithmetic out of range | Use a wider type or a _wrapped variant |
10. CI
leo build # compile the program
leo test --build-tests # compile tests without running
leo test --json-output # run tests, machine-readable output
leo fmt is a plugin in Leo 4.x rather than a built-in command, so a pipeline that calls leo fmt --check fails with ECLI0377045 on a clean install. Install the plugin explicitly if you want the formatting gate, and check leo plugins to see what the runner actually has.
Run leo test --prove on a slower schedule than every commit. It catches constraint blowups that the default path does not, and it costs minutes rather than seconds.
Pin the compiler version in CI. Leo's minor releases have changed language syntax more than once, and program.json carries a leo field that Leo 4.4 warns about when it drifts from the installed toolchain.
11. Security notes
Never commit a real private key in a @test(private_key = "...") fixture. Keep test keys scoped to local and devnode use, and rotate anything shared through CI secrets.
Treat decrypted record values in tests as sensitive. A CI log that prints record plaintext has published it.
Write negative tests for every authorization boundary. An access-control regression is silent otherwise: the code still compiles, still runs, and now lets the wrong account through. @should_fail on an unauthorized call is the cheapest guard against that.
12. Performance notes
Start with tests that assert on return values. They are the fastest and cover most logic errors.
Add finalization tests where public state changes matter, and group related assertions into one test to avoid repeating setup.
Use leo test --build-tests as an early CI stage; a syntax regression fails there in seconds rather than after a full run.
Keep devnode integration tests for end-to-end flows: deploy, execute, query. Unit logic belongs in leo test.
13. Agent workflow
- Write tests alongside the code. Every entry point deserves at least one.
- Use a plain
@test fnfor circuit logic: record creation, struct manipulation, arithmetic. - Use
@test fn ... -> Finalfor anything touching mappings or storage, calling.run()inside thefinalblock. - Add
@should_failtests for overflow, underflow, and access control. - Cover edge cases: zero, maximum values, unauthorized callers, missing keys.
- Run
leo testafter every change. - Run
leo test --provebefore deploying. - Use
leo devnode startfor the deploy-execute-query cycle. - On failure, match the error to the table in section 9 and apply that fix before editing anything else.
Write programs to test with aleo_smart_contracts. Deploy tested programs with aleo_deployment. Start from working code in aleo_cookbook.