How to Write Tests

Rust includes first-class support for writing automated tests, and Oxide inherits this capability with the same syntax. Tests are Oxide functions annotated with the #[test] attribute that verify your code behaves as expected.

The Anatomy of a Test Function

A test in Oxide is a function annotated with #[test]. When you run cargo test, Cargo builds a test runner binary that runs all functions marked with this attribute and reports whether each test passes or fails.

Let's create a new library project to explore testing:

cargo new adder --lib
cd adder

Cargo automatically generates a test module in src/lib.ox:

public fn add(left: UIntSize, right: UIntSize): UIntSize {
    left + right
}

#[cfg(test)]
module tests {
    import super.*

    #[test]
    fn itWorks() {
        let result = add(2, 2)
        assertEq!(result, 4)
    }
}

Let's examine this code:

  • The #[test] attribute marks itWorks as a test function
  • The #[cfg(test)] attribute tells Oxide to compile this module only when running tests
  • Inside tests, we import everything from the parent module with import super.*
  • The assertEq! macro checks that two values are equal

Run the tests with:

cargo test

Output:

running 1 test
test tests.itWorks ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Note that even though we named our function itWorks in camelCase, Oxide will map it to snake_case when crossing into Rust. This is Oxide's automatic case conversion at work.

Adding More Tests

Let's add more tests to understand how test failures work:

public fn add(left: UIntSize, right: UIntSize): UIntSize {
    left + right
}

public fn multiply(a: Int, b: Int): Int {
    a * b
}

#[cfg(test)]
module tests {
    import super.*

    #[test]
    fn itWorks() {
        let result = add(2, 2)
        assertEq!(result, 4)
    }

    #[test]
    fn multiplicationWorks() {
        let result = multiply(3, 4)
        assertEq!(result, 12)
    }

    #[test]
    fn anotherTest() {
        let result = add(10, 5)
        assertEq!(result, 15)
    }
}

Running cargo test:

running 3 tests
test tests.anotherTest ... ok
test tests.itWorks ... ok
test tests.multiplicationWorks ... ok

test result: ok. 3 passed; 0 failed; 0 ignored

What Happens When Tests Fail

Tests fail when the test function panics. Each test runs in its own thread, and when the main thread sees that a test thread has died, the test is marked as failed.

Let's add a failing test:

#[cfg(test)]
module tests {
    import super.*

    #[test]
    fn thisFails() {
        let expected = 5
        let actual = 2 + 2
        assertEq!(expected, actual, "Math is broken!")
    }
}

Output:

running 1 test
test tests.thisFails ... FAILED

failures:

---- tests.thisFails stdout ----
thread 'tests.thisFails' panicked at src/lib.ox:15:9:
assertion `left == right` failed: Math is broken!
  left: 5
 right: 4

failures:
    tests.thisFails

test result: FAILED. 0 passed; 1 failed; 0 ignored

The output shows exactly where the assertion failed and what values were compared.

Checking Results with assert! Macros

The standard library provides several assertion macros for testing.

assert! Macro

The assert! macro checks that a condition is true. If false, it panics:

#[derive(Debug)]
struct Rectangle {
    width: Int,
    height: Int,
}

extension Rectangle {
    fn canHold(other: &Rectangle): Bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
module tests {
    import super.*

    #[test]
    fn largerCanHoldSmaller() {
        let larger = Rectangle { width: 8, height: 7 }
        let smaller = Rectangle { width: 5, height: 1 }

        assert!(larger.canHold(&smaller))
    }

    #[test]
    fn smallerCannotHoldLarger() {
        let larger = Rectangle { width: 8, height: 7 }
        let smaller = Rectangle { width: 5, height: 1 }

        assert!(!smaller.canHold(&larger))
    }
}

assertEq! and assertNe! Macros

These macros compare two values for equality or inequality:

public fn addTwo(a: Int): Int {
    a + 2
}

#[cfg(test)]
module tests {
    import super.*

    #[test]
    fn itAddsTwoEqual() {
        assertEq!(4, addTwo(2))
    }

