Розробка Функціонала Бібліотеки із Test-Driven Development

Тепер, коли ми перенесли логіку в src/lib.rs та залишили збір аргументів та обробку помилок в src/main.rs, стало набагато простіше писати тести для основного функціонала нашого коду. Ми можемо викликати функції напряму із різноманітними аргументами та перевіряти повернуті значення без потреби виклику нашого двійкового файлу із командного рядка.

У цій секції ми додамо пошукову логіку до програми minigrep, використовуючи стиль розробки через тестування (TDD) із наступними кроками:

  1. Напишіть тест, який дає збій і запустить його, щоб переконатися, що він це робить через очікувану причину.
  2. Напишіть або змініть мінімум коду, щоб новий тест пройшов.
  3. Відрефакторіть щойно доданий або змінений код та впевніться, що тести продовжують проходити.
  4. Повторіть з першого кроку!

Хоча це лише один з багатьох способів написання програмного забезпечення, TDD може допомагати надавати потрібного напрямку оформленню коду. Створення тесту перед тим, як написати код, який забезпечить проходження тесту, допомагає підтримувати високий рівень покриття тестуванням протягом всього процесу розробки.

Ми протестуємо імплементацію функціоналу який буде робити пошуковий запит стрічки у вмісті файлу та створювати список рядків, які відповідають запиту. Ми додамо цей функціонал у функцію під назвою search.

Написання Провального Тесту

Видалімо інструкції println! які ми використовували для перевірки поведінки програми з src/lib.rs та src/main.rs, бо нам вони більше не потрібні. Потім додамо в src/lib.rs модуль tests із тестовою функцією, як ми зробили в Розділі 11. Тестова функція визначає бажану поведінку функції search: вона отримає запит та текст для пошуку, і вона буде повертати лише рядки з тексту, які містять запит. Блок коду 12-15 показує цей тест, який ще не компілюється.

Файл: 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));
    }
}

Блок коду 12-15: Створення невдалого тесту для функції search, яку ми хотіли б мати

Цей тест шукає рядок "duct". Текст, в якому ми робимо пошук, це три рядки, лише один з яких містить "duct" (Зауважте, що зворотний слеш після першої подвійної лапки каже Rust не розміщувати символ нового рядку на початку цієї стрічки). Ми стверджуємо, що значення, повернене з функції search містить тільки рядки, які ми очікуємо.

Ми ще не готові запустити цей тест та подивитися, як він дає збій, бо тест навіть не компілюється: функція search ще не існує! Згідно з принципами TDD, ми додамо лише мінімум коду, щоб тест почав компілюватися та виконуватися, додав визначення функції search, яке завжди повертає порожній вектор, як показано в Блоці коду 12-16. Тоді тест повинен скомпілюватися та провалитися, бо порожній вектор не зіставляється з вектором, який містить рядок "safe, fast, productive."

Файл: 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));
    }
}

Блок коду 12-16: Визначення функції search, якого досить для проходження тесту

Зауважте, що нам потрібно явно визначити час існування 'a в сигнатурі search та використати цей час існування з аргументом contents та поверненим значенням. Згадаємо Розділ 10 де час існування параметрів вказував, який час існування аргументу пов'язаний з поверненим значенням. У цьому випадку, ми вказуємо, що повернутий вектор має містити слайси стрічки, які посилаються на слайси аргументу 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` due to previous error

Rust не має можливості дізнатися, який з двох аргументів нам потрібен, тому ми маємо явно це вказати. Оскільки contents це аргумент, який містить увесь наш текст і ми хочемо повертати відповідні частини цього тексту, ми розуміємо, що contents це аргумент який має бути пов'язаний з поверненим значенням використовуючи синтаксис часу існування.

Інші мови програмування не вимагають від вас пов'язувати аргументи із поверненим значенням в сигнатурі функції, але ця практика з часом стане легшою. Ви можете захотіти порівняти цей приклад із “Validating References with Lifetimes” Розділу 10.

Тепер запустимо тест:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished test [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 'main' panicked at 'assertion failed: `(left == right)`
  left: `["safe, fast, productive."]`,
 right: `[]`', src/lib.rs:44:9
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, наша програма має виконати такі дії:

  • Ітерувати через кожний рядок вмісту.
  • Перевірити, чи містить цей рядок нашу стрічку запиту.
  • Якщо так, то додати його до списку значень який ми повертаємо.
  • Якщо ні, то нічого не робити.
  • Повернути отриманий список рядків, які збігаються.

Пройдімо кожен крок, починаючи з ітерації по рядках.

Ітерація Рядками із Методом lines

Rust має корисний метод для керування ітерацією по стрічці рядок за рядком який зручно названий lines, який працює як показано в Блоці Коду 12-17. Зверніть увагу, це ще не буде компілюватися.

Файл: 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));
    }
}

Блок коду 12-17: Ітерація по кожному рядку в contents

Метод lines повертає ітератор. Ми поговоримо про ітератори більш детально в Розділі 13, але пригадайте, що ви бачили цей спосіб використання ітератора в Блоці Коду 3-5, де ми використовували цикл for з ітератором для виконання деякого коду на кожному елементі колекції.

Пошук Запиту в Кожному Рядку

Далі, ми перевіримо, чи містить поточний рядок стрічку запиту. На щастя, стрічки мають корисний метод названий contains, який робить це для нас! Додайте виклик методу contains в функцію search, як показано в Блоці Коду 12-18. Зауважте, що це ще не буде компілюватися.

Файл: 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: Adding functionality to see whether the line contains the string in query

Ми зараз створюємо функціонал. Щоб він компілювався, нам потрібно повертати значення з вмісту функції, як ми вказали в її сигнатурі.

Зберігання Відповідних Рядків

Щоб завершити цю функцію, нам потрібен спосіб зберігання зіставлених рядків, які ми хочемо повертати. Для цього, ми можемо створити мутабельний вектор перед циклом for та викликати метод push, щоб зберегти line в векторі. Після циклу for, ми повертаємо вектор, як показано в Блоці Коду 12-19.

Файл: 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: Storing the lines that match so we can return them

Тепер функція search повинна повертати тільки рядки, що містять query, і наш тест повинен пройти. Запустимо тест:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished test [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

Наш тест пройшов, тому ми знаємо, що він працює!

На цьому етапі ми могли б розглянути можливості рефакторингу імплементації функції пошуку, зберігаючи проходження тестів та зберігаючи той самий функціонал. Код функції пошуку не настільки й поганий, але він не використовує переваги деяких корисних особливостей ітераторів. Ми повернемось до цього прикладу в Розділі 13, де ми дослідимо ітератори детальніше та розглянемо, як ми можемо їх вдосконалити.

Використовування Функції search в Функції run

Тепер, коли функція search працює та протестована, нам потрібно викликати search з нашої функції run. Нам потрібно передати значення config.query та contents яке run читає з файлу в функцію search. Потім run виведе в консолі кожен рядок повернений з search:

Файл: 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 [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 [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!

And finally, let’s make sure that we don’t get any lines when we search for a word that isn’t anywhere in the poem, such as “monomorphization”:

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

Блискуче! Ми побудували нашу власну мініверсію класичного інструменту та багато дізналися про структурування застосунків. Ми також дізналися дещо про ввід у файл, вивід файлу, часи існування, тестування та парсинг командного рядка.

To round out this project, we’ll briefly demonstrate how to work with environment variables and how to print to standard error, both of which are useful when you’re writing command line programs. ch10-03-lifetime-syntax.html#validating-references-with-lifetimes ch10-03-lifetime-syntax.html#validating-references-with-lifetimes