چگونه تست بنویسیم

تست‌ها توابعی در 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 شما باید شبیه به لیست ۱۱-۱ باشد.

Filename: src/lib.rs
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);
    }
}
Listing 11-1: کدی که به طور خودکار توسط 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
Listing 11-2: خروجی اجرای تستی که به طور خودکار تولید شده است

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 شما شبیه به لیست ۱۱-۳ شود.

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);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}
Listing 11-3: اضافه کردن یک تست دوم که به دلیل فراخوانی ماکروی 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`
Listing 11-4: نتایج تست زمانی که یک تست موفق می‌شود و یک تست شکست می‌خورد

به جای 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! چند تست برای آن بنویسید.

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
    }
}
Listing 11-5: ساختار Rectangle و متد can_hold آن از فصل ۵

متد can_hold یک مقدار بولی بازمی‌گرداند، که به این معنی است که یک مورد استفاده عالی برای ماکروی assert! است. در لیست ۱۱-۶، ما تستی می‌نویسیم که متد can_hold را با ایجاد یک نمونه از Rectangle که عرض ۸ و ارتفاع ۷ دارد آزمایش می‌کند و تأیید می‌کند که می‌تواند نمونه دیگری از Rectangle که عرض ۵ و ارتفاع ۱ دارد را در خود جای دهد.

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() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}
Listing 11-6: تستی برای 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! تست می‌کنیم.

Filename: src/lib.rs
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);
    }
}
Listing 11-7: تست تابع 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 زمانی که انتظار داریم رخ می‌دهند.

Filename: src/lib.rs
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);
    }
}
Listing 11-8: تست کردن اینکه آیا یک شرط باعث یک 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 با پیام‌های مختلف بسته به اینکه مقدار خیلی کوچک یا خیلی بزرگ باشد پانیک می‌کند.

Filename: src/lib.rs
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);
    }
}
Listing 11-9: تست کردن یک 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 استفاده کنیم بررسی کنیم.