You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

5.1 KiB

+++ title = "§17 Testing the Compiler" priority = 5 status = "todo" ticket_type = "task" dependencies = [] +++

§17 Testing the Compiler — Stub to fill

File: edu/src/lisp-compiler.md, section ### 17. Testing the Compiler

Replace the stub line with full content. Target 700900 words. Add unit tests per stage and integration tests that compile MiniLisp → C → binary → run and assert on output.

Learning objectives

  • Know which unit tests to write for each compiler stage
  • Write integration tests that invoke cc and run the result
  • Build a test corpus covering all language features
  • Understand how to test error paths (invalid programs should produce errors, not panics)

Content to write

Testing strategy

Three levels:

  1. Unit tests per module: already written in §815 for individual parsers and code-gen functions. This section consolidates and supplements them.
  2. compile() round-trip tests: call compile(source) and assert on the C output string (does it contain expected fragments?).
  3. Integration tests: compile → write to temp file → cc → run → assert stdout.

Additional unit tests

Supplement the tests from earlier sections with edge cases:

Parser edge cases:

#[test] fn empty_program()   { assert_eq!(parse("").unwrap(), vec![]); }
#[test] fn only_comments()   { assert!(parse("; hello\n; world").unwrap().is_empty()); }
#[test] fn nested_parens()   { parse("(f (g (h 1)))").unwrap(); }
#[test] fn string_escapes()  { assert_eq!(parse(r#""\n""#).unwrap()[0], Expr::Str("\n".into())); }

Analyser error cases:

#[test] fn undefined_rejects()   { assert!(analyse(parse("x").unwrap()).is_err()); }
#[test] fn bad_arity_rejects()   { assert!(analyse(parse("(+ 1 2 3)").unwrap()).is_err()); }
#[test] fn valid_mutual_rec()    { assert!(analyse(parse(MUTUAL_REC_SRC).unwrap()).is_ok()); }

Integration tests

Write an integration test helper that:

  1. Calls compile(source) to get C source
  2. Writes C source to a temp file (/tmp/ml_test_{N}.c)
  3. Invokes cc -o /tmp/ml_test_{N} /tmp/ml_test_{N}.c
  4. Runs the binary and captures stdout
  5. Asserts stdout matches expected output
  6. Cleans up temp files
#[cfg(test)]
fn run_minilisp(source: &str) -> String {
    use std::process::Command;
    let c_source = crate::compile(source).expect("compile failed");
    let c_path = "/tmp/ml_test.c";
    let bin_path = "/tmp/ml_test";
    std::fs::write(c_path, &c_source).unwrap();
    let cc_status = Command::new("cc")
        .args([c_path, "-o", bin_path])
        .status()
        .expect("cc not found");
    assert!(cc_status.success(), "C compilation failed:\n{}", c_source);
    let output = Command::new(bin_path).output().expect("run failed");
    String::from_utf8(output.stdout).unwrap()
}

Test corpus

Write integration tests for each major language feature:

#[test]
fn test_display_integer() {
    assert_eq!(run_minilisp("(display 42)(newline)"), "42\n");
}

#[test]
fn test_arithmetic() {
    assert_eq!(run_minilisp("(display (+ (* 3 4) (- 10 5)))(newline)"), "17\n");
}

#[test]
fn test_boolean_if() {
    let src = "(display (if #t 1 2))(newline)";
    assert_eq!(run_minilisp(src), "1\n");
}

#[test]
fn test_recursive_factorial() {
    let src = r#"
      (define (factorial n)
        (if (= n 0) 1 (* n (factorial (- n 1)))))
      (display (factorial 10))(newline)
    "#;
    assert_eq!(run_minilisp(src), "3628800\n");
}

#[test]
fn test_let() {
    let src = "(display (let ((x 3) (y 4)) (+ x y)))(newline)";
    assert_eq!(run_minilisp(src), "7\n");
}

#[test]
fn test_mutual_recursion() {
    let src = r#"
      (define (even? n) (if (= n 0) #t (odd?  (- n 1))))
      (define (odd?  n) (if (= n 0) #f (even? (- n 1))))
      (display (even? 10))(newline)
      (display (odd?  7))(newline)
    "#;
    assert_eq!(run_minilisp(src), "true\ntrue\n");
}

#[test]
fn test_higher_order() {
    let src = r#"
      (define (apply f x) (f x))
      (define (double x) (* x 2))
      (display (apply double 5))(newline)
    "#;
    assert_eq!(run_minilisp(src), "10\n");
}

#[test]
fn test_begin() {
    let src = "(begin (display 1)(display 2)(display 3)(newline))";
    assert_eq!(run_minilisp(src), "123\n");
}

Testing error paths

#[test]
fn test_undefined_symbol_error() {
    assert!(crate::compile("(display undefined-var)").is_err());
}

#[test]
fn test_unmatched_paren_error() {
    assert!(crate::compile("(define x 1").is_err());
}

#[test]
fn test_wrong_arity_error() {
    assert!(crate::compile("(+ 1 2 3)").is_err());
}

Running the tests

cargo test

Note: integration tests require cc (or gcc/clang) to be installed. On a system without a C compiler, they will panic with "cc not found". Consider gating them with #[ignore] or a feature flag.

Style notes

  • The run_minilisp helper is the key piece of infrastructure — spend time on it
  • The test corpus should cover every special form (define, lambda, if, let, begin) and every built-in
  • Temporary file management in tests is inelegant but explicit; mention that tempfile crate would be cleaner in production
  • Flag the cc dependency clearly — integration tests are not hermetic