Testcontainers + GoDog: Testing Against a Real Postgres, the Way the Docker Daemon Sees It

Mar 12, 2021 9 minute read Upd. Oct 16, 2026
@
Testcontainers + GoDog: Testing Against a Real Postgres, the Way the Docker Daemon Sees It cover
Testcontainers + GoDog: Testing Against a Real Postgres, the Way the Docker Daemon Sees It

A unit test that mocks the database is a guess about your database. A test that spins up a real postgres:16 container and runs the actual query against it is a proof — and it costs you nothing, because the container is gone by the time the test finishes. That is the whole thesis of this post: Testcontainers is not magic. It is a Go client that speaks the same Docker Engine HTTP API that your docker CLI speaks (create → start → publish a random port → wait → exec → kill), and GoDog is the Given/When/Then layer that turns a Gherkin feature file into a table of Go closures you write once.

By the end you can (1) write a feature file and the GoDog Context that makes it run, (2) stand up a disposable Postgres with testcontainers-go, and (3) see the Docker Engine API calls underneath, because the “under the hood” of Testcontainers is a plain http.Client pointed at /var/run/docker.sock.

The two layers, and who owns what

GoDog and Testcontainers solve two different problems and must not be conflated:

  • GoDog (github.com/DATA-DOG/go-godog) is the BDD runner. It reads a .feature file (Gherkin), and for each scenario wires your Given/When/Then step strings to Go closures. It knows nothing about containers.
  • Testcontainers-go (github.com/testcontainers/testcontainers-go) is the lifecycle manager. It talks to the Docker Engine daemon to bring a container up, hands you a host:port you can connect to, and tears it down. It knows nothing about Gherkin.

You glue them with a single Context struct: the When steps call postgres.Run(...), the Then steps run your code against the returned URL, and a deferred Terminate() cleans up.

flowchart TD
    F[feature file .feature] --> GD[GoDog runner
parses Given/When/Then] GD --> C[Context struct
the glue you write] C --> TC[testcontainers-go
modules/postgres] TC --> API[Docker Engine HTTP API
/var/run/docker.sock] API --> POST[POST /containers/create
POST /containers/id/start
GET /networks + port mapping] POST --> URL[returns 127.0.0.1:RANDOM
mapped TCP port] URL --> APP[your Go code under test] APP --> DB[(postgres:16
real container)] POST -.defer Terminate.-> K[POST /containers/id/kill
DELETE /containers/id] style K fill:#1a3a1a style API fill:#1a3a1a

The BDD layer: a feature file and the GoDog Context

The feature file is the specification. It is plain English and it is what a non-developer on the team can actually read.

Feature: Orders persist across a real Postgres

  Scenario: an order survives a reconnect
    Given a running Postgres container
    When an order for 42 units is stored
    And the connection is dropped and re-opened
    Then the stored order is 42 units

The Context is the only Go you write for the BDD half. godog hands you the runner; you register steps with Step("string", fn), and your scenario state lives on the Context so the When and Then can share the container. The InitializeScenario hook is where a fresh Context is made per scenario — that is what keeps one test’s leaked container from poisoning the next.

// pkg: godogs_test
package godogs_test

import (
	"context"
	"fmt"
	"testing"

	"github.com/DATA-DOG/go-godog"
)

// Context is the glue: one instance per scenario. Testcontainers-go gives us
// the container + its mapped URL; the BDD steps mutate it.
type Context struct {
	databaseURL string // "127.0.0.1:PORT"
	t           *testing.T
}

// InitializeScenario is called by godog *before* every scenario, so each
// scenario starts from a clean slate. This is the leak-prevention hook.
func (c *Context) InitializeScenario() {}

