#buildpack #cnb

libcnb-test

An integration testing framework for buildpacks written with libcnb.rs

34 releases (breaking)

Uses new Rust 2024

new 0.28.1 Mar 25, 2025
0.27.0 Feb 27, 2025
0.26.1 Dec 10, 2024
0.26.0 Nov 18, 2024
0.3.0 Mar 8, 2022

#77 in Testing

Download history 373/week @ 2024-12-09 63/week @ 2024-12-16 22/week @ 2024-12-30 193/week @ 2025-01-06 68/week @ 2025-01-13 138/week @ 2025-01-20 352/week @ 2025-01-27 642/week @ 2025-02-03 169/week @ 2025-02-10 187/week @ 2025-02-17 455/week @ 2025-02-24 428/week @ 2025-03-03 48/week @ 2025-03-10 280/week @ 2025-03-17 242/week @ 2025-03-24

1,013 downloads per month
Used in bullet_stream

BSD-3-Clause

250KB
5K SLoC

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", "tests/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", "tests/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", "tests/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", "tests/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", "tests/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);
                    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 mut response = ureq::get(&url).call().unwrap();
                    let body = response.body_mut().read_to_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", "tests/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", "tests/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::data::buildpack_id;
use libcnb_test::{BuildConfig, BuildpackReference, TestRunner};

// #[test]
fn additional_buildpacks() {
    TestRunner::default().build(
        BuildConfig::new("heroku/builder:22", "tests/fixtures/app").buildpacks([
            BuildpackReference::CurrentCrate,
            BuildpackReference::WorkspaceBuildpack(buildpack_id!("my-project/buildpack")),
            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.

Dependencies

~10–22MB
~319K SLoC