DEV Community

Cover image for Your Go Tests Take 6 Minutes Because Your Architecture Is Wrong
Gabriel Anhaia
Gabriel Anhaia

Posted on

Your Go Tests Take 6 Minutes Because Your Architecture Is Wrong


You run go test ./... and wait. The output trickles. A testcontainers-go container spins up PostgreSQL. Another starts Redis. Somewhere in the CI pipeline, a third test file fires up Kafka. Six minutes later, everything is green. You push.

An engineer I know tracked it across a quarter. His team ran the full suite somewhere around 30 to 40 times a day across a handful of developers. Six minutes per run. That added up to hours of blocked developer time, every single day, on a codebase with well under 200 tests.

The suite was not slow because the tests were bad. It was slow because the architecture forced every test to drag infrastructure along for the ride.

The Coupling Tax

Here is the shape of the problem. You have an order service. The handler parses HTTP, runs business logic, and talks to the database, all in one function:

func CreateOrderHandler(
    db *sql.DB,
) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var req CreateOrderRequest
        json.NewDecoder(r.Body).Decode(&req)

        if len(req.Items) == 0 {
            http.Error(
                w,
                "order must have items",
                http.StatusBadRequest,
            )
            return
        }
Enter fullscreen mode Exit fullscreen mode

That is the validation. Now the business logic and the persistence, all in the same breath:

        var total int
        for _, item := range req.Items {
            total += item.PriceCents * item.Quantity
        }

        _, err := db.ExecContext(
            r.Context(),
            `INSERT INTO orders
             (id, customer_id, total_cents, status)
             VALUES ($1, $2, $3, 'pending')`,
            uuid.New().String(),
            req.CustomerID,
            total,
        )
        if err != nil {
            http.Error(
                w, "failed", http.StatusInternalServerError,
            )
            return
        }

        w.WriteHeader(http.StatusCreated)
    }
}
Enter fullscreen mode Exit fullscreen mode

To test that len(req.Items) == 0 check, you need a *sql.DB. To test the total calculation, you need a *sql.DB. To test the happy path, you need a *sql.DB. Every test path leads through the database, even the ones that have nothing to do with data storage.

This is the coupling tax. You pay it on every test run for the lifetime of the project.

What a Decoupled Test Looks Like

Compare that to a world where the domain stands on its own:

// domain/order.go
type Order struct {
    ID         string
    CustomerID string
    Items      []LineItem
    TotalCents int
    Status     string
}

type LineItem struct {
    ProductID  string
    Quantity   int
    PriceCents int
}

var ErrOrderEmpty = errors.New("order must have items")
Enter fullscreen mode Exit fullscreen mode

The domain type owns its own validation and construction:

