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}