توسعه قابلیت‌های کتابخانه با توسعه آزمون‌محور (TDD) یا همان (Test-Driven Development)

اکنون که منطق را به src/lib.rs استخراج کرده‌ایم و جمع‌آوری آرگومان‌ها و مدیریت خطاها را در src/main.rs باقی گذاشته‌ایم، نوشتن تست برای قابلیت‌های اصلی کد ما بسیار آسان‌تر شده است. می‌توانیم مستقیماً توابع را با آرگومان‌های مختلف فراخوانی کرده و مقادیر بازگشتی را بررسی کنیم، بدون اینکه نیاز باشد از باینری ما از خط فرمان استفاده کنیم.

در این بخش، منطق جستجو را با استفاده از فرآیند توسعه آزمون‌محور (TDD) به برنامه minigrep اضافه خواهیم کرد. مراحل این فرآیند به شرح زیر است:

  1. نوشتن یک تست که شکست می‌خورد و اجرای آن برای اطمینان از اینکه به دلیلی که انتظار داشتید شکست می‌خورد.
  2. نوشتن یا تغییر کد به اندازه‌ای که تست جدید پاس شود.
  3. بازسازی کدی که به تازگی اضافه یا تغییر داده شده و اطمینان از اینکه تست‌ها همچنان پاس می‌شوند.
  4. تکرار از مرحله ۱!

TDD تنها یکی از روش‌های نوشتن نرم‌افزار است، اما می‌تواند به طراحی بهتر کد کمک کند. نوشتن تست قبل از نوشتن کدی که تست را پاس می‌کند، کمک می‌کند تا پوشش تست بالا در طول فرآیند حفظ شود.

ما با استفاده از TDD پیاده‌سازی قابلیت جستجوی رشته کوئری در محتوای فایل و تولید لیستی از خطوط مطابق با کوئری را توسعه خواهیم داد. این قابلیت را در تابعی به نام search اضافه خواهیم کرد.

نوشتن یک تست که شکست می‌خورد

از آنجا که دیگر به آن‌ها نیاز نداریم، بیایید عبارت‌های println! را از src/lib.rs و src/main.rs که برای بررسی رفتار برنامه استفاده می‌کردیم حذف کنیم. سپس، در src/lib.rs، یک ماژول tests با یک تابع تست اضافه خواهیم کرد، همانطور که در فصل ۱۱ انجام دادیم. تابع تست، رفتاری که می‌خواهیم تابع search داشته باشد را مشخص می‌کند: این تابع یک کوئری و متن برای جستجو دریافت می‌کند و تنها خطوطی از متن که شامل کوئری هستند را بازمی‌گرداند. لیست ۱۲-۱۵ این تست را نشان می‌دهد که هنوز کامپایل نخواهد شد.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-15: ایجاد یک تست شکست‌خورده برای تابع search که آرزو می‌کنیم داشته باشیم

این تست به دنبال رشته "duct" می‌گردد. متنی که در آن جستجو می‌کنیم شامل سه خط است که تنها یکی از آن‌ها شامل "duct" است (توجه داشته باشید که بک‌اسلش بعد از علامت نقل قول بازکننده به Rust می‌گوید که کاراکتر newline در ابتدای محتویات این literal رشته قرار ندهد). ما تأیید می‌کنیم که مقدار بازگردانده شده از تابع search تنها شامل خطی است که انتظار داریم.

هنوز قادر به اجرای این تست و مشاهده شکست آن نیستیم زیرا تست حتی کامپایل نمی‌شود: تابع search هنوز وجود ندارد! بر اساس اصول TDD، ما تنها به اندازه‌ای کد اضافه می‌کنیم که تست کامپایل و اجرا شود، با اضافه کردن یک تعریف از تابع search که همیشه یک بردار خالی بازمی‌گرداند، همانطور که در لیست ۱۲-۱۶ نشان داده شده است. سپس تست باید کامپایل و شکست بخورد زیرا یک بردار خالی با یک بردار شامل خط "safe, fast, productive." مطابقت ندارد.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-16: تعریف حداقل کد برای تابع search تا تست ما کامپایل شود

متوجه می‌شوید که ما نیاز داریم یک طول عمر صریح 'a در امضای تابع search تعریف کنیم و از آن طول عمر با آرگومان contents و مقدار بازگشتی استفاده کنیم. به یاد داشته باشید که در فصل ۱۰ توضیح دادیم که پارامترهای طول عمر مشخص می‌کنند کدام طول عمر آرگومان به طول عمر مقدار بازگشتی متصل است. در این مورد، ما مشخص می‌کنیم که بردار بازگشتی باید شامل برش‌های رشته‌ای باشد که به برش‌های آرگومان contents اشاره دارند (نه آرگومان query).

به عبارت دیگر، به Rust می‌گوییم داده‌ای که توسط تابع search بازگردانده می‌شود به اندازه داده‌ای که به تابع search در آرگومان contents منتقل می‌شود زنده خواهد بود. این مهم است! داده‌ای که توسط یک برش مرجع داده می‌شود باید معتبر باشد تا مرجع نیز معتبر باشد؛ اگر کامپایلر فرض کند که ما در حال ساختن برش‌های رشته‌ای از query هستیم به جای contents، بررسی‌های ایمنی را به اشتباه انجام خواهد داد.

اگر طول عمرها را فراموش کنیم و سعی کنیم این تابع را کامپایل کنیم، این خطا را دریافت خواهیم کرد:

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
  --> src/lib.rs:28:51
   |
