Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Разработка функциональности библиотеки с использованием разработки через тестирование

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

В этом разделе мы добавим логику поиска в программу minigrep, используя процесс разработки через тестирование (TDD) со следующими шагами:

  1. Напишите тест, который не проходит, и запустите его, чтобы убедиться, что он падает по ожидаемой причине.
  2. Напишите или измените ровно столько кода, чтобы новый тест прошёл.
  3. Рефакторинг только что добавленного или изменённого кода и убедитесь, что тесты продолжают проходить.
  4. Повторяйте с шага 1!

Хотя это лишь один из многих способов написания программного обеспечения, TDD может помочь формировать дизайн кода. Написание теста до написания кода, который заставляет тест проходить, помогает поддерживать высокий охват тестами на протяжении всего процесса.

Мы будем тестировать реализацию функциональности, которая будет фактически выполнять поиск строки запроса в содержимом файла и формировать список строк, соответствующих запросу. Мы добавим эту функциональность в функции с именем search.

Написание падающего теста

Поскольку они нам больше не нужны, давайте удалим операторы println! из src/lib.rs и src/main.rs, которые мы использовали для проверки поведения программы. Затем, в src/lib.rs, мы добавим модуль tests с тестовой функцией, как мы делали в Главе 11. Тестовая функция задаёт поведение, которое мы хотим видеть у функции search: она будет принимать запрос и текст для поиска и возвращать только строки из текста, содержащие запрос. Листинг 12-15 показывает этот тест, который пока не скомпилируется.

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 не добавлять символ новой строки в начале содержимого этой строковой константы). Мы утверждаем, что значение, возвращаемое функцией search, содержит только ожидаемую строку.

Мы пока не можем запустить этот тест и увидеть, как он падает, потому что тест даже не компилируется: функции search ещё не существует! В соответствии с принципами TDD мы добавим ровно столько кода, чтобы тест скомпилировался и запустился, добавив определение функции search, которая всегда возвращает пустой вектор, как показано в Листинге 12-16. Тогда тест должен скомпилироваться и упасть, потому что пустой вектор не соответствует вектору, содержащему строку "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 и возвращаемым значением. Вспомните из Главы 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` (lib) due to 1 previous error

Rust никак не может знать, какой из двух аргументов нам нужен, поэтому нам нужно сказать ему явно. Поскольку contents — это аргумент, который содержит весь наш текст, и мы хотим вернуть части этого текста, которые соответствуют, мы знаем, что contents — это аргумент, который должен быть связан с возвращаемым значением с помощью синтаксиса времени жизни.

Другие языки программирования не требуют от вас связывать аргументы с возвращаемыми значениями в сигнатуре, но эта практика со временем станет проще. Вы можете захотеть сравнить этот пример с примерами в разделе “Проверка ссылок с помощью времени жизни” в Главе 10.

Теперь давайте запустим тест:

$ 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, который работает так, как показано в Листинге 12-17. Обратите внимание, что это пока не скомпилируется.

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 возвращает итератор. Мы подробно поговорим об итераторах в Главе 13, но вспомните, что вы видели такой способ использования итератора в Листинге 3-5, где мы использовали цикл for с итератором для выполнения некоторого кода над каждым элементом коллекции.

Поиск запроса в каждой строке

Далее мы проверим, содержит ли текущая строка нашу строку запроса. К счастью, у строк есть полезный метод с именем contains, который делает это за нас! Добавьте вызов метода contains в функцию search, как показано в Листинге 12-18. Обратите внимание, что это всё ещё не скомпилируется.

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

В данный момент мы наращиваем функциональность. Чтобы код скомпилировался, нам нужно вернуть значение из тела, как мы указали в сигнатуре функции.

Хранение соответствующих строк

Чтобы завершить эту функцию, нам нужен способ хранить соответствующие строки, которые мы хотим вернуть. Для этого мы можем создать изменяемый вектор перед циклом for и вызвать метод push для сохранения line в векторе. После цикла for мы возвращаем вектор, как показано в Листинге 12-19.

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

Наш тест прошёл, значит, он работает!

На этом этапе мы могли бы рассмотреть возможности рефакторинга реализации функции поиска, сохраняя прохождение тестов для поддержания той же функциональности. Код в функции поиска не так уж плох, но он не использует некоторые полезные возможности итераторов. Мы вернёмся к этому примеру в Главе 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` 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`

Отлично! Мы построили свою мини-версию классического инструмента и узнали многое о том, как структурировать приложения. Мы также узнали немного о вводе-выводе файлов, времени жизни, тестировании и парсинге аргументов командной строки.

Чтобы завершить этот проект, мы кратко продемонстрируем, как работать с переменными окружения и как выводить в стандартный поток ошибок, что полезно при написании программ командной строки.