|
| 1 | +# 12.6 Developing Library Functionality with TDD |
| 2 | + |
| 3 | +## 12.6.0 Before We Begin |
| 4 | +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.** |
| 5 | + |
| 6 | +This project has several steps: |
| 7 | +- [Receive command-line arguments](../12.1/12.1._Receiving_Command-Line_Arguments.md) |
| 8 | +- [Read files](../12.2/12.2._Read_Files.md) |
| 9 | +- [Refactor: improve modules and error handling](../12.3/12.3._Refactoring_Pt.1_-_Improving_Modularity.md) |
| 10 | +- Use TDD (test-driven development) to develop library functionality (this article) |
| 11 | +- Use environment variables |
| 12 | +- Write error messages to standard error instead of standard output |
| 13 | + |
| 14 | +## 12.6.1 Review |
| 15 | +Here is all the code written up to the previous article. |
| 16 | + |
| 17 | +`lib.rs`: |
| 18 | +```rust |
| 19 | +use std::error::Error; |
| 20 | +use std::fs; |
| 21 | + |
| 22 | +pub struct Config { |
| 23 | + pub query: String, |
| 24 | + pub filename: String, |
| 25 | +} |
| 26 | + |
| 27 | +impl Config { |
| 28 | + pub fn new(args: &[String]) -> Result<Config, &'static str> { |
| 29 | + if args.len() < 3 { |
| 30 | + return Err("Not enough arguments"); |
| 31 | + } |
| 32 | + let query = args[1].clone(); |
| 33 | + let filename = args[2].clone(); |
| 34 | + Ok(Config { query, filename}) |
| 35 | + } |
| 36 | +} |
| 37 | + |
| 38 | +pub fn run(config: Config) -> Result<(), Box<dyn Error>> { |
| 39 | + let contents = fs::read_to_string(config.filename)?; |
| 40 | + println!("With text:\n{}", contents); |
| 41 | + Ok(()) |
| 42 | +} |
| 43 | +``` |
| 44 | + |
| 45 | +`main.rs`: |
| 46 | +```rust |
| 47 | +use std::env; |
| 48 | +use std::process; |
| 49 | +use minigrep::Config; |
| 50 | + |
| 51 | +fn main() { |
| 52 | + let args:Vec<String> = env::args().collect(); |
| 53 | + let config = Config::new(&args).unwrap_or_else(|err| { |
| 54 | + println!("Problem parsing arguments: {}", err); |
| 55 | + process::exit(1); |
| 56 | + }); |
| 57 | + if let Err(e) = minigrep::run(config) { |
| 58 | + println!("Application error: {}", e); |
| 59 | + process::exit(1); |
| 60 | + } |
| 61 | +} |
| 62 | +``` |
| 63 | + |
| 64 | +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. |
| 65 | + |
| 66 | +## 12.6.2 What Is Test-Driven Development? |
| 67 | +TDD stands for Test-Driven Development. It usually follows these steps: |
| 68 | +- Write a failing test, run it, and make sure it fails for the expected reason |
| 69 | +- Write or modify just enough code to make the new test pass |
| 70 | +- Refactor the code you just added or changed to make sure the tests still pass |
| 71 | +- Return to step 1 and continue |
| 72 | + |
| 73 | +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. |
| 74 | + |
| 75 | +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`. |
| 76 | + |
| 77 | +## 12.6.3 Modifying the Code |
| 78 | +Follow the TDD steps: |
| 79 | + |
| 80 | +### 1. Write a Failing Test |
| 81 | +First, write a test module in `lib.rs`: |
| 82 | +```rust |
| 83 | +#[cfg(test)] |
| 84 | +mod tests { |
| 85 | + use super::*; |
| 86 | + |
| 87 | + #[test] |
| 88 | + fn one_result() { |
| 89 | + let query = "duct"; |
| 90 | + let contents = "\ |
| 91 | +Rust: |
| 92 | +safe, fast, productive. |
| 93 | +Pick three."; |
| 94 | + assert_eq!(vec!["safe, fast, productive."],search(query, contents)); |
| 95 | + } |
| 96 | +} |
| 97 | +``` |
| 98 | +That is, because `"duct"` stored in `query` appears in the line `"safe, fast, productive."`, the return value should be a `Vector` whose element type is `String`, and it should contain only one element: `"safe, fast, productive."`. |
| 99 | + |
| 100 | +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`. |
| 101 | + |
| 102 | +After writing the test module, write the `search` function: |
| 103 | +```rust |
| 104 | +pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> { |
| 105 | + vec![] |
| 106 | +} |
| 107 | +``` |
| 108 | +- To make the function callable from outside, it must be declared `pub`. |
| 109 | +- 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. |
| 110 | +- 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. |
| 111 | +- 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. |
| 112 | + |
| 113 | +The test will fail, and that is exactly what we want in the first step of TDD. |
| 114 | + |
| 115 | +### 2. Write Just Enough Code for the New Test to Pass |
| 116 | +The code for `search_case_insensitive` is very similar to `search`; we only need a few changes. The logic is simple: convert both the query and the text to lowercase. |
| 117 | +```rust |
| 118 | +pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> { |
| 119 | + let mut results = Vec::new(); |
| 120 | + let query = query.to_lowercase(); |
| 121 | + for line in contents.to_lowercase().lines() { |
| 122 | + if line.contains(&query) { |
| 123 | + results.push(line); |
| 124 | + } |
| 125 | + } |
| 126 | + results |
| 127 | +} |
| 128 | +``` |
| 129 | +- The `to_lowercase` method converts a string to lowercase. |
| 130 | +- The result of `to_lowercase` is a `String`, which owns its data. That means the new `query` is a `String`, not a `&str`. In the `if` inside the loop, we use `&query` because `contains` does not accept `String`, so we must pass a reference. |
| 131 | + |
| 132 | +Run the tests again: |
| 133 | +``` |
| 134 | +$ cargo test |
| 135 | + Compiling minigrep v0.1.0 (file:///projects/minigrep) |
| 136 | + Finished `test` profile [unoptimized +debuginfo] target(s) in 1.33s |
| 137 | + Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94) |
| 138 | +
|
| 139 | +running 2 tests |
| 140 | +test tests::case_insensitive ... ok |
| 141 | +test tests::case_sensitive ... ok |
| 142 | +
|
| 143 | +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s |
| 144 | +
|
| 145 | + Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94) |
| 146 | +
|
| 147 | +running 0 tests |
| 148 | +
|
| 149 | +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s |
| 150 | +
|
| 151 | + Doc-tests minigrep |
| 152 | +
|
| 153 | +running 0 tests |
| 154 | +
|
| 155 | +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s |
| 156 | +``` |
| 157 | +Both tests pass. |
| 158 | + |
| 159 | +### 3. Use This Function in `run` |
| 160 | +Now that the function works, we can call it from `run`. |
| 161 | + |
| 162 | +But first, we need to add a field to the `Config` struct to decide whether to use the normal `search` or the case-insensitive `search_case_insensitive`: |
| 163 | +```rust |
| 164 | +pub struct Config { |
| 165 | + pub query: String, |
| 166 | + pub filename: String, |
| 167 | + pub case_sensitive: bool, |
| 168 | +} |
| 169 | +``` |
| 170 | + |
| 171 | +Update `run` so it checks the configuration: |
| 172 | +```rust |
| 173 | +pub fn run(config: Config) -> Result<(), Box<dyn Error>> { |
| 174 | + let contents = fs::read_to_string(config.filename)?; |
| 175 | + let results = if config.case_sensitive { |
| 176 | + search(&config.query, &contents) |
| 177 | + } else { |
| 178 | + search_case_insensitive(&config.query, &contents) |
| 179 | + }; |
| 180 | + for line in results { |
| 181 | + println!("{}", line); |
| 182 | + } |
| 183 | + Ok(()) |
| 184 | +} |
| 185 | +``` |
| 186 | + |
| 187 | +The `new` constructor on `Config` also needs to change, and it should set `case_sensitive` based on an environment variable: |
| 188 | +```rust |
| 189 | +impl Config { |
| 190 | + pub fn new(args: &[String]) -> Result<Config, &'static str> { |
| 191 | + if args.len() < 3 { |
| 192 | + return Err("Not enough arguments"); |
| 193 | + } |
| 194 | + let query = args[1].clone(); |
| 195 | + let filename = args[2].clone(); |
| 196 | + let case_sensitive = std::env::var("CASE_INSENSITIVE").is_err(); |
| 197 | + Ok(Config { query, filename, case_sensitive}) |
| 198 | + } |
| 199 | +} |
| 200 | +``` |
| 201 | +This uses `std::env::var` (you can also import `std::env` first and then use `env::var`). Its argument is the name of the environment variable, usually written in all caps. Here I use `CASE_INSENSITIVE`, which means “case-insensitive.” If this environment variable exists, we treat the search as case-insensitive; if it does not exist, we treat it as case-sensitive. |
| 202 | + |
| 203 | +`std::env::var` returns a `Result`. If `CASE_INSENSITIVE` is set, it returns `Ok(String)` containing the variable’s value; otherwise it returns `Err(std::env::VarError)`. |
| 204 | + |
| 205 | +The `is_err` method is chained after `std::env::var`. If the result is `Err`, it returns `true` and assigns `true` to `case_sensitive`; otherwise it assigns `false`. |
| 206 | + |
| 207 | +## 12.6.3 The Full Code and a Trial Run |
| 208 | +Here is all the code written so far. |
| 209 | + |
| 210 | +`lib.rs`: |
| 211 | +```rust |
| 212 | +use std::error::Error; |
| 213 | +use std::fs; |
| 214 | + |
| 215 | +pub struct Config { |
| 216 | + pub query: String, |
| 217 | + pub filename: String, |
| 218 | + pub case_sensitive: bool, |
| 219 | +} |
| 220 | + |
| 221 | +impl Config { |
| 222 | +``` |
0 commit comments