28 | pub fn search(query: &str, contents: &str) -> Vec<&str> {
   |                      ----            ----         ^ expected named lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `query` or `contents`
help: consider introducing a named lifetime parameter
   |
28 | pub fn search<'a>(query: &'a str, contents: &'a str) -> Vec<&'a str> {
   |              ++++         ++                 ++              ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error

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

دیگر زبان‌های برنامه‌نویسی نیازی ندارند آرگومان‌ها را به مقادیر بازگشتی در امضا متصل کنید، اما این تمرین با گذشت زمان آسان‌تر می‌شود. ممکن است بخواهید این مثال را با مثال‌های موجود در بخش “اعتبارسنجی مراجع با طول عمر” از فصل ۱۰ مقایسه کنید.

اکنون بیایید تست را اجرا کنیم:

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

running 1 test
test tests::one_result ... FAILED

failures:

---- tests::one_result stdout ----
thread 'tests::one_result' panicked at src/lib.rs:44:9:
assertion `left == right` failed
  left: ["safe, fast, productive."]
 right: []
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::one_result

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`

عالی است، تست دقیقا همانطور که انتظار داشتیم شکست می‌خورد. بیایید تست را پاس کنیم!

نوشتن کدی برای پاس کردن تست

در حال حاضر، تست ما به دلیل اینکه همیشه یک بردار خالی بازمی‌گرداند، شکست می‌خورد. برای رفع این مشکل و پیاده‌سازی search، برنامه ما باید این مراحل را دنبال کند:

  1. تکرار از طریق هر خط از محتوای فایل.
  2. بررسی اینکه آیا خط شامل رشته کوئری ما هست یا نه.
  3. اگر خط شامل کوئری بود، آن را به لیست مقادیر بازگشتی اضافه کنیم.
  4. اگر نبود، کاری انجام ندهیم.
  5. لیست نتایجی که مطابقت دارند را بازگردانیم.

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

تکرار از طریق خطوط با متد lines

Rust یک متد مفید برای مدیریت تکرار خط به خط در رشته‌ها ارائه می‌دهد که به طور مناسبی lines نامیده شده است و همانطور که در لیست ۱۲-۱۷ نشان داده شده کار می‌کند. توجه داشته باشید که این کد هنوز کامپایل نخواهد شد.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-17: تکرار از طریق هر خط در contents

متد lines یک iterator برمی‌گرداند. ما در فصل ۱۳ عمیقاً در مورد iteratorها صحبت خواهیم کرد، اما به یاد داشته باشید که قبلاً این روش استفاده از یک iterator را در لیست ۳-۵ دیدید، جایی که از یک حلقه for با یک iterator برای اجرای کدی روی هر آیتم در یک مجموعه استفاده کردیم.

جستجو در هر خط برای کوئری

اکنون، بررسی خواهیم کرد که آیا خط فعلی شامل رشته کوئری ما هست یا نه. خوشبختانه، رشته‌ها یک متد مفید به نام contains دارند که این کار را برای ما انجام می‌دهد! یک فراخوانی به متد contains را در تابع search اضافه کنید، همانطور که در لیست ۱۲-۱۸ نشان داده شده است. توجه داشته باشید که این کد همچنان کامپایل نخواهد شد.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // do something with line
        }
    }
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-18: اضافه کردن قابلیت بررسی اینکه آیا خط شامل رشته موجود در query هست یا نه

در حال حاضر، ما در حال ایجاد قابلیت‌های بیشتر هستیم. برای اینکه کد کامپایل شود، نیاز داریم مقداری را از بدنه تابع بازگردانیم همانطور که در امضای تابع اشاره کردیم.

ذخیره خطوط مطابق

برای تکمیل این تابع، نیاز داریم روشی برای ذخیره خطوط مطابق که می‌خواهیم بازگردانیم داشته باشیم. برای این کار، می‌توانیم یک بردار mutable قبل از حلقه for ایجاد کنیم و با استفاده از متد push یک خط را در بردار ذخیره کنیم. بعد از حلقه for، بردار را بازمی‌گردانیم، همانطور که در لیست ۱۲-۱۹ نشان داده شده است.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-19: ذخیره خطوط مطابق برای بازگرداندن آن‌ها

اکنون تابع search باید فقط خطوطی را که شامل query هستند بازگرداند، و تست ما باید پاس شود. بیایید تست را اجرا کنیم:

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

running 1 test
test tests::one_result ... ok

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

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

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

   Doc-tests minigrep

running 0 tests

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

تست ما پاس شد، بنابراین می‌دانیم که کار می‌کند!

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

استفاده از تابع search در تابع run

اکنون که تابع search کار می‌کند و تست شده است، باید تابع search را از تابع run فراخوانی کنیم. ما باید مقدار config.query و contents که run از فایل می‌خواند را به تابع search بدهیم. سپس run هر خطی که از search برگردانده شده را چاپ خواهد کرد:

Filename: src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

ما هنوز از یک حلقه for برای بازگرداندن هر خط از search و چاپ آن استفاده می‌کنیم.

اکنون کل برنامه باید کار کند! بیایید آن را امتحان کنیم، ابتدا با کلمه‌ای که باید دقیقاً یک خط از شعر امیلی دیکینسون را برگرداند: frog.

$ cargo run -- frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

عالی! حالا بیایید کلمه‌ای را امتحان کنیم که چندین خط را مطابقت دهد، مثل body:

$ cargo run -- body poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

و در نهایت، مطمئن شویم که وقتی کلمه‌ای را جستجو می‌کنیم که در هیچ جای شعر وجود ندارد، مثل monomorphization، هیچ خطی دریافت نخواهیم کرد:

$ cargo run -- monomorphization poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorphization poem.txt`

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

برای تکمیل این پروژه، به طور مختصر نشان خواهیم داد که چگونه با متغیرهای محیطی کار کنیم و چگونه به خطای استاندارد (standard error) چاپ کنیم، که هر دو در هنگام نوشتن برنامه‌های خط فرمان مفید هستند.