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:
- Receive command-line arguments
- Read files
- Refactor: improve modules and error handling
- Use TDD (test-driven development) to develop library functionality (this article)
- Use environment variables
- Write error messages to standard error instead of standard output
12.6.1 Review
Here is all the code written up to the previous article.
lib.rs:
#![allow(unused)]
fn main() {
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(())
}
}
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);
}
}
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:
#![allow(unused)]
fn main() {
#[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));
}
}
}
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:
#![allow(unused)]
fn main() {
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
vec![]
}
}
- To make the function callable from outside, it must be declared
pub. - The function needs lifetime annotations because it has more than one non-
selfparameter, so Rust cannot tell which parameter’s lifetime matches the return value. - The elements in the returned
Vectorare string slices taken fromcontents, so the return value should have the same lifetime ascontents. That is why both are annotated with the same lifetime'a, whilequerydoes 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`
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
linesmethod. 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
querystring, use thecontainsmethod. It returns a boolean:trueif there is a match, andfalseotherwise. - Do not forget to push matching lines into the
Vector.
With that in mind, you can write the code:
#![allow(unused)]
fn main() {
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
}
}
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
The test passes. No problems.
3. Use search in the run Function
Now that search works, you can call it from run:
#![allow(unused)]
fn main() {
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(())
}
}
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
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!
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`