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 marksitWorksas 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 withimport 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!, andassertNe!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
DebugandPartialEqfor types you want to compare
The next section covers how to control test execution.