Expand description

libcnb-test   Docs Latest Version MSRV

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!)

Dependencies

Integration tests require the following to be available on the host:

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.

Examples

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:

// In $CRATE_ROOT/tests/integration_test.rs
use libcnb_test::{assert_contains, assert_empty, 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| {
            assert_empty!(context.pack_stderr);
            assert_contains!(context.pack_stdout, "Expected build output");
        },
    );
}

Performing a second build of the same image to test cache handling, using TestContext::rebuild:

use libcnb_test::{assert_contains, BuildConfig, TestRunner};

// #[test]
fn rebuild() {
    TestRunner::default().build(
        BuildConfig::new("heroku/builder:22", "test-fixtures/app"),
        |context| {
            assert_contains!(context.pack_stdout, "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:

use libcnb_test::{assert_contains, BuildConfig, PackResult, TestRunner};

// #[test]
fn expected_pack_failure() {
    TestRunner::default().build(
        BuildConfig::new("heroku/builder:22", "test-fixtures/invalid-app")
            .expected_pack_result(PackResult::Failure),
        |context| {
            assert_contains!(context.pack_stderr, "ERROR: Invalid Procfile!");
        },
    );
}

Running a shell command against the built image, using TestContext::run_shell_command:

use libcnb_test::{assert_empty, BuildConfig, TestRunner};

// #[test]
fn run_shell_command() {
    TestRunner::default().build(
        BuildConfig::new("heroku/builder:22", "test-fixtures/app"),
        |context| {
            // ...
            let command_output = context.run_shell_command("python --version");
            assert_empty!(command_output.stderr);
            assert_eq!(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:

use libcnb_test::{assert_contains, assert_empty, BuildConfig, ContainerConfig, TestRunner};
use std::thread;
use std::time::Duration;

const TEST_PORT: u16 = 12345;

// #[test]
fn starting_web_server_container() {
    TestRunner::default().build(
        BuildConfig::new("heroku/builder:22", "test-fixtures/app"),
        |context| {
            // ...
            context.start_container(
                ContainerConfig::new()
                    .env("PORT", TEST_PORT.to_string())
                    .expose_port(TEST_PORT),
                |container| {
                    let address_on_host = container.address_for_port(TEST_PORT).unwrap();
                    let url = format!("http://{}:{}", address_on_host.ip(), address_on_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:

use libcnb_test::{assert_contains, BuildConfig, ContainerConfig, TestRunner};

// #[test]
fn shell_exec() {
    TestRunner::default().build(
        BuildConfig::new("heroku/builder:22", "test-fixtures/app"),
        |context| {
            // ...
            context.start_container(ContainerConfig::new(), |container| {
                // ...
                let exec_log_output = container.shell_exec("ps");
                assert_contains!(exec_log_output.stdout, "nginx");
            });
        },
    );
}

Dynamically modifying test fixtures during test setup, using BuildConfig::app_dir_preprocessor:

use libcnb_test::{BuildConfig, TestRunner};
use std::fs;

// #[test]
fn dynamic_fixture() {
    TestRunner::default().build(
        BuildConfig::new("heroku/builder:22", "test-fixtures/app").app_dir_preprocessor(
            |app_dir| {
                fs::write(app_dir.join("runtime.txt"), "python-3.10").unwrap();
            },
        ),
        |context| {
            // ...
        },
    );
}

Building with multiple buildpacks, using BuildConfig::buildpacks:

use libcnb_test::{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| {
            // ...
        },
    );
}

Tips

  • Rust tests are automatically run in parallel, however only if they are in the same crate. For integration tests Rust compiles each file as a separate crate. As such, make sure to include all integration tests in a single file (either inlined or by including additional test modules) to ensure they run in parallel.
  • If you would like to be able to more easily run your unit tests and integration tests separately, annotate each integration test with #[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.
  • If you wish to assert against multi-line log output, see the indoc crate.

Macros

Asserts that left contains right.
Asserts that the provided value is empty.
Asserts that left does not contain right.

Structs

Configuration for a test.
Config used when starting a container.
Context of a launched container.
Container log output.
Downloaded SBOM files.
Context for a currently executing test.
Runner for libcnb integration tests.

Enums

References a Cloud Native Buildpack.
The profile to use when invoking Cargo.
Result of a pack execution.
The type of SBOM.