Part 4- The Philosophy of Testing in a Bare-Metal World
Testing in a Rust no_std
Kernel
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`
Don’t be alarmed it just means we need our own runner.
Enabling a Custom Test Framework
- Switch to nightly and add the feature in
src/main.rs
orsrc/lib.rs
:
#![no_std]
#![no_main]
#![feature(custom_test_frameworks)]
#![test_runnerkernel_test_runner]
I renamed test_runner
to kernel_test_runner
to avoid conflicts if you add more entry points later.
- 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 {}
}
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
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)*); };
}
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 {}
}
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);
}
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");
}
}
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");
}
Set harness = false
in Cargo.toml
for tests without the default test harness.
~K