Mert Tosun
← Posts
Integration Testing in Go: Mocking, Databases, Queues, and External Calls — Advanced Guide

Integration Testing in Go: Mocking, Databases, Queues, and External Calls — Advanced Guide

Mert TosunGo

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 InMemoryBrokerservice-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:

  1. Start the container; declare exchange/queue via management API or AMQP
  2. Run application code with real host/credentials
  3. 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.Close with t.Cleanup
  • With gock, call gock.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.