Testing & Debugging Your Language

Testing a programming language is crucial - it ensures your interpreter works correctly and helps catch regressions. Let's build a comprehensive test strategy.

Testing Strategy

We'll test at three levels:

  1. Unit tests: Individual components (lexer, parser, interpreter)
  2. Integration tests: End-to-end program execution
  3. Error tests: Verify error handling

Unit Testing the Lexer

Create src/lexer.rs tests:

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ast::Token;

    #[test]
    fn test_simple_tokens() {
        let source = "+ - * / ( ) { } [ ] , ;".to_string();
        let mut lexer = Lexer::new(source);
        let tokens = lexer.tokenize().unwrap();

        let expected = vec![
            Token::Plus,
            Token::Minus,
            Token::Star,
            Token::Slash,
            Token::LeftParen,
            Token::RightParen,
            Token::LeftBrace,
            Token::RightBrace,
            Token::LeftBracket,
            Token::RightBracket,
            Token::Comma,
            Token::Semicolon,
            Token::Eof,
        ];

        assert_eq!(tokens.len(), expected.len());
        for (token_info, expected_token) in tokens.iter().zip(expected.iter()) {
            assert_eq!(&token_info.token, expected_token);
        }
    }

    #[test]
    fn test_numbers() {
        let source = "42 3.14 0.5".to_string();
        let mut lexer = Lexer::new(source);
        let tokens = lexer.tokenize().unwrap();

        assert_eq!(tokens[0].token, Token::Number(42.0));
        assert_eq!(tokens[1].token, Token::Number(3.14));
        assert_eq!(tokens[2].token, Token::Number(0.5));
    }

    #[test]
    fn test_strings() {
        let source = r#""hello" "world\n""#.to_string();
        let mut lexer = Lexer::new(source);
        let tokens = lexer.tokenize().unwrap();

        assert_eq!(tokens[0].token, Token::String("hello".to_string()));
        assert_eq!(tokens[1].token, Token::String("world\n".to_string()));
    }

    #[test]
    fn test_keywords() {
        let source = "let fn if else while for return true false nil".to_string();
        let mut lexer = Lexer::new(source);
        let tokens = lexer.tokenize().unwrap();

        let expected = vec![
            Token::Let,
            Token::Fn,
            Token::If,
            Token::Else,
            Token::While,
            Token::For,
            Token::Return,
            Token::True,
            Token::False,
            Token::Nil,
        ];

        for (token_info, expected_token) in tokens.iter().zip(expected.iter()) {
            assert_eq!(&token_info.token, expected_token);
        }
    }

    #[test]
    fn test_identifiers() {
        let source = "x y_var my_func123".to_string();
        let mut lexer = Lexer::new(source);
        let tokens = lexer.tokenize().unwrap();

        assert_eq!(tokens[0].token, Token::Identifier("x".to_string()));
        assert_eq!(tokens[1].token, Token::Identifier("y_var".to_string()));
        assert_eq!(tokens[2].token, Token::Identifier("my_func123".to_string()));
    }

    #[test]
    fn test_two_char_operators() {
        let source = "== != <= >=".to_string();
        let mut lexer = Lexer::new(source);
        let tokens = lexer.tokenize().unwrap();

        assert_eq!(tokens[0].token, Token::EqualEqual);
        assert_eq!(tokens[1].token, Token::BangEqual);
        assert_eq!(tokens[2].token, Token::LessEqual);
        assert_eq!(tokens[3].token, Token::GreaterEqual);
    }

    #[test]
    fn test_comments() {
        let source = "let x = 5; // this is a comment\nlet y = 10;".to_string();
        let mut lexer = Lexer::new(source);
        let tokens = lexer.tokenize().unwrap();

        // Should skip comment
        assert_eq!(tokens[0].token, Token::Let);
        assert_eq!(tokens[1].token, Token::Identifier("x".to_string()));
        // ... y declaration follows
        assert_eq!(tokens[5].token, Token::Let);
    }

    #[test]
    fn test_unterminated_string() {
        let source = r#"let x = "hello"#.to_string();
        let mut lexer = Lexer::new(source);
        let result = lexer.tokenize();

        assert!(result.is_err());
    }
}

Run lexer tests:

cargo test lexer

Unit Testing the Parser

Create src/parser.rs tests:

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ast::*;
    use crate::lexer::Lexer;

    fn parse_source(source: &str) -> Result<Vec<Stmt>, String> {
        let mut lexer = Lexer::new(source.to_string());
        let tokens = lexer.tokenize().map_err(|e| e.message)?;
        let mut parser = Parser::new(tokens);
        parser.parse().map_err(|e| e.message)
    }

    #[test]
    fn test_parse_let() {
        let stmts = parse_source("let x = 5;").unwrap();
        assert_eq!(stmts.len(), 1);

        match &stmts[0] {
            Stmt::Let { name, value } => {
                assert_eq!(name, "x");
                assert!(matches!(value, Expr::Number(5.0)));
            }
            _ => panic!("Expected Let statement"),
        }
    }

    #[test]
    fn test_parse_binary_expr() {
        let stmts = parse_source("let x = 2 + 3 * 4;").unwrap();

        match &stmts[0] {
            Stmt::Let { name: _, value } => match value {
                Expr::Binary { left, op, right } => {
                    assert!(matches!(**left, Expr::Number(2.0)));
                    assert!(matches!(op, BinaryOp::Add));
                    // Right should be 3 * 4
                    match &**right {
                        Expr::Binary { left, op, right } => {
                            assert!(matches!(**left, Expr::Number(3.0)));
                            assert!(matches!(op, BinaryOp::Multiply));
                            assert!(matches!(**right, Expr::Number(4.0)));
                        }
                        _ => panic!("Expected binary expression"),
                    }
                }
                _ => panic!("Expected binary expression"),
            },
            _ => panic!("Expected Let statement"),
        }
    }

    #[test]
    fn test_parse_function() {
        let stmts = parse_source("fn add(a, b) { return a + b; }").unwrap();

        match &stmts[0] {
            Stmt::Function { name, params, body } => {
                assert_eq!(name, "add");
                assert_eq!(params.len(), 2);
                assert_eq!(params[0], "a");
                assert_eq!(params[1], "b");
                assert_eq!(body.len(), 1);
            }
            _ => panic!("Expected Function statement"),
        }
    }

    #[test]
    fn test_parse_if() {
        let stmts = parse_source("if x > 5 { print x; }").unwrap();

        match &stmts[0] {
            Stmt::If {
                condition,
                then_branch,
                else_branch,
            } => {
                assert!(matches!(condition, Expr::Binary { .. }));
                assert!(matches!(**then_branch, Stmt::Block(_)));
                assert!(else_branch.is_none());
            }
            _ => panic!("Expected If statement"),
        }
    }

    #[test]
    fn test_parse_list() {
        let stmts = parse_source("let x = [1, 2, 3];").unwrap();

        match &stmts[0] {
            Stmt::Let { value, .. } => match value {
                Expr::List(elements) => {
                    assert_eq!(elements.len(), 3);
                }
                _ => panic!("Expected list expression"),
            },
            _ => panic!("Expected Let statement"),
        }
    }
}

Run parser tests:

cargo test parser

Unit Testing the Interpreter

Create src/interpreter.rs tests:

#[cfg(test)]
mod tests {
    use super::*;
    use crate::lexer::Lexer;
    use crate::parser::Parser;

    fn eval_source(source: &str) -> Result<(), String> {
        let mut lexer = Lexer::new(source.to_string());
        let tokens = lexer.tokenize().map_err(|e| e.message)?;
        let mut parser = Parser::new(tokens);
        let statements = parser.parse().map_err(|e| e.message)?;
        let mut interpreter = Interpreter::new();
        interpreter.interpret(statements).map_err(|e| e.message)
    }

    #[test]
    fn test_arithmetic() {
        assert!(eval_source("let x = 2 + 3;").is_ok());
        assert!(eval_source("let x = 10 - 3;").is_ok());
        assert!(eval_source("let x = 4 * 5;").is_ok());
        assert!(eval_source("let x = 20 / 4;").is_ok());
    }

    #[test]
    fn test_comparison() {
        assert!(eval_source("let x = 5 > 3;").is_ok());
        assert!(eval_source("let x = 5 < 10;").is_ok());
        assert!(eval_source("let x = 5 == 5;").is_ok());
        assert!(eval_source("let x = 5 != 3;").is_ok());
    }

