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:
- Unit tests: Individual components (lexer, parser, interpreter)
- Integration tests: End-to-end program execution
- 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 Type | Purpose | Command |
|---|---|---|
| Unit tests | Test individual components | cargo test |
| Integration tests | Test end-to-end execution | cargo test --test integration_test |
| Benchmarks | Measure performance | cargo bench |
| Coverage | Measure test coverage | cargo 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