DEV Community

Cover image for Your Go Fake Repository Drifts From Postgres. Run One Contract Suite Against Both
Gabriel Anhaia
Gabriel Anhaia

Posted on Edited on AI-assisted

Your Go Fake Repository Drifts From Postgres. Run One Contract Suite Against Both


Your checkout tests are green. One of them replays a request the way a flaky mobile client would: same order reference, sent twice. The in-memory repository stores both orders without a word, so the use case's "reference already used" branch never runs and the test passes. In production, Postgres rejects the second insert with SQLSTATE 23505, unique_violation, nothing maps it to a domain error, and the client gets a 500.

Same suite, another test. The order-history endpoint should return the newest order first. The fake appends to a slice, the test happens to insert in the order it asserts, and it passes. The Postgres adapter forgot its ORDER BY, and the Postgres docs are blunt about what that means: without sorting, "the rows will be returned in an unspecified order."

Neither bug is in the fake's code. The fake never agreed to the same rules as the database. Martin Fowler's definition of a fake already warns you: fakes "have working implementations, but usually take some shortcut." The fix he describes for doubles is a contract test, which checks that calls against your double "return the same results as a call to the external service would." In a hexagonal Go service you can do that with one exported function and a single call per adapter.

The port, with its rules written down

The contract starts as prose on the interface. If a rule isn't written here, no adapter can be held to it.

package domain

import (
    "context"
    "errors"
    "time"
)

var (
    ErrNotFound        = errors.New("order not found")
    ErrDuplicateRef    = errors.New("order ref already used")
    ErrVersionConflict = errors.New("order changed since read")
)

type Order struct {
    ID         string
    Ref        string
    CustomerID string
    Status     string
    CreatedAt  time.Time
    Version    int64
}

// OrderRepository is the port. Every adapter must:
//   - Create: store o with Version 1. ErrDuplicateRef if
//     another order already has o.Ref.
//   - Get: ErrNotFound if no order has that ID.
//   - ListByCustomer: newest CreatedAt first, ties broken
//     by ID in byte order.
//   - Update: write o.Status if the stored Version equals
//     o.Version, then bump it. ErrVersionConflict if not,
//     ErrNotFound if the order does not exist.
type OrderRepository interface {
    Create(ctx context.Context, o Order) error
    Get(ctx context.Context, id string) (Order, error)
    ListByCustomer(
        ctx context.Context, customerID string,
    ) ([]Order, error)
    Update(ctx context.Context, o Order) error
}
Enter fullscreen mode Exit fullscreen mode

The errors live in the domain because they are business facts. "That reference is taken" means something to a checkout use case. SQLSTATE 23505 does not, and it should never cross the port.

One exported function, every adapter

The suite goes in its own package, domain/ordertest, in a normal .go file rather than a _test.go file. go build ignores files that end in _test.go, so nothing declared in one can be imported by another package, and both adapter packages need to call this. Importing testing from a non-test package has a precedent in the Go project itself: nettest.TestConn "tests that a net.Conn implementation properly satisfies the interface," and it takes a constructor the same way.

One brass key ring hanging between two doors, a cardboard door and a steel door, both with the same lock
One set of checks, two implementations. Each door has to open the same way.

The constructor gives each subtest a fresh, empty repository, so subtests never see each other's rows.

package ordertest

import (
    "errors"
    "slices"
    "testing"
    "time"

    "example.com/shop/domain"
)

var t0 = time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)

