Implement should_panic expectation and refactor ktest

This commit is contained in:
Zhang Junyang
2023-11-06 14:44:26 +08:00
committed by Tate, Hongliang Tian
parent 45a6b2f46c
commit 8eb1e06c2a
11 changed files with 420 additions and 208 deletions

14
Cargo.lock generated
View File

@ -842,6 +842,14 @@ version = "0.1.0"
[[package]]
name = "ktest"
version = "0.1.0"
dependencies = [
"ktest-proc-macro",
"owo-colors",
]
[[package]]
name = "ktest-proc-macro"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
@ -1018,6 +1026,12 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
[[package]]
name = "owo-colors"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f"
[[package]]
name = "paste"
version = "1.0.14"

View File

@ -58,7 +58,7 @@ make run
### Unit Test
#### User mode unit test
#### User mode unit tests
Many of our crates does not require running on bare metal environment and can be tested through the standard Cargo testing framework. A specific list of which crates can be tested with `cargo test` is listed in the `[workspace.metadata.usermode_testable]` entry in the `Cargo.toml` file of the root workspace.
@ -69,7 +69,7 @@ make test
Nevertheless, you could enter the directory of a specific crate and invoke `cargo test` to perform user mode unit tests and doctests.
#### Kernel mode unit test
#### Kernel mode unit tests
We can run unit tests in kernel mode for crates like `jinux-frame` or `jinux-std`. This is powered by our [ktest](framework/libs/ktest) framework.
```bash

View File

@ -1,4 +1,4 @@
//! QEMU isa debug device.
//! Providing the ability to exit QEMU and return a value as debug result.
/// The exit code of x86 QEMU isa debug device. In `qemu-system-x86_64` the
/// exit code will be `(code << 1) | 1`. So you could never let QEMU invoke
@ -12,11 +12,18 @@ pub enum QemuExitCode {
Failed = 0x20,
}
/// Exit QEMU with the given exit code.
///
/// This function assumes that the kernel is run in QEMU with the following
/// QEMU command line arguments that specifies the ISA debug exit device:
/// `-device isa-debug-exit,iobase=0xf4,iosize=0x04`.
pub fn exit_qemu(exit_code: QemuExitCode) -> ! {
use x86_64::instructions::port::Port;
let mut port = Port::new(0xf4);
// Safety: The write to the ISA debug exit port is safe and `0xf4` should
// be the port number.
unsafe {
let mut port = Port::new(0xf4);
port.write(exit_code as u32);
}
unreachable!()

View File

@ -3,9 +3,10 @@
//!
pub mod kcmdline;
pub mod memory_region;
use kcmdline::KCmdlineArg;
pub mod memory_region;
use self::memory_region::MemoryRegion;
use alloc::{string::String, vec::Vec};
@ -116,7 +117,16 @@ pub fn call_jinux_main() -> ! {
}
#[cfg(ktest)]
{
use crate::arch::qemu::{exit_qemu, QemuExitCode};
use alloc::boxed::Box;
use core::any::Any;
crate::init();
ktest::do_ktests!();
let fn_catch_unwind = &(unwinding::panic::catch_unwind::<(), fn()>
as fn(fn()) -> Result<(), Box<(dyn Any + Send + 'static)>>);
use ktest::runner::{run_ktests, KtestResult};
match run_ktests(crate::console::print, fn_catch_unwind) {
KtestResult::Ok => exit_qemu(QemuExitCode::Success),
KtestResult::Failed => exit_qemu(QemuExitCode::Failed),
}
}
}

View File

@ -82,7 +82,7 @@ fn invoke_ffi_init_funcs() {
}
}
/// Unit test for the ktest framework and functions of the frame.
/// Simple unit tests for the ktest framework.
#[if_cfg_ktest]
mod test {
#[ktest]
@ -95,4 +95,10 @@ mod test {
fn failing_assertion() {
assert_eq!(0, 1);
}
#[ktest]
#[should_panic(expected = "expected panic message")]
fn expect_panic() {
panic!("expected panic message");
}
}

View File

@ -1,29 +1,13 @@
//! Panic support in Jinux Frame.
//! Panic support.
use alloc::boxed::Box;
use alloc::string::{String, ToString};
use alloc::{boxed::Box, string::ToString};
use crate::arch::qemu::{exit_qemu, QemuExitCode};
use crate::println;
#[derive(Clone, Debug)]
pub struct PanicInfo {
pub message: String,
pub file: String,
pub line: usize,
pub col: usize,
}
impl core::fmt::Display for PanicInfo {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
writeln!(f, "Panicked at {}:{}:{}", self.file, self.line, self.col)?;
writeln!(f, "{}", self.message)
}
}
#[panic_handler]
pub fn panic_handler(info: &core::panic::PanicInfo) -> ! {
let throw_info = PanicInfo {
fn panic_handler(info: &core::panic::PanicInfo) -> ! {
let throw_info = ktest::PanicInfo {
message: info.message().unwrap().to_string(),
file: info.location().unwrap().file().to_string(),
line: info.location().unwrap().line() as usize,

View File

@ -0,0 +1,15 @@
[package]
name = "ktest-proc-macro"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1.0.69"
quote = "1.0.33"
rand = "0.8.5"
syn = { version = "2.0.29", features = ["full"] }

View File

@ -0,0 +1,128 @@
#![feature(proc_macro_span)]
extern crate proc_macro2;
use proc_macro::TokenStream;
use quote::quote;
use rand::{distributions::Alphanumeric, Rng};
use syn::{parse_macro_input, Expr, Ident, ItemFn, ItemMod};
/// The conditional compilation attribute macro to control the compilation of test
/// modules.
#[proc_macro_attribute]
pub fn if_cfg_ktest(_attr: TokenStream, item: TokenStream) -> TokenStream {
// Assuming that the item is a module declearation, otherwise panics.
let input = parse_macro_input!(item as ItemMod);
let crate_name = std::env::var("CARGO_PKG_NAME").unwrap();
let output = quote! {
#[cfg(all(ktest, any(ktest = "all", ktest = #crate_name)))]
#input
};
TokenStream::from(output)
}
/// The test attribute macro to mark a test function.
#[proc_macro_attribute]
pub fn ktest(_attr: TokenStream, item: TokenStream) -> TokenStream {
// Assuming that the item has type `fn() -> ()`, otherwise panics.
let input = parse_macro_input!(item as ItemFn);
assert!(
input.sig.inputs.is_empty(),
"ktest function should have no arguments"
);
assert!(
matches!(input.sig.output, syn::ReturnType::Default),
"ktest function should return `()`"
);
// Generate a random identifier to avoid name conflicts.
let fn_id: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(8)
.map(char::from)
.collect();
let fn_name = &input.sig.ident;
let fn_ktest_item_name = Ident::new(
&format!("{}_ktest_item_{}", &input.sig.ident, &fn_id),
proc_macro2::Span::call_site(),
);
let is_should_panic_attr = |attr: &&syn::Attribute| {
attr.path()
.segments
.iter()
.any(|segment| segment.ident == "should_panic")
};
let mut attr_iter = input.attrs.iter();
let should_panic = attr_iter.find(is_should_panic_attr);
let (should_panic, expectation) = match should_panic {
Some(attr) => {
assert!(
!attr_iter.any(|attr: &syn::Attribute| is_should_panic_attr(&attr)),
"multiple `should_panic` attributes"
);
match &attr.meta {
syn::Meta::List(l) => {
let arg_err_message = "`should_panic` attribute should only have zero or one `expected` argument, with the format of `expected = \"<panic message>\"`";
let expected_assign =
syn::parse2::<syn::ExprAssign>(l.tokens.clone()).expect(arg_err_message);
let Expr::Lit(s) = *expected_assign.right else {
panic!("{}", arg_err_message);
};
let syn::Lit::Str(expectation) = s.lit else {
panic!("{}", arg_err_message);
};
(true, Some(expectation))
}
_ => (true, None),
}
}
None => (false, None),
};
let expectation_tokens = if let Some(s) = expectation {
quote! {
Some(#s)
}
} else {
quote! {
None
}
};
let package_name = std::env::var("CARGO_PKG_NAME").unwrap();
let span = proc_macro::Span::call_site();
let source = span.source_file().path();
let source = source.to_str().unwrap();
let line = span.line();
let col = span.column();
let register_ktest_item = quote! {
#[cfg(ktest)]
#[used]
#[link_section = ".ktest_array"]
static #fn_ktest_item_name: ktest::KtestItem = ktest::KtestItem::new(
#fn_name,
(#should_panic, #expectation_tokens),
ktest::KtestItemInfo {
module_path: module_path!(),
fn_name: stringify!(#fn_name),
package: #package_name,
source: #source,
line: #line,
col: #col,
},
);
};
let output = quote! {
#input
#register_ktest_item
};
TokenStream::from(output)
}

View File

@ -5,11 +5,6 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1.0.69"
quote = "1.0.33"
rand = "0.8.5"
syn = { version = "2.0.29", features = ["full"] }
owo-colors = "3.5.0"
ktest-proc-macro = { path = "../ktest-proc-macro" }

View File

@ -1,12 +1,17 @@
//! # The kernel mode testing framework of Jinux.
//!
//! `ktest` stands for kernel-mode testing framework. Its goal is to provide a
//! `cargo test`-like experience for any crates that depends on jinux-frame.
//! `cargo test`-like experience for any `#![no_std]` bare metal crates.
//!
//! All the tests written in the source tree of the crates will be run using the
//! `do_ktests!()` macro immediately after the initialization of jinux-frame.
//! Thus you can use any feature provided by the frame including the heap
//! allocator, etc.
//! In Jinux, all the tests written in the source tree of the crates will be run
//! immediately after the initialization of jinux-frame. Thus you can use any
//! feature provided by the frame including the heap allocator, etc.
//!
//! By all means, ktest is an individule crate that only requires:
//! - a custom linker script section `.ktest_array`,
//! - and an alloc implementation.
//! to work. And the frame happens to provide both of them. Thus, any crates depending
//! on the frame can use ktest without any extra dependency.
//!
//! ## Usage
//!
@ -26,6 +31,11 @@
//! fn failing_assertion() {
//! assert_eq!(0, 1);
//! }
//! #[ktest]
//! #[should_panic(expected = "expected panic message")]
//! fn expect_panic() {
//! panic!("expected panic message");
//! }
//! }
//! ```
//!
@ -59,198 +69,153 @@
//! a default conditional compilation setting:
//! `#[cfg(all(ktest, any(ktest = "all", ktest = #crate_name)))]`
//!
//! We do not support `#[should_panic]` attribute, but the implementation is quite
//! slow currently. Use it with cautious.
//! We support the `#[should_panic]` attribute just in the same way as the standard
//! library do, but the implementation is quite slow currently. Use it with cautious.
//!
//! Doctest is not taken into consideration yet, and the interface is subject to
//! change.
//!
//! ## How it works
//!
//! The `ktest` framework is implemented using the procedural macro feature of Rust.
//! The `ktest` attribute macro will generate a static fn pointer variable linked in
//! the `.ktest_array` section. The `do_ktests!()` macro will iterate over all the
//! static variables in the section and run the tests.
//!
#![feature(proc_macro_span)]
#![no_std]
#![feature(panic_info_message)]
extern crate proc_macro2;
pub mod runner;
use proc_macro::TokenStream;
use quote::quote;
use rand::{distributions::Alphanumeric, Rng};
use syn::{parse_macro_input, Ident, ItemFn, ItemMod};
extern crate alloc;
use alloc::{boxed::Box, string::String};
use core::result::Result;
/// The conditional compilation attribute macro to control the compilation of test
/// modules.
#[proc_macro_attribute]
pub fn if_cfg_ktest(_attr: TokenStream, item: TokenStream) -> TokenStream {
// Assuming that the item is a module declearation, otherwise panics.
let input = parse_macro_input!(item as ItemMod);
pub use ktest_proc_macro::{if_cfg_ktest, ktest};
let crate_name = std::env::var("CARGO_PKG_NAME").unwrap();
let output = quote! {
#[cfg(all(ktest, any(ktest = "all", ktest = #crate_name)))]
#input
};
TokenStream::from(output)
#[derive(Clone, Debug)]
pub struct PanicInfo {
pub message: String,
pub file: String,
pub line: usize,
pub col: usize,
}
/// The test attribute macro to mark a test function.
#[proc_macro_attribute]
pub fn ktest(_attr: TokenStream, item: TokenStream) -> TokenStream {
// Assuming that the item has type `fn() -> ()`, otherwise panics.
let input = parse_macro_input!(item as ItemFn);
assert!(
input.sig.inputs.is_empty(),
"ktest function should have no arguments"
);
assert!(
matches!(input.sig.output, syn::ReturnType::Default),
"ktest function should return `()`"
);
impl core::fmt::Display for PanicInfo {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
writeln!(f, "Panicked at {}:{}:{}", self.file, self.line, self.col)?;
writeln!(f, "{}", self.message)
}
}
// Generate a random identifier to avoid name conflicts.
let fn_id: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(8)
.map(char::from)
.collect();
#[derive(Clone)]
pub enum KtestError {
Panic(Box<PanicInfo>),
ShouldPanicButNoPanic,
ExpectedPanicNotMatch(&'static str, Box<PanicInfo>),
Unknown,
}
let fn_name = &input.sig.ident;
let fn_ktest_item_name = Ident::new(
&format!("{}_ktest_item_{}", &input.sig.ident, &fn_id),
proc_macro2::Span::call_site(),
);
#[derive(Clone)]
pub struct KtestItemInfo {
pub module_path: &'static str,
pub fn_name: &'static str,
pub package: &'static str,
pub source: &'static str,
pub line: usize,
pub col: usize,
}
// Since Rust does not support unamed structures, we have to generate a
// unique name for each test item structure.
let ktest_item_struct = Ident::new(
&format!("KtestItem{}", &fn_id),
proc_macro2::Span::call_site(),
);
#[derive(Clone)]
pub struct KtestItem {
fn_: fn() -> (),
should_panic: (bool, Option<&'static str>),
info: KtestItemInfo,
}
let should_panic = input.attrs.iter().any(|attr| {
attr.path()
.segments
.iter()
.any(|segment| segment.ident == "should_panic")
});
type CatchUnwindImpl = fn(f: fn() -> ()) -> Result<(), Box<dyn core::any::Any + Send>>;
let package_name = std::env::var("CARGO_PKG_NAME").unwrap();
let span = proc_macro::Span::call_site();
let source = span.source_file().path();
let source = source.to_str().unwrap();
let line = span.line();
let col = span.column();
let register = quote! {
struct #ktest_item_struct {
fn_: fn() -> (),
should_panic: bool,
module_path: &'static str,
fn_name: &'static str,
package: &'static str,
source: &'static str,
line: usize,
col: usize,
impl KtestItem {
pub const fn new(
fn_: fn() -> (),
should_panic: (bool, Option<&'static str>),
info: KtestItemInfo,
) -> Self {
Self {
fn_,
should_panic,
info,
}
#[cfg(ktest)]
#[used]
#[link_section = ".ktest_array"]
static #fn_ktest_item_name: #ktest_item_struct = #ktest_item_struct {
fn_: #fn_name,
should_panic: #should_panic,
module_path: module_path!(),
fn_name: stringify!(#fn_name),
package: #package_name,
source: #source,
line: #line,
col: #col,
};
};
}
let output = quote! {
#input
pub fn info(&self) -> &KtestItemInfo {
&self.info
}
#register
};
TokenStream::from(output)
/// Run the test with a given catch_unwind implementation.
pub fn run(&self, catch_unwind_impl: &CatchUnwindImpl) -> Result<(), KtestError> {
let test_result = catch_unwind_impl(self.fn_);
if !self.should_panic.0 {
// Should not panic.
match test_result {
Ok(()) => Ok(()),
Err(e) => match e.downcast::<PanicInfo>() {
Ok(s) => Err(KtestError::Panic(s)),
Err(_payload) => Err(KtestError::Unknown),
},
}
} else {
// Should panic.
match test_result {
Ok(()) => Err(KtestError::ShouldPanicButNoPanic),
Err(e) => match e.downcast::<PanicInfo>() {
Ok(s) => {
if let Some(expected) = self.should_panic.1 {
if s.message == expected {
Ok(())
} else {
Err(KtestError::ExpectedPanicNotMatch(expected, s))
}
} else {
Ok(())
}
}
Err(_payload) => Err(KtestError::Unknown),
},
}
}
}
}
/// The procedural macro to run all the tests.
#[proc_macro]
pub fn do_ktests(_item: TokenStream) -> TokenStream {
let body = quote! {
use crate::arch::qemu::{exit_qemu, QemuExitCode};
struct KtestItem {
fn_: fn() -> (),
should_panic: bool,
module_path: &'static str,
fn_name: &'static str,
package: &'static str,
source: &'static str,
line: usize,
col: usize,
};
macro_rules! ktest_array {
() => {{
extern "C" {
fn __ktest_array();
fn __ktest_array_end();
};
let item_size = core::mem::size_of::<KtestItem>() as u64;
let l = (__ktest_array_end as u64 - __ktest_array as u64) / item_size;
crate::println!("Running {} tests", l);
for i in 0..l {
unsafe {
let item_ptr = (__ktest_array as u64 + item_size * i) as *const u64;
let item = item_ptr as *const KtestItem;
crate::print!("[{}] test {}::{} ...", (*item).package, (*item).module_path, (*item).fn_name);
let test_result = unwinding::panic::catch_unwind((*item).fn_);
let print_failure_heading = || {
crate::println!("\nfailures:\n");
crate::println!("---- {}:{}:{} - {} ----", (*item).source, (*item).line, (*item).col, (*item).fn_name);
};
if !(*item).should_panic {
match test_result {
Ok(()) => {
crate::println!(" ok");
},
Err(e) => {
crate::println!(" FAILED");
print_failure_heading();
match e.downcast::<crate::panicking::PanicInfo>() {
Ok(s) => {
crate::println!("[caught panic] {}", s);
},
Err(payload) => {
crate::println!("[caught panic] unknown panic payload: {:#?}", payload);
},
}
exit_qemu(QemuExitCode::Failed);
},
}
} else {
match test_result {
Ok(()) => {
crate::println!(" FAILED");
print_failure_heading();
crate::println!("test did not panic as expected");
exit_qemu(QemuExitCode::Failed);
},
Err(_) => {
crate::println!(" ok");
},
}
}
}
}
exit_qemu(QemuExitCode::Success);
};
TokenStream::from(body)
let item_size = core::mem::size_of::<KtestItem>();
let l = (__ktest_array_end as usize - __ktest_array as usize) / item_size;
// Safety: __ktest_array is a static section consisting of KtestItem.
unsafe { core::slice::from_raw_parts(__ktest_array as *const KtestItem, l) }
}};
}
pub struct KtestIter {
index: usize,
}
impl KtestIter {
fn new() -> Self {
Self { index: 0 }
}
}
impl core::iter::Iterator for KtestIter {
type Item = KtestItem;
fn next(&mut self) -> Option<Self::Item> {
let Some(ktest_item) = ktest_array!().get(self.index) else {
return None;
};
self.index += 1;
Some(ktest_item.clone())
}
}
fn get_ktest_tests() -> (usize, KtestIter) {
(ktest_array!().len(), KtestIter::new())
}

View File

@ -0,0 +1,88 @@
use crate::{CatchUnwindImpl, KtestError, KtestItem};
use alloc::vec::Vec;
use core::format_args;
use owo_colors::OwoColorize;
pub enum KtestResult {
Ok,
Failed,
}
/// Run all the tests registered by `#[ktest]` in the `.ktest_array` section.
///
/// Need to provide a print function to print the test result, and a `catch_unwind`
/// implementation to catch the panic.
///
/// Returns the test result interpreted as `ok` or `FAILED`.
pub fn run_ktests<PrintFn>(print: PrintFn, catch_unwind: &CatchUnwindImpl) -> KtestResult
where
PrintFn: Fn(core::fmt::Arguments),
{
macro_rules! print {
($fmt: literal $(, $($arg: tt)+)?) => {
print(format_args!($fmt $(, $($arg)+)?))
}
}
let (n, ktests) = crate::get_ktest_tests();
print!("\nrunning {} tests\n\n", n);
let mut passed: usize = 0;
let mut failed_tests: Vec<(KtestItem, KtestError)> = Vec::new();
for test in ktests {
print!(
"[{}] test {}::{} ...",
test.info().package,
test.info().module_path,
test.info().fn_name
);
match test.run(catch_unwind) {
Ok(()) => {
print!(" {}\n", "ok".green());
passed += 1;
}
Err(e) => {
print!(" {}\n", "FAILED".red());
failed_tests.push((test.clone(), e.clone()));
}
}
}
let failed = failed_tests.len();
if failed == 0 {
print!("\ntest result: {}.", "ok".green());
} else {
print!("\ntest result: {}.", "FAILED".red());
}
print!(" {} passed; {} failed.\n", passed, failed);
if failed > 0 {
print!("\nfailures:\n\n");
for (t, e) in failed_tests {
print!(
"---- {}:{}:{} - {} ----\n\n",
t.info().source,
t.info().line,
t.info().col,
t.info().fn_name
);
match e {
KtestError::Panic(s) => {
print!("[caught panic] {}\n", s);
}
KtestError::ShouldPanicButNoPanic => {
print!("test did not panic as expected\n");
}
KtestError::ExpectedPanicNotMatch(expected, s) => {
print!("[caught panic] expected panic not match\n");
print!("expected: {}\n", expected);
print!("caught: {}\n", s);
}
KtestError::Unknown => {
print!("[caught panic] unknown panic payload! (fatal panic handling error in ktest)\n");
}
}
}
return KtestResult::Failed;
}
KtestResult::Ok
}