With the latest update to the Leo programming language, Aleo developers can now write unit tests directly in Leo code. This new feature enables developers to verify the correctness of their program logic before deploying it to the testnet, allowing for greater confidence in the quality and security of their dApps.
Unit testing ensures that your program logic behaves as intended by allowing you to test individual transition functions. This granular level inspection checks that each transition function performs correctly in isolation, thereby helping to identify edge cases and potential vulnerabilities before they can be exploited.
Unit tests also facilitate auditing, providing a clear and reproducible method for reviewing code behavior and ensuring compliance with project requirements or regulatory standards. Additionally, by catching bugs early in development, unit tests save you from the headache of redeploying a program to the testnet or mainnet after discovering an issue in production.
By incorporating native unit testing into your workflow, your Aleo programs become more reliable, secure, and maintainable.
Getting Started
First, make sure you are using the latest version of the Leo CLI. The command will automatically generate a test directory containing a file with template code containing a script and a compiled test.
leo new <project_name>
Wondering why there are two types of tests? That’s because the Aleo blockchain contains both public and private state. Public state is modified through on-chain execution. Async transition functions that access mappings can only be tested using scripts. All other logic, such as minting records or hashing data, can be tested using compiled tests.
Importing your main program
The test file is a Leo program, so in order to be able to test your main program’s functions, you will first need to import it into your test program. Think of your main program as an external dependency for your test program. All of the transitions that you’ll be testing from your main program will be called as external programs inside of your test function code blocks.
Just two more things to keep in mind. First thing to note – every test function must be annotated with the `@test` keyword before the function definition. Second point – test functions do NOT take inputs. All inputs must be hardcoded inside of the test function code block.
Running compiled tests
Compiled tests can be used to test any regular transition or helper function. The test will contain an external transition or external inline function, followed by an assert, as follows:
@test
transition test_simple_addition() {
let result: u32 = example_program.aleo/simple_addition(2u32, 3u32);
assert_eq(result, 5u32);
}
You can also test that invalid results are caught by adding a `@should_fail` annotation.
@test
@should_fail
transition test_simple_addition_fail() {
let result: u32 = example_program.aleo/simple_addition(2u32, 3u32);
assert_eq(result, 3u32);
}
You can also use compiled tests to make sure that structs and records contain correct values for their respective fields.
@test
transition test_record_maker() {
let r: example_program.aleo/Example = example_program.aleo/mint_record(0field);
assert_eq(r.x, 0field);
}
Running scripted tests
Now for the fun part! Scripts are useful for checking async transitions that interact with mappings. Since mapping data lives on-chain and since the testing framework can’t fetch on-chain data, we need to populate a mapping with some data before we can test it.
Let’s say our main program has an async transition and corresponding async function that updates a mapping called "map":
async transition set_mapping(x: field) -> Future {
let future_1: Future = finalize_set_mapping(x);
return future_1;
}
async function finalize_set_mapping(x: field) {
Mapping::set(map, 0field, x);
}
How would we write a test to check that the mapping is being updated correctly?
First, we need to replace the `transition` keyword with `script` to indicate that we’re running a script. Next, within the test code block, we’ll populate the mapping with a hard coded value. Since `set_mapping` is an async transition that returns a Future, we need to await the Future before checking that the value in the mapping matches the correct value:
@test
script test_async() {
const VAL: field = 12field;
let fut: Future = example_program.aleo/set_mapping(VAL);
fut.await();
assert_eq(Mapping::get(example_program.aleo/map, 0field), VAL);
}
How do I run the tests?
After you are confident that you have written all the tests required to satisfy unit testing gods, it’s time to give it a spin!
If you want to run all of the tests together, simply run the following from the root of your project directory.
leo test
If you only want to run select tests, you can either supply the test function name or a string that is contained within the name of the test function:
leo test test_async
Or
leo test async
Conclusion
With Leo's native testing framework, developers can now build more robust and secure Aleo applications by catching bugs early and ensuring their transition functions work correctly before deployment. Get started today by exploring the example repository and diving deeper into the comprehensive Leo language documentation to master unit testing for your dApps.