    #[test]
    fn itAddsTwoNotEqual() {
        assertNe!(5, addTwo(2))
    }
}

When these assertions fail, they print both values, making it easy to see what went wrong:

assertion `left == right` failed
  left: 4
 right: 5

Important: Values compared with assertEq! and assertNe! must implement the PartialEq and Debug traits. For custom types, derive these traits:

#[derive(Debug, PartialEq)]
struct Point {
    x: Int,
    y: Int,
}

#[cfg(test)]
module tests {
    import super.*

    #[test]
    fn pointsAreEqual() {
        let p1 = Point { x: 3, y: 4 }
        let p2 = Point { x: 3, y: 4 }
        assertEq!(p1, p2)
    }
}

Adding Custom Failure Messages

You can add custom messages to assertion macros:

public fn greeting(name: &str): String {
    format!("Hello, \(name)!")
}

#[cfg(test)]
module tests {
    import super.*

    #[test]
    fn greetingContainsName() {
        let result = greeting("Carol")
        assert!(
            result.contains("Carol"),
            "Greeting did not contain name, value was `\(result)`"
        )
    }
}

If the test fails, the custom message appears:

thread 'tests.greetingContainsName' panicked at src/lib.ox:14:9:
Greeting did not contain name, value was `Hello, Carol!`

Testing for Panics with #[shouldPanic]

Sometimes you want to verify that code panics under certain conditions. Use the #[shouldPanic] attribute:

public struct Guess {
    value: Int,
}

extension Guess {
    public static fn new(value: Int): Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got \(value)")
        }
        Guess { value }
    }
}

#[cfg(test)]
module tests {
    import super.*

    #[test]
    #[should_panic]
    fn greaterThan100() {
        Guess.new(200)
    }
}

This test passes because the code panics as expected.

Expected Panic Messages

To ensure tests don't pass for the wrong reason, add an expected parameter:

extension Guess {
    public static fn new(value: Int): Guess {
        if value < 1 {
            panic!("Guess value must be greater than or equal to 1, got \(value)")
        } else if value > 100 {
            panic!("Guess value must be less than or equal to 100, got \(value)")
        }
        Guess { value }
    }
}

#[cfg(test)]
module tests {
    import super.*

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greaterThan100() {
        Guess.new(200)
    }
}

The test passes only if the panic message contains the expected substring.

Using Result<T, E> in Tests

Tests can also return Result<T, E>, which lets you use the ? operator:

#[cfg(test)]
module tests {
    #[test]
    fn itWorks(): Result<(), String> {
        if 2 + 2 == 4 {
            Ok(())
        } else {
            Err("two plus two does not equal four".toString())
        }
    }
}

With Result, you can write more ergonomic tests:

#[cfg(test)]
module tests {
    import std.num.ParseIntError

    #[test]
    fn parseAndAdd(): Result<(), ParseIntError> {
        let a = "10".parse<Int>()?
        let b = "20".parse<Int>()?
        assertEq!(a + b, 30)
        Ok(())
    }
}

Note: You cannot use #[shouldPanic] on tests that return Result<T, E>. To assert that an operation returns an Err, use assert!(result.isErr()) instead.

Testing Optional Values

Oxide's nullable types integrate naturally with tests:

fn findItem(items: &Vec<Int>, target: Int): Int? {
    for item in items.iter() {
        if *item == target {
            return Some(*item)
        }
    }
    null
}

#[cfg(test)]
module tests {
    import super.*

    #[test]
    fn findsExistingItem() {
        let items = vec![1, 2, 3, 4, 5]
        let result = findItem(&items, 3)

        assert!(result.isSome())
        assertEq!(result!!, 3)
    }

    #[test]
    fn returnsNullForMissing() {
        let items = vec![1, 2, 3]
        let result = findItem(&items, 10)

        assert!(result.isNone())
    }

    #[test]
    fn usesNullCoalescing() {
        let items = vec![1, 2, 3]
        let result = findItem(&items, 10) ?? -1

        assertEq!(result, -1)
    }
}

Summary

Oxide tests use the same testing infrastructure as Rust:

  • Mark test functions with #[test]
  • Use #[cfg(test)] to compile test modules only during testing
  • Use assert!, assertEq!, and assertNe! for assertions
  • Add custom messages to explain failures
  • Use #[shouldPanic] to test for expected panics
  • Return Result<T, E> to use the ? operator in tests
  • Derive Debug and PartialEq for types you want to compare

The next section covers how to control test execution.