BASIC Interpreter with Rust

Testing and Test Coverage


Learning Objectives

  • You understand the difference between unit tests and integration tests.
  • You know how to measure test coverage.

Unit and Integration Tests

So far, we have added unit tests for individual components like the lexer, parser, and interpreter. Unit tests ensure that each component works correctly in isolation. They are placed close to the code they test, usually in the same module.

Integration tests verify that multiple components interact as expected. They are placed typically in a separate tests/ directory at the project root.

For our BASIC interpreter, integration tests could test the full flow from lexing to parsing to interpreting. We can, mostly, test this by writing small BASIC programs and checking their outputs or final states.

To check the final states, we need to provide access to the interpreter’s variable storage. Add a method get_var to the Interpreter struct that retrieves the value of a variable by name:

    pub fn get_var(&self, name: &str) -> Option<i32> {
        self.variables.get(name).copied()
    }

Now, we can write integration tests that load BASIC programs into the interpreter, run them, and check the final variable values. Add the following tests to tests/integration_tests.rs:

use basic_interpreter::interpreter::Interpreter;

#[test]
fn test_fibonacci_sequence() {
    let mut interp = Interpreter::new();

    interp.load_line("10 LET A = 0").unwrap();
    interp.load_line("20 LET B = 1").unwrap();
    interp.load_line("30 LET C = A + B").unwrap();
    interp.load_line("40 LET A = B").unwrap();
    interp.load_line("50 LET B = C").unwrap();
    interp.load_line("60 IF B < 100 THEN 30").unwrap();
    interp.load_line("70 END").unwrap();

    interp.run().unwrap();

    let b = interp.get_var("B").unwrap();
    assert!(b >= 100 && b < 200);
}

#[test]
fn test_factorial_calculation() {
    let mut interp = Interpreter::new();

    interp.load_line("10 LET N = 5").unwrap();
    interp.load_line("20 LET F = 1").unwrap();
    interp.load_line("30 LET I = 1").unwrap();
    interp.load_line("40 LET F = F * I").unwrap();
    interp.load_line("50 LET I = I + 1").unwrap();
    interp.load_line("60 IF I < N + 1 THEN 40").unwrap();
    interp.load_line("70 END").unwrap();

    interp.run().unwrap();

    assert_eq!(interp.get_var("F"), Some(120));
}

#[test]
fn test_sum_of_numbers() {
    let mut interp = Interpreter::new();

    interp.load_line("10 LET SUM = 0").unwrap();
    interp.load_line("20 LET I = 1").unwrap();
    interp.load_line("30 LET SUM = SUM + I").unwrap();
    interp.load_line("40 LET I = I + 1").unwrap();
    interp.load_line("50 IF I < 101 THEN 30").unwrap();
    interp.load_line("60 END").unwrap();

    interp.run().unwrap();

    assert_eq!(interp.get_var("SUM"), Some(5050));
}

#[test]
fn test_prime_check() {
    let mut interp = Interpreter::new();

    interp.load_line("10 LET N = 17").unwrap();
    interp.load_line("20 LET I = 2").unwrap();
    interp.load_line("30 LET R = N / I").unwrap();
    interp.load_line("40 LET R = R * I").unwrap();
    interp.load_line("50 IF R = N THEN 80").unwrap(); // Not prime
    interp.load_line("60 LET I = I + 1").unwrap();
    interp.load_line("70 IF I * I < N + 1 THEN 30").unwrap();
    interp.load_line("75 LET PRIME = 1").unwrap(); // Is prime
    interp.load_line("80 END").unwrap();

    interp.run().unwrap();

    assert_eq!(interp.get_var("PRIME"), Some(1));
}

#[test]
fn test_greatest_common_divisor() {
    let mut interp = Interpreter::new();

    interp.load_line("10 LET A = 48").unwrap();
    interp.load_line("20 LET B = 18").unwrap();
    interp.load_line("30 IF B = 0 THEN 100").unwrap();

    interp.load_line("40 LET T = B").unwrap();
    interp.load_line("50 LET Q = A / B").unwrap();
    interp.load_line("60 LET P = B * Q").unwrap();
    interp.load_line("70 LET B = A - P").unwrap();
    interp.load_line("80 LET A = T").unwrap();

    interp.load_line("90 GOTO 30").unwrap();
    interp.load_line("100 END").unwrap();
    interp.run().unwrap();

    let gcd = interp.get_var("A").unwrap();
    assert_eq!(gcd, 6);
}

