سازماندهی تست‌ها

همانطور که در ابتدای فصل ذکر شد، تست‌نویسی یک رشته پیچیده است، و افراد مختلف از اصطلاحات و سازماندهی متفاوتی استفاده می‌کنند. جامعه Rust تست‌ها را به دو دسته اصلی تقسیم می‌کند: تست‌های واحد و تست‌های یکپارچه. تست‌های واحد کوچک و متمرکزتر هستند، یک ماژول را به طور جداگانه در یک زمان تست می‌کنند و می‌توانند رابط‌های خصوصی را تست کنند. تست‌های یکپارچه کاملاً خارجی نسبت به کتابخانه شما هستند و از کد شما همانطور که هر کد خارجی دیگری استفاده می‌کند، تنها از طریق رابط عمومی استفاده می‌کنند و ممکن است چندین ماژول را در هر تست بررسی کنند.

نوشتن هر دو نوع تست برای اطمینان از اینکه قطعات کتابخانه شما به صورت جداگانه و با هم کار می‌کنند، مهم است.

تست‌های واحد

هدف تست‌های واحد این است که هر واحد کد را به طور جداگانه از سایر کدها تست کنند تا به سرعت مشخص شود که کد کجا به درستی کار می‌کند و کجا نه. تست‌های واحد را در دایرکتوری src در هر فایل با کدی که تست می‌کنند قرار می‌دهید. کنوانسیون این است که یک ماژول به نام tests در هر فایل ایجاد کنید تا توابع تست را در آن قرار دهید و ماژول را با cfg(test) حاشیه‌نویسی کنید.

ماژول تست‌ها و #[cfg(test)]

حاشیه‌نویسی #[cfg(test)] روی ماژول tests به Rust می‌گوید که کد تست فقط وقتی که cargo test اجرا شود کامپایل و اجرا شود، نه وقتی که cargo build اجرا شود. این باعث صرفه‌جویی در زمان کامپایل وقتی فقط می‌خواهید کتابخانه را بسازید می‌شود و فضای کمتری در نتیجه کامپایل‌شده می‌گیرد زیرا تست‌ها شامل نمی‌شوند. مشاهده خواهید کرد که چون تست‌های یکپارچه در یک دایرکتوری جداگانه قرار می‌گیرند، نیازی به حاشیه‌نویسی #[cfg(test)] ندارند. با این حال، چون تست‌های واحد در همان فایل‌هایی که کد قرار دارد قرار می‌گیرند، از #[cfg(test)] استفاده می‌کنید تا مشخص کنید که نباید در نتیجه کامپایل‌شده قرار گیرند.

به یاد بیاورید وقتی پروژه جدید adder را در بخش اول این فصل تولید کردیم، Cargo این کد را برای ما تولید کرد:

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

روی ماژول tests که به طور خودکار تولید شده است، ویژگی cfg مخفف پیکربندی است و به Rust می‌گوید که آیتم زیر فقط در صورت وجود یک گزینه پیکربندی مشخص گنجانده شود. در این مورد، گزینه پیکربندی test است، که توسط Rust برای کامپایل و اجرای تست‌ها ارائه می‌شود. با استفاده از ویژگی cfg، Cargo کد تست ما را فقط در صورتی که تست‌ها را به طور فعال با cargo test اجرا کنیم، کامپایل می‌کند. این شامل هر تابع کمکی که ممکن است در این ماژول باشد نیز می‌شود، علاوه بر توابعی که با #[test] حاشیه‌نویسی شده‌اند.

تست توابع خصوصی

در جامعه تست‌نویسی بحث‌هایی درباره اینکه آیا توابع خصوصی باید مستقیماً تست شوند یا نه وجود دارد، و برخی زبان‌ها تست کردن توابع خصوصی را دشوار یا غیرممکن می‌کنند. صرف نظر از اینکه از کدام ایدئولوژی تست‌نویسی پیروی می‌کنید، قوانین خصوصی‌سازی Rust به شما اجازه می‌دهند توابع خصوصی را تست کنید. کدی که در لیست ۱۱-۱۲ با تابع خصوصی internal_adder ارائه شده است را در نظر بگیرید.

