Self-hosted service
ory/dockertest avatar
ory/dockertest

ory/dockertest v4: container reuse, cleanup ownership and the client swap underneath

Write better integration tests! Dockertest helps you boot up ephermal docker images for your Go tests with minimal work.

4,530 stars271 forksGoApache-2.0

At a glance

What is it?
A reading of the Go library that boots Docker containers for integration tests, focused on what v4 changed: automatic container reuse, a lighter Docker client, and which cleanup call belongs to your code. Written for Go developers whose tests need a real database.
Who is it for?
Dockertest fits teams whose tests must talk to a real Postgres, Redis or MinIO and who would rather not maintain mocks for them; it is the wrong tool for unit tests that need no service at all. Check three things before adopting it: that every runner has a Docker daemon, that you decide which tests may reuse a container and which must not, and that you pin image tags instead of letting WithTag default to latest.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 1 day ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on November 14, 2026, and from our analysis. They are not legal advice.

From daily snapshots

Star history

  • ory/dockertest4,530 ★ · +4
Star history
Repository2026-09-232026-11-14
ory/dockertest4.5k4.5k

4 Stars in the last 52 days, measured from the daily GitHub snapshot. Compare with other projects

Editorial analysis

go get, one pool, one Postgres container

Installation is one line, and the import path carries the major version:

bash
go get github.com/ory/dockertest/v4

The sample below shows container setup, not a complete database integration test. Add a database connection and readiness check before issuing queries. A pool comes from NewPoolT(t, ""), a container comes from RunT with the image name "postgres", the tag "14" and two environment values, POSTGRES_PASSWORD=secret and POSTGRES_DB=testdb, and the host side of the connection comes from GetHostPort("5432/tcp"). The comment in that sample spells out the destination: postgres://postgres:secret@hostPort/testdb.

go
package myapp_test

import (
    "testing"

    dockertest "github.com/ory/dockertest/v4"
)

func TestPostgres(t *testing.T) {
    pool := dockertest.NewPoolT(t, "")

    // Container is automatically reused across test runs based on "postgres:14".
    postgres := pool.RunT(t, "postgres",
        dockertest.WithTag("14"),
        dockertest.WithEnv([]string{
            "POSTGRES_PASSWORD=secret",
            "POSTGRES_DB=testdb",
        }),
    )

    hostPort := postgres.GetHostPort("5432/tcp")
    t.Logf("Postgres endpoint: %s", hostPort)
    // Add a connection and readiness check before running queries.
}

The case for doing this instead of mocking is stated without much decoration: mocking a database or a DBAL is strenuous, a slight schema change means rewriting at least some of the mocks, and the same is true of an API change in the DBAL. Running the real service in a container and destroying it afterwards avoids that work entirely. It runs on Windows, macOS and Linux, against any image from Docker Hub or built from a Dockerfile.

Reuse keys turn a fresh container into a cached one

Version 4 is built around the idea that a test binary should not pay for a container on every run. The sample test carries the comment that a container is automatically reused across test runs based on "postgres:14", and the migration notes say that v4 introduces automatic container reuse, which makes tests faster by reusing containers across test runs.

The key behind that behaviour is an option. WithReuseID sets a custom reuse key and its default is "repository:tag", so the image and the tag decide which container you get back. WithoutReuse() disables container reuse for a single run.

That default has consequences. Tests using the same reuse key may share a container and its state when their resource lifetimes overlap. Cleanup follows the pool and resource lifecycle; use distinct WithReuseID values or WithoutReuse() when isolation is required. The gain is the one the project advertises, no startup cost per run. The bill is that a reused container keeps whatever state it held, so a suite that assumes an empty database has to clean up itself or opt out of reuse for the tests that need a clean slate.

NewPoolT cleans up through t.Cleanup, NewPool leaves it to you

Two constructors, two cleanup contracts, and the difference decides who owns the containers:

go
// For tests - auto-cleanup with t.Cleanup()
pool := dockertest.NewPoolT(t, "")

// With options
pool := dockertest.NewPoolT(t, "",
    dockertest.WithMaxWait(2*time.Minute),
)

// With a custom Docker client
pool := dockertest.NewPoolT(t, "",
    dockertest.WithMobyClient(myClient),
)

// For non-test code - requires manual Close()
ctx := context.Background()
pool, err := dockertest.NewPool(ctx, "")
if err != nil {
    panic(err)
}
defer pool.Close(ctx)