func RunOrderRepoContract(
    t *testing.T,
    newRepo func(t *testing.T) domain.OrderRepository,
) {
    t.Run("missing id", func(t *testing.T) {
        repo := newRepo(t)
        _, err := repo.Get(t.Context(), "nope")
        if !errors.Is(err, domain.ErrNotFound) {
            t.Fatalf("got %v, want ErrNotFound", err)
        }
    })

    t.Run("round trip", func(t *testing.T) {
        repo := newRepo(t)
        mustCreate(t, repo, ord("o-1", "R-1", "c-1", t0))
        got, err := repo.Get(t.Context(), "o-1")
        if err != nil {
            t.Fatal(err)
        }
        if got.Ref != "R-1" || got.Version != 1 ||
            !got.CreatedAt.Equal(t0) {
            t.Fatalf("got %+v", got)
        }
    })

    t.Run("duplicate ref", func(t *testing.T) {
        repo := newRepo(t)
        ctx := t.Context()
        mustCreate(t, repo, ord("o-1", "R-1", "c-1", t0))
        err := repo.Create(ctx, ord("o-2", "R-1", "c-2", t0))
        if !errors.Is(err, domain.ErrDuplicateRef) {
            t.Fatalf("got %v, want ErrDuplicateRef", err)
        }
        _, err = repo.Get(ctx, "o-2")
        if !errors.Is(err, domain.ErrNotFound) {
            t.Fatalf("o-2 was stored anyway: %v", err)
        }
    })
Enter fullscreen mode Exit fullscreen mode

t.Context() needs Go 1.24 or later. Timestamps are compared with Equal, because the time docs note that == "compares not just the time instant but also the Location," and a database adapter is free to hand back the same instant in a different location.

The function continues with the ordering rule:

    t.Run("list order", func(t *testing.T) {
        repo := newRepo(t)
        later := t0.Add(time.Hour)
        mustCreate(t, repo, ord("o-a", "R-1", "c-1", t0))
        mustCreate(t, repo, ord("o-c", "R-2", "c-1", later))
        mustCreate(t, repo, ord("o-B", "R-3", "c-1", t0))
        mustCreate(t, repo, ord("o-x", "R-4", "c-2", later))
        got, err := repo.ListByCustomer(t.Context(), "c-1")
        if err != nil {
            t.Fatal(err)
        }
        var ids []string
        for _, o := range got {
            ids = append(ids, o.ID)
        }
        want := []string{"o-c", "o-B", "o-a"}
        if !slices.Equal(ids, want) {
            t.Fatalf("got %v, want %v", ids, want)
        }
    })
Enter fullscreen mode Exit fullscreen mode

The list order subtest is built to fail a lazy fake. Insertion order is o-a, o-c, o-B, and neither that order nor its reverse matches the expected o-c, o-B, o-a. A fake that returns map iteration order fails too, at least some of the time, since the Go spec says map iteration order "is not specified and is not guaranteed to be the same from one iteration to the next." The uppercase B is there on purpose, and it comes back in the Postgres section.

The last two subtests cover updates, followed by the helpers:

    t.Run("stale version", func(t *testing.T) {
        repo := newRepo(t)
        ctx := t.Context()
        mustCreate(t, repo, ord("o-1", "R-1", "c-1", t0))
        a, err := repo.Get(ctx, "o-1")
        if err != nil {
            t.Fatal(err)
        }
        b := a
        a.Status = "paid"
        if err := repo.Update(ctx, a); err != nil {
            t.Fatal(err)
        }
        b.Status = "cancelled"
        err = repo.Update(ctx, b)
        if !errors.Is(err, domain.ErrVersionConflict) {
            t.Fatalf("got %v, want ErrVersionConflict", err)
        }
        got, err := repo.Get(ctx, "o-1")
        if err != nil {
            t.Fatal(err)
        }
        if got.Status != "paid" || got.Version != 2 {
            t.Fatalf("got %+v", got)
        }
    })

    t.Run("update missing", func(t *testing.T) {
        repo := newRepo(t)
        o := ord("nope", "R-9", "c-1", t0)
        o.Version = 1
        err := repo.Update(t.Context(), o)
        if !errors.Is(err, domain.ErrNotFound) {
            t.Fatalf("got %v, want ErrNotFound", err)
        }
    })
}

func ord(id, ref, cust string, at time.Time) domain.Order {
    return domain.Order{
        ID: id, Ref: ref, CustomerID: cust,
        Status: "pending", CreatedAt: at,
    }
}

