DEV Community

Cover image for Testing a Go Service in Microseconds: The Hexagonal Testing Strategy
Gabriel Anhaia
Gabriel Anhaia

Posted on

Testing a Go Service in Microseconds: The Hexagonal Testing Strategy

Your Go tests take 3 minutes because every "unit test" spins up PostgreSQL in Docker.

That's not a testing problem. That's an architecture problem.

When your business logic is tangled with your database calls, the only way to test it is to bring the database along. Hexagonal architecture untangles them — and testing becomes almost mechanical.

Three Layers, Three Strategies

Layer 1: Domain Tests — Microseconds

The domain is pure Go. No imports from database/sql, net/http, or any external package. Testing it is trivial:

func TestNewOrder_RejectsEmpty(t *testing.T) {
    _, err := NewOrder("ord-1", "cust-1", nil)
    if !errors.Is(err, ErrOrderEmpty) {
        t.Errorf("got %v, want ErrOrderEmpty", err)
    }
}

func TestOrder_Confirm(t *testing.T) {
    order := validOrder(t)
    if err := order.Confirm(); err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if order.Status != OrderStatusConfirmed {
        t.Errorf("status = %q, want confirmed", order.Status)
    }
}
Enter fullscreen mode Exit fullscreen mode

No setup. No teardown. No containers. These tests finish before your terminal redraws.

For the service layer, test doubles are trivial because the ports are small:

// 8 lines. That's the entire repository double.
type inMemoryRepo struct {
    orders map[string]Order
}

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

func (r *inMemoryRepo) 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

Layer 2: Adapter Tests — Milliseconds

HTTP handlers: Use httptest. Stub the domain behind the port interface. Test that JSON→domain translation works and errors→status codes map correctly:

func TestCreateOrder(t *testing.T) {
    svc := &stubService{}
    handler := httphandler.New(svc)

    body := `{"customer_id":"cust-1","items":[{"product_id":"p1","quantity":2,"price_cents":2500}]}`
    req := httptest.NewRequest(http.MethodPost, "/orders", strings.NewReader(body))
    rec := httptest.NewRecorder()

    handler.Create().ServeHTTP(rec, req)

    if rec.Code != http.StatusCreated {
        t.Errorf("status = %d, want 201", rec.Code)
    }
}
Enter fullscreen mode Exit fullscreen mode

No real service. No database. You're testing translation, not business logic.

Database adapters: For in-memory adapters, test directly. For PostgreSQL, use integration tests (testcontainers) — but only to verify the adapter implements the port correctly.

Layer 3: Integration Tests — Seconds

Full stack through HTTP. Build real adapters, wire through the service, send HTTP requests:

func TestFullStack_CreateAndGet(t *testing.T) {
    repo := memory.NewOrderRepository()
    svc := domain.NewOrderService(repo, &noopNotifier{}, &seqIDGen{})
    handler := httphandler.New(svc)
    srv := httptest.NewServer(handler.Routes())
    defer srv.Close()

    // Create
    resp, _ := http.Post(srv.URL+"/orders", "application/json",
        strings.NewReader(`{"customer_id":"cust-1","items":[...]}`))
    // assert 201...

    // Get
    resp, _ = http.Get(srv.URL + "/orders/ord-1")
    // assert 200 + correct body...
}
Enter fullscreen mode Exit fullscreen mode

Use sparingly. These catch wiring bugs, not business logic bugs.

The Pyramid

        /\
       /  \        Few integration tests (slow, catch wiring bugs)
      /----\
     /      \      Some adapter tests (medium, catch translation bugs)
    /--------\
   /          \    Many domain tests (fast, catch business logic bugs)
  /____________\
Enter fullscreen mode Exit fullscreen mode

This pyramid falls naturally out of the architecture. You don't need a testing strategy document. The architecture IS the testing strategy.

Hand-Written Doubles > Mock Frameworks

With 1-2 method interfaces, a hand-written stub is 5-10 lines. A mocking framework adds code generation, magic assertions, and runtime reflection. The framework overhead isn't worth it for interfaces this small.

Write the struct. Implement the methods. Move on.


📖 This is the testing strategy from Chapter 14 of Hexagonal Architecture in Go. The book also covers conformance tests (run the same suite against every adapter), error mapping tests, and when integration tests are genuinely necessary.

Companion code: github.com/gabrielanhaia/hexagonal-go-examples

Part 3 of 5. Next: the one rule that holds everything together — the dependency rule, and what breaks when you violate it.

Top comments (0)

The discussion has been locked. New comments can't be added.