Skip to main content

leo_lang/cli/commands/
test.rs

1// Copyright (C) 2019-2026 Provable Inc.
2// This file is part of the Leo library.
3
4// The Leo library is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8
9// The Leo library is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13
14// You should have received a copy of the GNU General Public License
15// along with the Leo library. If not, see <https://www.gnu.org/licenses/>.
16
17use super::*;
18
19use leo_ast::{NetworkName, TEST_PRIVATE_KEY};
20use leo_compiler::run;
21use leo_package::{Package, ProgramData};
22use leo_span::{Symbol, sym};
23
24use snarkvm::prelude::TestnetV0;
25
26use colored::Colorize as _;
27use std::fs;
28
29/// Test a leo program.
30#[derive(Parser, Debug)]
31pub struct LeoTest {
32    #[clap(
33        name = "TEST_NAME",
34        help = "If specified, run only tests whose qualified name matches against this string.",
35        default_value = ""
36    )]
37    pub(crate) test_name: String,
38
39    #[clap(long, help = "Run all tests with full proof generation.", default_value = "false")]
40    pub(crate) prove: bool,
41
42    #[clap(flatten)]
43    pub(crate) compiler_options: BuildOptions,
44    #[clap(flatten)]
45    pub(crate) env_override: EnvOptions,
46}
47
48impl Command for LeoTest {
49    type Input = <LeoBuild as Command>::Output;
50    type Output = TestOutput;
51
52    fn log_span(&self) -> Span {
53        tracing::span!(tracing::Level::INFO, "Leo")
54    }
55
56    fn prelude(&self, context: Context) -> Result<Self::Input> {
57        let mut options = self.compiler_options.clone();
58        options.build_tests = true;
59        (LeoBuild { env_override: self.env_override.clone(), options, rename: None }).execute(context)
60    }
61
62    fn apply(self, _: Context, input: Self::Input) -> Result<Self::Output> {
63        handle_test(self, input)
64    }
65
66    fn execute(self, context: Context) -> Result<Self::Output> {
67        // Check for workspace mode before falling through to the default
68        // prelude+apply flow, because test needs to build and test each
69        // member independently.
70        match context.resolve_targets()? {
71            Some((_, targets)) if targets.len() > 1 => {
72                let mut aggregate = TestOutput::default();
73                for target in &targets {
74                    let member_name = target.file_name().and_then(|n| n.to_str()).unwrap_or("?");
75                    println!("\n--- workspace member '{member_name}' ---");
76
77                    let member_ctx = context.with_path(target.clone());
78
79                    // Build with tests.
80                    let mut opts = self.compiler_options.clone();
81                    opts.build_tests = true;
82                    let package = (LeoBuild { env_override: self.env_override.clone(), options: opts, rename: None })
83                        .execute(member_ctx)?;
84
85                    // Run tests for this member.
86                    let member_test = LeoTest {
87                        test_name: self.test_name.clone(),
88                        prove: self.prove,
89                        compiler_options: self.compiler_options.clone(),
90                        env_override: self.env_override.clone(),
91                    };
92                    let result = handle_test(member_test, package)?;
93                    aggregate.passed += result.passed;
94                    aggregate.failed += result.failed;
95                    aggregate.tests.extend(result.tests);
96                }
97                Ok(aggregate)
98            }
99            _ => {
100                // Single target or no workspace - use the normal flow.
101                let input = self.prelude(context.clone())?;
102                let span = self.log_span();
103                let span = span.enter();
104                let out = self.apply(context, input);
105                drop(span);
106                out
107            }
108        }
109    }
110}
111
112struct TestFunction {
113    file: String,
114    program: String,
115    function: String,
116    should_fail: bool,
117    private_key: Option<String>,
118}
119
120/// Discover `@test`-annotated entry point functions from the compiled package.
121///
122/// Walks the Leo source files, parses them, and extracts functions with the
123/// `@test` annotation that are entry points (transitions).
124fn discover_test_functions(package: &Package, match_str: &str, network: NetworkName) -> Result<Vec<TestFunction>> {
125    use indexmap::IndexMap;
126    use leo_ast::NodeBuilder;
127    use leo_compiler::Compiler;
128    use leo_errors::Handler;
129    use std::rc::Rc;
130
131    let private_key_symbol = Symbol::intern("private_key");
132    let mut test_functions = Vec::new();
133
134    for unit in &package.compilation_units {
135        let ProgramData::SourcePath { directory, source } = &unit.data else {
136            continue;
137        };
138
139        let file = source.file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
140
141        let handler = Handler::default();
142        let node_builder = Rc::new(NodeBuilder::default());
143
144        let mut compiler =
145            Compiler::new(None, unit.kind.is_test(), handler, node_builder, None, IndexMap::new(), network);
146
147        // A test is a single standalone file; its `tests/` siblings are independent programs,
148        // so parse only this file rather than scanning the directory for modules.
149        let ast = if unit.kind.is_test() {
150            compiler.parse_program_from_file(source)
151        } else {
152            compiler.parse_program_from_directory(source, directory.join("src"))
153        };
154        let ast = match ast {
155            Ok(ast) => ast,
156            Err(_) => continue,
157        };
158
159        for scope in ast.program_scopes.values() {
160            let program_name = scope.program_id.name.to_string();
161
162            for (_, function) in &scope.functions {
163                let has_test = function.annotations.iter().any(|a| a.identifier.name == sym::test);
164                if !has_test {
165                    continue;
166                }
167
168                if !function.variant.is_entry() {
169                    continue;
170                }
171
172                if !match_str.is_empty() {
173                    let program_qualified = format!("{program_name}/{}", function.identifier);
174                    let file_qualified = format!("{file}::{}", function.identifier);
175                    if !program_qualified.contains(match_str) && !file_qualified.contains(match_str) {
176                        continue;
177                    }
178                }
179
180                let should_fail = function.annotations.iter().any(|a| a.identifier.name == sym::should_fail);
181
182                let private_key = function
183                    .annotations
184                    .iter()
185                    .find(|a| a.identifier.name == sym::test)
186                    .and_then(|a| a.map.get(&private_key_symbol).cloned());
187
188                test_functions.push(TestFunction {
189                    file: file.clone(),
190                    program: program_name.clone(),
191                    function: function.identifier.to_string(),
192                    should_fail,
193                    private_key,
194                });
195            }
196        }
197    }
198
199    Ok(test_functions)
200}
201
202fn handle_test(command: LeoTest, package: Package) -> Result<TestOutput> {
203    if package.compilation_units.last().map(|p| p.kind.is_library()).unwrap_or(false) {
204        return Err(crate::errors::custom("`leo test` is not supported for library packages.").into());
205    }
206
207    // Get the private key.
208    let _private_key = PrivateKey::<TestnetV0>::from_str(TEST_PRIVATE_KEY)?;
209
210    let network = command.env_override.network.unwrap_or(NetworkName::TestnetV0);
211    let test_functions = discover_test_functions(&package, &command.test_name, network)?;
212
213    let credits = Symbol::intern("credits.aleo");
214
215    // Get bytecode and name for all programs, either directly or from the filesystem if they were compiled.
216    let programs: Vec<run::Program> = package
217        .compilation_units
218        .iter()
219        .filter_map(|unit| {
220            // Skip credits.aleo so we don't try to deploy it again.
221            if unit.name == credits {
222                return None;
223            }
224            // Libraries have no bytecode - their consts are inlined into the main program.
225            if unit.kind.is_library() {
226                return None;
227            }
228            let bytecode = match &unit.data {
229                ProgramData::Bytecode(c) => c.clone(),
230                ProgramData::SourcePath { .. } => {
231                    // This was not a network dependency, so get its bytecode from its build directory.
232                    let aleo_path = package.unit_bytecode_path(&unit.name.to_string());
233                    fs::read_to_string(&aleo_path)
234                        .unwrap_or_else(|e| panic!("Failed to read Aleo file at {}: {}", aleo_path.display(), e))
235                }
236            };
237            Some(run::Program { bytecode, name: unit.name.to_string() })
238        })
239        .collect();
240
241    let test_metadata: Vec<_> = test_functions
242        .iter()
243        .map(|tf| {
244            (tf.should_fail, format!("{}::{}", tf.file, tf.function), format!("{}.aleo/{}", tf.program, tf.function))
245        })
246        .collect();
247
248    let cases: Vec<Vec<run::Case>> = test_functions
249        .into_iter()
250        .map(|tf| {
251            vec![run::Case {
252                program_name: format!("{}.aleo", tf.program),
253                function: tf.function,
254                private_key: tf.private_key,
255                input: Vec::new(),
256                seed_mapping: Vec::new(),
257            }]
258        })
259        .collect();
260
261    let total = cases.len();
262    if total == 0 {
263        println!("No tests run.");
264        return Ok(TestOutput::default());
265    }
266
267    let plural = if total == 1 { "" } else { "s" };
268    println!();
269    println!("{} {total} test{plural}", gutter("Running").green().bold());
270
271    let mut tests = Vec::with_capacity(total);
272    let mut passed = 0usize;
273    let mut failures: Vec<(usize, String)> = Vec::new();
274
275    // Debug logs make an in-place `RUNNING` status unreliable, so print each result after completion.
276    run::run_with_ledger(
277        &run::Config { seed: 0, start_height: None, programs, skip_proving: !command.prove },
278        &cases,
279        |index, outcomes| {
280            let outcome = &outcomes[0];
281            let (should_fail, display, qualified_name) = &test_metadata[index];
282
283            let message = match (&outcome.status, *should_fail) {
284                (run::ExecutionStatus::Accepted, false) => None,
285                (run::ExecutionStatus::Accepted, true) => Some("test succeeded when failure was expected".to_string()),
286                (_, true) => None,
287                (_, false) => Some(format!("{} -- {}", outcome.status, outcome.outcome.output)),
288            };
289
290            match message {
291                Some(err) => {
292                    println!("{} {display}", gutter("FAIL").red().bold());
293                    failures.push((index, err.clone()));
294                    tests.push(TestResult { name: qualified_name.clone(), passed: false, error: Some(err) });
295                }
296                None => {
297                    passed += 1;
298                    println!("{} {display}", gutter("PASS").green().bold());
299                    tests.push(TestResult { name: qualified_name.clone(), passed: true, error: None });
300                }
301            }
302        },
303    )?;
304
305    let failed = total - passed;
306    println!("{}", "─".repeat(24).dimmed());
307    let summary = gutter("Summary");
308    let summary = if failed == 0 { summary.green().bold() } else { summary.red().bold() };
309    println!("{summary} {total} test{plural} run: {passed} passed, {failed} failed");
310    for (index, err) in &failures {
311        let (_, display, _) = &test_metadata[*index];
312        println!("{} {display}\n{:>14}{}", gutter("FAIL").red().bold(), "", err.dimmed());
313    }
314
315    Ok(TestOutput { passed, failed, tests })
316}
317
318/// Right-align a status verb before adding ANSI color.
319fn gutter(word: &str) -> String {
320    format!("{word:>12}")
321}