Moby — Testing#
Sampling strategy#
Moby is an XL project. Testing analysis covered: the full internal/testutil/ helper tree, both test entrypoints (integration/container/main_test.go, integration-cli/check_test.go), representative unit tests (client/container_create_test.go, client/client_mock_test.go), a representative integration test (integration/container/create_test.go), the custom suite runner (internal/test/suite/suite.go), and CI workflows (.github/workflows/.test-unit.yml, .github/workflows/test.yml). Patterns result was read first.
Test metrics#
- Test files: 770
- Source files (non-test): 1,375
- Ratio (test/source): ~0.56 — high for an infrastructure project
- Test functions (
func Test*): 2,292 - Table-driven test occurrences: 1,214 (
t.Run,tc.name,testCases, etc.) - Test frameworks:
gotest.tools/v3exclusively (not testify); stdlibtesting; custom suite runner
Test organization#
Placement#
Both same-package and _test-package tests are used. Client unit tests use package client (white-box, can access unexported helpers). Integration tests use package container, package networking, etc. — always the _test package, keeping them as black-box API tests.
Three test tiers#
Tier 1 — Unit tests (client/, daemon/, individual packages)
- Live alongside source files
- No running daemon required
- Use mock HTTP transport to simulate the Docker API over a fake
RoundTripper - Fast; expected to pass on any platform
Tier 2 — Integration tests (integration/)
- Organized by domain:
integration/container/,integration/networking/,integration/image/,integration/daemon/, etc. - Require a running dockerd (local or remote, controlled by env var
DOCKER_HOST) - Each subdomain has its own
main_test.gowith OTel setup andenvironment.Executioninitialization - Platform-conditioned with build tags (
//go:build !windows)
Tier 3 — Legacy CLI integration tests (integration-cli/)
- Old-style tests that run the
dockerCLI binary viaicmd.RunCommand - Use a deprecated
internal/test/suiterunner (custom reflection-based test suite) - Comments in the code explicitly label this package as “legacy” and note that new tests should go in
integration/ - The
checker/sub-package is similarly marked for removal (// Please remove this package whenever possible)
Helper packages#
internal/testutil/ — the primary test infrastructure library:
| Sub-package | Purpose |
|---|---|
helpers.go | OTel tracing setup, StartSpan, GetContext/SetContext, CheckNotParallel |
daemon/ | Daemon struct — launch/stop a real dockerd subprocess for testing; supports TLS, custom sockets, Swarm |
environment/ | Execution struct — detects local vs remote daemon, OS type, protects named objects from cleanup |
request/ | Low-level HTTP helpers (Get, Post, Delete, Do) wrapping the Docker API client with OTel instrumentation |
fakestorage/ | In-memory fake Docker registry for image-push/pull tests |
fakecontext/ | Temporary build-context directory creation for docker build tests |
fakegit/ | Git repository builder for remote-context build tests |
registry/ | Real Docker registry process spawner for integration tests that need actual push/pull |
fixtures/ | Frozen image loading, plugin binaries |
netnsutils/ | Network namespace sanity checks for Linux tests |
labelstore/ | In-memory label store for testing label subsystem |
internal/test/suite/ — simplified testify/suite replacement (used only by integration-cli/):
- Reflection-based: finds all methods starting with
Teston a struct receiver - Lifecycle:
SetUpSuite,SetUpTest,TearDownTest,TearDownSuiteinterfaces - Integrates with OTel spans per test via
testutil.StartSpan - Comment: “Please remove this package whenever possible”
Test patterns#
Table-driven tests#
- Prevalence: Very heavy — 1,214 occurrences in test files
- Style: Anonymous struct slice with named
doc/namefield:testCases := []struct { doc string image string expectedError string }{ {doc: "image and tag", image: "test456:v1", expectedError: "No such image: test456:v1"}, {doc: "image no tag", image: "test456", expectedError: "No such image: test456"}, } for _, tc := range testCases { t.Run(tc.doc, func(t *testing.T) { t.Parallel() // ... }) }- File reference:
integration/container/create_test.go:40–73
- File reference:
- Assessment: Best-practice style.
t.Parallel()inside subtests is used consistently in integration tests to maximize parallelism.
Mock HTTP transport (client unit tests)#
- Strategy: The
client/package defines atestRoundTrippertype (exported from the production package) and aWithMockClient(func(*http.Request) (*http.Response, error)) Optoption for test use only. - Pattern:Generic helper:
client, err := New( WithMockClient(func(req *http.Request) (*http.Response, error) { // Assert URL, method, body return mockJSONResponse(http.StatusOK, nil, container.CreateResponse{ID: "abc"})(req) }), )mockJSONResponse[T any](statusCode, headers, resp T)marshalsrespto JSON and returns a well-formed*http.Response. No network required. - File reference:
client/client_mock_test.go:50–155 - Assessment: Elegant. The
testRoundTrippertype is defined in production code (client_options.go:144) as an unexported type, so tests inpackage clientcan use it freely. This avoids a separate mock-generation step entirely.
Mocking approach (integration tests — no mock framework)#
- Strategy: Real dockerd subprocess +
testutil/daemon.Daemonhelper. No gomock, no mockery. - How:
Daemon.Start(t)launches a realdockerdbinary on a temporary socket;Daemon.Stop(t)shuts it down. Tests interact via the productionclient.Client. - Example:
d := testdaemon.New(t) d.Start(t) defer d.Stop(t) c := d.NewClientT(t) // now do real API operations - Assessment: Higher fidelity than mocks — catches real daemon bugs. The trade-off is test speed and platform requirements. The daemon helper handles cleanup via
t.Cleanup.
OTel tracing in tests#
- Unique to Moby: Every integration test wraps its execution in an OpenTelemetry span.
- Pattern:Each
func TestMain(m *testing.M) { shutdown := testutil.ConfigureTracing() ctx, span := otel.Tracer("").Start(context.Background(), "integration/container/TestMain") // ... span.End() shutdown(ctx) } func setupTest(t *testing.T) context.Context { ctx := testutil.StartSpan(baseContext, t) // ... return ctx }t.Runsubtest gets its own span; on failure the span status is set toError. Spans are exported via OTLP ifOTEL_EXPORTER_OTLP_ENDPOINTis set. - Assessment: Forward-looking and unique. Enables distributed tracing of CI test runs, making it possible to debug flaky tests and slow subtests with a trace viewer (Jaeger, Tempo). This goes well beyond typical Go test infrastructure. Worth noting as an emerging best practice.
Polling for async state#
- Usage: 71 occurrences of
poll.WaitOnfromgotest.tools/v3/poll - Pattern:
poll.WaitOn(t, func(log poll.LogT) poll.Result { containers, err := apiClient.ContainerList(ctx, ...) if err != nil { return poll.Error(err) } if len(containers) == expected { return poll.Success() } return poll.Continue("waiting for %d containers", expected) }, poll.WithTimeout(10*time.Second)) - Assessment: Correct approach for testing eventually-consistent container state. Avoids
time.Sleepraces. Thepoll.WithTimeoutensures tests fail clearly rather than hanging.
Golden file tests#
- Usage: 11 occurrences of
golden.Assertfromgotest.tools/v3/golden - Used for: Network configuration output (iptables/nftables rules,
/etc/hostscontent, iptables documentation generation) - Pattern:Golden files live in
golden.Assert(t, getEtcHosts(), "TestEtcHostsDisconnect1.golden")integration/networking/testdata/andintegration/network/bridge/*/generated/. - Assessment: Appropriate for complex text output where char-by-char comparison is the right check. Updating goldens requires a flag; not overused.
Platform skipping#
- Usage: 132 occurrences of
skip.Iffromgotest.tools/v3/skipplus build tags - Pattern:
skip.If(t, testEnv.GitHubActions()) // skip flaky test in CI skip.If(t, runtime.GOOS == "windows") //go:build !windows // build tag for whole file - Assessment: The dual approach (build tags for whole files,
skip.Iffor individual tests) is slightly inconsistent but pragmatic. The build-tag approach prevents compilation failures on Windows for tests that use Linux-only syscalls.
Integration tests#
Present#
Yes — integration/ is the primary and integration-cli/ is the legacy integration test location. Together they contain the majority of the 770 test files.
How they work#
Tests connect to a real Docker daemon (either local or pointed to by DOCKER_HOST). The environment.Execution struct negotiates the API version with the daemon and determines capabilities:
// environment.go — auto-detected per test run:
type Execution struct {
client client.APIClient
DaemonInfo system.Info // OS, kernel, storage driver
DaemonMinAPIVersion string
PlatformDefaults PlatformDefaults
protectedElements protectedElements // images/containers to preserve
}protectedElements prevents test cleanup from deleting images/containers that were present before the test suite started.
Separation#
- By directory:
integration/vsintegration-cli/ - Within
integration/: by domain (container/,networking/,daemon/,build/,image/, etc.) - Build tags for platform:
//go:build !windows,//go:build linux - No
//go:build integrationtag — integration tests are separated by directory, not tag
CI workflow#
- Unit tests: Containerized, run with
docker buildx bake; matrix:["", "firewalld"] - Integration tests: Require a VM with dockerd installed; use
TEST_INTEGRATION_DIRenv var to select domains - Platforms: Linux (Ubuntu 24.04), Windows (2022, 2025), ARM64 — separate workflow files
Test quality observations#
What’s done well#
1. Rich test helper ecosystem. internal/testutil/ is a first-class library with 15+ sub-packages covering daemon lifecycle, fake registries, fake git repos, fake build contexts, and network namespace sanity checks. Teams clearly invest in test infrastructure.
2. OTel tracing in tests. The integration of OpenTelemetry spans into the test harness is architecturally notable. When OTEL_EXPORTER_OTLP_ENDPOINT is set, CI runs produce distributed traces that show exactly which test and subtest caused slowness or failure. This closes the feedback loop between CI observability and test debugging — a practice rare in the Go ecosystem.
3. No mock framework dependency. Client tests use hand-written RoundTripper mocks. Integration tests use a real daemon. This means test failures point to real bugs, not mock divergence. The avoidance of gomock/mockery is deliberate and pays off at this project’s scale.
4. gotest.tools/v3 is a better fit than testify. Moby is the primary user of gotest.tools (it’s the same maintainer ecosystem — Aaron Lehmann and Sebastiaan van Stijn are both Docker contributors). The assert.Check (non-fatal) vs assert.Assert (fatal) distinction enforces a discipline that testify’s require vs assert naming obscures.
5. t.Parallel() discipline. Integration tests consistently call t.Parallel() inside subtests. Combined with the CheckNotParallel helper in testutil, the project enforces the distinction between tests that must be serialized (daemon lifecycle) and those that can parallelize.
6. Protected elements prevent test pollution. The environment.Execution.protectedElements mechanism prevents cleanup from deleting images/containers pre-existing in the daemon — crucial when running tests against a shared daemon in CI.
What could improve#
1. Legacy integration-cli/ is a maintenance burden. The package runs Docker CLI commands via icmd.RunCommand, coupling tests to CLI output format rather than the API. The code is explicitly marked for migration, but as of this analysis it still contains hundreds of tests that have not been migrated to integration/. This creates two parallel test suites testing overlapping functionality.
2. No inter-tier consistency enforcement. The two test tiers (unit mock transport, real daemon integration) can drift: a client API method test might pass because the mock returns the right JSON, while the actual daemon behavior differs. There’s no intermediate contract test layer.
3. Test context management is complex. The testContextStore (map of TestingT → context.Context) managed by SetContext/GetContext/CleanupContext adds global mutable state to the test harness. The CheckNotParallel guard is a workaround for the fact that this approach is unsafe under t.Parallel() in some patterns.
4. sync.Once in ConfigureTracing with multiple calls. The tracingOnce.Do(...) in helpers.go combined with the fallback tp = otel.GetTracerProvider().(*trace.TracerProvider) is a type assertion that will panic if a non-TracerProvider was registered. This is a fragile pattern in a shared test helper.
Patterns worth emulating (for the book)#
| Pattern | Why notable |
|---|---|
Mock RoundTripper for HTTP client tests | Avoids mock generation, tests real serialization logic, no daemon needed |
OTel spans per test with testutil.StartSpan | Bridges testing and observability; CI failures become debuggable traces |
poll.WaitOn for async assertions | Eliminates time.Sleep races in container/daemon tests |
protectedElements in test environment | Prevents test pollution without requiring a pristine daemon per suite |
Daemon subprocess helper in testutil/daemon/ | Enables real daemon integration tests with t.Cleanup lifecycle |
| Custom suite runner (no testify dependency) | Shows how to replicate xUnit-style setup/teardown in stdlib-only Go |