func (c *Context) Steps(s *godog.ScenarioContext) {
	// Given a running Postgres container
	s.Step(`^a running Postgres container$`, func() (err error) {
		p, err := postgresRun(context.Background()) // testcontainers-go modules/postgres
		if err != nil {
			return err
		}
		defer p.Terminate(context.Background()) // freed when the *scenario* ends
		c.databaseURL = p.URL()                // host:port, already 127.0.0.1-mapped
		return nil
	})

	// When an order for 42 units is stored
	s.Step(`^an order for (\d+) units is stored$`, func(n int) error {
		return storeOrder(c.databaseURL, n)
	})

	// And the connection is dropped and re-opened
	s.Step(`^the connection is dropped and re-opened$`, func() error {
		return reopen(c.databaseURL) // closes + dials the same host:port
	})

	// Then the stored order is 42 units
	s.Step(`^the stored order is (\d+) units$`, func(n int) error {
		got, err := readOrder(c.databaseURL)
		if err != nil {
			return err
		}
		if got != n {
			return fmt.Errorf("expected %d, got %d", n, got)
		}
		return nil
	})
}

// TestGodogs is the single entry point go test runs. godog finds the
// .feature file in ./features and drives every scenario through Context.
func TestGodogs(t *testing.T) {
	f := godog.NewSuite(godog.SuiteOptions{
		Format: "cucumber",
		Paths:  []string{"features"},
	})
	if !f {
		t.Fatal("features failed")
	}
}

The storeOrder / readOrder / reopen helpers are deliberately boring database/sql against the 127.0.0.1:PORT the container published — the point is that the test never touches a mocked driver.

The container layer: Testcontainers-go is a Docker Engine client

Here is the part most people hand-wave. testcontainers-go does not have its own container engine. It imports github.com/docker/docker/client, which is a generated HTTP client over the Docker Engine API. The docker CLI you type in your terminal is the same client pointed at the same unix socket. So “running a container in a test” is, byte-for-byte, these requests:

// pkg: dockerdogs — the "under the hood" of Testcontainers, made literal.
// It is NOT a new engine: it is a plain HTTP client talking to the Docker
// Engine daemon over the unix socket, exactly like `docker` does.
package dockerdogs

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net"
	"net/http"
	"time"
)

// engineClient is a minimal client for the Docker Engine HTTP API.
// The real testcontainers-go uses docker/docker/client (the generated one);
// this is the same wire protocol, hand-rolled so the calls are visible.
type engineClient struct {
	http *http.Client
}

func newEngineClient() (*engineClient, error) {
	dialer := &net.Dialer{Timeout: 5 * time.Second}
	return &engineClient{
		http: &http.Client{
			Transport: &http.Transport{
				// The unix-socket transport is the whole trick: this is the
				// exact same socket the `docker` CLI dials.
				Dial: func(network, addr string) (net.Conn, error) {
					return dialer.Dial("unix", "/var/run/docker.sock")
				},
			},
		},
	}, nil
}

// create posts a container config to the Engine, mirroring
// `docker create -p ...:5432 postgres:16`.
func (c *engineClient) create(ctx context.Context, config map[string]any) (string, error) {
	body, _ := json.Marshal(config)
	req, _ := http.NewRequestWithContext(ctx, "POST",
		"/v1.43/containers/create", bytes.NewReader(body))
	resp, err := c.http.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	var out struct{ ID string }
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return "", err
	}
	return out.ID, nil
}

// startAndPublish is the three calls a Testcontainers `Run` makes. The
// published port is chosen by the daemon (random free port), which is why a
// test must read the mapping back instead of hardcoding 5432.
func (c *engineClient) startAndPublish(ctx context.Context, id string, containerPort int) (string, int, error) {
	if _, err := c.post(id, "start"); err != nil {
		return "", 0, err
	}
	var net struct{ Ports map[string]map[string][]struct{
		HostPort string
	}}
	if _, err := c.get("/networks/default", &net); err != nil {
		return "", 0, err
	}
	// The daemon records the host port it picked for us here.
	for _, v := range net.Ports[id] {
		for _, h := range v {
			if h.HostPort != "" {
				return "127.0.0.1", atoi(h.HostPort), nil
			}
		}
	}
	return "", 0, fmt.Errorf("no published port for %d", containerPort)
}