    #[test]
    fn test_function_call() {
        let source = r#"
            fn add(a, b) {
                return a + b;
            }
            let result = add(2, 3);
        "#;
        assert!(eval_source(source).is_ok());
    }

    #[test]
    fn test_closure() {
        let source = r#"
            fn make_counter() {
                let count = 0;
                fn increment() {
                    count = count + 1;
                    return count;
                }
                return increment;
            }
            let counter = make_counter();
            let x = counter();
            let y = counter();
        "#;
        assert!(eval_source(source).is_ok());
    }

    #[test]
    fn test_recursion() {
        let source = r#"
            fn factorial(n) {
                if n <= 1 {
                    return 1;
                }
                return n * factorial(n - 1);
            }
            let result = factorial(5);
        "#;
        assert!(eval_source(source).is_ok());
    }

    #[test]
    fn test_lists() {
        let source = r#"
            let list = [1, 2, 3];
            push(list, 4);
            let last = pop(list);
        "#;
        assert!(eval_source(source).is_ok());
    }

    #[test]
    fn test_division_by_zero() {
        let result = eval_source("let x = 5 / 0;");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Division by zero"));
    }

    #[test]
    fn test_undefined_variable() {
        let result = eval_source("let x = unknown_var;");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Undefined variable"));
    }
}

Run interpreter tests:

cargo test interpreter

Integration Tests

Create tests/integration_test.rs:

use std::process::Command;

fn run_lux_file(filename: &str) -> Result<String, String> {
    let output = Command::new("cargo")
        .args(&["run", "--", filename])
        .output()
        .expect("Failed to execute command");

    if output.status.success() {
        Ok(String::from_utf8_lossy(&output.stdout).to_string())
    } else {
        Err(String::from_utf8_lossy(&output.stderr).to_string())
    }
}

#[test]
fn test_hello_world() {
    let output = run_lux_file("tests/programs/hello.lux").unwrap();
    assert!(output.contains("Hello, World!"));
}

#[test]
fn test_fibonacci() {
    let output = run_lux_file("tests/programs/fibonacci.lux").unwrap();
    assert!(output.contains("55"));  // fib(10) = 55
}

#[test]
fn test_closures() {
    let output = run_lux_file("tests/programs/closures.lux").unwrap();
    assert!(output.contains("1"));
    assert!(output.contains("2"));
    assert!(output.contains("3"));
}

Create test programs in tests/programs/:

tests/programs/hello.lux:

print "Hello, World!";

tests/programs/fibonacci.lux:

