#buildpacks #cnb

libcnb-test

An integration testing framework for buildpacks written with libcnb.rs

23 releases (15 breaking)

new 0.19.0 Feb 23, 2024
0.17.0 Dec 6, 2023
0.16.0 Nov 17, 2023
0.13.0 Jun 21, 2023
0.3.0 Mar 8, 2022

#50 in Testing

Download history 51/week @ 2023-11-06 184/week @ 2023-11-13 50/week @ 2023-11-20 124/week @ 2023-11-27 162/week @ 2023-12-04 35/week @ 2023-12-11 13/week @ 2023-12-18 50/week @ 2023-12-25 100/week @ 2024-01-01 62/week @ 2024-01-08 68/week @ 2024-01-15 13/week @ 2024-01-22 140/week @ 2024-01-29 32/week @ 2024-02-05 189/week @ 2024-02-12 680/week @ 2024-02-19

1,041 downloads per month
Used in libherokubuildpack

BSD-3-Clause

240KB
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 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", "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

~9–21MB
~289K SLoC