#[test]
fn test_nested_loops() {
    let mut interp = Interpreter::new();

    interp.load_line("10 LET I = 1").unwrap();
    interp.load_line("20 LET J = 1").unwrap();
    interp.load_line("30 LET P = I * J").unwrap();
    interp.load_line("40 LET J = J + 1").unwrap();
    interp.load_line("50 IF J < 4 THEN 30").unwrap();
    interp.load_line("60 LET I = I + 1").unwrap();
    interp.load_line("70 IF I < 4 THEN 20").unwrap();
    interp.load_line("80 END").unwrap();

    interp.run().unwrap();

    assert_eq!(interp.get_var("I"), Some(4));
    assert_eq!(interp.get_var("P"), Some(9));
}

#[test]
fn test_complex_conditionals() {
    let mut interp = Interpreter::new();

    interp.load_line("10 LET X = 15").unwrap();
    interp.load_line("20 IF X < 10 THEN 100").unwrap();
    interp.load_line("30 IF X > 20 THEN 100").unwrap();
    interp.load_line("40 LET RESULT = 1").unwrap();
    interp.load_line("100 END").unwrap();

    interp.run().unwrap();

    assert_eq!(interp.get_var("RESULT"), Some(1));
}

#[test]
fn test_program_modification() {
    let mut interp = Interpreter::new();

    interp.load_line("10 LET X = 5").unwrap();
    interp.load_line("20 END").unwrap();

    interp.run().unwrap();
    assert_eq!(interp.get_var("X"), Some(5));
    interp.load_line("10 LET X = 10").unwrap();

    interp.run().unwrap();
    assert_eq!(interp.get_var("X"), Some(10));
}

The tests above include common programs like Fibonacci sequence, factorial calculation, sum of numbers, prime checking, GCD calculation, nested loops, complex conditionals, and program modification.

When you run the tests, you should see all tests passing.

Loading Exercise...

Checking for Edge Cases

Our tests are mostly happy-path tests. We should also test edge cases and error conditions. Here are some ideas:

  • Empty program
  • Division by zero
  • Undefined variable access
  • Syntax errors (invalid tokens, malformed statements)
  • Large numbers (overflow)
  • Deeply nested expressions
  • Invalid line numbers (negative, non-sequential)
  • GOTO to non-existent line
  • Infinite loops
  • … and so on.

If you read the above list with a thought, the “infinite loops” item might stand out. Testing infinite loops is tricky since they never terminate, and the halting problem basically says that there’s no general algorithm that can always detect an infinite loop.

However, we can implement a practical safeguard to prevent tests from hanging indefinitely. We could, for example, add a maximum instruction count to the interpreter. If the program exceeds this count, we can assume it’s in an infinite loop and terminate execution with an error.

We’ll leave these edge case for you to implement! If you’re interested, there’s a course called Software Testing and Quality Assurance at Aalto that goes into much more depth on testing.

Loading Exercise...

Test Coverage

Rust also tools for checking test coverage - how much of our code is exercised by tests. High coverage doesn’t guarantee correctness, but low coverage indicates untested code that may harbor bugs.

See Rust’s documentation for test coverage.

You can install cargo-llvm-cov as a helper tool to make extracting coverage reports easier. First, update your Rust toolchain to ensure you have the latest stable version:

rustup update stable

Then, install cargo-llvm-cov:

cargo install cargo-llvm-cov

And, finally, run the tests and check coverage:

cargo llvm-cov --open

This will run your tests and generate a coverage report, opening it in your web browser. Look for files with low coverage and consider adding tests to cover those areas.

Loading Exercise...

Summary

In this chapter, we briefly discussed testing. To summarize:

  • Unit tests test individual components in isolation.
  • Integration tests test multiple components working together.
  • When testing, you should also test edge cases and error conditions.
  • Test coverage measures how much of your code is exercised by tests, helping identify untested areas.
Loading Exercise...

In the next chapter, we’ll discuss some of the history related to the GOTO statement.