Integration Testing in Go: Mocking, Databases, Queues, and External Calls — Advanced Guide
Integration tests sit between unit tests and full end-to-end flows: they validate real mechanics (database protocols, drivers, serialization) while making uncontrolled externals deterministic via mocks, stubs, or test servers.
This article dives deep into database mocking, queue fakes, HTTP mocking, and combining real dependencies with Testcontainers in Go.
1. Test pyramid and where integration tests fit
/\
/ \ E2E (few, higher flake risk)
/____\
/ \ Integration (DB, queue, real protocol)
/________\
/ \ Unit (fast, pure logic)
/____________\
Integration tests typically combine:
- Real PostgreSQL or Redis (Testcontainers / local Docker)
- HTTP mocks for billing APIs (
httptest.Server) - Message queues via either a real RabbitMQ container or an in-process interface implementation
In unit tests, sqlmock verifies driver-level expectations — treat that as isolated DB interaction testing, not full integration.
2. What to mock vs what to run for real
+-------------------+---------------------------+------------------------+
| Dependency | Recommendation | Rationale |
+-------------------+---------------------------+------------------------+
| Own domain | Unit tests, no fakes | Pure functions / types |
| SQL driver calls | sqlmock OR real DB | Speed vs fidelity |
| Postgres behavior | Testcontainers Postgres | dialect, indexes, tx |
| External REST API | httptest or gock | cost, rate limits |
| Queue protocol | Real broker OR interface | AMQP details |
| Time / UUID | Injected Clock | determinism |
+-------------------+---------------------------+------------------------+
3. Architecture: ports and test seams
Define interfaces (ports) so infrastructure (adapters) can be swapped in tests.
+------------------+
| HTTP Handler |
+--------+---------+
|
+--------v---------+
| OrderService |
+--+-----------+---+
| |
+-----------+ +------------+
| |
+-------v--------+ +--------v--------+
| OrderRepository | | EventPublisher |
| (interface) | | (interface) |
+--------+--------+ +--------+--------+
| |
+--------v--------+ +--------v--------+
| postgres.Repo | | amqp.Publisher |
| (prod) | | (prod) |
+-----------------+ +-----------------+
Under test:
* Repo -> sqlmock OR tc.Postgres + real SQL
* Events -> inMemoryPublisher OR tc.RabbitMQ
This is the essence of hexagonal / ports & adapters: integration tests exercise adapters against real protocols; domain stays covered by fast unit tests.
4. Database: driver-level expectations with sqlmock
github.com/DATA-DOG/go-sqlmock defines expected queries and result rows. It does not validate migrations or complex planner behavior — it pins what the driver sends.
package repo
import (
"context"
"database/sql"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
func TestGetUserByID(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
rows := sqlmock.NewRows([]string{"id", "email"}).
AddRow(1, "[email protected]")
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, email FROM users WHERE id = $1`)).
WithArgs(1).
WillReturnRows(rows)
r := NewUserRepo(db)
u, err := r.GetByID(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if u.Email != "[email protected]" {
t.Fatalf("email: got %q", u.Email)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
When is it enough? Locking SQL contracts at the repository layer. When is it not? JSON operators, custom indexes, transaction isolation — use real Postgres.
5. Database: real PostgreSQL with Testcontainers
[go test] ---> [Testcontainers] ---> [Docker]
|
v
+--------------+
| postgres:16 |
| :5432 |
+--------------+
Skeleton (adjust module versions to your stack):
package orders_test
import (
"context"
"database/sql"
"testing"
_ "github.com/lib/pq"
tc "github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
func setupPostgres(t *testing.T) (dsn string, terminate func()) {
t.Helper()
ctx := context.Background()
req := tc.ContainerRequest{
Image: "postgres:16-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_USER": "test",
"POSTGRES_PASSWORD": "test",
"POSTGRES_DB": "testdb",
},
WaitingFor: wait.ForListeningPort("5432/tcp"),
}
c, err := tc.GenericContainer(ctx, tc.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
t.Fatal(err)
}
host, _ := c.Host(ctx)
port, _ := c.MappedPort(ctx, "5432")
dsn = "postgres://test:test@" + host + ":" + port.Port() + "/testdb?sslmode=disable"
return dsn, func() { _ = c.Terminate(ctx) }
}
Run migrations, then execute repository integration tests against a live connection. CI needs a Docker socket.
6. Message queues: interface + in-memory fake
If production uses AMQP, hide publish behind an interface:
type EventPublisher interface {
Publish(ctx context.Context, routingKey string, body []byte) error
}
type InMemoryBroker struct {
Messages []Published
}
type Published struct {
Key string
Body []byte
}
func (b *InMemoryBroker) Publish(ctx context.Context, routingKey string, body []byte) error {
b.Messages = append(b.Messages, Published{Key: routingKey, Body: append([]byte(nil), body...)})
return nil
}
Integration scenario: after OrderService persists an order, assert a message exists in InMemoryBroker — service-level integration without RabbitMQ.
7. Message queues: RabbitMQ via Testcontainers
When protocol details (ACK/NACK, headers, TTL) matter, run a real broker container.
[Service] --publish--> [RabbitMQ container]
|
+-- queue "orders.created"
Flow:
- Start the container; declare exchange/queue via management API or AMQP
- Run application code with real host/credentials
- Assert from a test consumer or a secondary stub consumer
These tests are slow and need care with parallelism (port clashes); use unique vhost or random queue names with t.Parallel().
8. External HTTP: httptest.Server
A controlled HTTP server from the standard library — the cleanest mock.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/charge" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":"ch_123","status":"succeeded"}`))
}))
defer srv.Close()
client := payments.NewClient(srv.URL) // inject base URL
Advanced: use closures with counters or channels to vary responses; simulate 429/500 sequences.
9. External HTTP: gock for declarative matching
Intercept HTTP clients with gock:
import "github.com/h2non/gock"
defer gock.Off()
gock.New("https://api.partner.com").
Get("/users/1").
Reply(200).
JSON(map[string]any{"name": "Ada"})
// client BaseURL must be https://api.partner.com
Caution: gock patches global transports — parallel tests may need package-level serialization or gock.DisableNetworking().
10. End-to-end example: order + payment + event
Test()
|
+-- httptest: payment API returns 200
|
+-- sqlmock OR tc.Postgres: order row inserted
|
+-- InMemoryBroker: "order.paid" message exists?
A single TestOrderFlow_Integration can assert all three. Split slow suites with build tags:
//go:build integration
package orders_test
Run:
go test -tags=integration ./...
11. Parallelism and cleanup
- Register container teardown and
db.Closewitht.Cleanup - With
gock, callgock.Off()+gock.Clean()per test - Testcontainers reuse modes (version-dependent) can shorten CI time
12. Anti-patterns
| Bad practice | Outcome |
|---|---|
| Mock everything | Miss real SQL/AMQP failures until production |
| Mock nothing | Slow tests, flaky external APIs |
Global DB in init() |
Tests pollute each other |
| Skip unit tests because integration exists | Domain logic untested |
Summary
- sqlmock: fast SQL contract checks; not schema semantics.
- Testcontainers: protocol-level confidence for Postgres, Redis, Kafka, RabbitMQ.
- httptest / gock: deterministic external HTTP.
- Interface + in-memory broker: integrate service logic without a real queue.
Integration testing in Go is about drawing the right boundary and picking the right tool: many teams run both fast sqlmock tests and heavier Testcontainers suites in the same repo.
Widen the integration layer in your pyramid; keep full E2E for only the most critical user journeys.
Related posts
Integration Testing with Testcontainers: Mocking, Networking, and Aliases
A deep dive into Testcontainers for integration tests—what to mock vs run in containers, Docker networking, network aliases, hybrid patterns, and production-grade pitfalls.
JWT Authentication in Go: Access Tokens, Refresh Tokens, and Secure Storage
Sign and verify JWTs in Go; short-lived access tokens, refresh rotation, HttpOnly cookies, and common pitfalls.
gRPC vs REST: When Should You Use Which? A Comparative Guide with Go
gRPC and REST in microservices: protobuf, HTTP/2, browser constraints, and Go examples — complements our Go vs Node.js service comparison.