cli_test_dir/
lib.rs

1//! This crate makes it easier to write integration tests for CLI applications.
2//! It's based on the "workdir" pattern used by BurntSushi's [xsv][] and
3//! [ripgrep][] crates, but packaged in an easy-to-reuse form.
4//!
5//! To use this crate, add the following lines to your `Cargo.toml` file:
6//!
7//! ```toml
8//! [dev-dependencies]
9//! # You can replace "*" with the current version of this crate.
10//! cli_test_dir = "*"
11//! ```
12//!
13//! You should now be able to write tests in `tests/tests.rs` as follows:
14//!
15//! ```
16//! use cli_test_dir::*;
17//!
18//! #[test]
19//! fn write_output_file() {
20//!     let testdir = TestDir::new("myapp", "write_output_file");
21//!     testdir.cmd()
22//!         .arg("out.txt")
23//!         .expect_success();
24//!     testdir.expect_path("out.txt");
25//! }
26//! ```
27//!
28//! You can use any options from [`std::process::Command`][Command] to invoke
29//! your program.
30//!
31//! [Command]: https://doc.rust-lang.org/std/process/struct.Command.html
32//!
33//! ## Testing that the program ran successfully
34//!
35//! To check that a command succeeds, we can write:
36//!
37//! ```
38//! # use cli_test_dir::*;
39//! # #[cfg(unix)]
40//! let testdir = TestDir::new("true", "true_succeeds");
41//! # #[cfg(windows)]
42//! # let testdir = TestDir::new("cmd", "true_succeeds");
43//! let mut cmd = testdir.cmd();
44//! # #[cfg(windows)]
45//! # cmd.args(&["/C", "exit 0"]);
46//! cmd.expect_success();
47//! ```
48//!
49//! But this test would fail:
50//!
51//! ```rust,should_panic
52//! # use cli_test_dir::*;
53//! // Fails.
54//! # #[cfg(unix)]
55//! let testdir = TestDir::new("false", "false_succeeds");
56//! # #[cfg(windows)]
57//! let testdir = TestDir::new("cmd", "false_succeeds");
58//! let mut cmd = testdir.cmd();
59//! # #[cfg(windows)]
60//! # cmd.args(&["/C", "exit 1"]);
61//! cmd.expect_success();
62//! ```
63//!
64//! ## Testing that the program exited with an error.
65//!
66//! Sometimes you want to test that a program fails to run successfully.
67//!
68//! ```
69//! # use cli_test_dir::*;
70//! # #[cfg(unix)]
71//! let testdir = TestDir::new("false", "false_fails");
72//! # #[cfg(windows)]
73//! # let testdir = TestDir::new("cmd", "false_fails");
74//! let mut cmd = testdir.cmd();
75//! # #[cfg(windows)]
76//! # cmd.args(&["/C", "exit 1"]);
77//! cmd.expect_failure();
78//! ```
79//!
80//! And as you would expect, this test would fail:
81//!
82//! ```rust,should_panic
83//! # use cli_test_dir::*;
84//! // Fails.
85//! # #[cfg(unix)]
86//! let testdir = TestDir::new("true", "true_fails");
87//! # #[cfg(windows)]
88//! # let testdir = TestDir::new("cmd", "true_fails");
89//! let mut cmd = testdir.cmd();
90//! # #[cfg(windows)]
91//! # cmd.args(&["/C", "exit 0"]);
92//! cmd.expect_failure();
93//! ```
94//!
95//! ## File input and output
96//!
97//! The `src_path` function can be used to build paths relative to the top-level
98//! of our crate, and `expect_path` can be used to make sure an output file
99//! exists:
100//!
101//! ```rust
102//! # use cli_test_dir::*;
103//! # #[cfg(unix)]
104//! let testdir = TestDir::new("cp", "cp_copies_files");
105//! # #[cfg(windows)]
106//! # let testdir = TestDir::new("cmd", "cp_copies_files");
107//! let mut cmd = testdir.cmd();
108//! # #[cfg(windows)]
109//! # cmd.args(&["/C", "copy"]);
110//! cmd
111//!   .arg(testdir.src_path("fixtures/input.txt"))
112//!   .arg("output.txt")
113//!   .expect_success();
114//! testdir.expect_path("output.txt");
115//! ```
116//!
117//! We can also create the input file manually or look for specific contents in
118//! the output file if we wish:
119//!
120//! ```
121//! # use cli_test_dir::*;
122//! # #[cfg(unix)]
123//! let testdir = TestDir::new("cp", "cp_copies_files_2");
124//! # #[cfg(windows)]
125//! # let testdir = TestDir::new("cmd", "cp_copies_files_2");
126//! let mut cmd = testdir.cmd();
127//! # #[cfg(windows)]
128//! # cmd.args(&["/C", "copy"]);
129//! testdir.create_file("input.txt", "Hello, world!\n");
130//! cmd
131//!   .arg("input.txt")
132//!   .arg("output.txt")
133//!   .expect_success();
134//! testdir.expect_contains("output.txt", "Hello");
135//! testdir.expect_file_contents("output.txt", "Hello, world!\n");
136//! ```
137//!
138//! There are also negative versions of these functions where useful:
139//!
140//! ```
141//! # use cli_test_dir::*;
142//! # #[cfg(unix)]
143//! let testdir = TestDir::new("cp", "negative_tests");
144//! # #[cfg(windows)]
145//! # let testdir = TestDir::new("cmd", "negative_tests");
146//! let mut cmd = testdir.cmd();
147//! # #[cfg(windows)]
148//! # cmd.args(&["/C", "copy"]);
149//! testdir.create_file("input.txt", "Hello, world!\n");
150//! cmd
151//!   .arg("input.txt")
152//!   .arg("output.txt")
153//!   .expect_success();
154//! testdir.expect_does_not_contain("output.txt", "Goodbye");
155//! testdir.expect_no_such_path("does_not_exist.txt");
156//! ```
157//!
158//! ## Standard input and output
159//!
160//! We can also test standard input and output:
161//!
162//! ```
163//! # use cli_test_dir::*;
164//! # #[cfg(unix)]
165//! let testdir = TestDir::new("cat", "cat_passes_data_through");
166//! # #[cfg(windows)]
167//! # let testdir = TestDir::new("cmd", "type_passes_data_through");
168//! let mut cmd = testdir.cmd();
169//! # #[cfg(windows)]
170//! # cmd.args(&["/C", "findstr x*"]); // https://superuser.com/a/853718
171//! let output = cmd
172//!   .output_with_stdin("Hello\n")
173//!   .expect_success();
174//! assert_eq!(output.stdout_str(), "Hello\n");
175//! ```
176//!
177//! If you wish, you can display a command's output using `tee_output`:
178//!
179//! ```
180//! # use cli_test_dir::*;
181//! # #[cfg(unix)]
182//! let testdir = TestDir::new("cat", "tee_output_shows_output");
183//! # #[cfg(windows)]
184//! # let testdir = TestDir::new("cmd", "tee_output_shows_output");
185//! let mut cmd = testdir.cmd();
186//! # #[cfg(windows)]
187//! # cmd.args(&["/C", "findstr x*"]); // https://superuser.com/a/853718
188//! let output = cmd
189//!   .output_with_stdin("Hello\n")
190//!   // Show `stdout` and `stderr`.
191//!   .tee_output()
192//!   .expect_success();
193//! assert_eq!(output.stdout_str(), "Hello\n");
194//! ```
195//!
196//! Note that this will currently print out all of `stdout` first, _then_ all of
197//! `stderr`, instead of interleaving them normally.
198//!
199//! To see the output of `tee_output`, you will also need to invoke `cargo` as
200//! follows:
201//!
202//! ```sh
203//! cargo test -- --nocapture
204//! ```
205//!
206//! ## Contributing
207//!
208//! Your feedback and contributions are welcome!  Please see
209//! [GitHub](https://github.com/emk/subtitles-rs) for details.
210//!
211//! [ripgrep]: https://github.com/BurntSushi/ripgrep
212//! [xsv]: https://github.com/BurntSushi/xsv
213
214use std::{
215    borrow::Cow,
216    env, fmt, fs, io,
217    io::prelude::*,
218    path::{Path, PathBuf},
219    process, str,
220    sync::atomic::{AtomicUsize, Ordering},
221    thread, time,
222};
223
224static TEST_ID: AtomicUsize = AtomicUsize::new(0);
225
226/// This code is inspired by the `WorkDir` pattern that BurntSushi uses to
227/// test CLI tools like `ripgrep` and `xsv`.
228pub struct TestDir {
229    bin: PathBuf,
230    dir: PathBuf,
231}
232
233#[cfg(unix)]
234fn exe_name(name: &str) -> Cow<str> {
235    Cow::Borrowed(name)
236}
237
238#[cfg(windows)]
239fn exe_name(name: &str) -> Cow<str> {
240    // Maybe something like...
241    if name.ends_with(".exe") {
242        Cow::Borrowed(name)
243    } else {
244        Cow::Owned(format!("{}.exe", name))
245    }
246}
247
248impl TestDir {
249    /// Create a new `TestDir` for the current test.  You must specify
250    /// `bin_name` (the name of a binary built by the current crate) and
251    /// `test_name` (a unique name for the current test).
252    ///
253    /// If our output directory exists from a previous test run, it will be
254    /// deleted.
255    pub fn new(bin_name: &str, test_name: &str) -> TestDir {
256        let mut bin_dir = env::current_exe()
257            .expect("Could not find executable")
258            .parent()
259            .expect("Could not find parent directory for executable")
260            .to_path_buf();
261        if bin_dir.ends_with("deps") {
262            bin_dir.pop();
263        }
264        let id = TEST_ID.fetch_add(1, Ordering::SeqCst);
265        let dir = bin_dir
266            .join("integration-tests")
267            .join(test_name)
268            .join(format!("{}", id));
269        if dir.exists() {
270            fs::remove_dir_all(&dir).expect("Could not remove test output directory");
271        }
272
273        // Work around https://github.com/rust-lang/rust/issues/33707.
274        let mut err = None;
275        for _ in 0..10 {
276            match fs::create_dir_all(&dir) {
277                Ok(_) => {
278                    err = None;
279                    break;
280                }
281                Err(e) => {
282                    err = Some(e);
283                }
284            }
285            thread::sleep(time::Duration::from_millis(500));
286        }
287        if let Some(e) = err {
288            panic!("Could not create test output directory: {}", e);
289        }
290
291        let mut bin = bin_dir.join(&*exe_name(bin_name));
292        if !bin.exists() {
293            writeln!(
294                io::stderr(),
295                "WARNING: could not find {}, will search PATH",
296                bin.display()
297            )
298            .expect("could not write to stderr");
299            bin = Path::new(&bin_name).to_owned();
300        }
301
302        TestDir { bin: bin, dir: dir }
303    }
304
305    /// Return a `std::process::Command` object that can be used to execute
306    /// the binary.
307    pub fn cmd(&self) -> process::Command {
308        let mut cmd = process::Command::new(&self.bin);
309        cmd.current_dir(&self.dir);
310        cmd
311    }
312
313    /// Construct a path relative to our test directory.
314    ///
315    /// ```
316    /// # use cli_test_dir::*;
317    /// # #[cfg(unix)]
318    /// let testdir = TestDir::new("touch", "path_builds_paths");
319    /// # #[cfg(windows)]
320    /// # let testdir = TestDir::new("cmd", "path_builds_paths");
321    /// let mut cmd = testdir.cmd();
322    /// # #[cfg(windows)]
323    /// # cmd.args(&["/C", "type", "nul", ">"]);
324    /// cmd
325    ///   .arg("example.txt")
326    ///   .expect_success();
327    /// assert!(testdir.path("example.txt").exists());
328    /// ```
329    pub fn path<P: AsRef<Path>>(&self, path: P) -> PathBuf {
330        self.dir.join(path)
331    }
332
333    /// Return a path relative to the source directory of the current
334    /// crate.  Useful for finding fixtures.
335    pub fn src_path<P: AsRef<Path>>(&self, path: P) -> PathBuf {
336        let cwd = env::current_dir().expect("Could not get current dir");
337        fs::canonicalize(cwd.join(path)).expect("Could not canonicalize path")
338    }
339
340    /// Create a file in our test directory with the specified contents.
341    pub fn create_file<P, S>(&self, path: P, contents: S)
342    where
343        P: AsRef<Path>,
344        S: AsRef<[u8]>,
345    {
346        let path = self.dir.join(path);
347        fs::create_dir_all(path.parent().expect("expected parent"))
348            .expect("could not create directory");
349        let mut f = fs::File::create(&path).expect("can't create file");
350        f.write_all(contents.as_ref()).expect("can't write to file");
351    }
352
353    /// If `path` does not point to valid path, fail the current test.
354    pub fn expect_path<P: AsRef<Path>>(&self, path: P) {
355        let path = self.dir.join(path);
356        assert!(path.exists(), "{} should exist", path.display());
357    }
358
359    /// If `path` does not point to valid path, fail the current test.
360    pub fn expect_no_such_path<P: AsRef<Path>>(&self, path: P) {
361        let path = self.dir.join(path);
362        assert!(!path.exists(), "{} should not exist", path.display());
363    }
364
365    /// Verify that the file contains the specified data.
366    pub fn expect_file_contents<P, S>(&self, path: P, expected: S)
367    where
368        P: AsRef<Path>,
369        S: AsRef<[u8]>,
370    {
371        let path = self.dir.join(path);
372        let expected = expected.as_ref();
373        self.expect_path(&path);
374        let mut f = fs::File::open(&path).expect("could not open file");
375        let mut found = vec![];
376        f.read_to_end(&mut found).expect("could not read file");
377        expect_data_eq(path.display(), &found, expected);
378    }
379
380    /// (Internal.) Read a `Path` and return a `String`.
381    fn read_file(&self, path: &Path) -> String {
382        self.expect_path(&path);
383        let mut f = fs::File::open(&path).expect("could not open file");
384        let mut found = vec![];
385        f.read_to_end(&mut found).expect("could not read file");
386        str::from_utf8(&found)
387            .expect("expected UTF-8 file")
388            .to_owned()
389    }
390
391    /// Verify that the contents of the file match the specified pattern.
392    /// Someday this should support `std::str::pattern::Pattern` so that we
393    /// can support both strings and regular expressions, but that hasn't
394    /// been stabilized yet.
395    pub fn expect_contains<P>(&self, path: P, pattern: &str)
396    where
397        P: AsRef<Path>,
398    {
399        let path = self.dir.join(path);
400        let contents = self.read_file(&path);
401        assert!(
402            contents.contains(pattern),
403            "expected {} to match {:?}, but it contained {:?}",
404            path.display(),
405            pattern,
406            contents
407        );
408    }
409
410    /// Verify that the contents of the file do not match the specified pattern.
411    /// Someday this should support `std::str::pattern::Pattern` so that we can
412    /// support both strings and regular expressions, but that hasn't been
413    /// stabilized yet.
414    pub fn expect_does_not_contain<P>(&self, path: P, pattern: &str)
415    where
416        P: AsRef<Path>,
417    {
418        let path = self.dir.join(path);
419        let contents = self.read_file(&path);
420        assert!(
421            !contents.contains(pattern),
422            "expected {} to not match {:?}, but it contained {:?}",
423            path.display(),
424            pattern,
425            contents
426        );
427    }
428}
429
430/// Internal helper function which compares to blobs of potentially binary data.
431fn expect_data_eq<D>(source: D, found: &[u8], expected: &[u8])
432where
433    D: fmt::Display,
434{
435    if found != expected {
436        // TODO: If the data appears to be actual binary, do a better job
437        // of printing it.
438        panic!(
439            "expected {} to equal {:?}, found {:?}",
440            source,
441            String::from_utf8_lossy(expected).as_ref(),
442            String::from_utf8_lossy(found).as_ref()
443        );
444    }
445}
446
447/// Extension methods for `std::process::Command`.
448pub trait CommandExt {
449    /// Spawn this command, passing it the specified data on standard
450    /// input.
451    fn output_with_stdin<S: AsRef<[u8]>>(
452        &mut self,
453        input: S,
454    ) -> io::Result<process::Output>;
455}
456
457impl CommandExt for process::Command {
458    fn output_with_stdin<S>(&mut self, input: S) -> io::Result<process::Output>
459    where
460        S: AsRef<[u8]>,
461    {
462        let input = input.as_ref().to_owned();
463        let mut child: process::Child = self
464            .stdin(process::Stdio::piped())
465            .stdout(process::Stdio::piped())
466            .stderr(process::Stdio::piped())
467            .spawn()
468            .expect("error running command");
469        let mut stdin = child.stdin.take().expect("std in is unexpectedly missing");
470        let worker = thread::spawn(move || {
471            stdin.write_all(&input).expect("could not write to stdin");
472            stdin
473                .flush()
474                .expect("could not flush data to child's stdin");
475        });
476        let result = child.wait_with_output();
477        worker.join().expect("stdin writer failed");
478        result
479    }
480}
481
482/// Display command output and return it for examination.
483pub trait TeeOutputExt {
484    /// Display the output of a test command on `stdout` and `stderr`, then return
485    /// the `Output` object for further processing.
486    fn tee_output(self) -> io::Result<process::Output>;
487}
488
489impl TeeOutputExt for &mut process::Command {
490    fn tee_output(self) -> io::Result<process::Output> {
491        self.output().tee_output()
492    }
493}
494
495impl TeeOutputExt for io::Result<process::Output> {
496    fn tee_output(self) -> io::Result<process::Output> {
497        let output = self?;
498        io::stdout().write_all(&output.stdout)?;
499        io::stderr().write_all(&output.stderr)?;
500        Ok(output)
501    }
502}
503
504/// Extension methods for `std::process::Output`.
505pub trait OutputExt {
506    /// Get standard output as a `str`.
507    fn stdout_str(&self) -> &str;
508
509    /// Get standard error as a `str`.
510    fn stderr_str(&self) -> &str;
511}
512
513impl OutputExt for process::Output {
514    fn stdout_str(&self) -> &str {
515        str::from_utf8(&self.stdout).expect("stdout was not UTF-8 text")
516    }
517
518    fn stderr_str(&self) -> &str {
519        str::from_utf8(&self.stderr).expect("stderr was not UTF-8 text")
520    }
521}
522
523/// We define `expect_status` on quite a few related types to support
524/// different calling patterns.
525pub trait ExpectStatus {
526    /// Expect the child process to succeed, and return a
527    /// `std::process::Output` object with its output.
528    fn expect_success(self) -> process::Output;
529
530    /// Expect the child process to fail, and return `std::process::Output`
531    /// object with its output.
532    fn expect_failure(self) -> process::Output;
533}
534
535impl ExpectStatus for process::Output {
536    fn expect_success(self) -> process::Output {
537        if !self.status.success() {
538            io::stdout()
539                .write_all(&self.stdout)
540                .expect("could not write to stdout");
541            io::stderr()
542                .write_all(&self.stderr)
543                .expect("could not write to stderr");
544            panic!("expected command to succeed, got {}", self.status)
545        }
546        self
547    }
548
549    fn expect_failure(self) -> process::Output {
550        if self.status.success() {
551            io::stdout()
552                .write_all(&self.stdout)
553                .expect("could not write to stdout");
554            io::stderr()
555                .write_all(&self.stderr)
556                .expect("could not write to stderr");
557            panic!("expected command to fail, got {}", self.status)
558        }
559        self
560    }
561}
562
563impl<ES: ExpectStatus, E: fmt::Debug> ExpectStatus for Result<ES, E> {
564    fn expect_success(self) -> process::Output {
565        // Unwrap the result, fail on error, and pass `expect_success` to
566        // our wrapped type.
567        match self {
568            Ok(es) => es.expect_success(),
569            Err(err) => panic!("error running command: {:?}", err),
570        }
571    }
572
573    fn expect_failure(self) -> process::Output {
574        // Unwrap the result, fail on error, and pass `expect_failure` to
575        // our wrapped type.
576        match self {
577            Ok(es) => es.expect_failure(),
578            // Note that this means we couldn't _run_ the command (perhaps
579            // because it doesn't exist or wasn't in our path), not that it
580            // ran but failed.
581            Err(err) => panic!("error running command: {:?}", err),
582        }
583    }
584}
585
586impl<'a> ExpectStatus for &'a mut process::Command {
587    fn expect_success(self) -> process::Output {
588        self.output().expect_success()
589    }
590
591    fn expect_failure(self) -> process::Output {
592        self.output().expect_failure()
593    }
594}
595
596impl ExpectStatus for process::Child {
597    fn expect_success(self) -> process::Output {
598        self.wait_with_output().expect_success()
599    }
600
601    fn expect_failure(self) -> process::Output {
602        self.wait_with_output().expect_failure()
603    }
604}