Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Writing Tests

Practical guidance for the Rust side of the test suite.

Where Tests Live

All Rust tests sit under tests/ in the suji-tests package. Four top-level files declare the suites, and each pulls in a directory of modules:

Entry pointDirectoryPurpose
tests/components.rstests/components/One component at a time
tests/integration.rstests/integration/Whole programs through the interpreter
tests/spec.rstests/spec/Rust counterparts to the spec areas
tests/stdlib.rstests/stdlib/One file per standard library module

Modules are wired in explicitly with #[path = "..."] declarations, so a new file must be added to its mod.rs (or to the suite entry point) before it will run.

cargo test --workspace
cargo test --package suji-tests --test integration --test components --test stdlib --test spec
make rust_tests

Crate-internal unit tests stay in the crate they belong to, in a #[cfg(test)] mod tests block next to the code — error_codes.rs, for example, carries its own uniqueness and range checks.

Unit or Integration?

Put it in tests/components/ whenPut it in tests/integration/ when
You are asserting on a token streamYou are asserting on a program’s value
You are asserting on AST shapeSeveral components must cooperate
You are calling a method dispatcher directlyThe behaviour crosses lexer, parser, and interpreter
You are checking a specific RuntimeError variantYou want the closest thing to a real program

If a test would read naturally as a .si program, it probably belongs in spec/ instead — see Spec Tests.

Shared Helpers

tests/common/mod.rs provides the plumbing so individual tests stay short:

HelperUse
parse_expression(src)Parse one expression
parse_statement(src)Parse one statement
parse_program(src)Parse a whole program
create_test_env()An Env with globals installed
eval_string_expr(src)Evaluate a single expression to a Value
eval_program(src)Evaluate a program, returning the last value
eval_program_with_modules(src)As above, with the module registry wired up
can_eval(src) / can_eval_program(src)Boolean success check
assert_parse_fails(src, fragment)Assert a parse error containing a fragment
assert_eval_fails(src, fragment)Assert a runtime error containing a fragment

Reach for these before writing your own setup. assert_parse_fails also accepts several acceptable fragments separated by ||, which is useful while an error message is in transition.

Testing the Lexer

Lex a small input and compare the token vector, remembering the trailing Token::Eof:

#[test]
fn test_keywords() {
    let input = "return loop as through with continue break match import export true false nil";
    let tokens = Lexer::lex(input).unwrap();

    let expected = vec![
        Token::Return,
        Token::Loop,
        // ...
        Token::Eof,
    ];

    let actual: Vec<Token> = tokens.into_iter().map(|t| t.token).collect();
    assert_eq!(actual, expected);
}

Group new cases into the existing files by construct: strings.rs, regex.rs, shell.rs, comments.rs, operators.rs, ranges.rs, unicode.rs, basics.rs. Regex-versus-division disambiguation belongs in regex.rs, and it is worth testing both the regex and the division reading of the same character.

Testing the Parser

Assert on the AST shape rather than on a rendered string, so the test survives formatting changes:

let expr = parse_expression("2 + 3 * 4").unwrap();
assert!(matches!(expr, Expr::Binary { .. }));

Precedence and associativity cases go in tests/components/parser/precedence.rs. The most valuable parser tests are the negative ones — confirming that an invalid program is rejected, and with a comprehensible message:

assert_parse_fails(
    "match x { 1 => \"one\", _ => \"other\" }",
    "Unexpected token",
);

Testing Runtime Behaviour

Evaluate a program and compare the resulting Value:

let value = eval_program("xs = [1, 2, 3]\nxs::sum()").unwrap();
assert_eq!(value, Value::Number(DecimalNumber::from_i64(6)));

Method dispatch can also be exercised directly through call_method, which is how tests/components/values/methods.rs covers the dispatcher without going through the parser:

let s = Value::String("hello".to_string());
let result = call_method(None, ValueRef::Immutable(&s), "length", vec![]).unwrap();
assert_eq!(result, Value::Number(DecimalNumber::from_i64(5)));

Asserting on Errors

Match on the RuntimeError variant rather than on its rendered text. Messages get reworded; variants do not:

assert!(matches!(
    result,
    Err(RuntimeError::MethodError { .. })
));

When the message itself is the thing under test, use assert_eval_fails with the smallest distinctive fragment.

Errors carry spans, and error.span() returns the source range. If your change touches where an error is raised, assert that the span still covers the right text — that is what makes the framed diagnostic point at the correct expression:

let span = error.span().expect("error should have a span");
assert!(source[span.start..span.end].contains('x'));

Testing the Standard Library

One file per module, named after it: std_path.rs, std_time.rs, std_crypto.rs. Test through Suji source with eval_program_with_modules so that the import path is exercised too, and keep assertions deterministic — assert on the shape or length of a UUID or a timestamp rather than on its value.

Add a Spec File Too

Almost every Rust test for user-visible behaviour should be accompanied by a .si file in spec/. The Rust test proves the mechanism; the spec file proves the behaviour, in the language itself, in a form that any contributor can read. Getting into that habit is the single most useful thing you can do for the suite. See Spec Tests.

Before You Push

make lint
make test

See Also