test_binary/
lib.rs

1//! Manage and build extra binaries for integration tests as regular Rust
2//! crates.
3//!
4//! If you have integration tests for things that involve subprocess management,
5//! inter-process communication, or platform tools, you might need to write some
6//! extra "supporting" binaries of your own to help with these tests. For
7//! example, if you want to test that your code does the right thing with the
8//! exit status for a managed subprocess, you might want a supporting binary
9//! that can be made to exit with a certain status code. If you're testing an
10//! IPC exchange, you might want to test against a binary "mock" that sends some
11//! scripted replies.
12//!
13//! And if you're already using Cargo to build and test, it would be nice to be
14//! able to write those extra binaries in Rust, near to the crate you're
15//! testing, as Cargo projects themselves. Then at least you'll know that your
16//! test environments will already have the right toolchain installed.
17//!
18//! *To some extent this is already possible without using this crate at all!*
19//! If you want an extra binary, you could put it under your `src/bin` or
20//! `examples` directory and use it that way. But there are limitations to
21//! what's currently possible under Cargo alone:
22//!
23//! - Crate binaries eg. under `src/bin`, or listed under `[[bin]]` in
24//!   `Cargo.toml`, can be found via the environment variable
25//!   [`CARGO_BIN_EXE_<name>`][cargo-env] when running tests. But they have to
26//!   share dependencies with your entire crate! So whatever your supporting
27//!   binaries depend on, your entire crate has to depend on as well. This is
28//!   discussed in [Cargo issue #1982][cargo-1982]
29//!
30//!     [cargo-env]: https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
31//!     [cargo-1982]: https://github.com/rust-lang/cargo/issues/1982
32//!
33//! - Example binaries (under `examples/` or `[[example]]`) use
34//!   `[dev-dependencies]` instead. But they have no equivalent environment
35//!   variable, and might not be built by the time your test runs.
36//!
37//! - More philosophically: such binaries are not examples, nor are they real
38//!   applications. They might not use any aspect of your crate whatsoever. They
39//!   might deliberately malfunction. It might be confusing to end users to find
40//!   these alongside your other examples. It might just not be the kind of
41//!   organisation you want for your tests.
42//!
43//! - Organising supporting binaries as workspace crates requires publishing
44//!   every one of those crates to [`crates.io`](https://crates.io) (or whatever
45//!   registry you're using), even if they have no use whatsoever outside of
46//!   your crate's integration tests.
47//!
48//! This crate provides a way to work around those constraints. It has a simple
49//! interface for invoking Cargo to build extra binaries organised in a separate
50//! directory under your crate.
51//!
52//! The first thing to note is that these extra binaries *aren't* binaries
53//! listed in your actual project's manifest. So start by picking a directory
54//! name and put them in there eg. this project uses `testbins`. **This is not
55//! going to be a workspace.** Under this directory you will have these extra
56//! binaries in their own Cargo packages.
57//!
58//! The structure should look something like this:
59//!
60//! ```none
61//! ├── Cargo.toml        (your crate's manifest)
62//! ├── src
63//! │  └── lib.rs         (your crate's lib.rs)
64//! ├── tests
65//! │  └── tests.rs       (your crate's tests, which want to use the supporting
66//! │                      binaries below)
67//! │
68//! └── testbins          (all the extra binary projects are under this
69//!    │                   directory)
70//!    ├── test-something (first extra binary)
71//!    │  ├── Cargo.toml  (extra binary manifest, name = "test-something")
72//!    │  └── src
73//!    │     └── main.rs  (extra binary source)
74//!    ├── test-whatever  (another extra binary, name = "test-whatever")
75//!    │  ├── Cargo.toml
76//!    │  └── src
77//!    │     └── main.rs
78//!     ...etc...
79//! ```
80//!
81//! > ## Note
82//! >
83//! > It can be useful to put an empty `[workspace]` section in the `Cargo.toml`
84//! > for these test binaries, so that Cargo knows not to [look in parent
85//! > directories][cargo-10872].
86//!
87//!   [cargo-10872]: https://github.com/rust-lang/cargo/issues/10872
88//!
89//! With this setup, you can now call [`build_test_binary("test-something",
90//! "testbins")`](crate::build_test_binary). See how:
91//!
92//! - `"test-something"` is the binary name you'd pass to Cargo *in the child
93//!   project* eg. if you changed directory to the nested project, you'd run
94//!   `cargo build --bin test-something`; it also has to be the name of the
95//!   subdirectory this project is in
96//! - `"testbins"` is the directory relative to your real project's manifest
97//!   containing this test binary project (and maybe others); think of it like
98//!   you'd think of the `examples` or `tests` directory
99//!
100//! If you need to set different profiles or features, or have more control over
101//! the directory structure, there is also [a builder API](crate::TestBinary).
102//! Also see [`build_test_binary_once!()`](crate::build_test_binary_once) for a
103//! macro that lazily builds the binary and caches the path.
104//!
105//! Here's an example of how you might use this crate's API in a test, with a
106//! binary named `does-build`:
107//!
108//! ```rust
109//! # use test_binary::build_test_binary;
110//!
111//! let test_bin_path = build_test_binary("does-build", "testbins")
112//!     .expect("error building test binary");
113//!
114//! let mut test_bin_subproc = std::process::Command::new(test_bin_path)
115//!     .spawn()
116//!     .expect("error running test binary");
117//!
118//! // Test behaviour of your program against the mock binary eg. send it
119//! // something on stdin and assert what it prints on stdout, do some IPC,
120//! // check for side effects.
121//!
122//! assert!(test_bin_subproc
123//!     .wait()
124//!     .expect("error waiting for test binary")
125//!     .success());
126//! ```
127//!
128//! The result returned by these functions contains the path of the built binary
129//! as a [`std::ffi::OsString`], which can be passed to
130//! [`std::process::Command`] or other crates that deal with subprocesses. The
131//! path is not resolved to an absolute path by this crate, although it might be
132//! one anyway. Since it is the path provided by Cargo after being invoked in
133//! the current process' working directory, it will be valid as long as you do
134//! not change the working directory between obtaining it and using it.
135
136#![forbid(unsafe_code)]
137#![warn(missing_docs, missing_debug_implementations)]
138#![cfg_attr(docsrs, feature(doc_cfg))]
139
140use std::{
141    ffi::OsString,
142    io::{BufReader, Read},
143    path::{Path, PathBuf},
144    process::{Command, Stdio},
145};
146
147// For the build_test_binary_once macro.
148pub use once_cell;
149pub use paste;
150
151mod stream;
152
153// Internal macros for OsString boilerplate.
154
155macro_rules! vec_oss {
156    ($($item:expr),* $(,)?) => {
157        vec![
158            $(::std::ffi::OsString::from($item),)+
159        ]
160    };
161}
162
163macro_rules! push_oss {
164    ($args:expr, $item:expr) => {
165        $args.push(::std::ffi::OsString::from($item))
166    };
167}
168
169/// Builder constructor for a test binary.
170///
171/// Start with [`TestBinary::relative_to_parent(name,
172/// manifest)`](TestBinary::relative_to_parent) where
173/// - `name` is the name of the binary in the child project's manifest
174/// - `manifest` is the path to the manifest file for the test binary, relative
175///   to the directory that the containing project is in. It should probably end
176///   in `Cargo.toml`.
177///
178/// Note that you can pass a path in a cross-platform way by using
179/// [`PathBuf::from_iter()`][std::path::PathBuf::from_iter()]:
180///
181/// ```
182/// # use std::path::PathBuf;
183/// # use test_binary::TestBinary;
184/// TestBinary::relative_to_parent(
185///     "does-build",
186///     &PathBuf::from_iter(["testbins", "does-build", "Cargo.toml"]),
187/// );
188/// ```
189#[derive(Debug)]
190pub struct TestBinary<'a> {
191    binary: &'a str,
192    manifest: &'a Path,
193    features: Vec<&'a str>,
194    default_features: bool,
195    profile: Option<&'a str>,
196}
197
198impl<'a> TestBinary<'a> {
199    /// Creates a new `TestBinary` by specifying the child binary's manifest
200    /// relative to the parent.
201    pub fn relative_to_parent(name: &'a str, manifest: &'a Path) -> Self {
202        Self {
203            binary: name,
204            manifest,
205            features: vec![],
206            default_features: true,
207            profile: None,
208        }
209    }
210
211    /// Specifies a profile to build the test binary with.
212    pub fn with_profile(&mut self, profile: &'a str) -> &mut Self {
213        self.profile = Some(profile);
214        self
215    }
216
217    /// Specifies not to enable default features.
218    pub fn no_default_features(&mut self) -> &mut Self {
219        self.default_features = false;
220        self
221    }
222
223    /// Specifies a feature to enable for the test binary. These are additive,
224    /// so if you call this multiple times all the features you specify will be
225    /// enabled.
226    pub fn with_feature(&mut self, feature: &'a str) -> &mut Self {
227        self.features.push(feature);
228        self
229    }
230
231    /// Builds the binary crate we've prepared. This goes through Cargo, so it
232    /// should function identically to `cargo build --bin testbin` along with
233    /// any additional flags from the builder methods.
234    pub fn build(&mut self) -> Result<OsString, TestBinaryError> {
235        fn get_cargo_env(key: &str) -> Result<OsString, TestBinaryError> {
236            std::env::var_os(key).ok_or_else(|| {
237                TestBinaryError::NonCargoRun(format!(
238                    "{} '{}' {}",
239                    "The environment variable ", key, "is not set",
240                ))
241            })
242        }
243
244        let cargo_path = get_cargo_env("CARGO")?;
245
246        // Resolve test binary project manifest.
247        let mut manifest_path = PathBuf::from(get_cargo_env("CARGO_MANIFEST_DIR")?);
248        manifest_path.push(self.manifest);
249
250        let mut cargo_args = vec_oss![
251            "build",
252            "--message-format=json",
253            "-q",
254            "--manifest-path",
255            manifest_path,
256            "--bin",
257            self.binary,
258        ];
259
260        if let Some(prof) = self.profile {
261            push_oss!(cargo_args, "--profile");
262            push_oss!(cargo_args, prof);
263        }
264
265        if !self.default_features {
266            push_oss!(cargo_args, "--no-default-features");
267        }
268
269        for feature in &self.features {
270            push_oss!(cargo_args, "--features");
271            push_oss!(cargo_args, feature);
272        }
273
274        let mut cargo_command = Command::new(cargo_path)
275            .args(cargo_args)
276            .stdout(Stdio::piped())
277            .stderr(Stdio::piped())
278            .spawn()?;
279
280        let reader = BufReader::new(
281            // The child process' stdout being None is legitimately a
282            // programming error, since we created it ourselves two lines ago.
283            //
284            // Use as_mut() instead of take() here because if we detach
285            // ownership from the subprocess, we risk letting it drop
286            // prematurely, which can make it close before the subprocess is
287            // finished, resulting in a broken pipe error (but in a highly
288            // timing/platform/performance dependent and intermittent way).
289            cargo_command
290                .stdout
291                .as_mut()
292                .expect("Cargo subprocess output has already been claimed"),
293        );
294
295        let cargo_outcome = stream::process_messages(reader, self.binary);
296
297        // See above re. stderr being None.
298        let mut error_reader = BufReader::new(
299            cargo_command
300                .stderr
301                .as_mut()
302                .expect("Cargo subprocess error output has already been claimed"),
303        );
304
305        let mut error_msg = String::new();
306        error_reader.read_to_string(&mut error_msg)?;
307
308        if cargo_command.wait()?.success() {
309            // The process succeeded. There should be a result from the JSON
310            // output above.
311            cargo_outcome
312                .expect("Cargo succeeded but produced no output")
313                .map(Into::into)
314        } else if let Some(Err(err)) = cargo_outcome {
315            // The process failed and there's an error we extracted from the
316            // JSON output. Usually this means a compiler error.
317            Err(err)
318        } else {
319            // The process failed but there's no error from the JSON output.
320            // This will happen if there's an invocation error eg. the manifest
321            // does not exist.
322            //
323            // This case also covers process failure but an Ok() result from the
324            // above message parsing. This would be strange (if it's even
325            // possible), but if it happens we should still report the error.
326            Err(TestBinaryError::CargoFailure(error_msg))
327        }
328    }
329}
330
331/// Simplified function for building a test binary where the binary is in a
332/// subdirectory of the same name, the manifest is named `Cargo.toml`, and you
333/// don't need any non-default features or to specify a profile.
334///
335/// For example, if your parent contains the child binary in
336/// `testbins/does-build`, and the binary is named `does-build` in its
337/// `Cargo.toml`, then you can just call `build_test_binary("does_build",
338/// "testbins")`.
339pub fn build_test_binary<R: AsRef<Path>>(
340    name: &str,
341    directory: R,
342) -> Result<OsString, TestBinaryError> {
343    TestBinary::relative_to_parent(
344        name,
345        &PathBuf::from_iter([directory.as_ref(), name.as_ref(), "Cargo.toml".as_ref()]),
346    )
347    .build()
348}
349
350/// Error type for build result.
351#[derive(thiserror::Error, Debug)]
352pub enum TestBinaryError {
353    /// We are not running under Cargo.
354    #[error("{0}; is this running under a 'cargo test' command?")]
355    NonCargoRun(String),
356    /// An error running Cargo itself.
357    #[error("IO error running Cargo")]
358    CargoRunError(#[from] std::io::Error),
359    /// Cargo ran but did not succeed.
360    #[error("Cargo failed, stderr: {0}")]
361    CargoFailure(String),
362    /// Cargo ran but there was a compilation error.
363    #[error("build error:\n{0}")]
364    BuildError(String),
365    /// Cargo ran and seemed to succeed but the requested binary did not appear
366    /// in its build output.
367    #[error(r#"could not find binary "{0}" in Cargo output"#)]
368    BinaryNotBuilt(String),
369}
370
371/// Generate a singleton function to save invoking Cargo multiple times for the
372/// same binary.
373///
374/// This is useful when you have many integration tests that use the one test
375/// binary, and don't want to invoke Cargo over and over for each one. Note that
376/// Cargo itself implements both locking and caching at the filesystem level, so
377/// all this macro will save you is the overhead of spawning the Cargo process
378/// to do its checks. That may still be appreciable for high numbers of tests or
379/// on slow systems.
380///
381/// Calling `build_test_binary_once!(binary_name, "tests_dir")` (no quotes on
382/// `binary_name`) will generate a function `path_to_binary_name()` that returns
383/// the path of the built test binary as an `OsString`, just like
384/// `build_test_binary("binary_name", "tests_dir")` would. Unlike
385/// `build_test_binary()`, the generated function will only build the binary
386/// once, and only on the first call. Subsequent calls will use a cached path
387/// and assume the initial build is still valid. The generated function unwraps
388/// the result internally and will panic on build errors.
389///
390/// For example, if you use `build_test_binary_once!(my_test, "testbins")` in
391/// `tests/common/mod.rs`, that module will then contain a function
392/// `path_to_my_test() -> std::ffi::OsString`. Multiple integration tests can
393/// then use `common::path_to_my_test()` to obtain the path. Cargo will only be
394/// run once for this binary, even if the integration tests that use it are
395/// being run in multiple threads.
396///
397/// > ## Note
398/// >
399/// > That this means the binary name must be a valid identifier eg. not have
400/// > dashes in it.
401///
402/// ```rust
403/// # use test_binary::build_test_binary_once;
404/// // Build a test binary named "multiple".
405/// build_test_binary_once!(multiple, "testbins");
406///
407/// // The first test that gets run will cause the binary "multiple" to be built
408/// // and the path will be cached inside the `path_to_multiple()` function.
409///
410/// let test_bin_path = path_to_multiple();
411/// assert!(std::process::Command::new(test_bin_path)
412///     .status()
413///     .expect("Error running test binary")
414///     .success());
415///
416/// // Subsequent tests will just get the cached path without spawning Cargo
417/// // again.
418///
419/// let test_bin_path_again = path_to_multiple();
420/// assert!(std::process::Command::new(test_bin_path_again)
421///     .status()
422///     .expect("Error running test binary")
423///     .success());
424/// ```
425///
426/// If you need to use extra features or a non-default profile, you will need to
427/// go back to using the builder.
428#[macro_export]
429macro_rules! build_test_binary_once {
430    ($name:ident, $tests_dir:expr) => {
431        $crate::paste::paste! {
432            pub fn [<path_to_ $name>]() -> std::ffi::OsString {
433                use $crate::once_cell::sync::Lazy;
434                use std::ffi::OsString;
435
436                static [<LAZY_PATH_TO_ $name>]: Lazy<OsString> =
437                    Lazy::new(|| $crate::build_test_binary(
438                        stringify!($name),
439                        $tests_dir
440                    ).unwrap());
441                [<LAZY_PATH_TO_ $name>].clone()
442            }
443        }
444    };
445}