libcnb_test/
test_runner.rs

1use crate::docker::{DockerRemoveImageCommand, DockerRemoveVolumeCommand};
2use crate::pack::PackBuildCommand;
3use crate::util::CommandError;
4use crate::{BuildConfig, BuildpackReference, PackResult, TestContext, app, build, util};
5use std::borrow::Borrow;
6use std::env;
7use std::path::PathBuf;
8use tempfile::tempdir;
9
10/// Runner for libcnb integration tests.
11///
12/// # Example
13/// ```no_run
14/// use libcnb_test::{assert_contains, assert_empty, BuildConfig, TestRunner};
15///
16/// TestRunner::default().build(
17///     BuildConfig::new("heroku/builder:22", "tests/fixtures/app"),
18///     |context| {
19///         assert_empty!(context.pack_stderr);
20///         assert_contains!(context.pack_stdout, "Expected build output");
21///     },
22/// )
23/// ```
24#[derive(Default)]
25pub struct TestRunner {}
26
27impl TestRunner {
28    /// Starts a new integration test build.
29    ///
30    /// This function copies the application to a temporary directory (if necessary), cross-compiles the current
31    /// crate, packages it as a buildpack and then invokes [pack](https://buildpacks.io/docs/tools/pack/)
32    /// to build a new Docker image with the buildpacks specified by the passed [`BuildConfig`].
33    ///
34    /// After the passed test function has returned, the Docker image and volumes created by Pack are removed.
35    ///
36    /// Since this function is supposed to only be used in integration tests, failures are not
37    /// signalled via [`Result`] values. Instead, this function panics whenever an unexpected error
38    /// occurred to simplify testing code.
39    ///
40    /// # Example
41    /// ```no_run
42    /// use libcnb_test::{assert_contains, assert_empty, BuildConfig, TestRunner};
43    ///
44    /// TestRunner::default().build(
45    ///     BuildConfig::new("heroku/builder:22", "tests/fixtures/app"),
46    ///     |context| {
47    ///         assert_empty!(context.pack_stderr);
48    ///         assert_contains!(context.pack_stdout, "Expected build output");
49    ///     },
50    /// )
51    /// ```
52    pub fn build<C: Borrow<BuildConfig>, F: FnOnce(TestContext)>(&self, config: C, f: F) {
53        let image_name = util::random_docker_identifier();
54        let docker_resources = TemporaryDockerResources {
55            build_cache_volume_name: format!("{image_name}.build-cache"),
56            launch_cache_volume_name: format!("{image_name}.launch-cache"),
57            image_name,
58        };
59        self.build_internal(docker_resources, config, f);
60    }
61
62    pub(crate) fn build_internal<C: Borrow<BuildConfig>, F: FnOnce(TestContext)>(
63        &self,
64        docker_resources: TemporaryDockerResources,
65        config: C,
66        f: F,
67    ) {
68        let config = config.borrow();
69
70        let cargo_manifest_dir = env::var("CARGO_MANIFEST_DIR").map_or_else(
71            |error| panic!("Error determining Cargo manifest directory: {error}"),
72            PathBuf::from,
73        );
74
75        let app_dir = {
76            let normalized_app_dir_path = if config.app_dir.is_relative() {
77                cargo_manifest_dir.join(&config.app_dir)
78            } else {
79                config.app_dir.clone()
80            };
81
82            assert!(
83                normalized_app_dir_path.is_dir(),
84                "App dir is not a valid directory: {}",
85                normalized_app_dir_path.display()
86            );
87
88            // Copy the app to a temporary directory if an app_dir_preprocessor is specified and run the
89            // preprocessor. Skip app copying if no changes to the app will be made.
90            if let Some(app_dir_preprocessor) = &config.app_dir_preprocessor {
91                let temporary_app_dir = app::copy_app(&normalized_app_dir_path)
92                    .expect("Error copying app fixture to temporary location");
93
94                (app_dir_preprocessor)(temporary_app_dir.as_path().to_owned());
95
96                temporary_app_dir
97            } else {
98                normalized_app_dir_path.into()
99            }
100        };
101
102        let buildpacks_target_dir =
103            tempdir().expect("Error creating temporary directory for compiled buildpacks");
104
105        let mut pack_command = PackBuildCommand::new(
106            &config.builder_name,
107            &app_dir,
108            &docker_resources.image_name,
109            &docker_resources.build_cache_volume_name,
110            &docker_resources.launch_cache_volume_name,
111        );
112
113        config.env.iter().for_each(|(key, value)| {
114            pack_command.env(key, value);
115        });
116
117        for buildpack in &config.buildpacks {
118            match buildpack {
119                BuildpackReference::CurrentCrate => {
120                    let crate_buildpack_dir = build::package_crate_buildpack(
121                        config.cargo_profile,
122                        &config.target_triple,
123                        &cargo_manifest_dir,
124                        buildpacks_target_dir.path(),
125                    )
126                    .unwrap_or_else(|error| {
127                        panic!("Error packaging current crate as buildpack: {error}")
128                    });
129                    pack_command.buildpack(crate_buildpack_dir);
130                }
131
132                BuildpackReference::WorkspaceBuildpack(buildpack_id) => {
133                    let buildpack_dir = build::package_buildpack(
134                        buildpack_id,
135                        config.cargo_profile,
136                        &config.target_triple,
137                        &cargo_manifest_dir,
138                        buildpacks_target_dir.path(),
139                    )
140                    .unwrap_or_else(|error| {
141                        panic!("Error packaging buildpack '{buildpack_id}': {error}")
142                    });
143                    pack_command.buildpack(buildpack_dir);
144                }
145
146                BuildpackReference::Other(id) => {
147                    pack_command.buildpack(id.clone());
148                }
149            }
150        }
151
152        let pack_result = util::run_command(pack_command);
153
154        let output = match (&config.expected_pack_result, pack_result) {
155            (PackResult::Success, Ok(output)) => output,
156            (PackResult::Failure, Err(CommandError::NonZeroExitCode { log_output, .. })) => {
157                log_output
158            }
159            (PackResult::Failure, Ok(log_output)) => {
160                panic!("The pack build was expected to fail, but did not:\n\n{log_output}");
161            }
162            (_, Err(command_err)) => {
163                panic!("Error performing pack build:\n\n{command_err}");
164            }
165        };
166
167        let test_context = TestContext {
168            pack_stdout: output.stdout,
169            pack_stderr: output.stderr,
170            docker_resources,
171            config: config.clone(),
172            runner: self,
173        };
174
175        f(test_context);
176    }
177}
178
179#[allow(clippy::struct_field_names)]
180pub(crate) struct TemporaryDockerResources {
181    pub(crate) build_cache_volume_name: String,
182    pub(crate) image_name: String,
183    pub(crate) launch_cache_volume_name: String,
184}
185
186impl Drop for TemporaryDockerResources {
187    fn drop(&mut self) {
188        // Ignoring errors here since we don't want to panic inside Drop.
189        // We don't emit a warning to stderr since that gets too noisy in some common
190        // cases (such as running a test suite when Docker isn't started) where the tests
191        // themselves will also report the same error message.
192        let _ = util::run_command(DockerRemoveImageCommand::new(&self.image_name));
193        let _ = util::run_command(DockerRemoveVolumeCommand::new([
194            &self.build_cache_volume_name,
195            &self.launch_cache_volume_name,
196        ]));
197    }
198}