NewPoolT takes testing.T and arranges cleanup through t.Cleanup(), so cleanup is registered with the test and nobody has to remember to call Close. The same constructor takes options: WithMaxWait caps how long the library waits, and WithMobyClient hands in your own Docker client, which matters when your code already talks to a daemon through a configured client.

NewPool(ctx, "") is for code that is not a test, and the example makes the price explicit: the constructor returns an error, the caller panics on it, and the pool is released only by defer pool.Close(ctx). Any harness outside go test, a fixture binary, a migration tool or a scratch program, has to close the pool itself, and a missing Close leaves containers running on a laptop or a CI runner until somebody prunes them. Porting the same fixture out of a test means changing the constructor and the run call, since RunT fails the test on error while Run returns the error to you.

WithTag defaults to latest, WithReuseID defaults to repository:tag

Two defaults in the option list deserve a close look before anything else.

WithTag(tag string) sets the image tag and its default is "latest". A suite that never names a tag follows whatever the registry publishes under that name, on every run and on every machine, so the thing your test exercised last week is not guaranteed to be the thing it exercises today. The samples avoid this by pinning WithTag("14").

WithReuseID(id string) sets a custom reuse key, default "repository:tag", which means the same image and tag also share one container between runs. The two defaults point in opposite directions: an unpinned tag makes the image float, and a shared reuse key makes the container persist. Pinning a tag without setting WithoutReuse() or a distinct reuse ID gives you a stable image inside a dirty container.

The remaining options are plain configuration. WithEnv takes a slice of KEY=value strings, WithCmd overrides the default command, WithEntrypoint replaces the entrypoint, WithUser accepts "user" or "user:group", WithWorkingDir sets the directory, WithLabels adds a map, WithHostname and WithName set the container identity, WithMounts takes bind strings in "host:container" or "host:container:mode" form, and WithPortBindings takes a network.PortMap when the ports should be fixed rather than allocated for you.

WithContainerConfig and WithHostConfig carry health checks and limits

Some settings are too specific for a named option, so two of them take a function instead. WithContainerConfig hands you the container config directly, and the example sets a stop timeout of 30, a stop signal of SIGTERM and a healthcheck:

go
stopTimeout := 30
resource := pool.RunT(t, "app",
    dockertest.WithContainerConfig(func(cfg *container.Config) {
        cfg.StopTimeout = &stopTimeout
        cfg.StopSignal = "SIGTERM"
        cfg.Healthcheck = &container.HealthConfig{
            Test:     []string{"CMD", "curl", "-f", "http://localhost/health"},
            Interval: 10 * time.Second,
            Timeout:  5 * time.Second,
            Retries:  3,
        }
    }),
)

WithHostConfig does the same on the host side, and its example attaches a restart policy of RestartPolicyOnFailure with a maximum retry count of 3. The option's own description names port bindings, volumes, restart policy and memory or CPU limits as the territory it reaches, which is how you set a memory ceiling on a container that would otherwise consume the whole CI machine.

For test authors the timing matters more than the syntax. A container that is running is not a container that is ready, and the healthcheck block is where that difference gets expressed: an HTTP probe, a 10 second interval, a 5 second timeout and 3 retries. Leave it out and readiness has to be handled another way; the README's table of contents lists a separate Waiting for readiness section.

GetHostPort and the /tcp probe string shape the connection string

The seam between the container and your test is a single method. In the sample, hostPort := postgres.GetHostPort("5432/tcp"), and the credentials that complete the URL are the same ones passed through WithEnv.

Two things follow. The probe string carries port and protocol, "5432/tcp", so the value you get back is the host mapping for that container port rather than an address inside the container. And because that host port is allocated for you, a data source name with 5432 hardcoded breaks as soon as the port is busy on the machine running the suite, which is why the sample keeps the port in a variable instead of pasting a literal into the URL.

When you do want a fixed port, WithPortBindings takes a network.PortMap and moves the decision back to your own configuration, at the cost of the collision you were avoiding. For a team running suites in parallel, GetHostPort is the first line to read when tests pass alone and fail together: the port is allocated, the container is shared through the reuse key, and the question of how long the data inside it survives is answered by WithReuseID, not by the port mapping.

The v4 module path and the moby client underneath it

v4 is a different module, and that shows up in your imports. go.mod declares module github.com/ory/dockertest/v4 with go 1.25.5, and the sample imports it under the name dockertest. The migration notes explain the two headline changes and point at UPGRADE.md for the complete guide: automatic container reuse, and a lightweight Docker client that reduces the number of third party dependencies.

