Working with complex input and output can make command line applications challenging to test, as it is inconvenient to capture the output stream to test if the program returns the correct output. Using abstraction through Rust’s
Write traits, we can swap the input and output for byte arrays and vectors during testing instead.
Standard streams are abstractions used to handle data input and output to an operating system process. Each program has access to an input stream (standard input, or stdin), an output stream (standard output, or stdout), and an error stream (standard error, or stderr) inherited from the parent process.
grep(1) filters lines read from stdin with a search pattern (“three” in this case) and prints the matching lines to stdout.
grep halts to wait for input from stdin.
By typing this input into the terminal, we can see that
grep prints any line that matches the pattern back to stdout, which the terminal displays.
Then, the program returns to waiting for input until it receives an EOF (end-of-file), which we pass by pressing ctrl+D in the terminal.
$ grep three one two three three
Because of this abstraction, programs can use pipelines to pass the output from one program as the input to another by piping stdout from one process to stdin for another.
$ ls -l ~/pager | grep Cargo Cargo.lock Cargo.toml
ls(1) prints the current directory’s contents to stdout.
This example uses a pipe (
|) to create a pipeline, to pass the output from
ls as input to
grep then filters to only print lines matching the passed pattern (“Cargo”).
Rust provides handles to the standard streams through the
Stderr structs, which are created with the
io::stderr() functions respectively.
This program takes input through stdin, converts the received string to uppercase and prints it back out to the terminal through stdout:
The stream handlers implement the
Write traits to read from and write to the streams. Because of that, they share part of their implementation with other “Readers” and “Writers”, like
One of the issues in the example above is that it uses the
Stdin structs directly, making our program challenging to test because it is inconvenient to pass input through stdin and capture stdout to assert that the program produces the correct results.
To make our program more modular, we will decouple it from the
Stdout structs and pass the input and output as arguments to a separate function.
This approach is called dependency injection, as it injects a function’s dependencies (the input and output streams) instead of creating them inside the function in order to make the program independent of the input device.
In the test for the extracted function, we swap
Stdout out for other implementors of the
Write traits: a byte array for input and a vector for output.
The implementation that satisfies the test looks like the original example, with one significant difference.
Because the test passes the input and output as arguments, we can use trait objects to allow any type as long as it implements the
Finally, we replace the prototype in
src/main.rs with a call to our new implementation with a
Stdout struct for the input and output:
Stdout out of the implementation, we made our program more modular, allowing us to test the code without resorting to capturing stdout to assert that the printed result matched our expectations.
Aside from better testability, making our implementation more modular will allow us to work with other data types in the future.
For example, we might add a command-line option that takes a filename and pass a
File also implements the
Read trait, that would work without further modifications in our implementation.
Read::read_to_string(), which will read the contents of the whole stream from the input before writing everything to stdout at once, which is inefficient, especially for larger inputs. A more efficient implementation could use buffered reading through the
BufReadtrait to read and write the input stream line by line.