MiniCart

Stage 14 - Prove It With Tests

Test the business logic with hand-written fakes and no infrastructure, then prove the SQL wiring against a real Postgres.

Prereqs: stage 13 complete · Time: ~30 min

By the end: a fast unit suite for logic and a gated integration suite for wiring - the two divide cleanly because the architecture made them.

The full testing map is bigger than this stage, but the boundaries are simple:

KindProvesUses
UnitBusiness logic and orchestrationFakes, no infrastructure
IntegrationOne adapter wired to a real dependencyReal Postgres, real migrations
ComponentOne service through its public adapterHTTP plus real infra, other services faked
E2E smokeThe deployed pieces can talkThe running stack, few happy paths

Unit tests

Goal: test PlaceOrder with hand-written fakes - no database, no broker, no server.

Learn

PlaceOrder is the most important code in MiniMart: it resolves prices across a domain boundary, computes money, and publishes an event. It has three dependencies, and you injected all three as interfaces - domain.Repository, contracts.Modules, kafka.Publisher. So a test hands in tiny structs that satisfy them and controls every input. These stand-ins are fakes, and they cost about thirty lines.

Because the ports are interfaces, faking them needs no framework. The test is table-driven (stage 02's pattern at a harder job): happy path, empty order, unknown product, bad quantity - plus an assertion on how many events were published.

Build

All of this is one file, order/application/place_order_test.go.

1. The three fakes:

Go
package application

import (
	"context"
	"errors"
	"testing"

	catalogclient "minimart/catalog/presentation/module/client"
	"minimart/order/domain"
	"minimart/pkg/kafka"
	"minimart/pkg/message"
)

// fakeRepo is a hand-written test double for the order Repository port.
type fakeRepo struct {
	saved   domain.Order
	saveErr error
}

func (f *fakeRepo) Save(ctx context.Context, o domain.Order) error {
	f.saved = o
	return f.saveErr
}
func (f *fakeRepo) GetByID(ctx context.Context, id string) (domain.Order, error) {
	return f.saved, nil
}

// fakeModules impersonates the entire catalog domain via the published contract.
type fakeModules struct {
	products map[string]catalogclient.Product
}

func (f *fakeModules) GetProduct(ctx context.Context, id string) (catalogclient.Product, error) {
	p, ok := f.products[id]
	if !ok {
		return catalogclient.Product{}, catalogclient.ErrProductNotFound
	}
	return p, nil
}

// fakePublisher records published events instead of talking to Kafka.
type fakePublisher struct {
	published []message.OrderPlaced
}

func (f *fakePublisher) PublishMessage(ctx context.Context, topic kafka.Topic, key string, payload interface{}) error {
	if e, ok := payload.(message.OrderPlaced); ok {
		f.published = append(f.published, e)
	}
	return nil
}

fakeModules is the whole catalog domain in a dozen lines - possible only because the order service depends on the slim contracts.Modules interface (stage 10), not the concrete registry. fakePublisher records what it received, so the test can assert on a side effect: how many events went out.

2. The table and the test:

Go
func TestPlaceOrder(t *testing.T) {
	catalog := &fakeModules{products: map[string]catalogclient.Product{
		"p1": {ID: "p1", Name: "Widget", PriceCents: 500},
		"p2": {ID: "p2", Name: "Gadget", PriceCents: 250},
	}}

	tests := []struct {
		name       string
		req        PlaceOrderRequest
		wantTotal  int64
		wantEvents int
		wantErr    error
	}{
		{
			name:       "two items sums line totals and publishes one event",
			req:        PlaceOrderRequest{UserID: "u1", Items: []PlaceOrderItem{{"p1", 2}, {"p2", 1}}},
			wantTotal:  1250,
			wantEvents: 1,
		},
		{
			name:    "no items",
			req:     PlaceOrderRequest{UserID: "u1"},
			wantErr: domain.ErrNoItems,
		},
		{
			name:    "unknown product",
			req:     PlaceOrderRequest{UserID: "u1", Items: []PlaceOrderItem{{"nope", 1}}},
			wantErr: catalogclient.ErrProductNotFound,
		},
		{
			name:    "non-positive quantity",
			req:     PlaceOrderRequest{UserID: "u1", Items: []PlaceOrderItem{{"p1", 0}}},
			wantErr: domain.ErrInvalidQuantity,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			pub := &fakePublisher{}
			svc := NewService(&fakeRepo{}, catalog, pub)
			got, err := svc.PlaceOrder(context.Background(), tt.req)
			if tt.wantErr != nil {
				if !errors.Is(err, tt.wantErr) {
					t.Fatalf("want error %v, got %v", tt.wantErr, err)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if got.TotalCents != tt.wantTotal {
				t.Fatalf("want total %d, got %d", tt.wantTotal, got.TotalCents)
			}
			if len(pub.published) != tt.wantEvents {
				t.Fatalf("want %d events published, got %d", tt.wantEvents, len(pub.published))
			}
		})
	}
}

Two assertions earn their place. errors.Is(err, catalogclient.ErrProductNotFound) still matches even though PlaceOrder wrapped it with %w - stage 06's promise. And len(pub.published) proves the success path publishes exactly one event and the error paths publish none, because a fresh fakePublisher is built per row.

In production: a //go:generate moq ... line above each port generates these doubles - hundreds of them, with a -stub flag so unconfigured methods return zero values instead of panicking. Same idea, industrialized. Writing them by hand once is why generated mocks never become magic.

Checkpoint

Shell
go test ./order/application/
Code
ok  	minimart/order/application	0.004s

Green in milliseconds, with no server, database, or broker. Now trust the net - break order/domain/order.go's line-total math to items[i].UnitPriceCents (drop the * Quantity), rerun, watch want total 1250, got 750, then undo it. A suite you have watched fail is a suite you can trust.

Exercise

Why are the fakes hand-written instead of generated?

Answer

The interfaces are tiny and owned by the application boundary. Hand-written fakes make the test's assumptions visible: which product lookup succeeds, which publish happened, and which repository behavior is under test.


Integration tests

Goal: prove the SQL wiring the unit tests deliberately faked, against a real Postgres.

Learn

The unit tests in the Unit tests section prove logic - but they fake the repository, so they can never catch a wrong column name, a broken query, or a bad row mapping. That is the job of an integration test: boot the real adapter against a real database and round-trip data through it.

These two suites must not mix. The convention:

  • Integration tests live in a <domain>/test/ directory.
  • Each file opens with //go:build integration, so a plain go test ./... skips them by construction - the unit suite stays fast.
  • They run only with -tags=integration and an INTEGRATION_TEST_ENV=true runtime guard, so they never fire by accident (in CI, or on a laptop with no database).
  • They mock only third-party services, never their own database - the SQL is exactly the wiring the test exists to prove.

The two kinds divide cleanly precisely because of the architecture: fast unit tests for logic (many, run on every save), slower integration tests for wiring (few, run before merge).

A good integration test has four properties:

  • Fast enough to run locally. Docker is acceptable; a shared cloud database is not.
  • Focused. It tests the adapter contract, not every domain corner case.
  • Deterministic. It owns its test data and does not depend on global row counts.
  • Real where it matters. If the purpose is SQL wiring, use real Postgres and real migrations.

Build

1. Create catalog/test/catalog_integration_test.go:

Go
//go:build integration

// Package test holds the catalog domain's integration tests. Unlike the unit tests,
// these run against a REAL Postgres (the docker-compose db) and prove the SQL wiring
// the unit tests deliberately fake out.
//
// They are excluded from a normal `go test ./...` by the build tag above, and guarded
// again at runtime by INTEGRATION_TEST_ENV, so they never run by accident. Run them
// against a migrated database with:
//
//	docker compose up -d
//	go run . migrate
//	INTEGRATION_TEST_ENV=true go test -count=1 -tags=integration ./catalog/test/
package test

import (
	"context"
	"os"
	"testing"

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

	"minimart/catalog/domain"
	"minimart/catalog/infrastructure/postgres"
	"minimart/conf"
)

// TestCatalogRepository_SaveGetByID round-trips a product through the real Postgres
// adapter: Save, then GetByID, and asserts the value comes back unchanged. The
// upsert query makes the fixed ID safe to re-run.
func TestCatalogRepository_SaveGetByID(t *testing.T) {
	if os.Getenv("INTEGRATION_TEST_ENV") != "true" {
		t.Skip("integration test: set INTEGRATION_TEST_ENV=true (and run against a migrated Postgres)")
	}
	ctx := context.Background()

	pool, err := pgxpool.New(ctx, conf.DatabaseURL())
	if err != nil {
		t.Fatalf("connect: %v", err)
	}
	defer pool.Close()

	// Locally both pools point at the same database.
	repo := postgres.NewCatalogRepository(pool, pool)

	want, err := domain.NewProduct("it-widget", "Integration Widget", 1299, 7)
	if err != nil {
		t.Fatalf("NewProduct: %v", err)
	}
	if err := repo.Save(ctx, want); err != nil {
		t.Fatalf("Save: %v", err)
	}

	got, err := repo.GetByID(ctx, "it-widget")
	if err != nil {
		t.Fatalf("GetByID: %v", err)
	}
	if got != want {
		t.Fatalf("round-trip mismatch: got %+v, want %+v", got, want)
	}
}

Two guards, deliberately redundant: the build tag keeps this file out of the normal compile, and the INTEGRATION_TEST_ENV check skips it at runtime if it does get compiled. It constructs the real CatalogRepository (both pools pointing at the one local database), saves a product, and reads it back - exercising the sqlc query, the row mapping, and the schema together. got != want works because domain.Product is a comparable value.

The fixed ID is deliberate because the write is an upsert. In tests that insert append-only rows, prefer unique IDs per test or per table, and assert the row you own instead of "there are N rows now." Shared databases become flaky when tests depend on global counts.

2. Add one convenience target to the Makefile from the Run the stack section:

Makefile
.PHONY: test-integration

test-integration:
	INTEGRATION_TEST_ENV=true go test -count=1 -tags=integration ./catalog/test/

-count=1 disables Go's test cache. Infrastructure tests can pass from cache even after a compose file, migration, or environment variable changed, so make the command run the database round trip every time.

In production: every domain keeps such a suite in <domain>/test/, run in CI against a Postgres container after migrations, gated by the same tag and env var. They are the tests that catch a renamed column or a migration that never ran - the failures unit tests structurally cannot see.

Checkpoint

A normal test run must skip this file entirely (no tag, so it is not even compiled):

Shell
go test ./...
# ... no catalog/test line runs; the unit suites pass

Now run it for real, against a migrated database:

Shell
docker compose up -d
go run . migrate
INTEGRATION_TEST_ENV=true go test -count=1 -tags=integration ./catalog/test/

or:

Shell
make test-integration
Code
ok  	minimart/catalog/test	0.02s

The product made a full round trip through Postgres. Drop the -tags=integration and the same command reports no test files - the tag did its job.

Trust it the same way you trusted the unit test in the Unit tests section: temporarily break a column mapping in the adapter or misspell a query name, watch the integration test fail, then undo the break.

Exercise

Why does this test use both a build tag and an environment guard?

Answer

The build tag keeps integration tests out of normal unit-test runs. The environment guard prevents an accidental tagged run from touching a real database unless the caller explicitly opted in.


Where to go

Goal: the whole architecture in one page, and concrete next steps.

The architecture, recapped

MiniMart is two request-serving domains (catalog, order) and one worker-only domain (notification), each a hexagon with four layers, wired by one module lifecycle.

  • The Dependency Rule. Imports point inward. domain imports nothing; application imports domain; presentation imports application; infrastructure imports the domain ports and entities it implements. Every import statement obeys it.
  • Valid by construction. domain.NewProduct / NewOrder are the only ways to make an entity, so every value in the system is already valid.
  • Ports in the domain, adapters in infrastructure. domain.Repository is an interface the domain owns; memory and postgres implement it. The var _ domain.Repository line makes the fit a compile error if it drifts.
  • Inject interfaces, never construct. Services take ports as constructor arguments; the module's Init is the one place concrete adapters are named - which is why the storage swap (stage 11) touched no business code.
  • Boot in phases, verify before serving. InitRegisterContractsVerifyRegisterHTTP (server) or RegisterWorkers (worker). A missing contract is a crash at startup, not a nil panic on the first request.
  • Cross domains only through published contracts. The order domain calls the catalog through catalog/presentation/module/client and the contracts.Modules interface - never its application or domain.

Two transports (HTTP, Kafka) are both just input adapters in presentation/. Two test kinds (unit for logic, integration for wiring) divide cleanly because the ports made them. Storage, broker, and delivery are all replaceable behind their ports.

The use cases also split naturally into commands and queries: PlaceOrder changes state and emits an event; GetOrder asks a question. MiniMart keeps them as methods on one service for readability. Larger systems often make that split explicit with command/ and query/ packages or read models, but the rule is the same: writes protect invariants, reads serve callers.

Where to go next

  • Read a larger service with this shape. Look for sealed domains, HTTP and message adapters side by side, generated mocks, and integration suites gated by build tags. You will recognize every piece.
  • Add a domain. A user hexagon: publish a contract, have order record which user placed an order. No checklist this time - you know the shape.
  • Add a use case with a new rule. Reject an order when quantity > stock. Decide which domain owns that rule before writing it - the catalog's stock is stale by checkout time, so the answer is not obvious.
  • Add observability. Structured logs with a request ID threaded through ctx; metrics on the HTTP edge and the consumer loop; traces across the publish/consume boundary. All of it attaches at the edges - the core stays clean.
  • Add a component test. Start the HTTP adapter with httptest, use real Postgres, fake the catalog contract or publisher, and test one happy path through a single service. Keep edge cases in unit tests.
  • Harden delivery. Replace the best-effort publish (the Publish on place section) with a transactional outbox, so an OrderPlaced event can never be lost.
  • Add authorization. Use an identity provider for authentication at the edge, but pass a typed actor into use cases for authorization decisions. Do not hide required identity in context.Context.

The value you carry off is not MiniMart. It is that the next codebase shaped like this - and many are - is already legible to you: you know where each rule lives and why every file is where it is.

Exercise

Pick one next step above. Which boundary would it change, and which layers should stay untouched?

Answer

For example, adding observability should mostly touch edges and shared middleware, not domain constructors. Adding authorization should add typed actor data at the edge and application boundary, not hide required identity in context.Context. The habit is to name the boundary before naming files.