چگونه تست بنویسیم
تستها توابعی در Rust هستند که بررسی میکنند کد غیرتستی به شکل مورد انتظار کار میکند. بدنه توابع تست معمولاً این سه عمل را انجام میدهد:
- تنظیم هر داده یا وضعیت مورد نیاز.
- اجرای کدی که میخواهید تست کنید.
- تأیید اینکه نتایج همان چیزی است که انتظار دارید.
بیایید به ویژگیهایی که Rust به طور خاص برای نوشتن تستهایی که این اقدامات را انجام میدهند فراهم کرده است نگاهی بیندازیم. این ویژگیها شامل ویژگی test
، چند ماکرو و ویژگی should_panic
هستند.
آناتومی یک تابع تست
در سادهترین حالت، یک تست در Rust یک تابع است که با ویژگی test
حاشیهنویسی شده است. ویژگیها متادادههایی درباره بخشهای کد Rust هستند؛ یک مثال ویژگی derive
است که در فصل ۵ با ساختارها استفاده کردیم. برای تغییر یک تابع به یک تابع تست، #[test]
را به خط قبل از fn
اضافه کنید. وقتی تستهای خود را با فرمان cargo test
اجرا میکنید، Rust یک باینری تست رانر ایجاد میکند که توابع حاشیهنویسیشده را اجرا میکند و گزارش میدهد که آیا هر تابع تست موفق یا ناموفق بوده است.
هر زمان که یک پروژه کتابخانهای جدید با Cargo ایجاد میکنیم، یک ماژول تست با یک تابع تست در آن به صورت خودکار برای ما تولید میشود. این ماژول یک قالب برای نوشتن تستهای شما فراهم میکند تا نیازی به جستجوی ساختار و نحو دقیق هر بار که یک پروژه جدید شروع میکنید نداشته باشید. میتوانید هر تعداد تابع تست اضافی و هر تعداد ماژول تست اضافی که میخواهید اضافه کنید!
ما برخی از جنبههای نحوه عملکرد تستها را با آزمایش قالب تست قبل از اینکه واقعاً کدی را تست کنیم بررسی خواهیم کرد. سپس تستهایی در دنیای واقعی مینویسیم که برخی کدهایی که نوشتهایم را فراخوانی میکنند و تأیید میکنند که رفتار آن صحیح است.
بیایید یک پروژه کتابخانهای جدید به نام adder
ایجاد کنیم که دو عدد را با هم جمع کند:
$ cargo new adder --lib
Created library `adder` project
$ cd adder
محتویات فایل src/lib.rs در کتابخانه adder
شما باید شبیه به لیست ۱۱-۱ باشد.
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
cargo new
تولید میشوداین فایل با یک تابع نمونه به نام add
شروع میشود تا چیزی برای تست کردن داشته باشیم.
فعلاً روی تابع it_works
تمرکز میکنیم. به حاشیهنویسی #[test]
توجه کنید: این ویژگی نشان میدهد که این یک تابع تست است، بنابراین تست رانر میداند که این تابع را به عنوان یک تست در نظر بگیرد. ممکن است توابع غیرتستی نیز در ماژول tests
داشته باشیم که به تنظیم سناریوهای مشترک یا انجام عملیاتهای مشترک کمک میکنند، بنابراین همیشه باید مشخص کنیم کدام توابع تست هستند.
بدنه تابع نمونه از ماکرو assert_eq!
استفاده میکند تا اطمینان حاصل کند که result
، که حاوی نتیجه فراخوانی add
با مقادیر ۲ و ۲ است، برابر با ۴ باشد. این اطمینان به عنوان یک مثال از فرمت یک تست معمولی عمل میکند. بیایید آن را اجرا کنیم تا ببینیم این تست پاس میشود.
فرمان cargo test
تمام تستهای پروژه ما را اجرا میکند، همانطور که در لیست ۱۱-۲ نشان داده شده است.
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
Running unittests src/lib.rs (file:///projects/adder/target/debug/deps/adder-7acb243c25ffd9dc)
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Cargo تست را کامپایل و اجرا کرد. خط running 1 test
را میبینیم. خط بعدی نام تابع تست تولیدشده را نشان میدهد، که tests::it_works
نام دارد، و نتیجه اجرای آن تست ok
است. خلاصه کلی test result: ok.
نشان میدهد که تمام تستها پاس شدهاند، و بخشی که 1 passed; 0 failed
را میخواند تعداد تستهایی که پاس شدهاند یا ناموفق بودهاند را نشان میدهد.
این امکان وجود دارد که یک تست را به عنوان نادیدهگرفتهشده علامتگذاری کنیم تا در یک نمونه خاص اجرا نشود؛ ما این مورد را در بخش “نادیدهگرفتن برخی تستها مگر اینکه صریحاً درخواست شوند” در ادامه این فصل پوشش خواهیم داد. چون اینجا این کار را انجام ندادهایم، خلاصه 0 ignored
را نشان میدهد.
آمار 0 measured
برای تستهای بنچمارک است که عملکرد را اندازهگیری میکنند. تستهای بنچمارک، در زمان نوشتن این متن، فقط در نسخه شبانه Rust موجود هستند. برای اطلاعات بیشتر مستندات مربوط به تستهای بنچمارک را ببینید.
ما میتوانیم یک آرگومان به فرمان cargo test
بدهیم تا فقط تستهایی که نام آنها با یک رشته مطابقت دارد اجرا شوند؛ این به فیلتر کردن معروف است و ما آن را در بخش “اجرای زیرمجموعهای از تستها با نام” پوشش خواهیم داد. اینجا ما تستهای در حال اجرا را فیلتر نکردهایم، بنابراین پایان خلاصه 0 filtered out
را نشان میدهد.
قسمت بعدی خروجی تست که با Doc-tests adder
شروع میشود، نتایج هر تست مستنداتی را نشان میدهد. هنوز هیچ تست مستنداتی نداریم، اما Rust میتواند هر نمونه کدی که در مستندات API ما ظاهر میشود را کامپایل کند. این ویژگی به همگام نگه داشتن مستندات و کد شما کمک میکند! ما نحوه نوشتن تستهای مستنداتی را در بخش “توضیحات مستندات به عنوان تستها” از فصل ۱۴ بررسی خواهیم کرد. فعلاً خروجی Doc-tests
را نادیده میگیریم.
بیایید تست را مطابق نیازهای خود شخصیسازی کنیم. ابتدا نام تابع it_works
را به یک نام دیگر، مانند exploration
تغییر دهید، به این صورت:
Filename: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exploration() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
سپس دوباره cargo test
را اجرا کنید. خروجی اکنون به جای it_works
نام exploration
را نشان میدهد:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.59s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::exploration ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
حالا یک تست دیگر اضافه میکنیم، اما این بار تستی مینویسیم که شکست بخورد! تستها زمانی شکست میخورند که چیزی در تابع تست باعث ایجاد panic شود. هر تست در یک نخ (thread) جدید اجرا میشود، و وقتی نخ اصلی میبیند که یک نخ تست متوقف شده است، تست به عنوان شکستخورده علامتگذاری میشود. در فصل ۹، درباره اینکه سادهترین راه برای panic کردن فراخوانی ماکروی panic!
است صحبت کردیم. تابع جدیدی به نام another
وارد کنید تا فایل src/lib.rs شما شبیه به لیست ۱۱-۳ شود.
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exploration() {
let result = add(2, 2);
assert_eq!(result, 4);
}
#[test]
fn another() {
panic!("Make this test fail");
}
}
panic!
شکست میخورددوباره تستها را با استفاده از cargo test
اجرا کنید. خروجی باید شبیه به لیست ۱۱-۴ باشد، که نشان میدهد تست exploration
موفق شده است و another
شکست خورده است.
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.72s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok
failures:
---- tests::another stdout ----
thread 'tests::another' panicked at src/lib.rs:17:9:
Make this test fail
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::another
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
به جای ok
، خط test tests::another
نشان میدهد FAILED
. دو بخش جدید بین نتایج فردی و خلاصه ظاهر میشود: بخش اول دلیل دقیق شکست هر تست را نشان میدهد. در این مورد، ما جزئیات را دریافت میکنیم که another
به دلیل panicked at 'Make this test fail'
در خط ۱۷ فایل src/lib.rs شکست خورده است. بخش بعدی فقط نام تمام تستهای شکستخورده را لیست میکند، که وقتی تعداد زیادی تست و خروجیهای شکستخورده زیاد هستند مفید است. ما میتوانیم نام یک تست شکستخورده را برای اجرای فقط همان تست استفاده کنیم تا راحتتر آن را اشکالزدایی کنیم؛ ما در بخش “کنترل نحوه اجرای تستها” بیشتر در مورد روشهای اجرای تستها صحبت خواهیم کرد.
خط خلاصه در انتها نمایش داده میشود: به طور کلی، نتیجه تست ما FAILED
است. یک تست موفق شد و یک تست شکست خورد.
حالا که دیدید نتایج تست در سناریوهای مختلف چگونه به نظر میرسند، بیایید به برخی از ماکروهای دیگر به جز panic!
که در تستها مفید هستند نگاهی بیندازیم.
بررسی نتایج با ماکروی assert!
ماکروی assert!
که توسط کتابخانه استاندارد ارائه شده است، زمانی مفید است که بخواهید اطمینان حاصل کنید که یک شرط در یک تست به true
ارزیابی میشود. ماکروی assert!
یک آرگومان میگیرد که به یک مقدار بولی ارزیابی میشود. اگر مقدار true
باشد، هیچ اتفاقی نمیافتد و تست پاس میشود. اگر مقدار false
باشد، ماکروی assert!
فراخوانی panic!
را انجام میدهد تا باعث شکست تست شود. استفاده از ماکروی assert!
به ما کمک میکند تا بررسی کنیم که کد ما همانطور که قصد داریم عمل میکند.
در فصل ۵، لیست ۵-۱۵، از یک ساختار Rectangle
و یک متد can_hold
استفاده کردیم، که در لیست ۱۱-۵ دوباره تکرار شده است. این کد را در فایل src/lib.rs قرار دهید، سپس با استفاده از ماکروی assert!
چند تست برای آن بنویسید.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
Rectangle
و متد can_hold
آن از فصل ۵متد can_hold
یک مقدار بولی بازمیگرداند، که به این معنی است که یک مورد استفاده عالی برای ماکروی assert!
است. در لیست ۱۱-۶، ما تستی مینویسیم که متد can_hold
را با ایجاد یک نمونه از Rectangle
که عرض ۸ و ارتفاع ۷ دارد آزمایش میکند و تأیید میکند که میتواند نمونه دیگری از Rectangle
که عرض ۵ و ارتفاع ۱ دارد را در خود جای دهد.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(larger.can_hold(&smaller));
}
}
can_hold
که بررسی میکند آیا یک مستطیل بزرگتر میتواند واقعاً یک مستطیل کوچکتر را در خود جای دهدبه خط use super::*;
در داخل ماژول tests
توجه کنید. ماژول tests
یک ماژول معمولی است که از قوانین دیدپذیری معمولی که در فصل ۷ در بخش “مسیرها برای اشاره به یک مورد در درخت ماژول” پوشش دادیم پیروی میکند. از آنجا که ماژول tests
یک ماژول داخلی است، باید کدی که در ماژول خارجی است را به دامنه ماژول داخلی بیاوریم. در اینجا از یک glob استفاده میکنیم، بنابراین هر چیزی که در ماژول خارجی تعریف کنیم برای این ماژول tests
در دسترس است.
تست خود را larger_can_hold_smaller
نامگذاری کردهایم، و دو نمونه Rectangle
که نیاز داشتیم را ایجاد کردهایم. سپس ماکروی assert!
را فراخوانی کردیم و نتیجه فراخوانی larger.can_hold(&smaller)
را به آن پاس دادیم. این عبارت قرار است true
بازگرداند، بنابراین تست ما باید پاس شود. بیایید ببینیم چه اتفاقی میافتد!
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 1 test
test tests::larger_can_hold_smaller ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rectangle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
پاس شد! حالا یک تست دیگر اضافه کنیم، این بار تأیید میکنیم که یک مستطیل کوچکتر نمیتواند یک مستطیل بزرگتر را در خود جای دهد:
Filename: src/lib.rs
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
// --snip--
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(larger.can_hold(&smaller));
}
#[test]
fn smaller_cannot_hold_larger() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(!smaller.can_hold(&larger));
}
}
از آنجا که نتیجه صحیح تابع can_hold
در این مورد false
است، باید آن نتیجه را قبل از پاس دادن به ماکروی assert!
منفی کنیم. به این ترتیب، تست ما زمانی پاس میشود که can_hold
مقدار false
را بازگرداند:
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rectangle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
دو تست که پاس میشوند! حالا بیایید ببینیم وقتی باگی به کد خود وارد میکنیم چه اتفاقی برای نتایج تست ما میافتد. پیادهسازی متد can_hold
را با جایگزینی علامت بزرگتر (>
) با علامت کوچکتر (<
) هنگام مقایسه عرضها تغییر میدهیم:
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
// --snip--
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width < other.width && self.height > other.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(larger.can_hold(&smaller));
}
#[test]
fn smaller_cannot_hold_larger() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(!smaller.can_hold(&larger));
}
}
اجرای تستها اکنون خروجی زیر را تولید میکند:
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok
failures:
---- tests::larger_can_hold_smaller stdout ----
thread 'tests::larger_can_hold_smaller' panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::larger_can_hold_smaller
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
تستهای ما باگ را پیدا کردند! از آنجا که larger.width
مقدار 8
و smaller.width
مقدار 5
دارد، مقایسه عرضها در can_hold
اکنون false
بازمیگرداند: ۸ کمتر از ۵ نیست.
تست برابری با ماکروهای assert_eq!
و assert_ne!
یک روش معمول برای بررسی عملکرد، تست برابری بین نتیجه کد تحت تست و مقدار مورد انتظار است. میتوانید این کار را با استفاده از ماکروی assert!
و پاس دادن یک عبارت با استفاده از عملگر ==
انجام دهید. با این حال، این یک تست بسیار معمول است که کتابخانه استاندارد یک جفت ماکرو—assert_eq!
و assert_ne!
—برای انجام این تست به صورت راحتتر فراهم کرده است. این ماکروها به ترتیب دو آرگومان را برای برابری یا نابرابری مقایسه میکنند. اگر ادعا شکست بخورد، این ماکروها دو مقدار را نیز چاپ میکنند، که مشاهده دلیل شکست تست را آسانتر میکند. در مقابل، ماکروی assert!
فقط نشان میدهد که یک مقدار false
برای عبارت ==
دریافت کرده است، بدون چاپ مقادیری که منجر به مقدار false
شدهاند.
در لیست ۱۱-۷، تابعی به نام add_two
مینویسیم که ۲
را به پارامتر خود اضافه میکند، سپس این تابع را با استفاده از ماکروی assert_eq!
تست میکنیم.
pub fn add_two(a: usize) -> usize {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
}
add_two
با استفاده از ماکروی assert_eq!
بیایید بررسی کنیم که آیا پاس میشود!
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
یک متغیر به نام result
ایجاد میکنیم که نتیجه فراخوانی add_two(2)
را نگه میدارد. سپس result
و 4
را به عنوان آرگومانها به assert_eq!
پاس میدهیم. خط خروجی برای این تست test tests::it_adds_two ... ok
است، و متن ok
نشان میدهد که تست ما پاس شده است!
بیایید یک باگ به کد خود وارد کنیم تا ببینیم ماکروی assert_eq!
وقتی شکست میخورد چگونه به نظر میرسد. پیادهسازی تابع add_two
را تغییر میدهیم تا به جای ۲
مقدار ۳
را اضافه کند:
pub fn add_two(a: usize) -> usize {
a + 3
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
}
تستها را دوباره اجرا کنید:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::it_adds_two ... FAILED
failures:
---- tests::it_adds_two stdout ----
thread 'tests::it_adds_two' panicked at src/lib.rs:12:9:
assertion `left == right` failed
left: 5
right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::it_adds_two
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
تست ما باگ را پیدا کرد! تست it_adds_two
شکست خورد، و پیام به ما میگوید assertion `left == right` failed
و مقادیر left
و right
چیستند. این پیام به ما کمک میکند اشکالزدایی را شروع کنیم: آرگومان left
، جایی که نتیجه فراخوانی add_two(2)
را داشتیم، مقدار 5
بود، اما آرگومان right
مقدار 4
بود. میتوانید تصور کنید که این موضوع وقتی تعداد زیادی تست داشته باشیم بسیار مفید خواهد بود.
توجه داشته باشید که در برخی زبانها و چارچوبهای تست، پارامترهای توابع بررسی برابری expected
و actual
نامیده میشوند و ترتیب مشخص کردن آرگومانها مهم است. اما در Rust، آنها left
و right
نامیده میشوند، و ترتیب مشخص کردن مقداری که انتظار داریم و مقداری که کد تولید میکند مهم نیست. میتوانیم ادعا را در این تست به صورت assert_eq!(4, result)
بنویسیم، که همان پیام شکست را که assertion failed: `(left == right)`
نمایش میدهد، تولید میکند.
ماکروی assert_ne!
زمانی پاس میشود که دو مقداری که به آن میدهیم برابر نباشند و شکست میخورد اگر برابر باشند. این ماکرو برای مواردی مفید است که مطمئن نیستیم یک مقدار چه خواهد بود، اما میدانیم که مقدار به طور قطع چه نباید باشد. برای مثال، اگر تابعی را تست میکنیم که تضمین شده است ورودی خود را به نوعی تغییر دهد، اما نحوه تغییر ورودی به روز هفتهای که تستهای خود را اجرا میکنیم بستگی دارد، بهترین چیزی که میتوانیم تأیید کنیم این است که خروجی تابع برابر با ورودی نیست.
در پسزمینه، ماکروهای assert_eq!
و assert_ne!
به ترتیب از عملگرهای ==
و !=
استفاده میکنند. وقتی ادعا شکست میخورد، این ماکروها آرگومانهای خود را با استفاده از قالببندی دیباگ چاپ میکنند، که به این معنی است که مقادیر مقایسهشده باید ویژگیهای PartialEq
و Debug
را پیادهسازی کنند. تمام نوعهای اولیه و بیشتر نوعهای کتابخانه استاندارد این ویژگیها را پیادهسازی میکنند. برای ساختارها و انومهایی که خودتان تعریف میکنید، باید PartialEq
را برای تأیید برابری این نوعها پیادهسازی کنید. همچنین باید Debug
را برای چاپ مقادیر زمانی که ادعا شکست میخورد پیادهسازی کنید. از آنجا که هر دو ویژگی قابل اشتقاق هستند، همانطور که در لیست ۵-۱۲ فصل ۵ اشاره شد، این معمولاً به سادگی افزودن حاشیهنویسی #[derive(PartialEq, Debug)]
به تعریف ساختار یا انوم شما است. برای جزئیات بیشتر در مورد این ویژگیها و سایر ویژگیهای قابل اشتقاق، به ضمیمه ج، “ویژگیهای قابل اشتقاق” مراجعه کنید.
افزودن پیامهای شکست سفارشی
همچنین میتوانید یک پیام سفارشی برای چاپ همراه با پیام شکست به عنوان آرگومانهای اختیاری به ماکروهای assert!
، assert_eq!
و assert_ne!
اضافه کنید. هر آرگومانی که بعد از آرگومانهای اجباری مشخص شده باشد به ماکروی format!
(که در فصل ۸ در بخش “ادغام با عملگر +
یا ماکروی format!
” بحث شد) پاس داده میشود، بنابراین میتوانید یک رشته قالب که شامل نگهدارندههای {}
است و مقادیری که در آن نگهدارندهها قرار میگیرند را پاس دهید. پیامهای سفارشی برای مستندسازی معنای یک ادعا مفید هستند؛ وقتی یک تست شکست میخورد، ایده بهتری از مشکل کد خواهید داشت.
برای مثال، فرض کنید تابعی داریم که افراد را با نامشان خوشامد میگوید و میخواهیم تست کنیم که نامی که به تابع پاس میدهیم در خروجی ظاهر میشود:
Filename: src/lib.rs
pub fn greeting(name: &str) -> String {
format!("Hello {name}!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(result.contains("Carol"));
}
}
نیازمندیهای این برنامه هنوز مورد توافق قرار نگرفتهاند، و ما تقریباً مطمئن هستیم که متن Hello
در ابتدای پیام خوشامد تغییر خواهد کرد. تصمیم گرفتیم که نمیخواهیم وقتی نیازمندیها تغییر میکنند، تست را بهروزرسانی کنیم، بنابراین به جای بررسی برابری دقیق با مقدار بازگشتی از تابع greeting
، فقط تأیید میکنیم که خروجی شامل متن پارامتر ورودی است.
حالا بیایید یک باگ به این کد وارد کنیم با تغییر greeting
بهطوری که name
را شامل نشود تا ببینیم پیام شکست تست پیشفرض چگونه است:
pub fn greeting(name: &str) -> String {
String::from("Hello!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(result.contains("Carol"));
}
}
اجرای این تست خروجی زیر را تولید میکند:
$ cargo test
Compiling greeter v0.1.0 (file:///projects/greeter)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)
running 1 test
test tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
assertion failed: result.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::greeting_contains_name
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
این نتیجه فقط نشان میدهد که ادعا شکست خورده است و خطی که ادعا در آن قرار دارد کدام است. یک پیام شکست مفیدتر مقدار بازگشتی از تابع greeting
را چاپ میکرد. بیایید یک پیام شکست سفارشی اضافه کنیم که از یک رشته قالب با یک نگهدارنده که با مقدار واقعی بازگشتی از تابع greeting
پر شده است، تشکیل شده باشد:
pub fn greeting(name: &str) -> String {
String::from("Hello!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(
result.contains("Carol"),
"Greeting did not contain name, value was `{result}`"
);
}
}
حالا وقتی تست را اجرا میکنیم، یک پیام خطای اطلاعرسانتر دریافت خواهیم کرد:
$ cargo test
Compiling greeter v0.1.0 (file:///projects/greeter)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.93s
Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)
running 1 test
test tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
Greeting did not contain name, value was `Hello!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::greeting_contains_name
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
ما میتوانیم مقدار واقعیای که در خروجی تست دریافت کردیم را ببینیم، که به ما کمک میکند تا اشکالزدایی کنیم که چه اتفاقی افتاد به جای آنچه که انتظار داشتیم اتفاق بیفتد.
بررسی پانیک با should_panic
علاوه بر بررسی مقادیر بازگشتی، مهم است که بررسی کنیم کد ما شرایط خطا را همانطور که انتظار داریم مدیریت میکند. برای مثال، نوع Guess
را که در فصل ۹، لیست ۹-۱۳ ایجاد کردیم در نظر بگیرید. سایر کدهایی که از Guess
استفاده میکنند به این تضمین وابسته هستند که نمونههای Guess
فقط مقادیر بین ۱ و ۱۰۰ را شامل میشوند. میتوانیم تستی بنویسیم که اطمینان حاصل کند که تلاش برای ایجاد یک نمونه Guess
با مقداری خارج از این بازه منجر به پانیک میشود.
این کار را با افزودن ویژگی should_panic
به تابع تست خود انجام میدهیم. اگر کد داخل تابع پانیک کند، تست پاس میشود؛ اگر کد داخل تابع پانیک نکند، تست شکست میخورد.
لیست ۱۱-۸ یک تست را نشان میدهد که بررسی میکند شرایط خطای Guess::new
زمانی که انتظار داریم رخ میدهند.
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
panic!
میشودما ویژگی #[should_panic]
را بعد از ویژگی #[test]
و قبل از تابع تستی که به آن اعمال میشود قرار میدهیم. بیایید به نتیجهای که وقتی این تست پاس میشود نگاه کنیم:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests guessing_game
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
به نظر خوب میآید! حالا بیایید یک باگ در کد خود وارد کنیم با حذف شرطی که تابع new
را مجبور میکند اگر مقدار بیشتر از ۱۰۰ باشد پانیک کند:
pub struct Guess {
value: i32,
}
// --snip--
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
وقتی تست در لیست ۱۱-۸ را اجرا میکنیم، شکست میخورد:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... FAILED
failures:
---- tests::greater_than_100 stdout ----
note: test did not panic as expected
failures:
tests::greater_than_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
در این مورد پیام خیلی مفیدی دریافت نمیکنیم، اما وقتی به تابع تست نگاه میکنیم، میبینیم که با #[should_panic]
حاشیهنویسی شده است. شکست به این معناست که کدی که در تابع تست قرار دارد باعث یک پانیک نشده است.
تستهایی که از should_panic
استفاده میکنند میتوانند دقیق نباشند. یک تست should_panic
حتی اگر تست برای دلیلی غیر از آنچه انتظار داشتیم پانیک کند، پاس میشود. برای دقیقتر کردن تستهای should_panic
، میتوانیم یک پارامتر اختیاری expected
به ویژگی should_panic
اضافه کنیم. تست رانر اطمینان حاصل میکند که پیام شکست شامل متن ارائهشده است. برای مثال، کد تغییر دادهشده برای Guess
در لیست ۱۱-۹ را در نظر بگیرید که تابع new
با پیامهای مختلف بسته به اینکه مقدار خیلی کوچک یا خیلی بزرگ باشد پانیک میکند.
pub struct Guess {
value: i32,
}
// --snip--
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!(
"Guess value must be greater than or equal to 1, got {value}."
);
} else if value > 100 {
panic!(
"Guess value must be less than or equal to 100, got {value}."
);
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
panic!
با یک پیام پانیک که حاوی یک زیررشته مشخص استاین تست پاس میشود زیرا مقداری که در پارامتر expected
ویژگی should_panic
قرار دادهایم یک زیررشته از پیامی است که تابع Guess::new
با آن پانیک میکند. میتوانستیم کل پیام پانیکی که انتظار داریم را مشخص کنیم، که در این مورد میشد Guess value must be less than or equal to 100, got 200
. آنچه انتخاب میکنید بستگی به این دارد که چه مقدار از پیام پانیک منحصر به فرد یا پویا است و چقدر میخواهید تست شما دقیق باشد. در این مورد، یک زیررشته از پیام پانیک کافی است تا اطمینان حاصل شود که کد در تابع تست مورد else if value > 100
را اجرا میکند.
برای دیدن اینکه وقتی یک تست should_panic
با یک پیام expected
شکست میخورد چه اتفاقی میافتد، بیایید دوباره یک باگ به کد خود وارد کنیم با جابهجا کردن بدنههای بلوکهای if value < 1
و else if value > 100
:
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!(
"Guess value must be less than or equal to 100, got {value}."
);
} else if value > 100 {
panic!(
"Guess value must be greater than or equal to 1, got {value}."
);
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
این بار وقتی تست should_panic
را اجرا میکنیم، شکست خواهد خورد:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... FAILED
failures:
---- tests::greater_than_100 stdout ----
thread 'tests::greater_than_100' panicked at src/lib.rs:12:13:
Guess value must be greater than or equal to 1, got 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
panic message: `"Guess value must be greater than or equal to 1, got 200."`,
expected substring: `"less than or equal to 100"`
failures:
tests::greater_than_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
پیام شکست نشان میدهد که این تست همانطور که انتظار داشتیم پانیک کرد، اما پیام پانیک شامل رشته مورد انتظار less than or equal to 100
نبود. پیام پانیکی که در این مورد دریافت کردیم Guess value must be greater than or equal to 1, got 200.
بود. حالا میتوانیم شروع به پیدا کردن محل باگ کنیم!
استفاده از Result<T, E>
در تستها
تستهای ما تا اینجا همه زمانی که شکست میخورند پانیک میکنند. همچنین میتوانیم تستهایی بنویسیم که از Result<T, E>
استفاده کنند! در اینجا تست لیست ۱۱-۱ را بازنویسی کردهایم تا از Result<T, E>
استفاده کند و به جای پانیک کردن، یک Err
بازگرداند:
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() -> Result<(), String> {
let result = add(2, 2);
if result == 4 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}
}
تابع it_works
اکنون نوع بازگشتی Result<(), String>
دارد. در بدنه تابع، به جای فراخوانی ماکروی assert_eq!
، وقتی تست پاس میشود Ok(())
و وقتی تست شکست میخورد یک Err
با یک String
داخل آن بازمیگردانیم.
نوشتن تستهایی که یک Result<T, E>
بازمیگردانند به شما اجازه میدهد از عملگر سوالی ?
در بدنه تستها استفاده کنید، که میتواند راهی راحت برای نوشتن تستهایی باشد که اگر هر عملیاتی در آنها یک واریانت Err
بازگرداند، شکست بخورند.
شما نمیتوانید از حاشیهنویسی #[should_panic]
در تستهایی که از Result<T, E>
استفاده میکنند استفاده کنید. برای تأیید اینکه یک عملیات یک واریانت Err
بازمیگرداند، از عملگر سوالی روی مقدار Result<T, E>
استفاده نکنید. در عوض، از assert!(value.is_err())
استفاده کنید.
حالا که چندین روش برای نوشتن تستها را یاد گرفتید، بیایید نگاهی به آنچه هنگام اجرای تستها اتفاق میافتد بیندازیم و گزینههای مختلفی را که میتوانیم با cargo test
استفاده کنیم بررسی کنیم.