MinIO Testing Guide: Self-Hosted S3-Compatible Storage Testing with TestContainers

MinIO Testing Guide: Self-Hosted S3-Compatible Storage Testing with TestContainers

MinIO is an S3-compatible object storage server you can run locally or in production. Testing it properly means running a real MinIO instance — TestContainers makes this easy in both Java and Python by spinning up a MinIO Docker container per test suite and tearing it down automatically.

Key Takeaways

TestContainers gives you a real MinIO, not a mock. Unlike moto or localstack, TestContainers starts an actual MinIO Docker container — your tests hit real MinIO behavior including its policy engine and presigned URL signing.

MinIO's default credentials in tests are just "minioadmin/minioadmin". Change them per-container using environment variables; never use production credentials in test fixtures.

Bucket policies in MinIO follow AWS IAM JSON syntax. Test them by setting a policy and verifying anonymous vs authenticated access behaves correctly.

Java and Python SDKs differ in API style but MinIO compatibility is identical. The same container works for both; you can share a MinIO TestContainer across mixed-language integration tests.

Always configure endpoint override in your SDK client. Both boto3 and the MinIO Java SDK need the container's host+port as the endpoint — don't let them default to real AWS/MinIO endpoints.

MinIO is the go-to choice for teams who need S3-compatible storage on-premises — Kubernetes clusters, air-gapped environments, or just keeping cloud costs low. But "S3-compatible" doesn't mean "identical," and mocking MinIO with moto misses MinIO-specific behavior like its bucket policy evaluation and quota enforcement.

TestContainers is the answer. It starts a real MinIO Docker container in your test process, runs your tests against it, and shuts it down when you're done. No mocking. No approximations.

Java Setup with TestContainers

Add the dependencies to your pom.xml:

<dependencies>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers</artifactId>
        <version>1.19.7</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.minio</groupId>
        <artifactId>minio</artifactId>
        <version>8.5.9</version>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>

MinIO doesn't have an official TestContainers module, but GenericContainer works perfectly:

import io.minio.*;
import io.minio.messages.*;
import org.junit.jupiter.api.*;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.io.ByteArrayInputStream;
import java.util.List;

@Testcontainers
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class MinioIntegrationTest {

    @Container
    static GenericContainer<?> minio = new GenericContainer<>("minio/minio:latest")
        .withCommand("server /data --console-address :9001")
        .withEnv("MINIO_ROOT_USER", "testuser")
        .withEnv("MINIO_ROOT_PASSWORD", "testpassword")
        .withExposedPorts(9000, 9001);

    private MinioClient minioClient;
    private static final String BUCKET = "test-bucket";

    @BeforeAll
    void setupClient() throws Exception {
        String endpoint = "http://localhost:" + minio.getMappedPort(9000);
        
        minioClient = MinioClient.builder()
            .endpoint(endpoint)
            .credentials("testuser", "testpassword")
            .build();
        
        // Create test bucket
        minioClient.makeBucket(MakeBucketArgs.builder().bucket(BUCKET).build());
    }

    @Test
    void uploadAndDownloadObject() throws Exception {
        byte[] content = "Hello from MinIO test".getBytes();
        
        minioClient.putObject(
            PutObjectArgs.builder()
                .bucket(BUCKET)
                .object("hello.txt")
                .stream(new ByteArrayInputStream(content), content.length, -1)
                .contentType("text/plain")
                .build()
        );

        var response = minioClient.getObject(
            GetObjectArgs.builder()
                .bucket(BUCKET)
                .object("hello.txt")
                .build()
        );

        byte[] downloaded = response.readAllBytes();
        Assertions.assertArrayEquals(content, downloaded);
    }

    @Test
    void listObjectsInBucket() throws Exception {
        // Upload a few objects
        for (int i = 1; i <= 3; i++) {
            byte[] data = ("object " + i).getBytes();
            minioClient.putObject(
                PutObjectArgs.builder()
                    .bucket(BUCKET)
                    .object("prefix/file" + i + ".txt")
                    .stream(new ByteArrayInputStream(data), data.length, -1)
                    .contentType("text/plain")
                    .build()
            );
        }

        Iterable<Result<Item>> results = minioClient.listObjects(
            ListObjectsArgs.builder()
                .bucket(BUCKET)
                .prefix("prefix/")
                .build()
        );

        int count = 0;
        for (Result<Item> result : results) {
            Item item = result.get();
            Assertions.assertTrue(item.objectName().startsWith("prefix/"));
            count++;
        }
        Assertions.assertEquals(3, count);
    }
}

