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 } 

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) } }) 

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) } }) 

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) } } 

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 } 

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() }) } 

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 (`+c...
Source:https://dev.to/gabrielanhaia/your-go-fake-repository-drifts-from-postgres-run-one-contract-suite-against-both-3lm4