That client is visible in the require block. The direct dependencies are github.com/moby/moby/client v0.4.0, github.com/moby/moby/api v1.54.1, github.com/moby/docker-image-spec v1.3.1, github.com/cenkalti/backoff/v5 v5.0.3, github.com/containerd/errdefs v1.0.0, github.com/distribution/reference v0.6.0, go.uber.org/goleak v1.3.0 and golang.org/x/sys v0.47.0, with OpenTelemetry packages, containerd errdefs/pkg, docker go-connections, go-units and logr listed as indirect requirements.

The module path has a practical consequence: upgrading means changing the import path to /v4 wherever the library is used. The dates tell their own story as well, with v4.0.0-beta.4 on 2026-03-18, v4.0.0 on 2026-04-14 and v4.0.1 on 2026-10-19, while the default branch, which is named v4, took a push on 2026-10-23. The repository is not archived and commits continue after the newest tag.

The Makefile shows what the project's own pipeline runs

The repository carries its own tooling, and the Makefile is the shortest description of what the maintainers expect to run:

make
test:
	go vet ./...
	go test -short -race ./...
	go test -covermode=atomic -coverprofile="coverage.out" ./...

The lint target runs golangci-lint with the .golangci.yml configuration, the format target runs the ory binary for headers and copyright, gofmt and prettier, and the licenses target installs .bin/licenses from the ory/ci repository over curl before running it. The test target also needs a working Docker daemon for the tests that start containers.

Two details matter if you plan to run this yourself. The test target runs `go test -short -race ./...` and then the coverage run over `./...`, and the examples directory carries its own go.mod and go.sum, which makes the per service tests a separate module. Those example files are the fastest way to see real usage: postgres_test.go, mysql_test.go, cockroachdb_test.go, cassandra_test.go, mongodb_test.go, redis_test.go, minio_test.go, mountebank_test.go, plus multi_container_test.go, build_dockerfile_test.go and cleanup_test.go. Nothing in the default target runs them, so a suite that copies from them is also taking on the task of deciding how those examples get executed in your pipeline.

Editorial conclusion

Dockertest fits teams whose tests must talk to a real Postgres, Redis or MinIO and who would rather not maintain mocks for them; it is the wrong tool for unit tests that need no service at all. Check three things before adopting it: that every runner has a Docker daemon, that you decide which tests may reuse a container and which must not, and that you pin image tags instead of letting WithTag default to latest. If you are moving from v3, read UPGRADE.md first, because the module path changed.

Frequently asked questions

ory dockertest vs testcontainers

The repository publishes no comparison between the two. What it does publish is its own surface: NewPoolT and NewPool for the pool, RunT and Run to start containers, a list of With options with their defaults, and pointers to the Go API documentation at pkg.go.dev. The choice between the two libraries is left to you.

How do I install ory/dockertest in a Go project?

Run go get github.com/ory/dockertest/v4 and import it as dockertest "github.com/ory/dockertest/v4". The module's go.mod declares go 1.25.5, and the v4 path is a different module from v3.

Does ory/dockertest reuse containers between test runs?

Yes, since v4. The sample test notes that a container is automatically reused across test runs based on "postgres:14". The reuse key defaults to "repository:tag", WithReuseID sets a custom key, and WithoutReuse() disables reuse for one run, which matters when a test needs a container with no data from an earlier run.

Which services does ory/dockertest have example tests for?

The examples directory holds postgres_test.go, mysql_test.go, cockroachdb_test.go, cassandra_test.go, mongodb_test.go, redis_test.go, minio_test.go and mountebank_test.go, along with multi_container_test.go, build_dockerfile_test.go and cleanup_test.go. It has its own go.mod and go.sum, so those examples are a separate module from the library.

How do I run the ory/dockertest test suite myself?

The Makefile test target runs go vet ./..., then go test -short -race ./..., then go test -covermode=atomic -coverprofile="coverage.out" ./.... Linting uses golangci-lint with .golangci.yml, and a Docker daemon has to be available because the suite starts containers.

Official sources

  1. License: Apache-2.0
  2. ory/dockertest on GitHub
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/ory-dockertest.svg)](https://hysenlabs.com/projects/ory-dockertest)

Star history chart for your README

markdown
[![Star history](https://hysenlabs.com/chart/ory-dockertest.svg)](https://hysenlabs.com/projects/ory-dockertest)

Was this useful?