An integration testing framework for Cloud Native Buildpacks written in Rust with libcnb.rs.
The framework:
- Automatically cross-compiles and packages the buildpack under test
- Performs a build with specified configuration using pack build
- Supports starting containers using the resultant application image
- Supports concurrent test execution
- Handles cleanup of the test containers and images
- Provides additional test assertion macros to simplify common test scenarios (for example, assert_contains!
)
Integration tests require the following to be available on the host:
libcnb-cargo
itself is not required)Only local Docker daemons are fully supported. As such, if you are using Circle CI you must use the
machine
executor rather than the
remote docker feature.
A basic test that performs a build with the specified builder image and app source fixture,
and then asserts against the resultant pack build
log output:
```rust,norun // In $CRATEROOT/tests/integrationtest.rs use libcnbtest::{assertcontains, assertempty, BuildConfig, TestRunner};
// Note: In your code you'll want to uncomment the #[test]
annotation here.
// It's commented out in these examples so that this documentation can be
// run as a doctest
and so checked for correctness in CI.
// #[test]
fn basic() {
TestRunner::default().build(
BuildConfig::new("heroku/builder:22", "test-fixtures/app"),
|context| {
assertempty!(context.packstderr);
assertcontains!(context.packstdout, "Expected build output");
},
);
}
```
Performing a second build of the same image to test cache handling, using [TestContext::rebuild
]:
```rust,norun use libcnbtest::{assert_contains, BuildConfig, TestRunner};
// #[test] fn rebuild() { TestRunner::default().build( BuildConfig::new("heroku/builder:22", "test-fixtures/app"), |context| { assertcontains!(context.packstdout, "Installing dependencies");
let config = context.config.clone();
context.rebuild(config, |rebuild_context| {
assert_contains!(rebuild_context.pack_stdout, "Using cached dependencies");
});
},
);
} ```
Testing expected buildpack failures, using [BuildConfig::expected_pack_result
]:
```rust,norun use libcnbtest::{assert_contains, BuildConfig, PackResult, TestRunner};
// #[test] fn expectedpackfailure() { TestRunner::default().build( BuildConfig::new("heroku/builder:22", "test-fixtures/invalid-app") .expectedpackresult(PackResult::Failure), |context| { assertcontains!(context.packstderr, "ERROR: Invalid Procfile!"); }, ); } ```
Running a shell command against the built image, using [TestContext::run_shell_command
]:
```rust,norun use libcnbtest::{assert_empty, BuildConfig, TestRunner};
// #[test] fn runshellcommand() { TestRunner::default().build( BuildConfig::new("heroku/builder:22", "test-fixtures/app"), |context| { // ... let commandoutput = context.runshellcommand("python --version"); assertempty!(commandoutput.stderr); asserteq!(command_output.stdout, "Python 3.10.4\n"); }, ); } ```
Starting a container using the default process with an exposed port to test a web server, using [TestContext::start_container
]:
```rust,norun use libcnbtest::{assertcontains, assertempty, BuildConfig, ContainerConfig, TestRunner}; use std::thread; use std::time::Duration;
const TEST_PORT: u16 = 12345;
// #[test] fn startingwebservercontainer() { TestRunner::default().build( BuildConfig::new("heroku/builder:22", "test-fixtures/app"), |context| { // ... context.startcontainer( ContainerConfig::new() .env("PORT", TESTPORT.tostring()) .exposeport(TESTPORT), |container| { let addressonhost = container.addressforport(TESTPORT); let url = format!("http://{}:{}", addressonhost.ip(), addresson_host.port());
// Give the server time to start.
thread::sleep(Duration::from_secs(2));
let server_log_output = container.logs_now();
assert_empty!(server_log_output.stderr);
assert_contains!(
server_log_output.stdout,
&format!("Listening on port {TEST_PORT}")
);
let response = ureq::get(&url).call().unwrap();
let body = response.into_string().unwrap();
assert_contains!(body, "Expected response substring");
},
);
},
);
} ```
Inspecting an already running container using Docker Exec, using [ContainerContext::shell_exec
]:
```rust,norun use libcnbtest::{assert_contains, BuildConfig, ContainerConfig, TestRunner};
// #[test] fn shellexec() { TestRunner::default().build( BuildConfig::new("heroku/builder:22", "test-fixtures/app"), |context| { // ... context.startcontainer(ContainerConfig::new(), |container| { // ... let execlogoutput = container.shellexec("ps"); assertcontains!(execlogoutput.stdout, "nginx"); }); }, ); } ```
Dynamically modifying test fixtures during test setup, using [BuildConfig::app_dir_preprocessor
]:
```rust,norun use libcnbtest::{BuildConfig, TestRunner}; use std::fs;
// #[test] fn dynamicfixture() { TestRunner::default().build( BuildConfig::new("heroku/builder:22", "test-fixtures/app").appdirpreprocessor( |appdir| { fs::write(app_dir.join("runtime.txt"), "python-3.10").unwrap(); }, ), |context| { // ... }, ); } ```
Building with multiple buildpacks, using [BuildConfig::buildpacks
]:
```rust,norun use libcnbtest::{BuildConfig, BuildpackReference, TestRunner};
// #[test] fn additional_buildpacks() { TestRunner::default().build( BuildConfig::new("heroku/builder:22", "test-fixtures/app").buildpacks(vec![ BuildpackReference::Crate, BuildpackReference::Other(String::from("heroku/another-buildpack")), ]), |context| { // ... }, ); } ```
#[ignore = "integration test"]
, which
causes cargo test
to skip them (running unit/doc tests only). The integration tests
can then be run using cargo test -- --ignored
, or all tests can be run at once using
cargo test -- --include-ignored
.