func (c *engineClient) post(id, path string) (*http.Response, error) {
	req, _ := http.NewRequestWithContext(context.Background(), "POST",
		fmt.Sprintf("/v1.43/containers/%s/%s", id, path), nil)
	return c.http.Do(req)
}

func (c *engineClient) get(path string, out any) (*http.Response, error) {
	req, _ := http.NewRequestWithContext(context.Background(), "GET",
		"/v1.43"+path, nil)
	resp, err := c.http.Do(req)
	if err != nil {
		return resp, err
	}
	if out != nil {
		defer resp.Body.Close()
		if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
			return resp, err
		}
	}
	return resp, nil
}

func atoi(s string) int {
	n := 0
	for _, r := range s {
		if r < '0' || r > '9' {
			break
		}
		n = n*10 + int(r-'0')
	}
	return n
}

Warning: The mapped host port is chosen by the daemon at runtime, not by you. A test that hardcodes :5432 passes on a laptop and races other containers on a shared CI node. Always read the mapping back (as startAndPublish does) and connect to whatever the daemon published.

The lifecycle, and why cleanup is the whole game

The sequence below is the entire container contract Testcontainers honors. Run is create + start + publish + wait; Terminate is kill + remove. Because the test never owns the socket, a leaked Run with no Terminate is a zombie Postgres holding a port and your RAM until the daemon is restarted.

sequenceDiagram
    participant G as godog Step
    participant C as Context
    participant T as testcontainers-go
    participant D as Docker Engine daemon
    participant P as postgres:16
    G->>C: Given a running Postgres container
    C->>T: postgres.Run(ctx, "postgres:16", opts)
    T->>D: POST /containers/create
    D-->>T: {ID}
    T->>D: POST /containers/{ID}/start
    T->>D: GET /networks (read published port)
    D-->>T: 127.0.0.1:39042
    T-->>C: Container{URL: "127.0.0.1:39042"}
    C->>P: connect + create schema
    G->>C: When / Then steps
    C->>P: run real SQL
    Note over C,T: scenario ends
    C->>T: defer p.Terminate(ctx)
    T->>D: POST /containers/{ID}/kill
    T->>D: DELETE /containers/{ID}
    D-->>T: {}

Note: The defer p.Terminate(ctx) is what makes a container disposable. Testcontainers-go registers cleanup hooks, but the moment your Given step returns the step is done — the container must be tied to the scenario boundary (the defer in the Given step, or a CleanUp hook), not to a function scope, or a panic mid-scenario strands the container.

The comparison: what each layer is and is not

Concern docker CLI Testcontainers-go GoDog
Talks to Engine API directly Yes (generated client) Yes (via docker/docker/client) No
Knows about Gherkin / Given No No Yes
Picks the published port for you Yes Yes (returns mapped host:port) No
Ties container lifetime to a test Manual Yes (defer Terminate) No
Owns the test outcome No No Yes (asserts here)

Reading across the row: the Engine API and the test outcome are the two ends, and the two libraries are the middle. Nothing in Testcontainers asserts your code; nothing in GoDog touches a socket. The Context struct is the seam.

Takeaways

  • Testcontainers-go is a Docker Engine HTTP client, pointed at /var/run/docker.sock — the same socket the docker CLI uses. A Run is literally create → start → publish → wait, and Terminate is kill → remove.
  • GoDog is the Given/When/Then runner; it parses a .feature file and maps each step string to a closure you register in a Context struct.
  • The Context is the seam. One instance per scenario (InitializeScenario), and it owns the container URL so When and Then share state without a global.
  • The mapped port is the daemon’s choice, not yours. Read the mapping back; never hardcode :5432.
  • Cleanup is the whole game. A Run with no Terminate is a zombie container eating a port and RAM until the daemon restarts.

Next steps

  1. Add github.com/testcontainers/testcontainers-go and github.com/DATA-DOG/go-godog to go.mod, drop a features/ dir, and run go test -v — the first green scenario is the bar.
  2. Promote the Context pattern to a small internal testing package so every service team’s acceptance suite is the same Run/Terminate lifecycle, and the only thing that varies is the container image.
  3. Keep the docker.sock behind the test (CI must have the daemon running) and treat a leaked container as a failed test: after the suite, GET /containers should return an empty list.

