Aleo for Agents is offline at present

skill·2026-08-03·8 min read

Aleo Testing and Debugging

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

  • @test marks a function that leo test runs.
  • @should_fail marks 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 what std::ctx::signer() returns and determines record ownership.
  • A test that only reads return values needs no final block.
  • A test that calls an entry point returning Final must itself return Final and run the callee inside its own final block. 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

leo
// 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.

leo
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

bash
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.

bash
leo devnode start
leo devnode advance --blocks 10   # advance the ledger

leo devnet runs a multi-validator network when you need consensus behaviour:

bash
leo devnet --num-validators 4 --num-clients 2

A full local cycle:

bash
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

FailureCauseFix
"must contain a finalize block, since it calls ..."test calls a Final entry point without its own final blockGive 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 testMove it inside the test's final block
ETYC0372034Mapping::get outside finalizationSame fix: move it into final { }
EPAR0370047 "expected 'fn' or 'constructor'"script testRewrite as @test fn; script was removed
EPAR0370053identifier starting with _Rename it to start with a letter
ETYC0372084test program has no constructorAdd @noupgrade constructor() {}
Unresolved external typemy_project.aleo/TokenUse my_project.aleo::Token
Record ownership mismatchtest account does not own the recordSet the caller with @test(private_key = "...")
Assertion failure in finalizationget on a missing keyUse get_or_use with a default
Import not foundmissing import in the test fileAdd import my_project.aleo;
Arithmetic overflowchecked arithmetic out of rangeUse a wider type or a _wrapped variant

10. CI

bash
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

  1. Write tests alongside the code. Every entry point deserves at least one.
  2. Use a plain @test fn for circuit logic: record creation, struct manipulation, arithmetic.
  3. Use @test fn ... -> Final for anything touching mappings or storage, calling .run() inside the final block.
  4. Add @should_fail tests for overflow, underflow, and access control.
  5. Cover edge cases: zero, maximum values, unauthorized callers, missing keys.
  6. Run leo test after every change.
  7. Run leo test --prove before deploying.
  8. Use leo devnode start for the deploy-execute-query cycle.
  9. 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.

Sources