Java: Testing Presigned URLs

Presigned URLs from MinIO are fully functional within the container's network. In tests, you generate the URL and then use it with a plain HTTP client:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.ZonedDateTime;

@Test
void presignedUrlAllowsDownloadWithoutCredentials() throws Exception {
    // Upload a file
    byte[] secret = "secret document".getBytes();
    minioClient.putObject(
        PutObjectArgs.builder()
            .bucket(BUCKET)
            .object("docs/secret.txt")
            .stream(new ByteArrayInputStream(secret), secret.length, -1)
            .contentType("text/plain")
            .build()
    );

    // Generate presigned URL (valid 10 minutes)
    String presignedUrl = minioClient.getPresignedObjectUrl(
        GetPresignedObjectUrlArgs.builder()
            .method(Method.GET)
            .bucket(BUCKET)
            .object("docs/secret.txt")
            .expiry(10, TimeUnit.MINUTES)
            .build()
    );

    // Verify the URL contains expected query params
    Assertions.assertTrue(presignedUrl.contains("X-Amz-Expires=600"));
    Assertions.assertTrue(presignedUrl.contains("X-Amz-Signature"));

    // Use the presigned URL with a plain HTTP client (no credentials needed)
    HttpClient httpClient = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(presignedUrl))
        .GET()
        .build();
    
    HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
    
    Assertions.assertEquals(200, response.statusCode());
    Assertions.assertArrayEquals(secret, response.body());
}

Python Setup with TestContainers

pip install testcontainers boto3 pytest minio
import pytest
import boto3
from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_utils import wait_for_logs
import time

@pytest.fixture(scope="session")
def minio_container():
    container = DockerContainer("minio/minio:latest")
    container.with_command("server /data --console-address :9001")
    container.with_env("MINIO_ROOT_USER", "testuser")
    container.with_env("MINIO_ROOT_PASSWORD", "testpassword")
    container.with_exposed_ports(9000)
    
    container.start()
    wait_for_logs(container, "API:", timeout=30)
    
    yield container
    container.stop()

@pytest.fixture(scope="session")
def s3_client(minio_container):
    port = minio_container.get_exposed_port(9000)
    endpoint = f"http://localhost:{port}"
    
    client = boto3.client(
        "s3",
        endpoint_url=endpoint,
        aws_access_key_id="testuser",
        aws_secret_access_key="testpassword",
        region_name="us-east-1"
    )
    return client

@pytest.fixture(scope="session")
def test_bucket(s3_client):
    bucket = "integration-test-bucket"
    s3_client.create_bucket(Bucket=bucket)
    yield bucket

def test_upload_and_retrieve_file(s3_client, test_bucket):
    s3_client.put_object(
        Bucket=test_bucket,
        Key="data/records.csv",
        Body=b"id,name\n1,Alice\n2,Bob",
        ContentType="text/csv"
    )
    
    response = s3_client.get_object(Bucket=test_bucket, Key="data/records.csv")
    content = response["Body"].read()
    
    assert b"Alice" in content
    assert b"Bob" in content

def test_object_metadata(s3_client, test_bucket):
    s3_client.put_object(
        Bucket=test_bucket,
        Key="meta/file.txt",
        Body=b"content",
        Metadata={"uploaded-by": "test-suite", "environment": "ci"}
    )
    
    head = s3_client.head_object(Bucket=test_bucket, Key="meta/file.txt")
    
    assert head["Metadata"]["uploaded-by"] == "test-suite"
    assert head["Metadata"]["environment"] == "ci"

Testing MinIO Bucket Policies