func mustCreate(
    t *testing.T, r domain.OrderRepository, o domain.Order,
) {
    t.Helper()
    if err := r.Create(t.Context(), o); err != nil {
        t.Fatalf("create %s: %v", o.ID, err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The stale version subtest is the optimistic-locking rule from an earlier post on version conflicts in a Go repository adapter, folded into the shared suite instead of standing alone.

The fake, held to it

This map-backed fake passes the suite, and it is still small.

package memory

import (
    "context"
    "fmt"
    "slices"
    "strings"
    "sync"

    "example.com/shop/domain"
)

type OrderRepo struct {
    mu    sync.Mutex
    byID  map[string]domain.Order
    byRef map[string]string
}

func NewOrderRepo() *OrderRepo {
    return &OrderRepo{
        byID:  map[string]domain.Order{},
        byRef: map[string]string{},
    }
}

func (r *OrderRepo) Create(
    _ context.Context, o domain.Order,
) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    if _, ok := r.byRef[o.Ref]; ok {
        return domain.ErrDuplicateRef
    }
    if _, ok := r.byID[o.ID]; ok {
        return fmt.Errorf("memory: id %s exists", o.ID)
    }
    o.Version = 1
    r.byID[o.ID] = o
    r.byRef[o.Ref] = o.ID
    return nil
}

func (r *OrderRepo) Get(
    _ context.Context, id string,
) (domain.Order, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    o, ok := r.byID[id]
    if !ok {
        return domain.Order{}, domain.ErrNotFound
    }
    return o, nil
}

func (r *OrderRepo) ListByCustomer(
    _ context.Context, customerID string,
) ([]domain.Order, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    var out []domain.Order
    for _, o := range r.byID {
        if o.CustomerID == customerID {
            out = append(out, o)
        }
    }
    slices.SortFunc(out, func(a, b domain.Order) int {
        if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 {
            return c
        }
        return strings.Compare(a.ID, b.ID)
    })
    return out, nil
}

func (r *OrderRepo) Update(
    _ context.Context, o domain.Order,
) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    cur, ok := r.byID[o.ID]
    if !ok {
        return domain.ErrNotFound
    }
    if cur.Version != o.Version {
        return domain.ErrVersionConflict
    }
    cur.Status = o.Status
    cur.Version++
    r.byID[o.ID] = cur
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The byRef index is the fake's version of a unique constraint, and the sort is its version of ORDER BY. Without those two pieces, the duplicate and ordering bugs from the opening are exactly what you would have shipped.

Wiring it to the suite is one call:

package memory_test

import (
    "testing"

    "example.com/shop/adapter/memory"
    "example.com/shop/domain"
    "example.com/shop/domain/ordertest"
)

func TestOrderRepoContract(t *testing.T) {
    ordertest.RunOrderRepoContract(t,
        func(*testing.T) domain.OrderRepository {
            return memory.NewOrderRepo()
        })
}
Enter fullscreen mode Exit fullscreen mode

The pgx adapter, mapping SQLSTATE 23505

The real adapter uses pgx v5. A failed insert carries a *pgconn.PgError with a Code (the SQLSTATE) and a ConstraintName. Checking both matters: a primary-key collision is also 23505, and it is not the same domain fact as a reused reference.

package postgres

import (
    "context"
    "errors"
    "fmt"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgconn"
    "github.com/jackc/pgx/v5/pgxpool"

    "example.com/shop/domain"
)

const cols = `id, ref, customer_id, status, created_at, version`

type OrderRepo struct{ pool *pgxpool.Pool }

func NewOrderRepo(p *pgxpool.Pool) *OrderRepo {
    return &OrderRepo{pool: p}
}

func (r *OrderRepo) Create(
    ctx context.Context, o domain.Order,
) error {
    _, err := r.pool.Exec(ctx, `
        INSERT INTO orders (`+cols+`)
        VALUES ($1, $2, $3, $4, $5, 1)`,
        o.ID, o.Ref, o.CustomerID, o.Status, o.CreatedAt)
    var pgErr *pgconn.PgError
    if errors.As(err, &pgErr) && pgErr.Code == "23505" &&
        pgErr.ConstraintName == "orders_ref_key" {
        return domain.ErrDuplicateRef
    }
    if err != nil {
        return fmt.Errorf("create order %s: %w", o.ID, err)
    }
    return nil
}

func (r *OrderRepo) Get(
    ctx context.Context, id string,
) (domain.Order, error) {
    rows, err := r.pool.Query(ctx,
        `SELECT `+cols+` FROM orders WHERE id = $1`, id)
    if err != nil {
        return domain.Order{}, fmt.Errorf("get %s: %w", id, err)
    }
    o, err := pgx.CollectExactlyOneRow(rows, scanOrder)
    if errors.Is(err, pgx.ErrNoRows) {
        return domain.Order{}, domain.ErrNotFound
    }
    if err != nil {
        return domain.Order{}, fmt.Errorf("get %s: %w", id, err)
    }
    return o, nil
}

func (r *OrderRepo) ListByCustomer(
    ctx context.Context, customerID string,
) ([]domain.Order, error) {
    rows, err := r.pool.Query(ctx, `
        SELECT `+cols+` FROM orders
         WHERE customer_id = $1
         ORDER BY created_at DESC, id COLLATE "C"`,
        customerID)
    if err != nil {
        return nil, fmt.Errorf("list %s: %w", customerID, err)
    }
    return pgx.CollectRows(rows, scanOrder)
}

func (r *OrderRepo) Update(
    ctx context.Context, o domain.Order,
) error {
    tag, err := r.pool.Exec(ctx, `
        UPDATE orders SET status = $3, version = version + 1
         WHERE id = $1 AND version = $2`,
        o.ID, o.Version, o.Status)
    if err != nil {
        return fmt.Errorf("update %s: %w", o.ID, err)
    }
    if tag.RowsAffected() == 1 {
        return nil
    }
    if _, err := r.Get(ctx, o.ID); err != nil {
        return err // ErrNotFound, or a real failure
    }
    return domain.ErrVersionConflict
}

func scanOrder(row pgx.CollectableRow) (domain.Order, error) {
    var o domain.Order
    err := row.Scan(&o.ID, &o.Ref, &o.CustomerID,
        &o.Status, &o.CreatedAt, &o.Version)
    return o, err
}
Enter fullscreen mode Exit fullscreen mode

CollectExactlyOneRow returns an error matching pgx.ErrNoRows when the query finds nothing, which is the one place the adapter translates to ErrNotFound. CollectRows closes the rows for you.

Now the uppercase B. The fake breaks ties with strings.Compare, and the Go spec says strings "are compared lexically byte-wise." Postgres sorts text by the column's collation, and a linguistic collation such as en_US.utf8 does not have to put o-B before o-a the way byte order does. The C collation sorts "by byte values rather than natural language order," so COLLATE "C" on the tiebreak makes both adapters agree no matter what the database default is. Drop it, and the list order subtest is where you find out.

Real Postgres, behind a build tag

The integration test starts Postgres with the testcontainers-go Postgres module, runs the schema, and hands the suite a constructor that truncates the table. A //go:build line keeps it out of the default run.

A workbench with a small wooden model engine and a real steel engine, both wired to the same burnt-orange test gauge
The model and the real engine go on the same gauge. Only one of them needs Docker.

//go:build integration

package postgres_test

import (
    "testing"

    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/testcontainers/testcontainers-go"
    tcpg "github.com/testcontainers/testcontainers-go/modules/postgres"

    "example.com/shop/adapter/postgres"
    "example.com/shop/domain"
    "example.com/shop/domain/ordertest"
)

const schema = `
CREATE TABLE orders (
  id          text        PRIMARY KEY,
  ref         text        NOT NULL,
  customer_id text        NOT NULL,
  status      text        NOT NULL,
  created_at  timestamptz NOT NULL,
  version     bigint      NOT NULL,
  CONSTRAINT orders_ref_key UNIQUE (ref)
)`

func TestOrderRepoContract(t *testing.T) {
    ctx := t.Context()
    ctr, err := tcpg.Run(ctx, "postgres:16-alpine",
        tcpg.WithDatabase("shop"),
        tcpg.WithUsername("shop"),
        tcpg.WithPassword("shop"),
        tcpg.BasicWaitStrategies(),
    )
    testcontainers.CleanupContainer(t, ctr)
    if err != nil {
        t.Fatal(err)
    }
    dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
    if err != nil {
        t.Fatal(err)
    }
    pool, err := pgxpool.New(ctx, dsn)
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(pool.Close)
    if _, err := pool.Exec(ctx, schema); err != nil {
        t.Fatal(err)
    }

    ordertest.RunOrderRepoContract(t,
        func(t *testing.T) domain.OrderRepository {
            _, err := pool.Exec(t.Context(), "TRUNCATE orders")
            if err != nil {
                t.Fatal(err)
            }
            return postgres.NewOrderRepo(pool)
        })
}
Enter fullscreen mode Exit fullscreen mode

CleanupContainer goes before the error check because its docs say a nil container is a no-op, so a half-started container still gets removed. The module page also says it supplies no default wait strategy, which is why BasicWaitStrategies() is there. One container serves all six subtests, and TRUNCATE gives each one an empty table, which keeps the run to a single container start.

The constraint is named explicitly, orders_ref_key, because the adapter matches on that name. In a real service, replace the inline schema with your migration runner, so the test checks the constraint names your migrations actually create.

Two commands now cover both worlds:

go test -race ./...                    # fake only, no Docker
go test -race -tags integration ./...  # fake and Postgres
Enter fullscreen mode Exit fullscreen mode

What the suite catches next

The suite only knows the rules you gave it, and Postgres has more. Two worth adding as subtests before they bite:

Timestamp precision. Go's time.Time holds nanoseconds, and Postgres timestamptz resolution is 1 microsecond. The suite uses whole-second timestamps, so it can't see this. A subtest that creates an order with time.Now() and compares CreatedAt after a round trip will pass on the fake and, whenever the clock reading has sub-microsecond digits, fail on Postgres. Decide in the port whether the repository truncates, then make the fake do the same.

Empty results. The two adapters in this post already disagree here. The fake above returns a nil slice for a customer with no orders. The pgx adapter does not: CollectRows is return AppendRows([]T{}, rows, fn), so it starts from an empty, non-nil slice. Marshal both and the encoding/json docs say "a nil slice encodes as the null JSON value," so one adapter gives the client null and the other [], and the suite as written passes both. Add a subtest that pins the shape, or normalise it in the handler.

When a third adapter shows up (a read-through cache wrapping the Postgres repo, a SQLite build for a CLI), its test is one RunOrderRepoContract call with a different constructor. If it disagrees with the other two, the suite names the rule.


If this was useful

A contract suite only works if the port says what the rules are and database errors stay in the adapter. Hexagonal Architecture in Go is the book I wrote about that layer: where repositories, adapters and their tests sit in a Go service, and how to test both sides of a port without a pile of mocks. It is part of the Thinking in Go series, with a companion book on the language itself.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)