fn fibonacci(n) {
    if n <= 1 {
        return n;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
}
print fibonacci(10);

tests/programs/closures.lux:

fn make_counter() {
    let count = 0;
    return fn() {
        count = count + 1;
        return count;
    };
}

let counter = make_counter();
print counter();
print counter();
print counter();

Run integration tests:

cargo test --test integration_test

Test Suite Organization

lux/
├── src/
│   ├── main.rs
│   ├── lexer.rs      # with #[cfg(test)] mod tests
│   ├── parser.rs     # with #[cfg(test)] mod tests
│   └── interpreter.rs # with #[cfg(test)] mod tests
├── tests/
│   ├── integration_test.rs
│   └── programs/
│       ├── hello.lux
│       ├── fibonacci.lux
│       └── closures.lux
└── Cargo.toml

Debugging Tools

1. Token Dump

fn dump_tokens(source: &str) {
    let mut lexer = Lexer::new(source.to_string());
    match lexer.tokenize() {
        Ok(tokens) => {
            println!("=== TOKENS ===");
            for (i, token_info) in tokens.iter().enumerate() {
                println!(
                    "{:3}: {:?} (line {}, col {})",
                    i, token_info.token, token_info.line, token_info.column
                );
            }
        }
        Err(e) => eprintln!("Lexer error: {}", e.message),
    }
}

2. AST Pretty Print

impl Stmt {
    pub fn pretty_print(&self, indent: usize) -> String {
        let indent_str = "  ".repeat(indent);
        match self {
            Stmt::Let { name, value } => {
                format!(
                    "{}Let {} = {}",
                    indent_str,
                    name,
                    value.pretty_print(0)
                )
            }
            Stmt::Function { name, params, body } => {
                let mut s = format!("{}Function {}({})", indent_str, name, params.join(", "));
                for stmt in body {
                    s.push_str(&format!("\n{}", stmt.pretty_print(indent + 1)));
                }
                s
            }
            // ... other variants
            _ => format!("{}{:?}", indent_str, self),
        }
    }
}

impl Expr {
    pub fn pretty_print(&self, _indent: usize) -> String {
        match self {
            Expr::Number(n) => n.to_string(),
            Expr::String(s) => format!("\"{}\"", s),
            Expr::Identifier(name) => name.clone(),
            Expr::Binary { left, op, right } => {
                format!(
                    "({} {:?} {})",
                    left.pretty_print(0),
                    op,
                    right.pretty_print(0)
                )
            }
            // ... other variants
            _ => format!("{:?}", self),
        }
    }
}

3. Execution Trace

impl Interpreter {
    pub fn interpret_with_trace(&mut self, statements: Vec<Stmt>) -> Result<(), String> {
        for (i, stmt) in statements.iter().enumerate() {
            println!("Executing statement {}: {:?}", i, stmt);
            self.execute_statement(stmt.clone())?;
        }
        Ok(())
    }
}

Performance Testing

Benchmark Fibonacci

Create benches/fibonacci_bench.rs:

use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn fibonacci_benchmark(c: &mut Criterion) {
    let source = r#"
        fn fibonacci(n) {
            if n <= 1 {
                return n;
            }
            return fibonacci(n - 1) + fibonacci(n - 2);
        }
        fibonacci(20);
    "#;

    c.bench_function("fibonacci 20", |b| {
        b.iter(|| {
            // Run Lux interpreter
            black_box(run_lux_source(source))
        })
    });
}

criterion_group!(benches, fibonacci_benchmark);
criterion_main!(benches);

Add to Cargo.toml:

[dev-dependencies]
criterion = "0.5"

[[bench]]
name = "fibonacci_bench"
harness = false

Run benchmarks:

cargo bench

Test Coverage

Generate coverage reports with cargo-tarpaulin:

# Install
cargo install cargo-tarpaulin

# Generate coverage
cargo tarpaulin --out Html

# Open coverage report
open tarpaulin-report.html

Continuous Integration

Create .github/workflows/test.yml:

name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
      - name: Run tests
        run: cargo test --verbose
      - name: Run clippy
        run: cargo clippy -- -D warnings
      - name: Check formatting
        run: cargo fmt -- --check

Testing Best Practices

1. Test Edge Cases

#[test]
fn test_empty_string() {
    assert!(eval_source(r#"let x = "";"#).is_ok());
}

#[test]
fn test_zero() {
    assert!(eval_source("let x = 0;").is_ok());
}

#[test]
fn test_negative_numbers() {
    assert!(eval_source("let x = -5;").is_ok());
}

2. Test Error Conditions

#[test]
fn test_all_errors() {
    // Undefined variable
    assert!(eval_source("print x;").is_err());
    
    // Type error
    assert!(eval_source(r#"let x = "hello" - 5;"#).is_err());
    
    // Wrong arity
    assert!(eval_source("fn f(a) {} f(1, 2);").is_err());
    
    // Out of bounds
    assert!(eval_source("let x = [1, 2, 3]; print x[10];").is_err());
}

3. Regression Tests

When you fix a bug, add a test:

#[test]
fn test_issue_42_closure_capture() {
    // Fixed bug where closures didn't properly capture
    let source = r#"
        fn outer() {
            let x = 10;
            fn inner() {
                return x;
            }
            return inner;
        }
        let f = outer();
        let result = f();
    "#;
    assert!(eval_source(source).is_ok());
}

Summary

Test TypePurposeCommand
Unit testsTest individual componentscargo test
Integration testsTest end-to-end executioncargo test --test integration_test
BenchmarksMeasure performancecargo bench
CoverageMeasure test coveragecargo tarpaulin

What's Next?

Lux is complete and well-tested! Let's explore what features to add next and resources for continued learning.

→ Continue to Chapter 10: What's Next