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}