Part 4- The Philosophy of Testing in a Bare-Metal World

Testing in a Rust no_std Kernel

Tip

This guide adapts traditional Rust testing for a bare-metal, no_std environment.

In this chapter I walk through how I set up both unit and integration tests for a custom kernel in QEMU. Along the way, I'll drop some callouts when I hit common pitfalls.

Why cargo test Fails Out of the Box

Rust’s built-in test harness requires std (and the test crate), which doesn't exist in a no_std OS. If you run:

cargo test

you’ll see a complaint like:

error: cannot find crate `test`
Warning

Don’t be alarmed it just means we need our own runner.

Enabling a Custom Test Framework

  1. Switch to nightly and add the feature in src/main.rs or src/lib.rs:
#![no_std]
#![no_main]
#![feature(custom_test_frameworks)]
#![test_runnerkernel_test_runner]
Note

I renamed test_runner to kernel_test_runner to avoid conflicts if you add more entry points later.

  1. Define your runner (only in test builds):
#[cfg(test)]
pub fn kernel_test_runner(tests: &[&dyn Fn()]) {
    serial_println!("Running {} tests...", tests.len());
    for test in tests {
        test();
    }
    exit_qemuSuccess;
}

Hooking Into _start

To invoke the harness, re-export the test harness entry point and call it in your boot code:

#![reexport_test_harness_main = "kernel_tests"]

#[no_mangle]
pub extern "C" fn _start() -> ! {
    println!("Kernel init...");
    #[cfg(test)]
    kernel_tests();
    loop {}
}
Tip

The loop {} keeps QEMU from dropping out—just make sure you signal exit explicitly.

Writing a Simple Test

#[test_case]
fn trivial_assertion() {
    serial_print!("Checking 1 == 1... ");
    assert_eq!(1, 1);
    serial_println!("[OK]");
}

Run with:

cargo test -- --nocapture
Warning

Always pass --nocapture or you’ll miss your serial output.

Exiting QEMU Automatically

Add QEMU’s ISA debug-exit device in Cargo.toml under [package.metadata.bootimage]:

test-args = [
  "-device", "isa-debug-exit,iobase=0xf4,iosize=0x04",
  "-serial", "stdio",
  "-display", "none"
]

Then define an exit helper in src/lib.rs:

use x86_64::instructions::port::Port;

#[repr(u32)]
pub enum ExitCode {
    Success = 0x10,
    Failed  = 0x11,
}

pub fn exit_qemu(code: ExitCode) {
    unsafe {
        let mut port = Port::new(0xf4);
        port.write(code as u32);
    }
}

Update the test runner to call exit_qemuSuccess after all tests.

Serial Console Output

I prefer serial over VGA for tests. In src/serial.rs:

// src/serial.rs
use uart_16550::SerialPort;
use spin::Mutex;
use lazy_static::lazy_static;

lazy_static! {
    pub static ref SERIAL_PORT: Mutex<SerialPort> = {
        let mut port = unsafe { SerialPort::new(0x3F8) };
        port.init();
        Mutex::new(port)
    };
}

pub fn _serial_print(args: core::fmt::Arguments) {
    use core::fmt::Write;
    SERIAL_PORT.lock().write_fmt(args).unwrap();
}

#[macro_export]
macro_rules! serial_print {
    ($($arg:tt)*) => { $crate::serial::_serial_print(format_args!($($arg)*)); };
}

#[macro_export]
macro_rules! serial_println {
    () => { $crate::serial_print!("\n"); };
    ($fmt:expr) => { $crate::serial_print!(concat!($fmt, "\n")); };
    ($fmt:expr, $($arg:tt)*) => { $crate::serial_print!(concat!($fmt, "\n"), $($arg)*); };
}
Note

I swapped SERIAL1 for SERIAL_PORT—it reads more clearly in code.

Panic Handlers

Tailor panic behavior for runtime vs. tests:

#[cfg(not(test))]
#[panic_handler]
fn panic(info: &core::panic::PanicInfo) -> ! {
    println!("PANIC: {}", info);
    loop {}
}

#[cfg(test)]
#[panic_handler]
fn panic(info: &core::panic::PanicInfo) -> ! {
    serial_println!("[FAILED]\nError: {}", info);
    exit_qemuFailed;
    loop {}
}
Warning

Without a separate test panic handler, a test failure will hang QEMU.

Automating Test Prints with a Trait

pub trait Testable {
    fn run(&self);
}

impl<T> Testable for T
where
    T: Fn()
{
    fn run(&self) {
        serial_print!("{}...\t", core::any::type_name::<T>());
        self();
        serial_println!("[ok]");
    }
}

#[cfg(test)]
pub fn kernel_test_runner(tests: &[&dyn Testable]) {
    serial_println!("Running {} tests", tests.len());
    for t in tests { t.run(); }
    exit_qemuSuccess;
}

Test example becomes:

#[test_case]
fn trivial_assertion() {
    assert_eq!(2 + 2, 4);
}
Tip

Tests now automatically include their function name in the serial log.

VGA Buffer Stress Tests

In src/vga_buffer.rs, add extra edge cases:

#[test_case]
fn test_scroll_up() {
    for _ in 0..(BUFFER_HEIGHT + 5) {
        println!("overflow line");
    }
}
Note

I like to test scroll behavior by overshooting the buffer height.

Integration Tests in tests/

Create tests/boot.rs:

#![no_std]
#![no_main]
#![feature(custom_test_frameworks)]
#![test_runnerkernel_test_runner]
#![reexport_test_harness_main = "kernel_tests"]

use core::panic::PanicInfo;
use blog_os::{exit_qemu, serial_println, ExitCode};

#[no_mangle]
pub extern "C" fn _start() -> ! {
    kernel_tests();
    exit_qemuSuccess;
    loop {}
}

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    serial_println!("Integration test panic: {}", info);
    exit_qemuFailed;
    loop {}
}

#[test_case]
fn integration_println() {
    println!("Hello from integration test");
}
Warning

Set harness = false in Cargo.toml for tests without the default test harness.


~K