func NewOrder(
    id, customerID string,
    items []LineItem,
) (Order, error) {
    if len(items) == 0 {
        return Order{}, ErrOrderEmpty
    }

    var total int
    for _, li := range items {
        total += li.PriceCents * li.Quantity
    }

    return Order{
        ID:         id,
        CustomerID: customerID,
        Items:      items,
        TotalCents: total,
        Status:     "pending",
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

The test for this:

func TestNewOrder_EmptyItems(t *testing.T) {
    _, err := NewOrder("o-1", "c-1", nil)
    if !errors.Is(err, ErrOrderEmpty) {
        t.Fatalf("got %v, want ErrOrderEmpty", err)
    }
}

func TestNewOrder_CalculatesTotal(t *testing.T) {
    items := []LineItem{
        {"p1", 2, 1000},
        {"p2", 1, 500},
    }
    order, err := NewOrder("o-1", "c-1", items)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if order.TotalCents != 2500 {
        t.Errorf(
            "total = %d, want 2500",
            order.TotalCents,
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

No database. No containers. No setup or teardown. These tests finish in microseconds.

The Port Is the Boundary

The trick is the interface. Your domain defines what it needs as a port:

// domain/ports.go
type OrderRepository interface {
    Save(ctx context.Context, order Order) error
    FindByID(
        ctx context.Context, id string,
    ) (Order, error)
}

type OrderService struct {
    repo OrderRepository
}

func NewOrderService(
    repo OrderRepository,
) *OrderService {
    return &OrderService{repo: repo}
}

func (s *OrderService) PlaceOrder(
    ctx context.Context,
    id, customerID string,
    items []LineItem,
) error {
    order, err := NewOrder(id, customerID, items)
    if err != nil {
        return err
    }
    return s.repo.Save(ctx, order)
}
Enter fullscreen mode Exit fullscreen mode

PostgreSQL implements this interface somewhere in an adapter package. But your tests do not need PostgreSQL. They need something that satisfies OrderRepository. A map will do.

The In-Memory Fake: 12 Lines

type fakeOrderRepo struct {
    orders map[string]Order
}

func newFakeRepo() *fakeOrderRepo {
    return &fakeOrderRepo{
        orders: make(map[string]Order),
    }
}

func (r *fakeOrderRepo) Save(
    _ context.Context, order Order,
) error {
    r.orders[order.ID] = order
    return nil
}

func (r *fakeOrderRepo) FindByID(
    _ context.Context, id string,
) (Order, error) {
    o, ok := r.orders[id]
    if !ok {
        return Order{}, ErrOrderNotFound
    }
    return o, nil
}
Enter fullscreen mode Exit fullscreen mode

You get this without code generation, without a mock library, without reflection. A concrete struct with a map.

Now the service test:

func TestPlaceOrder_Success(t *testing.T) {
    repo := newFakeRepo()
    svc := NewOrderService(repo)

    items := []LineItem{{"p1", 3, 800}}
    err := svc.PlaceOrder(
        context.Background(),
        "o-1", "c-1", items,
    )
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    got, ok := repo.orders["o-1"]
    if !ok {
        t.Fatal("order not saved")
    }
    if got.TotalCents != 2400 {
        t.Errorf(
            "total = %d, want 2400",
            got.TotalCents,
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

And the validation path:

func TestPlaceOrder_EmptyItems(t *testing.T) {
    repo := newFakeRepo()
    svc := NewOrderService(repo)

    err := svc.PlaceOrder(
        context.Background(),
        "o-1", "c-1", nil,
    )
    if !errors.Is(err, ErrOrderEmpty) {
        t.Fatalf("got %v, want ErrOrderEmpty", err)
    }
    if len(repo.orders) != 0 {
        t.Error("should not save on validation error")
    }
}
Enter fullscreen mode Exit fullscreen mode

Both tests exercise business logic. Both verify the repository interaction. Neither one needs a running database.

Benchmark: Testcontainers vs. In-Memory

The difference is not marginal. Here are wall-clock times from a project I worked on (an order service with 15 domain tests, 8 adapter tests, and 4 integration tests, measured on an M-series Mac with a single-node Postgres testcontainer).

With testcontainers-go for every test:

$ go test ./... -count=1
...
ok   service/internal/order   47.312s
ok   service/internal/handler 31.887s
ok   service/integration      52.441s
---
Total wall time: ~6m 12s (with container startup)
Enter fullscreen mode Exit fullscreen mode

After hexagonal refactor, domain and service tests on in-memory fakes:

$ go test ./internal/domain/... -count=1
ok   service/internal/domain  0.003s

$ go test ./internal/service/... -count=1
ok   service/internal/service 0.004s

$ go test ./internal/adapter/... -count=1
ok   service/internal/adapter 0.891s

$ go test ./integration/... -count=1
ok   service/integration      48.220s
---
Total wall time: ~49s
Enter fullscreen mode Exit fullscreen mode

The domain and service tests combined take 7 milliseconds. The adapter tests (HTTP handler with httptest, Postgres adapter against a testcontainer) take under a second. The only slow tests are the integration tests, and those run against a real database because that is what they are for.

The 6-minute suite dropped to under a minute. The fast feedback loop — the one where you run domain tests after every code change — takes 7 milliseconds.

You Still Need Testcontainers (For 5% of Your Tests)

This is not an argument against testcontainers. You still need integration tests. You still need to verify that your SQL queries actually work against a real PostgreSQL. You still need to confirm that your Kafka consumer handles rebalances correctly.

But those tests belong in a separate layer. They test the adapter, not the business logic. And because the adapter surface is small (a handful of methods behind an interface), you need fewer of them.

The distribution should look like this: most of your tests are domain tests, microseconds, pure Go. A smaller layer of adapter tests runs in milliseconds via httptest. The few integration tests that actually need testcontainers take seconds.

When every test is an integration test, you have no pyramid. You have an inverted one -- slow feedback and the same coverage you could get for free with a map and an interface.

Where Teams Get Stuck

The common objection: "Our domain is too simple for this. We're mostly CRUD."

Maybe. But track how many tests you have that spin up a container just to assert a validation rule. Count the test files that import testcontainers-go for logic that never touches the database. If it is more than zero, the architecture is bleeding infrastructure into business logic.

Another objection: "Adding interfaces for everything is over-engineering."

You do not need interfaces for everything. You need interfaces at the boundary between your domain and the outside world. That is one or two repository interfaces, maybe a notifier, maybe an event publisher. Three to five interfaces total, each with two to four methods. That is not over-engineering. That is a seam.

The Refactor Path

If you have an existing codebase with tangled tests, the migration is incremental:

  1. Extract the domain type. Move Order (and its validation) out of the handler and into its own package with zero infrastructure imports.
  2. Define the port. Write the OrderRepository interface in the domain package.
  3. Create the fake. Build the map[string]Order implementation. It takes five minutes.
  4. Write domain tests against the fake. You can run these in parallel with your existing integration tests. No big-bang refactor needed.
  5. Migrate one handler at a time. Move business logic out, leave HTTP translation in. Each handler gets thinner. Each extracted function gets its own fast tests.

You do not have to refactor the entire service. Start with the package that has the slowest tests. Extract the domain, add the fake, and watch the test time collapse.

The Real Payoff

Fast tests change behavior. When your domain tests run in milliseconds, you run them after every save. You catch bugs in seconds instead of minutes. You stop batching your go test calls and start treating them like a spell-checker — always on, always fast.

The architecture does not exist to satisfy a pattern. It exists so that the 95% of your tests that check business logic never wait for a container to boot.


If this was useful

The full pattern — domain modeling, port design, adapter testing, error mapping across boundaries, and the incremental migration path from spaghetti to hexagonal — is the subject of Hexagonal Architecture in Go. It is the second book in the Thinking in Go series, after The Complete Guide to Go Programming.

I build Hermes IDE, an IDE for developers who ship with Claude Code and other AI coding tools. If you work with AI-assisted coding and want a purpose-built environment, check it out.

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

Top comments (0)