Filename: src/lib.rs
pub fn add_two(a: usize) -> usize {
    internal_adder(a, 2)
}

fn internal_adder(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn internal() {
        let result = internal_adder(2, 2);
        assert_eq!(result, 4);
    }
}
Listing 11-12: تست یک تابع خصوصی

توجه داشته باشید که تابع internal_adder با pub علامت‌گذاری نشده است. تست‌ها فقط کد Rust هستند، و ماژول tests فقط یک ماژول دیگر است. همانطور که در بخش “مسیرها برای اشاره به یک مورد در درخت ماژول” بحث شد، آیتم‌های موجود در ماژول‌های فرزند می‌توانند از آیتم‌های موجود در ماژول‌های والد خود استفاده کنند. در این تست، تمام آیتم‌های والد ماژول tests را با use super::* به دامنه وارد می‌کنیم، و سپس تست می‌تواند internal_adder را فراخوانی کند. اگر فکر می‌کنید توابع خصوصی نباید تست شوند، هیچ چیزی در Rust وجود ندارد که شما را مجبور به انجام این کار کند.

تست‌های یکپارچه

در Rust، تست‌های یکپارچه کاملاً خارجی نسبت به کتابخانه شما هستند. آن‌ها از کتابخانه شما همانطور که هر کد دیگری استفاده می‌کند استفاده می‌کنند، که به این معنی است که فقط می‌توانند توابعی را که بخشی از رابط عمومی کتابخانه شما هستند فراخوانی کنند. هدف آن‌ها این است که بررسی کنند آیا قسمت‌های مختلف کتابخانه شما با یکدیگر به درستی کار می‌کنند یا نه. واحدهای کدی که به تنهایی به درستی کار می‌کنند می‌توانند هنگام یکپارچه‌سازی مشکل داشته باشند، بنابراین پوشش تست کد یکپارچه نیز مهم است. برای ایجاد تست‌های یکپارچه، ابتدا به یک دایرکتوری به نام tests نیاز دارید.

دایرکتوری tests

ما یک دایرکتوری به نام tests در سطح بالای دایرکتوری پروژه خود، در کنار src ایجاد می‌کنیم. Cargo می‌داند که باید به دنبال فایل‌های تست یکپارچه در این دایرکتوری بگردد. سپس می‌توانیم به هر تعداد فایل تست که می‌خواهیم ایجاد کنیم، و Cargo هر یک از فایل‌ها را به عنوان یک crate جداگانه کامپایل می‌کند.

بیایید یک تست یکپارچه ایجاد کنیم. با کدی که هنوز در فایل src/lib.rs از لیست ۱۱-۱۲ قرار دارد، یک دایرکتوری tests ایجاد کنید و یک فایل جدید به نام tests/integration_test.rs بسازید. ساختار دایرکتوری شما باید به این صورت باشد:

adder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── integration_test.rs

کد موجود در لیست ۱۱-۱۳ را در فایل tests/integration_test.rs وارد کنید.

Filename: tests/integration_test.rs
use adder::add_two;

#[test]
fn it_adds_two() {
    let result = add_two(2);
    assert_eq!(result, 4);
}
Listing 11-13: یک تست یکپارچه برای تابعی در crate adder

هر فایل در دایرکتوری tests یک crate جداگانه است، بنابراین باید کتابخانه خود را به دامنه هر crate تست وارد کنیم. به همین دلیل، در بالای کد use adder::add_two; را اضافه می‌کنیم، که در تست‌های واحد نیازی به آن نداشتیم.

نیازی نیست هیچ کدی در فایل tests/integration_test.rs را با #[cfg(test)] علامت‌گذاری کنیم. Cargo دایرکتوری tests را به طور خاص مدیریت می‌کند و فایل‌های موجود در این دایرکتوری را فقط زمانی که cargo test اجرا کنیم کامپایل می‌کند. اکنون cargo test را اجرا کنید:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.31s
     Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)

running 1 test
test 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

