DEV Community

Cover image for [Rust Guide] 12.6 Developing Library Functionality with TDD
SomeB1oody
SomeB1oody

Posted on • Edited on • Originally published at someb1oody.github.io

[Rust Guide] 12.6 Developing Library Functionality with TDD

12.6.0 Before We Begin

In Chapter 12, we will build a real project: a command-line program. This program is a grep (Global Regular Expression Print), a tool for global regular-expression search and output. Its job is to search for the specified text in the specified file.

This project has several steps:

12.6.1 Review

Here is all the code written up to the previous article.

lib.rs:

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

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

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }
        let query = args[1].clone();
        let filename = args[2].clone();
        Ok(Config { query, filename})
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.filename)?;
    println!("With text:\n{}", contents);
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

main.rs:

use std::env;
use std::process;
use minigrep::Config;

fn main() {
    let args:Vec<String> = env::args().collect();
    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });
    if let Err(e) = minigrep::run(config) {
        println!("Application error: {}", e);
        process::exit(1);
    }
}
Enter fullscreen mode Exit fullscreen mode

In the previous sections, we moved the business logic into lib.rs. That helps a lot with writing tests, because the logic in lib.rs can be called directly with different parameters without running the program from the command line, and we can verify its return values. In other words, we can test the business logic directly.

12.6.2 What Is Test-Driven Development?

TDD stands for Test-Driven Development. It usually follows these steps:

  • Write a failing test, run it, and make sure it fails for the expected reason
  • Write or modify just enough code to make the new test pass
  • Refactor the code you just added or changed to make sure the tests still pass
  • Return to step 1 and continue

TDD is just one of many software development methods, but it can guide and help code design. Writing tests first and then writing code to pass those tests also helps maintain a high level of test coverage during development.

In this article, we will use TDD to implement the search logic: search for the specified string in the file contents and put the matching lines into a list. This function will be named search.

12.6.3 Modifying the Code

Follow the TDD steps:

1. Write a Failing Test

First, write a test module in lib.rs:

#[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));
    }
}
Enter fullscreen mode Exit fullscreen mode

That is, because "duct" stored in query appears in the line "safe, fast, productive.", the return value should be a Vector of string slices with only one element: "safe, fast, productive.".

The return value is a Vector because search is expected to handle multiple matching results. Of course, this particular test can only have one result, which is why the test is named one_result.

After writing the test module, write the search function:

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}
Enter fullscreen mode Exit fullscreen mode
  • To make the function callable from outside, it must be declared pub.
  • The function needs lifetime annotations because it has more than one non-self parameter, so Rust cannot tell which parameter’s lifetime matches the return value.
  • The elements in the returned Vector are string slices taken from contents, so the return value should have the same lifetime as contents. That is why both are annotated with the same lifetime 'a, while query does not need a lifetime annotation.
  • The function body only needs to compile, because the first step of TDD is to write a failing test. Failure is the desired outcome right now.

Test result:

$ cargo test
   Compiling minigrep v0.1.0 (/tmp/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.13s
     Running unittests src/lib.rs (target/debug/deps/minigrep-dfdfbb86b622af32)

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

failures:

---- tests::one_result stdout ----

thread 'tests::one_result' (469719) panicked at src/lib.rs:41: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`
Enter fullscreen mode Exit fullscreen mode

The test failed, but that is fine. This is exactly what the first TDD step is supposed to produce.

2. Write Just Enough Code for the New Test to Pass

With step 1 done, move on to TDD step 2: write or modify just enough code to make the new test pass.

Think through how search should work: iterate over each line of contents, check whether that line contains the query string, and if it does, put the line into the list of results; if it does not, do nothing and move to the next line. Finally, return all the results in a Vector.

  • To iterate over each line, use the lines method. It returns an iterator (13.5. Iterators Pt. 1 covers iterators in more detail) that yields the string’s contents one line at a time.
  • To check whether a line contains the query string, use the contains method. It returns a boolean: true if there is a match, and false otherwise.
  • Do not forget to push matching lines into the Vector.

With that in mind, you can write the code:

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
}
Enter fullscreen mode Exit fullscreen mode

Note: you do not need to declare the element type of results explicitly, because later you push line (a &str) into the Vector, and Rust infers that the elements are &str.

Now run the tests:

$ cargo test
   Compiling minigrep v0.1.0 (/tmp/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.15s
     Running unittests src/lib.rs (target/debug/deps/minigrep-dfdfbb86b622af32)

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-4c31ade9c6771135)

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
Enter fullscreen mode Exit fullscreen mode

The test passes. No problems.

3. Use search in the run Function

Now that search works, you can call it from run:

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.filename)?;
    for line in search(&config.query, &contents) {
        println!("{}", line);
    }
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The loop prints each matching line as soon as it is found.

Try a run:

$ cargo run -- frog poem.txt
   Compiling minigrep v0.1.0 (/tmp/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog
Enter fullscreen mode Exit fullscreen mode

This example matched only one line. Try a query that matches multiple lines:

$ cargo run -- body poem.txt
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!
Enter fullscreen mode Exit fullscreen mode

Try a word that does not appear:

$ cargo run -- monomorphization poem.txt
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/minigrep monomorphization poem.txt`
Enter fullscreen mode Exit fullscreen mode

Top comments (0)