تست کردن
محتوای ترجمه شده: این یک ترجمه از جامعه کاربران برای پست Testing است. ممکن است ناقص، منسوخ شده یا دارای خطا باشد. لطفا هر گونه مشکل را در این ایشو گزارش دهید!
ترجمه توسط @hamidrezakp و @MHBahrampour.
این پست به بررسی تستهای واحد (ترجمه: unit) و یکپارچه (ترجمه: integration) در فایلهای اجرایی no_std
میپردازد. ما از پشتیبانی Rust برای فریمورک تستهای سفارشی استفاده میکنیم تا توابع تست را درون کرنلمان اجرا کنیم. برای گزارش کردن نتایج خارج از QEMU، از ویژگیهای مختلف QEMU و ابزار bootimage
استفاده میکنیم.
این بلاگ بصورت آزاد روی گیتهاب توسعه داده شده است. اگر شما مشکل یا سوالی دارید، لطفاً آنجا یک ایشو باز کنید. شما همچنین میتوانید در زیر این پست کامنت بگذارید. منبع کد کامل این پست را میتوانید در بِرَنچ post-04
پیدا کنید.
فهرست مطالب
🔗نیازمندیها
این پست جایگزین (حالا منسوخ شده) پستهای Unit Testing و Integration Tests میشود. فرض بر این است که شما پست یک کرنل مینیمال با Rust را پس از 27-09-2019 دنبال کردهاید. اساساً نیاز است که شما یک فایل .cargo/config.toml
داشته باشید که یک هدف پیشفرض مشخص میکند و یک اجرا کننده قابل اجرا تعریف میکند.
🔗تست کردن در Rust
زبان Rust یک فریمورک تست توکار دارد که قادر به اجرای تستهای واحد بدون نیاز به تنظیم هر چیزی است. فقط کافی است تابعی ایجاد کنید که برخی نتایج را از طریق اَسرشنها (کلمه: assertions) بررسی کند و صفت #[test]
را به هدر تابع (ترجمه: function header) اضافه کنید. سپس cargo test
به طور خودکار تمام تابعهای تست کریت شما را پیدا و اجرا میکند.
متأسفانه برای برنامههای no_std
مانند هسته ما کمی پیچیدهتر است. مسئله این است که فریمورک تست Rust به طور ضمنی از کتابخانه test
داخلی استفاده میکند که به کتابخانه استاندارد وابسته است. این بدان معناست که ما نمیتوانیم از فریمورک تست پیشفرض برای هسته #[no_std]
خود استفاده کنیم.
وقتی میخواهیم cargo test
را در پروژه خود اجرا کنیم، چنین چیزی میبینیم:
> cargo test
Compiling blog_os v0.1.0 (/…/blog_os)
error[E0463]: can't find crate for `test`
از آنجایی که کریت test
به کتابخانه استاندارد وابسته است، برای هدف bare metal ما در دسترس نیست. در حالی که استفاده از کریت test
در یک #[no_std]
امکان پذیر است، اما بسیار ناپایدار بوده و به برخی هکها مانند تعریف مجدد ماکرو panic
نیاز دارد.
🔗فریمورک تست سفارشی
خوشبختانه، Rust از جایگزین کردن فریمورک تست پیشفرض از طریق ویژگی custom_test_frameworks
ناپایدار پشتیبانی میکند. این ویژگی به کتابخانه خارجی احتیاج ندارد و بنابراین در محیطهای #[no_std]
نیز کار میکند. این کار با جمع آوری تمام توابع دارای صفت #[test_case]
و سپس فراخوانی یک تابع اجرا کننده مشخص شده توسط کاربر و با لیست تستها به عنوان آرگومان کار میکند. بنابراین حداکثر کنترل فرآیند تست را به ما میدهد.
نقطه ضعف آن در مقایسه با فریمورک تست پیشفرض این است که بسیاری از ویژگیهای پیشرفته مانند تستهای should_panic
در دسترس نیست. در عوض، تهیه این ویژگیها در صورت نیاز به پیادهسازی ما بستگی دارد. این برای ما ایده آل است، زیرا ما یک محیط اجرای بسیار ویژه داریم که پیاده سازی پیشفرض چنین ویژگیهای پیشرفتهای احتمالاً کارساز نخواهد بود. به عنوان مثال، صفت #[should_panic]
متکی به stack unwinding برای گرفتن پنیکها (کلمه: panics) است، که ما آن را برای هسته خود غیرفعال کردیم.
برای اجرای یک فریمورک تست سفارشی برای هسته خود، موارد زیر را به main.rs
اضافه میکنیم:
// in src/main.rs
#![feature(custom_test_frameworks)]
#![test_runner(crate::test_runner)]
#[cfg(test)]
pub fn test_runner(tests: &[&dyn Fn()]) {
println!("Running {} tests", tests.len());
for test in tests {
test();
}
}
اجرا کننده ما فقط یک پیام کوتاه اشکال زدایی را چاپ میکند و سپس هر تابع تست درون لیست را فراخوانی میکند. نوع آرگومان &[&dyn Fn()]
یک slice از trait object است که آن هم ارجاعی از تِرِیت (کلمه: trait) Fn() میباشد. در اصل لیستی از ارجاع به انواع است که میتوان آنها را مانند یک تابع صدا زد. از آنجایی که این تابع برای اجراهایی که تست نباشند بی فایده است، از ویژگی #[cfg(test)]
استفاده میکنیم تا آن را فقط برای تست کردن در اضافه کنیم.
حال وقتی که cargo test
را اجرا میکنیم، میبینیم که الان موفقیت آمیز است (اگر اینطور نیست یادداشت زیر را بخوانید). اگرچه، همچنان “Hello World” را به جای پیام test_runner
میبینیم. دلیلش این است که تابع _start
هنوز بعنوان نقطه شروع استفاده میشود. ویژگی فریمورک تست سفارشی، یک تابع main
ایجاد میکند که test_runner
را صدا میزند، اما این تابع نادیده گرفته میشود چرا که ما از ویژگی #[no_main]
استفاده میکنیم و نقطه شروع خودمان را ایجاد کردیم.
یادداشت: درحال حاضر یک باگ در کارگو وجود دارد که در برخی موارد وقتی از cargo test
استفاده میکنیم ما را به سمت خطای “duplicate lang item” میبرد. زمانی رخ میدهد که شما panic = "abort"
را برای یک پروفایل در Cargo.toml
تنظیم کردهاید. سعی کنید آن را حذف کنید، سپس cargo test
باید به درستی کار کند. برای اطلاعات بیشتر ایشوی کارگو را ببینید.
برای حل کردن این مشکل، ما ابتدا نیاز داریم که نام تابع تولید شده را از طریق صفت reexport_test_harness_main
به چیزی غیر از main
تغییر دهیم. سپس میتوانیم تابع تغییر نام داده شده را از تابع _start
صدا بزنیم:
// in src/main.rs
#![reexport_test_harness_main = "test_main"]
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
#[cfg(test)]
test_main();
loop {}
}
ما نام فریمورک تست تابع شروع را test_main
گذاشتیم و آن را درون _start
صدا زدیم. از conditional compilation برای اضافه کردن فراخوانی test_main
فقط در زمینههای تست استفاده میکنیم زیرا تابع روی یک اجرای عادی تولید نشده است.
زمانی که cargo test
را اجرا میکنیم، میبینیم که پیام “Running 0 tests” از test_runner
روی صفحه نمایش داده میشود. حال ما آمادهایم تا اولین تابع تست را بسازیم:
// in src/main.rs
#[test_case]
fn trivial_assertion() {
print!("trivial assertion... ");
assert_eq!(1, 1);
println!("[ok]");
}
حال وقتی cargo test
را اجرا میکنیم، خروجی زیر را میبینیم:
حالا بخش tests
ارسال شده به تابع test_runner
شامل یک ارجاع به تابع trivial_assertion
است. از خروجی trivial assertion... [ok]
روی صفحه میفهمیم که تست مورد نظر فراخوانی شده و موفقیت آمیز بوده است.
پس از اجرای تستها، test_runner
به تابع test_main
برمیگردد، که به نوبه خود به تابع _start
برمیگردد. در انتهای _start
، یک حلقه بیپایان ایجاد میکنیم زیرا تابع شروع اجازه برگردادن چیزی را ندارد (یعنی بدون خروجی است). این یک مشکل است، زیرا میخواهیم cargo test
پس از اجرای تمام تستها به کار خود پایان دهد.
🔗خروج از QEMU
در حال حاضر ما یک حلقه بیپایان در انتهای تابع "_start"
داریم و باید QEMU را به صورت دستی در هر مرحله از cargo test
ببندیم. این جای تأسف دارد زیرا ما همچنین میخواهیم cargo test
را در اسکریپتها بدون تعامل کاربر اجرا کنیم. یک راه حل خوب میتواند اجرای یک روش مناسب برای خاموش کردن سیستم عامل باشد. متأسفانه این کار نسبتاً پیچیده است، زیرا نیاز به پشتیبانی از استاندارد APM یا ACPI مدیریت توان دارد.
خوشبختانه، یک دریچه فرار وجود دارد: QEMU از یک دستگاه خاص isa-debug-exit
پشتیبانی میکند، که راهی آسان برای خروج از سیستم QEMU از سیستم مهمان فراهم میکند. برای فعال کردن آن، باید یک آرگومان -device
را به QEMU منتقل کنیم. ما میتوانیم این کار را با اضافه کردن کلید پیکربندی pack.metadata.bootimage.test-args
در Cargo.toml
انجام دهیم:
# in Cargo.toml
[package.metadata.bootimage]
test-args = ["-device", "isa-debug-exit,iobase=0xf4,iosize=0x04"]
bootimage runner
برای کلیه تستهای اجرایی test-args
را به دستور پیش فرض QEMU اضافه می کند. برای یک cargo run
عادی، آرگومانها نادیده گرفته میشوند.
همراه با نام دستگاه (isa-debug-exit
)، دو پارامتر iobase
و iosize
را عبور میدهیم که پورت I/O را مشخص میکند و هسته از طریق آن میتواند به دستگاه دسترسی داشته باشد.
🔗پورتهای I/O
برای برقراری ارتباط بین پردازنده و سخت افزار جانبی در x86، دو رویکرد مختلف وجود دارد،memory-mapped I/O و port-mapped I/O. ما قبلاً برای دسترسی به بافر متن VGA از طریق آدرس حافظه 0xb8000
از memory-mapped I/O استفاده کردهایم. این آدرس به RAM مپ (ترسیم) نشده است، بلکه به برخی از حافظههای دستگاه VGA مپ شده است.
در مقابل، port-mapped I/O از یک گذرگاه I/O جداگانه برای ارتباط استفاده میکند. هر قسمت جانبی متصل دارای یک یا چند شماره پورت است. برای برقراری ارتباط با چنین پورت I/O، دستورالعملهای CPU خاصی وجود دارد که in
و out
نامیده میشوند، که یک عدد پورت و یک بایت داده را میگیرند (همچنین این دستورات تغییراتی دارند که اجازه می دهد یک u16
یا u32
ارسال کنید).
دستگاههای isa-debug-exit
از port-mapped I/O استفاده میکنند. پارامتر iobase
مشخص میکند که دستگاه باید در کدام آدرس پورت قرار بگیرد (0xf4
یک پورت معمولاً استفاده نشده در گذرگاه IO x86 است) و iosize
اندازه پورت را مشخص میکند (0x04
یعنی چهار بایت).
🔗استفاده از دستگاه خروج
عملکرد دستگاه isa-debug-exit
بسیار ساده است. وقتی یک مقدار به پورت I/O مشخص شده توسط iobase
نوشته میشود، باعث می شود QEMU با exit status خارج شود (value << 1) | 1
. بنابراین هنگامی که ما 0
را در پورت مینویسیم، QEMU با وضعیت خروج (0 << 1) | 1 = 1
خارج میشود و وقتی که ما 1
را در پورت مینویسیم با وضعیت خروج (1 << 1) | 1 = 3
از آن خارج می شود.
به جای فراخوانی دستی دستورالعمل های اسمبلی in
و out
، ما از انتزاعات ارائه شده توسط کریت x86_64
استفاده میکنیم. برای افزودن یک وابستگی به آن کریت، آن را به بخش dependencies
در Cargo.toml
اضافه میکنیم:
# in Cargo.toml
[dependencies]
x86_64 = "0.14.2"
اکنون میتوانیم از نوع Port
ارائه شده توسط کریت برای ایجاد عملکرد exit_qemu
استفاده کنیم:
// in src/main.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum QemuExitCode {
Success = 0x10,
Failed = 0x11,
}
pub fn exit_qemu(exit_code: QemuExitCode) {
use x86_64::instructions::port::Port;
unsafe {
let mut port = Port::new(0xf4);
port.write(exit_code as u32);
}
}
این تابع یک Port
جدید در 0xf4
ایجاد میکند، که iobase
دستگاه isa-debug-exit
است. سپس کد خروجی عبور داده شده را در پورت مینویسد. ما از u32
استفاده میکنیم زیرا iosize
دستگاه isa-debug-exit
را به عنوان 4 بایت مشخص کردیم. هر دو عملیات ایمن نیستند، زیرا نوشتن در یک پورت I/O میتواند منجر به رفتار خودسرانه شود.
برای تعیین وضعیت خروج، یک اینام (کلمه: enum) QemuExitCode
ایجاد می کنیم. ایده این است که اگر همه تستها موفقیت آمیز بود، با کد خروج موفقیت (ترجمه: success exit code) خارج شود و در غیر این صورت با کد خروج شکست (ترجمه: failure exit code) خارج شود. enum به عنوان #[repr(u32)]
علامت گذاری شده است تا هر نوع را با یک عدد صحیح u32
نشان دهد. برای موفقیت از کد خروجی 0x10
و برای شکست از 0x11
استفاده میکنیم. کدهای خروجی واقعی چندان هم مهم نیستند، به شرطی که با کدهای خروجی پیش فرض QEMU مغایرت نداشته باشند. به عنوان مثال، استفاده از کد خروجی 0
برای موفقیت ایده خوبی نیست زیرا پس از تغییر شکل تبدیل به (0 << 1) | 1 = 1
میشود، که کد خروجی پیش فرض است برای زمانی که QEMU نمیتواند اجرا شود. بنابراین ما نمیتوانیم خطای QEMU را از یک تست موفقیت آمیز تشخیص دهیم.
اکنون می توانیم test_runner
خود را به روز کنیم تا پس از اتمام تستها از QEMU خارج شویم:
fn test_runner(tests: &[&dyn Fn()]) {
println!("Running {} tests", tests.len());
for test in tests {
test();
}
/// new
exit_qemu(QemuExitCode::Success);
}
حال وقتی cargo test
را اجرا میکنیم، میبینیم که QEMU پس از اجرای تستها بلافاصله بسته میشود. مشکل این است که cargo test
تست را به عنوان شکست تفسیر میکند حتی اگر کد خروج Success
را عبور دهیم:
> cargo test
Finished dev [unoptimized + debuginfo] target(s) in 0.03s
Running target/x86_64-blog_os/debug/deps/blog_os-5804fc7d2dd4c9be
Building bootloader
Compiling bootloader v0.5.3 (/home/philipp/Documents/bootloader)
Finished release [optimized + debuginfo] target(s) in 1.07s
Running: `qemu-system-x86_64 -drive format=raw,file=/…/target/x86_64-blog_os/debug/
deps/bootimage-blog_os-5804fc7d2dd4c9be.bin -device isa-debug-exit,iobase=0xf4,
iosize=0x04`
error: test failed, to rerun pass '--bin blog_os'
مسئله این است که cargo test
همه کدهای خطا به غیر از 0
را به عنوان شکست در نظر میگیرد.
🔗کد خروج موفقیت
برای کار در این مورد، bootimage
یک کلید پیکربندی test-success-exit-code
ارائه میدهد که یک کد خروجی مشخص را به کد خروجی 0
مپ میکند:
[package.metadata.bootimage]
test-args = […]
test-success-exit-code = 33 # (0x10 << 1) | 1
با استفاده از این پیکربندی، bootimage
کد خروج موفقیت ما را به کد خروج 0 مپ میکند، به طوری که cargo test
به درستی مورد موفقیت را تشخیص میدهد و تست را شکست خورده به حساب نمیآورد.
اجرا کننده تست ما اکنون به طور خودکار QEMU را میبندد و نتایج تست را به درستی گزارش میکند. ما همچنان میبینیم که پنجره QEMU برای مدت بسیار کوتاهی باز است، اما این مدت بسیار کوتاه برای خواندن نتایج کافی نیست. جالب میشود اگر بتوانیم نتایج تست را به جای QEMU در کنسول چاپ کنیم، بنابراین پس از خروج از QEMU هنوز میتوانیم آنها را ببینیم.
🔗چاپ کردن در کنسول
برای دیدن خروجی تست روی کنسول، باید دادهها را از هسته خود به نحوی به سیستم میزبان ارسال کنیم. روشهای مختلفی برای دستیابی به این هدف وجود دارد، به عنوان مثال با ارسال دادهها از طریق رابط شبکه TCP. با این حال، تنظیم پشته شبکه یک کار کاملا پیچیده است، بنابراین ما به جای آن راه حل سادهتری را انتخاب خواهیم کرد.
🔗پورت سریال
یک راه ساده برای ارسال دادهها استفاده از پورت سریال است، یک استاندارد رابط قدیمی که دیگر در رایانههای مدرن یافت نمیشود. پیادهسازی آن آسان است و QEMU میتواند بایتهای ارسالی از طریق سریال را به خروجی استاندارد میزبان یا یک فایل هدایت کند.
تراشههای پیاده سازی یک رابط سریال [UART] نامیده میشوند. در x86 مدلهای UART زیادی وجود دارد، اما خوشبختانه تنها تفاوت آنها ویژگیهای پیشرفتهای است که نیازی به آنها نداریم. UART هایِ رایج امروزه همه با 16550 UART سازگار هستند، بنابراین ما از آن مدل برای فریمورک تست خود استفاده خواهیم کرد.
ما از کریت uart_16550
برای شروع اولیه UART و ارسال دادهها از طریق پورت سریال استفاده خواهیم کرد. برای افزودن آن به عنوان یک وابستگی، ما Cargo.toml
و main.rs
خود را به روز میکنیم:
# in Cargo.toml
[dependencies]
uart_16550 = "0.2.0"
کریت uart_16550
حاوی ساختار SerialPort
است که نمایانگر ثباتهای UART است، اما ما هنوز هم باید نمونهای از آن را خودمان بسازیم. برای آن ما یک ماژول serial
جدید با محتوای زیر ایجاد میکنیم:
// in src/main.rs
mod serial;
// in src/serial.rs
use uart_16550::SerialPort;
use spin::Mutex;
use lazy_static::lazy_static;
lazy_static! {
pub static ref SERIAL1: Mutex<SerialPort> = {
let mut serial_port = unsafe { SerialPort::new(0x3F8) };
serial_port.init();
Mutex::new(serial_port)
};
}
مانند بافر متن VGA vga lazy-static، ما از lazy_static
و یک spinlock برای ایجاد یک نمونه نویسنده static
استفاده میکنیم. با استفاده از lazy_static
میتوان اطمینان حاصل کرد که متد init
در اولین استفاده دقیقاً یک بار فراخوانی میشود.
مانند دستگاه isa-debug-exit
، UART با استفاده از پورت I/O برنامه نویسی میشود. از آنجا که UART پیچیدهتر است، از چندین پورت I/O برای برنامه نویسی رجیسترهای مختلف دستگاه استفاده میکند. تابع ناامن SerialPort::new
انتظار دارد که آدرس اولین پورت I/O از UART به عنوان آرگومان باشد، که از آن میتواند آدرس تمام پورتهای مورد نیاز را محاسبه کند. ما در حال عبور دادنِ آدرس پورت 0x3F8
هستیم که شماره پورت استاندارد برای اولین رابط سریال است.
برای اینکه پورت سریال به راحتی قابل استفاده باشد، ماکروهای serial_print!
و serial_println!
را اضافه میکنیم:
#[doc(hidden)]
pub fn _print(args: ::core::fmt::Arguments) {
use core::fmt::Write;
SERIAL1.lock().write_fmt(args).expect("Printing to serial failed");
}
/// Prints to the host through the serial interface.
#[macro_export]
macro_rules! serial_print {
($($arg:tt)*) => {
$crate::serial::_print(format_args!($($arg)*));
};
}
/// Prints to the host through the serial interface, appending a newline.
#[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)*));
}
پیاده سازی بسیار شبیه به پیاده سازی ماکروهای print
و println
است. از آنجا که نوع SerialPort
تِرِیت fmt::Write
را پیاده سازی میکند، نیازی نیست این پیاده سازی را خودمان انجام دهیم.
اکنون میتوانیم به جای بافر متن VGA در کد تست خود، روی رابط سریال چاپ کنیم:
// in src/main.rs
#[cfg(test)]
fn test_runner(tests: &[&dyn Fn()]) {
serial_println!("Running {} tests", tests.len());
[…]
}
#[test_case]
fn trivial_assertion() {
serial_print!("trivial assertion... ");
assert_eq!(1, 1);
serial_println!("[ok]");
}
توجه داشته باشید که ماکرو serial_println
مستقیماً در زیر فضای نام (ترجمه: namespace) ریشه قرار میگیرد زیرا ما از صفت #[macro_export]
استفاده کردیم، بنابراین وارد کردن آن از طریق use crate::serial::serial_println
کار نمی کند.
🔗آرگومانهای QEMU
برای دیدن خروجی سریال از QEMU، باید از آرگومان -serial
برای هدایت خروجی به stdout (خروجی استاندارد) استفاده کنیم:
# in Cargo.toml
[package.metadata.bootimage]
test-args = [
"-device", "isa-debug-exit,iobase=0xf4,iosize=0x04", "-serial", "stdio"
]
حالا وقتی cargo test
را اجرا میکنیم، خروجی تست را مستقیماً در کنسول مشاهده خواهیم گرد:
> cargo test
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
Running target/x86_64-blog_os/debug/deps/blog_os-7b7c37b4ad62551a
Building bootloader
Finished release [optimized + debuginfo] target(s) in 0.02s
Running: `qemu-system-x86_64 -drive format=raw,file=/…/target/x86_64-blog_os/debug/
deps/bootimage-blog_os-7b7c37b4ad62551a.bin -device
isa-debug-exit,iobase=0xf4,iosize=0x04 -serial stdio`
Running 1 tests
trivial assertion... [ok]
با این حال، هنگامی که یک تست ناموفق بود، ما همچنان خروجی را داخل QEMU مشاهده میکنیم، زیرا panic handler هنوز از println
استفاده میکند. برای شبیهسازی این، میتوانیم assertion درون تست trivial_assertion
را به assert_eq!(0, 1)
تغییر دهیم:
میبینیم که پیام panic (تلفظ: پَنیک) هنوز در بافر VGA چاپ میشود، در حالی که خروجی تست دیگر (منظور تستی میباشد که پنیک نکند) در پورت سریال چاپ میشود. پیام پنیک کاملاً مفید است، بنابراین دیدن آن در کنسول نیز مفید خواهد بود.
🔗چاپ کردن پیام خطا هنگام پنیک کردن
برای خروج از QEMU با یک پیام خطا هنگامی که پنیک رخ میدهد، میتوانیم از conditional compilation برای استفاده از یک panic handler متفاوت در حالت تست استفاده کنیم:
// our existing panic handler
#[cfg(not(test))] // new attribute
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
println!("{}", info);
loop {}
}
// our panic handler in test mode
#[cfg(test)]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
serial_println!("[failed]\n");
serial_println!("Error: {}\n", info);
exit_qemu(QemuExitCode::Failed);
loop {}
}
برای panic handler تستِ خودمان، از serial_println
به جای println
استفاده میکنیم و سپس با کد خروج خطا از QEMU خارج میشویم. توجه داشته باشید که بعد از فراخوانی exit_qemu
هنوز به یک حلقه بیپایان نیاز داریم زیرا کامپایلر نمیداند که دستگاه isa-debug-exit
باعث خروج برنامه میشود.
اکنون QEMU برای تستهای ناموفق نیز خارج شده و یک پیام خطای مفید روی کنسول چاپ می کند:
> cargo test
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
Running target/x86_64-blog_os/debug/deps/blog_os-7b7c37b4ad62551a
Building bootloader
Finished release [optimized + debuginfo] target(s) in 0.02s
Running: `qemu-system-x86_64 -drive format=raw,file=/…/target/x86_64-blog_os/debug/
deps/bootimage-blog_os-7b7c37b4ad62551a.bin -device
isa-debug-exit,iobase=0xf4,iosize=0x04 -serial stdio`
Running 1 tests
trivial assertion... [failed]
Error: panicked at 'assertion failed: `(left == right)`
left: `0`,
right: `1`', src/main.rs:65:5
از آنجایی که اکنون همه خروجیهای تست را در کنسول مشاهده میکنیم، دیگر نیازی به پنجره QEMU نداریم که برای مدت کوتاهی ظاهر میشود. بنابراین میتوانیم آن را کاملا پنهان کنیم.
🔗پنهان کردن QEMU
از آنجا که ما نتایج کامل تست را با استفاده از دستگاه isa-debug-exit
و پورت سریال گزارش میکنیم، دیگر نیازی به پنجره QEMU نداریم. ما میتوانیم آن را با عبور دادن آرگومان -display none
به QEMU پنهان کنیم:
# in Cargo.toml
[package.metadata.bootimage]
test-args = [
"-device", "isa-debug-exit,iobase=0xf4,iosize=0x04", "-serial", "stdio",
"-display", "none"
]
اکنون QEMU کاملا در پس زمینه اجرا میشود و دیگر هیچ پنجرهای باز نمیشود. این نه تنها کمتر آزار دهنده است، بلکه به فریمورک تست ما این امکان را میدهد که در محیطهای بدون رابط کاربری گرافیکی مانند سرویسهای CI یا کانکشنهای SSH اجرا شود.
🔗Timeouts
از آنجا که cargo test
منتظر میماند تا test runner (ترجمه: اجرا کننده تست) پایان یابد، تستی که هرگز به اتمام نمیرسد (چه موفق، چه ناموفق) میتواند برای همیشه اجرا کننده تست را مسدود کند. این جای تأسف دارد، اما در عمل مشکل بزرگی نیست زیرا اجتناب از حلقههای بیپایان به طور معمول آسان است. با این حال، در مورد ما، حلقههای بیپایان میتوانند در موقعیتهای مختلف رخ دهند:
- بوت لودر موفق به بارگیری هسته نمیشود، در نتیجه سیستم به طور بیوقفه راه اندازی مجدد شود.
- فریمورک BIOS/UEFI قادر به بارگیری بوت لودر نمیشود، در نتیجه باز هم باعث راهاندازی مجدد بیپایان میشود.
- وقتی که CPU در انتهای برخی از توابع ما وارد یک
loop {}
(حلقه بیپایان) میشود، به عنوان مثال به دلیل اینکه دستگاه خروج QEMU به درستی کار نمیکند. - یا وقتی که سخت افزار باعث ریست شدن سیستم میشود، به عنوان مثال وقتی یک استثنای پردازنده (ترجمه: CPU exception) گیر نمیافتد (در پست بعدی توضیح داده شده است).
از آنجا که حلقه های بیپایان در بسیاری از شرایط ممکن است رخ دهد، به طور پیش فرض ابزار bootimage
برای هر تست ۵ دقیقه زمان تعیین میکند. اگر تست در این زمان به پایان نرسد، به عنوان ناموفق علامت گذاری شده و خطای “Timed Out” در کنسول چاپ می شود. این ویژگی تضمین میکند که تستهایی که در یک حلقه بیپایان گیر کردهاند، cargo test
را برای همیشه مسدود نمیکنند.
خودتان میتوانید با افزودن عبارت loop {}
در تست trivial_assertion
آن را امتحان کنید. هنگامی که cargo test
را اجرا میکنید، میبینید که این تست پس از ۵ دقیقه به پایان رسیده است. مدت زمان مهلت از طریق یک کلید test-timeout
در Cargo.toml قابل پیکربندی است:
# in Cargo.toml
[package.metadata.bootimage]
test-timeout = 300 # (in seconds)
اگر نمیخواهید ۵ دقیقه منتظر بمانید تا تست trivial_assertion
تمام شود، میتوانید به طور موقت مقدار فوق را کاهش دهید.
🔗اضافه کردن چاپ خودکار
تست trivial_assertion
در حال حاضر باید اطلاعات وضعیت خود را با استفاده از serial_print!
/serial_println!
چاپ کند:
#[test_case]
fn trivial_assertion() {
serial_print!("trivial assertion... ");
assert_eq!(1, 1);
serial_println!("[ok]");
}
افزودن دستی این دستورات چاپی برای هر تستی که مینویسیم دست و پا گیر است، بنابراین بیایید test_runner
خود را به روز کنیم تا به صورت خودکار این پیامها را چاپ کنیم. برای انجام این کار، ما باید یک تریت جدید به نام Testable
ایجاد کنیم:
// in src/main.rs
pub trait Testable {
fn run(&self) -> ();
}
این ترفند اکنون پیاده سازی این تریت برای همه انواع T
است که Fn()
trait را پیاده سازی میکنند:
// in src/main.rs
impl<T> Testable for T
where
T: Fn(),
{
fn run(&self) {
serial_print!("{}...\t", core::any::type_name::<T>());
self();
serial_println!("[ok]");
}
}
ما با اولین چاپِ نام تابع از طریق تابعِ any::type_name
، تابع run
را پیاده سازی می کنیم. این تابع مستقیماً در کامپایلر پیاده سازی شده و یک رشته توضیح از هر نوع را برمیگرداند. برای توابع، نوع آنها نامشان است، بنابراین این دقیقاً همان چیزی است که ما در این مورد میخواهیم. کاراکتر \t
کاراکتر tab است، که مقداری ترازبندی به پیامهای [ok]
اضافه میکند.
پس از چاپ نام تابع، ما از طریق self ()
تابع تست را فراخوانی میکنیم. این فقط به این دلیل کار میکند که ما نیاز داریم که self
تریت Fn()
را پیاده سازی کند. بعد از بازگشت تابع تست، ما [ok]
را چاپ میکنیم تا نشان دهد که تابع پنیک نکرده است.
آخرین مرحله به روزرسانی test_runner
برای استفاده از تریت جدید Testable
است:
// in src/main.rs
#[cfg(test)]
pub fn test_runner(tests: &[&dyn Testable]) {
serial_println!("Running {} tests", tests.len());
for test in tests {
test.run(); // new
}
exit_qemu(QemuExitCode::Success);
}
تنها دو تغییر رخ داده، نوع آرگومان tests
از &[&dyn Fn()]
به &[&dyn Testable]
است و ما اکنون test.run()
را به جای test()
فراخوانی میکنیم.
اکنون میتوانیم عبارات چاپ را از تست trivial_assertion
حذف کنیم، زیرا آنها اکنون به طور خودکار چاپ میشوند:
// in src/main.rs
#[test_case]
fn trivial_assertion() {
assert_eq!(1, 1);
}
خروجی cargo test
اکنون به این شکل است:
Running 1 tests
blog_os::trivial_assertion... [ok]
نام تابع اکنون مسیر کامل به تابع را شامل میشود، که زمانی مفید است که توابع تست در ماژولهای مختلف نام یکسانی دارند. در غیر اینصورت خروجی همانند قبل است، اما دیگر نیازی نیست که به صورت دستی دستورات چاپ را به تستهای خود اضافه کنیم.
🔗تست کردن بافر VGA
اکنون که یک فریمورک تستِ کارا داریم، میتوانیم چند تست برای اجرای بافر VGA خود ایجاد کنیم. ابتدا، ما یک تست بسیار ساده برای تأیید اینکه println
بدون پنیک کردن کار میکند ایجاد میکنیم:
// in src/vga_buffer.rs
#[test_case]
fn test_println_simple() {
println!("test_println_simple output");
}
این تست فقط چیزی را در بافر VGA چاپ می کند. اگر بدون پنیک تمام شود، به این معنی است که فراخوانی println
نیز پنیک نکرده است.
برای اطمینان از این که پنیک ایجاد نمیشود حتی اگر خطوط زیادی چاپ شده و خطوط از صفحه خارج شوند، میتوانیم آزمایش دیگری ایجاد کنیم:
// in src/vga_buffer.rs
#[test_case]
fn test_println_many() {
for _ in 0..200 {
println!("test_println_many output");
}
}
همچنین میتوانیم تابع تستی ایجاد کنیم تا تأیید کنیم که خطوط چاپ شده واقعاً روی صفحه ظاهر می شوند:
// in src/vga_buffer.rs
#[test_case]
fn test_println_output() {
let s = "Some test string that fits on a single line";
println!("{}", s);
for (i, c) in s.chars().enumerate() {
let screen_char = WRITER.lock().buffer.chars[BUFFER_HEIGHT - 2][i].read();
assert_eq!(char::from(screen_char.ascii_character), c);
}
}
این تابع یک رشته آزمایشی را تعریف میکند، آن را با استفاده از println
چاپ میکند و سپس بر روی کاراکترهای صفحه از WRITER
ثابت تکرار (iterate) میکند، که نشان دهنده بافر متن vga است. از آنجا که println
در آخرین خط صفحه چاپ میشود و سپس بلافاصله یک خط جدید اضافه میکند، رشته باید در خط BUFFER_HEIGHT - 2
ظاهر شود.
با استفاده از enumerate
، تعداد تکرارها را در متغیر i
حساب میکنیم، سپس از آنها برای بارگذاری کاراکتر صفحه مربوط به c
استفاده میکنیم. با مقایسه ascii_character
از کاراکتر صفحه با c
، اطمینان حاصل میکنیم که هر کاراکتر از این رشته واقعاً در بافر متن vga ظاهر میشود.
همانطور که میتوانید تصور کنید، ما میتوانیم توابع تست بیشتری ایجاد کنیم، به عنوان مثال تابعی که تست میکند هنگام چاپ خطوط طولانی پنیک ایجاد نمیشود و به درستی بستهبندی میشوند. یا تابعی برای تست این که خطوط جدید، کاراکترهای غیرقابل چاپ (ترجمه: non-printable) و کاراکترهای non-unicode به درستی اداره میشوند.
برای بقیه این پست، ما نحوه ایجاد integration tests را برای تست تعامل اجزای مختلف با هم توضیح خواهیم داد.
🔗تستهای یکپارچه
قرارداد تستهای یکپارچه در Rust این است که آنها را در یک دایرکتوری tests
در ریشه پروژه قرار دهید (یعنی در کنار فهرست src
). فریمورک تست پیش فرض و فریمورکهای تست سفارشی به طور خودکار تمام تستهای موجود در آن فهرست را انتخاب و اجرا میکنند.
همه تستهای یکپارچه، فایل اجرایی خاص خودشان هستند و کاملاً از main.rs
جدا هستند. این بدان معناست که هر تست باید تابع نقطه شروع خود را مشخص کند. بیایید یک نمونه تست یکپارچه به نام basic_boot
ایجاد کنیم تا با جزئیات ببینیم که چگونه کار میکند:
// in tests/basic_boot.rs
#![no_std]
#![no_main]
#![feature(custom_test_frameworks)]
#![test_runner(crate::test_runner)]
#![reexport_test_harness_main = "test_main"]
use core::panic::PanicInfo;
#[no_mangle] // don't mangle the name of this function
pub extern "C" fn _start() -> ! {
test_main();
loop {}
}
fn test_runner(tests: &[&dyn Fn()]) {
unimplemented!();
}
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
loop {}
}
از آنجا که تستهای یکپارچه فایلهای اجرایی جداگانهای هستند، ما باید تمام صفتهای کریت (no_std
، no_main
، test_runner
و غیره) را دوباره تهیه کنیم. ما همچنین باید یک تابع شروع جدید _start
ایجاد کنیم که تابع نقطه شروع تست test_main
را فراخوانی میکند. ما به هیچ یک از ویژگیهای cfg (test)
نیازی نداریم زیرا اجراییهای تست یکپارچه هرگز در حالت غیر تست ساخته نمیشوند.
ما از ماکرو [ʻunimplemented] استفاده میکنیم که همیشه به عنوان یک مکان نگهدار برای تابع test_runner
پنیک میکند و فقط در حلقه رسیدگی کننده panic
فعلاً loop
میزند. در حالت ایده آل، ما میخواهیم این توابع را دقیقاً همانطور که در main.rs
خود با استفاده از ماکرو serial_println
و تابع exit_qemu
پیاده سازی کردیم، پیاده سازی کنیم. مشکل این است که ما به این توابع دسترسی نداریم زیرا تستها کاملاً جدا از اجرایی main.rs
ساخته شدهاند.
اگر در این مرحله cargo test
را انجام دهید، یک حلقه بیپایان خواهید گرفت زیرا رسیدگی کننده پنیک دارای حلقه بیپایان است. برای خروج از QEMU باید از میانبر صفحه کلید Ctrl + c
استفاده کنید.
🔗ساخت یک کتابخانه
برای در دسترس قرار دادن توابع مورد نیاز در تست یکپارچه، باید یک کتابخانه را از main.rs
جدا کنیم، کتابخانهای که میتواند توسط کریتهای دیگر و تستهای یکپارچه مورد استفاده قرار بگیرد. برای این کار، یک فایل جدید src/lib.rs
ایجاد میکنیم:
// src/lib.rs
#![no_std]
مانند main.rs
،lib.rs
یک فایل خاص است که به طور خودکار توسط کارگو شناسایی میشود. کتابخانه یک واحد تلفیقی جداگانه است، بنابراین باید ویژگی #![no_std]
را دوباره مشخص کنیم.
برای اینکه کتابخانهمان با cargo test
کار کند، باید توابع و صفتهای تست را نیز اضافه کنیم:
To make our library work with cargo test
, we need to also add the test functions and attributes:
// in src/lib.rs
#![cfg_attr(test, no_main)]
#![feature(custom_test_frameworks)]
#![test_runner(crate::test_runner)]
#![reexport_test_harness_main = "test_main"]
use core::panic::PanicInfo;
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]");
}
}
pub fn test_runner(tests: &[&dyn Testable]) {
serial_println!("Running {} tests", tests.len());
for test in tests {
test.run();
}
exit_qemu(QemuExitCode::Success);
}
pub fn test_panic_handler(info: &PanicInfo) -> ! {
serial_println!("[failed]\n");
serial_println!("Error: {}\n", info);
exit_qemu(QemuExitCode::Failed);
loop {}
}
/// Entry point for `cargo test`
#[cfg(test)]
#[no_mangle]
pub extern "C" fn _start() -> ! {
test_main();
loop {}
}
#[cfg(test)]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
test_panic_handler(info)
}
برای اینکه test_runner
را در دسترس تستهای یکپارچه و فایلهای اجرایی قرار دهیم، صفت cfg(test)
را روی آن اعمال نمیکنیم و عمومی نمیکنیم. ما همچنین پیاده سازی رسیدگی کننده پنیک خود را به یک تابع عمومی test_panic_handler
تبدیل میکنیم، به طوری که برای اجراییها نیز در دسترس باشد.
از آنجا که lib.rs
به طور مستقل از main.rs
ما تست میشود، هنگام کامپایل کتابخانه در حالت تست، باید یک نقطه شروع _start
و یک رسیدگی کننده پنیک اضافه کنیم. با استفاده از صفت کریت cfg_attr
، در این حالت ویژگیno_main
را به طور مشروط فعال میکنیم.
ما همچنین اینام QemuExitCode
و تابع exit_qemu
را عمومی میکنیم:
// in src/lib.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum QemuExitCode {
Success = 0x10,
Failed = 0x11,
}
pub fn exit_qemu(exit_code: QemuExitCode) {
use x86_64::instructions::port::Port;
unsafe {
let mut port = Port::new(0xf4);
port.write(exit_code as u32);
}
}
اکنون فایلهای اجرایی و تستهای یکپارچه میتوانند این توابع را از کتابخانه وارد کنند و نیازی به تعریف پیاده سازیهای خود ندارند. برای در دسترس قرار دادن println
و serial_println
، اعلان ماژولها را نیز منتقل میکنیم:
// in src/lib.rs
pub mod serial;
pub mod vga_buffer;
ما ماژولها را عمومی میکنیم تا از خارج از کتابخانه قابل استفاده باشند. این امر همچنین برای استفاده از ماکروهای println
و serial_println
مورد نیاز است، زیرا آنها از توابع _print
ماژولها استفاده میکنند.
اکنون می توانیم main.rs
خود را برای استفاده از کتابخانه به روز کنیم:
// src/main.rs
#![no_std]
#![no_main]
#![feature(custom_test_frameworks)]
#![test_runner(blog_os::test_runner)]
#![reexport_test_harness_main = "test_main"]
use core::panic::PanicInfo;
use blog_os::println;
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
#[cfg(test)]
test_main();
loop {}
}
/// This function is called on panic.
#[cfg(not(test))]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
println!("{}", info);
loop {}
}
#[cfg(test)]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
blog_os::test_panic_handler(info)
}
کتابخانه مانند یک کریت خارجی معمولی قابل استفاده است. و مانند کریت (که در مورد ما کریت blog_os
است) فراخوانی میشود. کد فوق از تابع blog_os :: test_runner
در صفت test_runner
و تابع blog_os :: test_panic_handler
در رسیدگی کننده پنیک cfg(test)
استفاده میکند. همچنین ماکرو println
را وارد میکند تا در اختیار توابع _start
و panic
قرار گیرد.
در این مرحله، cargo run
و cargo test
باید دوباره کار کنند. البته، cargo test
هنوز هم در یک حلقه بیپایان گیر میکند (با ctrl + c
میتوانید خارج شوید). بیایید با استفاده از توابع مورد نیاز کتابخانه در تست یکپارچه این مشکل را برطرف کنیم.
🔗تمام کردن تست یکپارچه
مانند src/main.rs
، اجرایی test/basic_boot.rs
میتواند انواع مختلفی را از کتابخانه جدید ما وارد کند. که این امکان را به ما میدهد تا اجزای گمشده را برای تکمیل آزمایش وارد کنیم.
// in tests/basic_boot.rs
#![test_runner(blog_os::test_runner)]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
blog_os::test_panic_handler(info)
}
ما به جای پیاده سازی مجدد اجرا کننده تست، از تابع test_runner
در کتابخانه خود استفاده میکنیم. برای رسیدگی کننده panic
، ما تابع blog_os::test_panic_handler
را مانند آنچه در main.rs
انجام دادیم، فراخوانی میکنیم.
اکنون cargo test
مجدداً به طور معمول وجود دارد. وقتی آن را اجرا میکنید ، میبینید که تستهای lib.rs
، main.rs
و basic_boot.rs
ما را به طور جداگانه و یکی پس از دیگری ایجاد و اجرا میکند. برای تستهای یکپارچه main.rs
و basic_boot
، متن “Running 0 tests” را نشان میدهد زیرا این فایلها هیچ تابعی با حاشیه نویسی #[test_case]
ندارد.
اکنون میتوانیم تستها را به basic_boot.rs
خود اضافه کنیم. به عنوان مثال، ما میتوانیم آزمایش کنیم که println
بدون پنیک کار میکند، مانند آنچه در تستهای بافر vga انجام دادیم:
// in tests/basic_boot.rs
use blog_os::println;
#[test_case]
fn test_println() {
println!("test_println output");
}
حال وقتی cargo test
را اجرا میکنیم، میبینیم که این تابع تست را پیدا و اجرا میکند.
این تست ممکن است در حال حاضر کمی بیفایده به نظر برسد، زیرا تقریباً مشابه یکی از تستهای بافر VGA است. با این حال، در آینده ممکن است توابع _start
ما از main.rs
و lib.rs
رشد کرده و روالهای اولیه مختلفی را قبل از اجرای تابع test_main
فراخوانی کنند، به طوری که این دو تست در محیطهای بسیار مختلف اجرا میشوند.
🔗تستهای آینده
قدرت تستهای یکپارچه این است که با آنها به عنوان اجرایی کاملاً جداگانه برخورد میشود. این امر به آنها اجازه کنترل کامل بر محیط را میدهد، و امکان تست کردن این که کد به درستی با CPU یا دستگاههای سختافزاری ارتباط دارد را به ما میدهد.
تست basic_boot
ما یک مثال بسیار ساده برای تست یکپارچه است. در آینده، هسته ما ویژگیهای بسیار بیشتری پیدا میکند و از راههای مختلف با سخت افزار ارتباط برقرار میکند. با افزودن تست های یکپارچه، میتوانیم اطمینان حاصل کنیم که این تعاملات مطابق انتظار کار میکنند (و به کار خود ادامه میدهند). برخی از ایدهها برای تستهای احتمالی در آینده عبارتند از:
-
استثنائات CPU: هنگامی که این کد عملیات نامعتبری را انجام میدهد (به عنوان مثال تقسیم بر صفر)، CPU یک استثنا را ارائه میدهد. هسته میتواند توابع رسیدگی کننده را برای چنین مواردی ثبت کند. یک تست یکپارچه میتواند تأیید کند که در صورت بروز استثنا پردازنده ، رسیدگی کننده استثنای صحیح فراخوانی میشود یا اجرای آن پس از استثناهای قابل حل به درستی ادامه دارد.
-
جدولهای صفحه: جدولهای صفحه مشخص میکند که کدام مناطق حافظه معتبر و قابل دسترسی هستند. با اصلاح جدولهای صفحه، میتوان مناطق حافظه جدیدی را اختصاص داد، به عنوان مثال هنگام راهاندازی برنامهها. یک تست یکپارچه میتواند برخی از تغییرات جدولهای صفحه را در تابع
_start
انجام دهد و سپس تأیید کند که این تغییرات در تابعهای# [test_case]
اثرات مطلوبی دارند. -
برنامههای فضای کاربر: برنامههای فضای کاربر برنامههایی با دسترسی محدود به منابع سیستم هستند. به عنوان مثال، آنها به ساختار دادههای هسته یا حافظه برنامههای دیگر دسترسی ندارند. یک تست یکپارچه میتواند برنامههای فضای کاربر را که عملیاتهای ممنوعه را انجام میدهند راهاندازی کرده و بررسی کند هسته از همه آنها جلوگیری میکند.
همانطور که میتوانید تصور کنید، تستهای بیشتری امکان پذیر است. با افزودن چنین تستهایی، میتوانیم اطمینان حاصل کنیم که وقتی ویژگیهای جدیدی به هسته خود اضافه میکنیم یا کد خود را دوباره میسازیم، آنها را به طور تصادفی خراب نمیکنیم. این امر به ویژه هنگامی مهمتر میشود که هسته ما بزرگتر و پیچیدهتر شود.
🔗تستهایی که باید پنیک کنند
فریمورک تست کتابخانه استاندارد از صفت #[should_panic]
پشتیبانی میکند که اجازه میدهد تستهایی را بسازد که باید ناموفق شوند (باید پنیک کنند). این مفید است، به عنوان مثال برای تأیید پنیک کردن یک تابع هنگام عبور دادن یک آرگومان نامعتبر به آن. متأسفانه این ویژگی در کریتهای #[no_std]
پشتیبانی نمیشود زیرا به پشتیبانی از کتابخانه استاندارد نیاز دارد.
اگرچه نمیتوانیم از صفت #[should_panic]
در هسته خود استفاده کنیم، اما میتوانیم با ایجاد یک تست یکپارچه که با کد خطای موفقیت آمیز از رسیدگی کننده پنیک خارج میشود، رفتار مشابهی داشته باشیم. بیایید شروع به ایجاد چنین تستی با نام should_panic
کنیم:
// in tests/should_panic.rs
#![no_std]
#![no_main]
use core::panic::PanicInfo;
use blog_os::{QemuExitCode, exit_qemu, serial_println};
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
serial_println!("[ok]");
exit_qemu(QemuExitCode::Success);
loop {}
}
این تست هنوز ناقص است زیرا هنوز تابع _start
یا هیچ یک از صفتهای اجرا کننده تست سفارشی را مشخص نکرده. بیایید قسمتهای گمشده را اضافه کنیم:
// in tests/should_panic.rs
#![feature(custom_test_frameworks)]
#![test_runner(test_runner)]
#![reexport_test_harness_main = "test_main"]
#[no_mangle]
pub extern "C" fn _start() -> ! {
test_main();
loop {}
}
pub fn test_runner(tests: &[&dyn Fn()]) {
serial_println!("Running {} tests", tests.len());
for test in tests {
test();
serial_println!("[test did not panic]");
exit_qemu(QemuExitCode::Failed);
}
exit_qemu(QemuExitCode::Success);
}
به جای استفاده مجدد از test_runner
از lib.rs
، تست تابع test_runner
خود را تعریف میکند که هنگام بازگشت یک تست بدون پنیک با یک کد خروج خطا خارج میشود (ما میخواهیم تستهایمان پنیک داشته باشند). اگر هیچ تابع تستی تعریف نشده باشد، اجرا کننده با کد خطای موفقیت خارج میشود. از آنجا که اجرا کننده همیشه پس از اجرای یک تست خارج میشود، منطقی نیست که بیش از یک تابع #[test_case]
تعریف شود.
اکنون میتوانیم یک تست ایجاد کنیم که باید شکست بخورد:
// in tests/should_panic.rs
use blog_os::serial_print;
#[test_case]
fn should_fail() {
serial_print!("should_panic::should_fail...\t");
assert_eq!(0, 1);
}
این تست با استفاده از assert_eq
ادعا (ترجمه: assert) میکند که 0
و 1
برابر هستند. این البته ناموفق است، به طوری که تست ما مطابق دلخواه پنیک میکند. توجه داشته باشید که ما باید نام تابع را با استفاده از serial_print!
در اینجا چاپ دستی کنیم زیرا از تریت Testable
استفاده نمیکنیم.
هنگامی که ما تست را از طریق cargo test --test should_panic
انجام دهیم، میبینیم که موفقیت آمیز است زیرا تست مطابق انتظار پنیک کرد. وقتی ادعا را کامنت کنیم و تست را دوباره اجرا کنیم، میبینیم که با پیام “test did not panic” با شکست مواجه میشود.
یک اشکال قابل توجه در این روش این است که این روش فقط برای یک تابع تست کار میکند. با چندین تابع #[test_case]
، فقط اولین تابع اجرا میشود زیرا پس اینکه رسیدگی کننده پنیک فراخوانی شد، اجرا تمام میشود. من در حال حاضر راه خوبی برای حل این مشکل نمیدانم، بنابراین اگر ایدهای دارید به من اطلاع دهید!
🔗تست های بدون مهار
برای تستهای یکپارچه که فقط یک تابع تست دارند (مانند تست should_panic
ما)، اجرا کننده تست مورد نیاز نیست. برای مواردی از این دست، ما میتوانیم اجرا کننده تست را به طور کامل غیرفعال کنیم و تست خود را مستقیماً در تابع _start
اجرا کنیم.
کلید این کار غیرفعال کردن پرچم harness
برای تست در Cargo.toml
است، که مشخص میکند آیا از یک اجرا کننده تست برای تست یکپارچه استفاده میشود. وقتی روی false
تنظیم شود، هر دو اجرا ککنده تست پیش فرض و سفارشی غیرفعال میشوند، بنابراین با تست مانند یک اجرای معمولی رفتار میشود.
بیایید پرچم harness
را برای تست should_panic
خود غیرفعال کنیم:
# in Cargo.toml
[[test]]
name = "should_panic"
harness = false
اکنون ما با حذف کد مربوط به آاجرا کننده تست، تست should_panic
خود را بسیار ساده کردیم. نتیجه به این شکل است:
// in tests/should_panic.rs
#![no_std]
#![no_main]
use core::panic::PanicInfo;
use blog_os::{exit_qemu, serial_print, serial_println, QemuExitCode};
#[no_mangle]
pub extern "C" fn _start() -> ! {
should_fail();
serial_println!("[test did not panic]");
exit_qemu(QemuExitCode::Failed);
loop{}
}
fn should_fail() {
serial_print!("should_panic::should_fail...\t");
assert_eq!(0, 1);
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
serial_println!("[ok]");
exit_qemu(QemuExitCode::Success);
loop {}
}
اکنون تابع should_fail
را مستقیماً از تابع _start
خود فراخوانی میکنیم و در صورت بازگشت با کد خروج شکست خارج میشویم. اکنون وقتی cargo test --test should_panic
را اجرا میکنیم، میبینیم که تست دقیقاً مانند قبل عمل میکند.
غیر از ایجاد تستهای should_panic
، غیرفعال کردن صفت harness
همچنین میتواند برای تستهای یکپارچه پیچیده مفید باشد، به عنوان مثال هنگامی که تابعهای منفرد دارای عوارض جانبی هستند و باید به ترتیب مشخصی اجرا شوند.
🔗خلاصه
تست کردن یک تکنیک بسیار مفید است تا اطمینان حاصل شود که اجزای خاصی رفتار مطلوبی دارند. حتی اگر آنها نتوانند فقدان اشکالات را نشان دهند، آنها هنوز هم یک ابزار مفید برای یافتن آنها و به ویژه برای جلوگیری از دوباره کاری و پسرفت هستند.
در این پست نحوه تنظیم فریمورک تست برای هسته Rust ما توضیح داده شده است. ما از ویژگی فریمورک تست سفارشی Rust برای پیاده سازی پشتیبانی از یک صفت ساده #[test_case]
در محیط bare-metal خود استفاده کردیم. با استفاده از دستگاه isa-debug-exit
شبیهساز ماشین و مجازیساز QEMU، اجرا کننده تست ما میتواند پس از اجرای تستها از QEMU خارج شده و وضعیت تست را گزارش دهد. برای چاپ پیامهای خطا به جای بافر VGA در کنسول، یک درایور اساسی برای پورت سریال ایجاد کردیم.
پس از ایجاد چند تست برای ماکرو println
، در نیمه دوم پست به بررسی تستهای یکپارچه پرداختیم. ما فهمیدیم که آنها در دایرکتوری tests
قرار میگیرند و به عنوان اجرایی کاملاً مستقل با آنها رفتار میشود. برای دسترسی دادن به آنها به تابع exit_qemu
و ماکرو serial_println
، بیشتر کدهای خود را به یک کتابخانه منتقل کردیم که میتواند توسط همه اجراها و تستهای یکپارچه وارد (import) شود. از آنجا که تستهای یکپارچه در محیط جداگانه خود اجرا میشوند، آنها تست تعاملاتی با سختافزار یا ایجاد تستهایی که باید پنیک کنند را امکان پذیر می کنند.
اکنون یک فریمورک تست داریم که در یک محیط واقع گرایانه در داخل QEMU اجرا میشود. با ایجاد تستهای بیشتر در پستهای بعدی، میتوانیم هسته خود را هنگامی که پیچیدهتر شود، نگهداری کنیم.
🔗مرحله بعدی چیست؟
در پست بعدی، ما استثنائات CPU را بررسی خواهیم کرد. این موارد استثنایی توسط CPU در صورت بروز هرگونه اتفاق غیرقانونی، مانند تقسیم بر صفر یا دسترسی به صفحه حافظه مپ نشده (اصطلاحاً “خطای صفحه”)، رخ میدهد. امکان کشف و بررسی این موارد استثنایی برای رفع اشکال در خطاهای آینده بسیار مهم است. رسیدگی به استثناها نیز بسیار شبیه رسیدگی به وقفههای سختافزاری است، که برای پشتیبانی صفحه کلید مورد نیاز است.
نظرات
Do you have a problem, want to share feedback, or discuss further ideas? Feel free to leave a comment here! Please stick to English and follow Rust's code of conduct. This comment thread directly maps to a discussion on GitHub, so you can also comment there if you prefer.
Instead of authenticating the giscus application, you can also comment directly on GitHub.
لطفا نظرات خود را در صورت امکان به انگلیسی بنویسید.