About Me

Computer systems engineer with 8+ years of production Go and hands-on AI/ML engineering: LLM fine-tuning (QLoRA), quantisation, and local serving of 27B-class models, plus production ML-ops — model hot-swap with rollback and regime-based strategy switching. I am a Senior/Lead SRE at TikTok, leading distributed multi-country engineering teams and maintaining systems at massive global scale.

I define myself as a driven Polyglot Engineer. I don’t just use tools; I build them. Deeply involved in AI Engineering—creating and fine-tuning models rather than just consuming APIs.

My passion lies in the deep end of technology: Embedded Systems, Reverse Engineering (Hacking), and Quantum Computing. I obsess over Design Patterns and advanced system architecture, specializing in microservice patterns like Live Context, RPC, and mTLS.

I know a lot because I never stop learning. Whether it’s optimizing a high-frequency trading bot or securing an IoT network, I bring a depth of knowledge and a hacker’s mindset to every challenge.

Experience

04/2025 - Present
Senior/Lead Site Reliability Engineer (SRE) @ TikTok
Leading distributed, multi-country teams (4–8 engineers) across the US, Singapore, China and the UK on TikTok’s Data Security organisation. Building Go automation across several data-security value chains, integrating LLM/AI techniques to streamline complex workflows, and owning critical on-call rotations and alarm management under strict SLA/SLO targets.
01/2024 - 04/2025
Senior Site Reliability Engineer @ Deloitte
Contracted to improve client services: innovated automation, owned on-call duties, and trained team members across multiple languages and cloud platforms.
11/2023 - 01/2024
Senior Lead Software Engineer @ Snaptrip
Owned AWS services and Go services during fast startup growth: improved codebases, introduced new design methodologies, and mentored engineers.
04/2022 - 09/2023
Senior Software Engineer @ InfoSum
Built Go services for large data streams over gRPC, designed for client privacy (data never moves). Optimised DevSecOps across Kubernetes, Docker, GitLab, AWS, Grafana and Greylog.
02/2021 - 04/2022
Senior Software Engineer @ Crypto Quantique
Go/C++/Rust/Python for cryptography and identity key derivation (quantum-tunnelling based). DevSecOps on Kubernetes, ArgoCD, Azure and AWS.
07/2018 - 02/2021
Software Engineer @ Roke Manor Research
Delivered data science, computer vision and DevOps projects to client deadlines; led projects and gave public talks on cybersecurity, blockchain and quantum computing.
2016 - 2017
Graduate Developer @ ASM Assembly Systems
Industrial automation software for SMT placement machines; C#, WPF and real-time control systems.
2015
Developer & Test @ EposNow
Development and testing support for cloud-based POS systems; scripted automation tools to reduce manual support load.

Professional Skills

Interactive 3D Skill Matrix

Projects

📈
AI / Go ML Trading Platform
Go service bridging a Python ML bot to the Trading212 API: hot model deployment with rollback, genetic-algorithm feature discovery, VIX regime detection, portfolio rebalancing. 80%+ unit-test coverage; deployed on k3s.
⚡
Systems Zero-Copy IPC Bridge
iceoryx2 v2 shared-memory pub/sub via cgo / C FFI on the daemonless v2 architecture — no broker, no copies. Tracks upstream SOTA for the iceoryx2 bridge.
📷
Embedded AI Edge Computer Vision
YOLO26 object detection on RPi5 + Hailo-8L: HEF model compilation, real-time inference, and LLM-powered detection refinement.
🔐
Cryptography Post-Quantum Migration
Cryptographic inventory / CBOM with Kyber (KEM) and Dilithium key-exchange and signature replacement paths for a production PQC migration.
🗂️
DevOps Publishing Infrastructure
Go + MongoDB CMS with Hugo / Netlify front-ends, Cloudflare Tunnels and Access, and SMTP delivery — the stack behind this site.