سه بخش خروجی شامل تست‌های واحد، تست یکپارچه، و تست‌های مستندات هستند. توجه داشته باشید که اگر هر تستی در یک بخش شکست بخورد، بخش‌های بعدی اجرا نخواهند شد. برای مثال، اگر یک تست واحد شکست بخورد، هیچ خروجی‌ای برای تست‌های یکپارچه و مستندات وجود نخواهد داشت زیرا آن تست‌ها فقط در صورتی اجرا می‌شوند که تمام تست‌های واحد پاس شوند.

بخش اول برای تست‌های واحد همان چیزی است که قبلاً دیده‌ایم: یک خط برای هر تست واحد (یکی به نام internal که در لیست ۱۱-۱۲ اضافه کردیم) و سپس یک خط خلاصه برای تست‌های واحد.

بخش تست‌های یکپارچه با خط Running tests/integration_test.rs شروع می‌شود. سپس یک خط برای هر تابع تست در آن تست یکپارچه و یک خط خلاصه برای نتایج تست یکپارچه دقیقاً قبل از شروع بخش Doc-tests adder وجود دارد.

هر فایل تست یکپارچه بخش خاص خود را دارد، بنابراین اگر فایل‌های بیشتری در دایرکتوری tests اضافه کنیم، بخش‌های بیشتری برای تست‌های یکپارچه خواهیم داشت.

ما هنوز می‌توانیم یک تابع تست خاص در یکپارچه را با مشخص کردن نام تابع تست به عنوان یک آرگومان برای cargo test اجرا کنیم. برای اجرای تمام تست‌های یک فایل تست یکپارچه خاص، از آرگومان --test برای cargo test به همراه نام فایل استفاده کنید:

$ cargo test --test integration_test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s
     Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

این فرمان فقط تست‌های موجود در فایل tests/integration_test.rs را اجرا می‌کند.

زیرماژول‌ها در تست‌های یکپارچه

با اضافه کردن تست‌های یکپارچه بیشتر، ممکن است بخواهید فایل‌های بیشتری در دایرکتوری tests برای کمک به سازماندهی آن‌ها ایجاد کنید؛ برای مثال، می‌توانید توابع تست را بر اساس عملکردی که تست می‌کنند گروه‌بندی کنید. همانطور که قبلاً ذکر شد، هر فایل در دایرکتوری tests به عنوان یک crate جداگانه کامپایل می‌شود، که برای ایجاد دامنه‌های جداگانه مفید است تا بیشتر شبیه نحوه استفاده کاربران نهایی از crate شما باشد. با این حال، این به این معنی است که فایل‌های موجود در دایرکتوری tests رفتار یکسانی با فایل‌های موجود در src ندارند، همانطور که در فصل ۷ درباره جدا کردن کد به ماژول‌ها و فایل‌ها آموختید.

این رفتار متفاوت فایل‌های دایرکتوری tests بیشترین توجه را زمانی جلب می‌کند که مجموعه‌ای از توابع کمکی برای استفاده در چندین فایل تست یکپارچه دارید و سعی می‌کنید مراحل بخش “جدا کردن ماژول‌ها به فایل‌های مختلف” در فصل ۷ را برای استخراج آن‌ها به یک ماژول مشترک دنبال کنید. برای مثال، اگر tests/common.rs ایجاد کنیم و یک تابع به نام setup در آن قرار دهیم، می‌توانیم کدی به setup اضافه کنیم که می‌خواهیم از چندین تابع تست در چندین فایل تست فراخوانی کنیم:

Filename: tests/common.rs

pub fn setup() {
    // setup code specific to your library's tests would go here
}

وقتی دوباره تست‌ها را اجرا می‌کنیم، یک بخش جدید در خروجی تست برای فایل common.rs خواهیم دید، حتی اگر این فایل هیچ تابع تستی ندارد و تابع setup را از هیچ جایی فراخوانی نکرده‌ایم:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.89s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/common.rs (target/debug/deps/common-92948b65e88960b4)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4)

running 1 test
test 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

داشتن common در نتایج تست با running 0 tests نمایش داده شده برای آن، چیزی نبود که می‌خواستیم. ما فقط می‌خواستیم برخی کدها را با دیگر فایل‌های تست یکپارچه به اشتراک بگذاریم. برای جلوگیری از نمایش common در خروجی تست، به جای ایجاد tests/common.rs، فایل tests/common/mod.rs را ایجاد می‌کنیم. اکنون ساختار دایرکتوری پروژه به این شکل است:

├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    └── integration_test.rs

این یک نام‌گذاری قدیمی است که Rust نیز آن را درک می‌کند، همانطور که در بخش “مسیرهای جایگزین فایل” فصل ۷ ذکر شد. نام‌گذاری فایل به این شکل به Rust می‌گوید که ماژول common را به عنوان یک فایل تست یکپارچه در نظر نگیرد. وقتی کد تابع setup را به tests/common/mod.rs منتقل می‌کنیم و فایل tests/common.rs را حذف می‌کنیم، دیگر بخش مربوطه در خروجی تست ظاهر نخواهد شد. فایل‌های موجود در زیرشاخه‌های دایرکتوری tests به عنوان crate‌های جداگانه کامپایل نمی‌شوند یا بخش‌هایی در خروجی تست ندارند.

پس از ایجاد tests/common/mod.rs، می‌توانیم از آن به عنوان یک ماژول در هر یک از فایل‌های تست یکپارچه استفاده کنیم. در اینجا یک مثال از فراخوانی تابع setup از تست it_adds_two در tests/integration_test.rs آمده است:

Filename: tests/integration_test.rs

use adder::add_two;

mod common;

#[test]
fn it_adds_two() {
    common::setup();

    let result = add_two(2);
    assert_eq!(result, 4);
}

توجه داشته باشید که اعلان mod common; مشابه اعلان ماژولی است که در لیست ۷-۲۱ نشان دادیم. سپس، در تابع تست، می‌توانیم تابع common::setup() را فراخوانی کنیم.

تست‌های یکپارچه برای crate‌های دودویی

اگر پروژه ما یک crate دودویی باشد که فقط شامل یک فایل src/main.rs است و فایل src/lib.rs ندارد، نمی‌توانیم تست‌های یکپارچه را در دایرکتوری tests ایجاد کنیم و توابع تعریف‌شده در فایل src/main.rs را با یک عبارت use به دامنه وارد کنیم. فقط crate‌های کتابخانه‌ای توابعی را که سایر crate‌ها می‌توانند استفاده کنند در معرض قرار می‌دهند؛ crate‌های دودویی برای اجرای مستقل طراحی شده‌اند.

این یکی از دلایلی است که پروژه‌های Rust که یک دودویی ارائه می‌دهند، معمولاً یک فایل src/main.rs ساده دارند که به منطق موجود در فایل src/lib.rs فراخوانی می‌کند. با استفاده از این ساختار، تست‌های یکپارچه می‌توانند crate کتابخانه‌ای را با use تست کنند تا قابلیت مهم را در دسترس قرار دهند. اگر قابلیت مهم کار کند، مقدار کمی کد در فایل src/main.rs نیز کار خواهد کرد، و نیازی به تست آن مقدار کم از کد نیست.

خلاصه

ویژگی‌های تست‌نویسی در Rust راهی برای مشخص کردن نحوه عملکرد کد فراهم می‌کنند تا اطمینان حاصل شود که کد همانطور که انتظار می‌رود کار می‌کند، حتی زمانی که تغییراتی در آن ایجاد می‌کنید. تست‌های واحد بخش‌های مختلف یک کتابخانه را به طور جداگانه آزمایش می‌کنند و می‌توانند جزئیات پیاده‌سازی خصوصی را تست کنند. تست‌های یکپارچه بررسی می‌کنند که آیا بخش‌های مختلف کتابخانه به درستی با یکدیگر کار می‌کنند یا نه، و از رابط عمومی کتابخانه برای تست کد به همان روشی که کد خارجی از آن استفاده می‌کند، استفاده می‌کنند. حتی با وجود اینکه سیستم نوع‌ها و قوانین مالکیت در Rust به جلوگیری از برخی انواع باگ‌ها کمک می‌کند، تست‌ها همچنان برای کاهش باگ‌های منطقی که به نحوه عملکرد مورد انتظار کد مربوط می‌شوند، مهم هستند.

بیایید دانش خود را که در این فصل و فصل‌های قبلی یاد گرفتید، ترکیب کرده و روی یک پروژه کار کنیم!