MinIO's bucket policies use IAM JSON syntax. Testing them means verifying anonymous access behaves exactly as the policy dictates:

import json
import requests

def test_public_read_bucket_policy(s3_client, test_bucket, minio_container):
    port = minio_container.get_exposed_port(9000)
    endpoint = f"http://localhost:{port}"
    
    # Upload a public file
    s3_client.put_object(
        Bucket=test_bucket,
        Key="public/readme.txt",
        Body=b"This is public content"
    )
    
    # Set a public-read policy for the public/ prefix
    policy = {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Effect": "Allow",
                "Principal": {"AWS": ["*"]},
                "Action": ["s3:GetObject"],
                "Resource": [f"arn:aws:s3:::{test_bucket}/public/*"]
            }
        ]
    }
    
    s3_client.put_bucket_policy(
        Bucket=test_bucket,
        Policy=json.dumps(policy)
    )
    
    # Verify: anonymous GET on public/ prefix should succeed
    public_url = f"{endpoint}/{test_bucket}/public/readme.txt"
    response = requests.get(public_url)
    assert response.status_code == 200
    assert response.text == "This is public content"
    
    # Verify: anonymous GET on private/ prefix should fail
    s3_client.put_object(
        Bucket=test_bucket,
        Key="private/secret.txt",
        Body=b"secret"
    )
    private_url = f"{endpoint}/{test_bucket}/private/secret.txt"
    private_response = requests.get(private_url)
    assert private_response.status_code == 403

def test_read_back_bucket_policy(s3_client, test_bucket):
    policy = {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Effect": "Allow",
                "Principal": {"AWS": ["*"]},
                "Action": ["s3:GetObject"],
                "Resource": [f"arn:aws:s3:::{test_bucket}/*"]
            }
        ]
    }
    
    s3_client.put_bucket_policy(
        Bucket=test_bucket,
        Policy=json.dumps(policy)
    )
    
    # Read it back and verify
    response = s3_client.get_bucket_policy(Bucket=test_bucket)
    returned_policy = json.loads(response["Policy"])
    
    assert returned_policy["Version"] == "2012-10-17"
    assert len(returned_policy["Statement"]) == 1
    assert returned_policy["Statement"][0]["Effect"] == "Allow"

Testing Large File Uploads with MinIO SDK

The MinIO Python SDK (not boto3) handles multipart uploads automatically for files over 5MB:

from minio import Minio
from minio.error import S3Error
import io

@pytest.fixture(scope="session")
def minio_client(minio_container):
    port = minio_container.get_exposed_port(9000)
    return Minio(
        f"localhost:{port}",
        access_key="testuser",
        secret_key="testpassword",
        secure=False
    )

def test_large_file_upload(minio_client, test_bucket):
    # Generate a 10MB file (above MinIO's 5MB multipart threshold)
    large_content = b"x" * (10 * 1024 * 1024)
    data_stream = io.BytesIO(large_content)
    
    minio_client.put_object(
        test_bucket,
        "large/10mb-file.bin",
        data_stream,
        length=len(large_content),
        content_type="application/octet-stream"
    )
    
    # Verify the object exists and has the right size
    stat = minio_client.stat_object(test_bucket, "large/10mb-file.bin")
    assert stat.size == len(large_content)
    assert stat.content_type == "application/octet-stream"

def test_object_not_found_raises_s3error(minio_client, test_bucket):
    with pytest.raises(S3Error) as exc_info:
        minio_client.stat_object(test_bucket, "nonexistent/file.txt")
    
    assert exc_info.value.code == "NoSuchKey"

CI Configuration

In GitHub Actions, TestContainers works out of the box because Docker is available:

# .github/workflows/integration-tests.yml
name: MinIO Integration Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      
      - name: Install dependencies
        run: pip install -r requirements-test.txt
      
      - name: Run integration tests
        run: pytest tests/integration/ -v --timeout=120
        env:
          TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE: /var/run/docker.sock

No additional MinIO service configuration needed — TestContainers handles the Docker setup automatically. The container image is pulled on first run and cached by Docker's layer cache on subsequent runs, keeping CI fast.

Read more