Test Organization
The Oxide and Rust community thinks about tests in terms of two main categories: unit tests and integration tests. Unit tests are small and focused, testing one module in isolation and can test private interfaces. Integration tests are external to your library and use your code the same way external code would, using only the public interface.
Unit Tests
The purpose of unit tests is to test each unit of code in isolation to quickly pinpoint where code is or isn't working as expected. Unit tests go in the src directory in each file with the code they're testing.
The Tests Module and #[cfg(test)]
The #[cfg(test)] annotation tells Oxide to compile and run the test code only when you run cargo test, not when you run cargo build. This saves compile time and reduces the binary size.
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)
}
}
The #[cfg(test)] attribute on the tests module means:
- The module is only compiled during
cargo test - All code inside is test-only, including helper functions
- The module is stripped from release builds
Testing Private Functions
Unlike some languages, Oxide allows you to test private functions directly. Since tests are in the same file as the code, they have access to private items:
fn internalAdd(a: Int, b: Int): Int {
a + b
}
public fn publicAdd(a: Int, b: Int): Int {
internalAdd(a, b)
}
#[cfg(test)]
module tests {
import super.*
#[test]
fn testInternalAdd() {
// We can test the private function directly
assertEq!(internalAdd(2, 3), 5)
}
#[test]
fn testPublicAdd() {
assertEq!(publicAdd(2, 3), 5)
}
}
Whether you choose to test private functions is a matter of opinion. Oxide makes it possible, but you're not required to do so.
Organizing Unit Tests
For larger modules, you might organize tests into submodules:
public struct Calculator {
value: Int,
}
extension Calculator {
public static fn new(): Calculator {
Calculator { value: 0 }
}
public fn add(amount: Int): Int {
self.value + amount
}
public fn multiply(factor: Int): Int {
self.value * factor
}
}
#[cfg(test)]
module tests {
import super.*
module additionTests {
import super.*
#[test]
fn addsPositiveNumbers() {
let calc = Calculator { value: 5 }
assertEq!(calc.add(3), 8)
}
#[test]
fn addsNegativeNumbers() {
let calc = Calculator { value: 5 }
assertEq!(calc.add(-3), 2)
}
}
module multiplicationTests {
import super.*
#[test]
fn multipliesPositiveNumbers() {
let calc = Calculator { value: 5 }
assertEq!(calc.multiply(3), 15)
}
#[test]
fn multipliesByZero() {
let calc = Calculator { value: 5 }
assertEq!(calc.multiply(0), 0)
}
}
}
Integration Tests
Integration tests are entirely external to your library. They use your library in the same way any other code would, which means they can only call public functions. Their purpose is to test that multiple parts of your library work together correctly.
The tests Directory
To create integration tests, create a tests directory at the top level of your project, next to src:
adder/
Cargo.toml
src/
lib.ox
tests/
integration_test.ox
Let's create tests/integration_test.ox:
import adder
#[test]
fn itAddsTwo() {
assertEq!(4, adder.add(2, 2))
}
Key differences from unit tests:
- No
#[cfg(test)]needed - Cargo knows thetestsdirectory contains tests - External perspective - We import our crate with
import adder - Public API only - We can only use public functions and types
Run integration tests with:
cargo test --test integration_test
Or run all tests:
cargo test
Output:
running 1 test
test tests.itWorks ... ok
Running tests/integration_test.ox (target/debug/deps/integration_test-...)
running 1 test
test itAddsTwo ... ok
test result: ok. 1 passed; 0 failed
Each file in tests is compiled as a separate crate.
Submodules in Integration Tests
As you add more integration tests, you might want to organize them. Each file in tests compiles as its own crate, so they don't share behavior like modules in src.
For shared helper code, create a subdirectory with a mod.ox file:
tests/
common/
mod.ox
integration_test.ox
In tests/common/mod.ox:
public fn setup(): String {
// Setup code that might be needed by multiple tests
"test_database".toString()
}
public struct TestConfig {
public name: String,
public debug: Bool,
}
extension TestConfig {
public static fn default(): TestConfig {
TestConfig {
name: "test".toString(),
debug: true,
}
}
}
In tests/integration_test.ox:
external module common
import adder
import crate.common.{ setup, TestConfig }
#[test]
fn itAddsTwo() {
let _config = TestConfig.default()
let _db = setup()
assertEq!(4, adder.add(2, 2))
}
#[test]
fn itAddsLargeNumbers() {
assertEq!(1000000002, adder.add(1000000000, 2))
}
Files in subdirectories of tests don't get compiled as separate test crates. The common/mod.ox pattern prevents Cargo from treating common as a test file while allowing other tests to import it.
Integration Tests for Binary Crates
If your project only contains a src/main.ox and no src/lib.ox, you can't create integration tests in the tests directory and import functions with import cratename.
This is one reason Oxide projects with a binary have a straightforward src/main.ox that calls logic in src/lib.ox. The library can be tested with integration tests while the main file remains minimal.
Multiple Integration Test Files
For larger projects, organize integration tests by feature:
tests/
common/
mod.ox
api_tests.ox
database_tests.ox
user_tests.ox
Each file runs as its own test suite. Run a specific one:
cargo test --test api_tests
Test Organization Best Practices
Unit Test Guidelines
- Keep tests close to code - Tests in the same file as the code they test
- Test one thing - Each test should verify a single behavior
- Use descriptive names -
testAddWithNegativeNumbersnottest1 - Arrange-Act-Assert - Structure tests clearly
#[test]
fn userCanChangeName() {
// Arrange
var user = User.new("Alice")
// Act
user.setName("Bob")
// Assert
assertEq!(user.name, "Bob")
}
Integration Test Guidelines
- Test the public interface - Don't try to access private internals
- Test realistic scenarios - Combine operations as users would
- Share setup code - Use the
commonmodule pattern - One concern per file - Organize by feature or subsystem
When to Use Each
| Test Type | Use For |
|---|---|
| Unit tests | Individual functions, edge cases, error handling |
| Integration tests | Feature workflows, API contracts, module interactions |
Documentation Tests
Oxide also runs code examples in documentation comments as tests:
/// Adds two numbers together.
///
/// # Examples
///
/// ```oxide
/// let result = adder.add(2, 2)
/// assertEq!(result, 4)
/// ```
public fn add(left: UIntSize, right: UIntSize): UIntSize {
left + right
}
Run documentation tests with:
cargo test --doc
Documentation tests ensure your examples stay correct as code evolves.
Summary
Oxide's testing features help you write and organize tests effectively:
Unit Tests:
- Placed in
#[cfg(test)]modules alongside code - Can test private functions
- Fast to compile and run
- Use for isolated, focused tests
Integration Tests:
- Placed in the
testsdirectory - Use your library as an external consumer would
- Test only the public API
- Use for testing feature workflows
Best Practices:
- Keep tests close to the code they test
- Test one behavior per test
- Use descriptive test names
- Share setup code through common modules
- Run tests frequently during development
Testing is a skill that improves with practice. Start with simple tests and grow your test suite as your codebase grows.