Building Docker Images¶
Testcontainers for Rust supports building Docker images directly within your tests. This is useful when you need to test against custom-built images with specific configurations, or when your test requires a dynamically generated Dockerfile.
Building a Simple Image¶
Use GenericBuildableImage to define an image that will be built from a Dockerfile:
use testcontainers::{
core::WaitFor,
runners::AsyncRunner,
GenericBuildableImage,
};
#[tokio::test]
async fn test_custom_image() -> Result<(), Box<dyn std::error::Error>> {
let image = GenericBuildableImage::new("my-test-app", "latest")
.with_dockerfile_string(r#"
FROM alpine:latest
COPY --chmod=0755 app.sh /usr/local/bin/app
ENTRYPOINT ["/usr/local/bin/app"]
"#)
.with_data(
r#"#!/bin/sh
echo "Hello from custom image!"
"#,
"./app.sh",
)
.build_image()
.await?;
let container = image
.with_wait_for(WaitFor::message_on_stdout("Hello from custom image!"))
.start()
.await?;
Ok(())
}
Adding Files to the Build Context¶
You can add files from your filesystem or provide inline data:
From Filesystem¶
let image = GenericBuildableImage::new("my-app", "latest")
.with_dockerfile("./path/to/Dockerfile")
.with_file("./target/release/myapp", "./myapp")
.build_image()
.await?;
Inline Data¶
let image = GenericBuildableImage::new("my-app", "latest")
.with_dockerfile_string("FROM alpine:latest\nCOPY config.json /config.json")
.with_data(r#"{"port": 8080}"#, "./config.json")
.build_image()
.await?;
Build Options¶
The BuildImageOptions struct provides fine-grained control over the build process.
Skip Building if Image Exists¶
When running tests repeatedly, you can skip rebuilding if the image already exists:
use testcontainers::core::BuildImageOptions;
let image = GenericBuildableImage::new("my-app", "v1.0")
.with_dockerfile_string("FROM alpine:latest")
.build_image_with(
BuildImageOptions::new()
.with_skip_if_exists(true)
)
.await?;
This option: - Checks if an image with the same descriptor (name:tag) already exists - Skips the build if found, using the existing image - Is thread-safe - parallel tests building the same image will be serialized
Disable Build Cache¶
Force a fresh build without using Docker's layer cache:
let image = GenericBuildableImage::new("my-app", "latest")
.with_dockerfile_string("FROM alpine:latest\nRUN apk update")
.build_image_with(
BuildImageOptions::new()
.with_no_cache(true)
)
.await?;
Build Arguments¶
Pass build-time variables to your Dockerfile using ARG instructions:
let image = GenericBuildableImage::new("my-app", "latest")
.with_dockerfile_string(r#"
FROM alpine:latest
ARG VERSION
ARG BUILD_DATE
RUN echo "Building version ${VERSION} on ${BUILD_DATE}"
"#)
.build_image_with(
BuildImageOptions::new()
.with_build_arg("VERSION", "1.0.0")
.with_build_arg("BUILD_DATE", "2024-10-25")
)
.await?;
You can also provide build arguments as a HashMap:
use std::collections::HashMap;
let mut args = HashMap::new();
args.insert("VERSION".to_string(), "1.0.0".to_string());
args.insert("ENVIRONMENT".to_string(), "test".to_string());
let image = GenericBuildableImage::new("my-app", "latest")
.with_dockerfile_string("FROM alpine:latest\nARG VERSION\nARG ENVIRONMENT")
.build_image_with(
BuildImageOptions::new()
.with_build_args(args)
)
.await?;
Combining Options¶
All build options can be chained together:
let image = GenericBuildableImage::new("my-app", "latest")
.with_dockerfile_string("FROM alpine:latest\nARG VERSION")
.build_image_with(
BuildImageOptions::new()
.with_skip_if_exists(true)
.with_no_cache(false)
.with_build_arg("VERSION", "1.0.0")
)
.await?;
Synchronous API¶
For non-async tests, use the SyncBuilder trait to build images and SyncRunner to run containers (requires the blocking feature):
use testcontainers::{
core::BuildImageOptions,
runners::{SyncBuilder, SyncRunner},
GenericBuildableImage,
};
#[test]
fn test_sync_build() -> Result<(), Box<dyn std::error::Error>> {
let image = GenericBuildableImage::new("my-app", "latest")
.with_dockerfile_string("FROM alpine:latest")
.build_image()?;
let container = image.start()?;
Ok(())
}
#[test]
fn test_sync_build_with_options() -> Result<(), Box<dyn std::error::Error>> {
let image = GenericBuildableImage::new("my-app", "latest")
.with_dockerfile_string("FROM alpine:latest\nARG VERSION")
.build_image_with(
BuildImageOptions::new()
.with_skip_if_exists(true)
.with_build_arg("VERSION", "1.0.0")
)?;
let container = image.start()?;
Ok(())
}
Best Practices¶
Use Descriptive Tags¶
Use meaningful image names and tags to avoid conflicts:
// Good: specific and descriptive
GenericBuildableImage::new("test-user-service", "integration-v1")
// Avoid: generic names that might conflict
GenericBuildableImage::new("test", "latest")
Leverage skip_if_exists for Faster Tests¶
When your image doesn't change between test runs:
let image = GenericBuildableImage::new("my-stable-app", "v1.0")
.with_dockerfile_string("FROM alpine:latest\nRUN apk add curl")
.build_image_with(
BuildImageOptions::new()
.with_skip_if_exists(true)
)
.await?;
Use Build Arguments for Flexibility¶
Make your test images configurable:
fn build_test_image(version: &str) -> GenericBuildableImage {
GenericBuildableImage::new("my-app", version)
.with_dockerfile_string("FROM alpine:latest\nARG APP_VERSION\nENV VERSION=$APP_VERSION")
.build_image_with(
BuildImageOptions::new()
.with_build_arg("APP_VERSION", version)
)
}
Common Patterns¶
Building from a Complex Dockerfile¶
let dockerfile = r#"
FROM rust:1.75 as builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release
FROM debian:bookworm-slim
COPY --from=builder /app/target/release/myapp /usr/local/bin/
CMD ["/usr/local/bin/myapp"]
"#;
let image = GenericBuildableImage::new("my-rust-app", "latest")
.with_dockerfile_string(dockerfile)
.with_file("./Cargo.toml", "./Cargo.toml")
.with_file("./Cargo.lock", "./Cargo.lock")
.with_file("./src", "./src")
.build_image()
.await?;
Building Multiple Variants¶
async fn build_variant(variant: &str, port: u16) -> Result<GenericImage, Box<dyn std::error::Error>> {
GenericBuildableImage::new("my-app", variant)
.with_dockerfile_string(format!(r#"
FROM alpine:latest
ARG PORT
ENV APP_PORT=$PORT
CMD ["sh", "-c", "echo 'Running on port $APP_PORT' && sleep infinity"]
"#))
.build_image_with(
BuildImageOptions::new()
.with_build_arg("PORT", port.to_string())
.with_skip_if_exists(true)
)
.await
}
#[tokio::test]
async fn test_multiple_variants() -> Result<(), Box<dyn std::error::Error>> {
let image1 = build_variant("dev", 8080).await?;
let image2 = build_variant("staging", 8081).await?;
// Use images in tests...
Ok(())
}
Troubleshooting¶
Build Fails with "no active session" Error¶
This typically occurs when BuildKit encounters issues. Potential solutions:
-
Remove the build cache and retry:
BuildImageOptions::new().with_no_cache(true) -
Ensure Docker daemon is running properly:
docker info -
In CI environments, ensure BuildKit is properly initialized
Image Not Found After Build¶
Verify the descriptor matches exactly:
let image = GenericBuildableImage::new("my-app", "v1.0") // Name and tag must match
.with_dockerfile_string("FROM alpine:latest")
.build_image()
.await?;
// The image is now available as "my-app:v1.0"
Build Arguments Not Working¶
Ensure ARG instructions are in the Dockerfile before they're used:
# Correct
ARG VERSION
ENV APP_VERSION=$VERSION
# Incorrect - ARG comes after usage
ENV APP_VERSION=$VERSION
ARG VERSION