Article 6 of the series “From Specification to Execution.” Five articles earlier, I established the spec, roles, measurement, and property-based testing. Now, we need to see what the workflow looks like on a real stack.

I’m intentionally using a common stack here. React on the frontend, Go with Gin, GORM, and PostgreSQL on the backend. The use case is simple. That’s exactly what makes it useful. The public repo contains a runnable boilerplate in examples/billing-react-go/.

I want to show three things. First, how I map the tests by layer. Next, which choices truly change reliability. Finally, how I can start from this skeleton without rewriting the workflow by hand.

Mapping the test stack

I keep a classic test pyramid, with a layer of property-based testing at the lower level.

LayerMain ToolSpeedWhat I Test
Front, utils and formattersVitestmsIsolated business logic
Front, React componentsVitest + RTLms-sRendering and interactions
Front, views with APIVitest + RTL + MSWsComponents with mocked data
Front, logical invariantsfast-checksParsing, calculation, state
Back, pure functionstesting + testifyµs-msHelpers, calculation, validation
Back, HTTP handlershttptest + GinmsRouting, validation, responses
Back, GORM repositorytestcontainers-go + PostgreSQLsReal SQL, migrations, transactions
Back, logical invariantsrapidsProperties on pricing, routing, state
Cross-stack, API contractPactsFront-back compatibility without full E2E
Cross-stack, E2EPlaywrights-minCritical user journeys

The pyramid remains valid. I don’t try to put everything at the end of the chain. Property-based testing mainly allows me to increase the strength of the tests at the bottom of the stack, not their volume.

Three structural choices make the difference here. They matter more than the name of the framework.

Structural choice 1: testcontainers-go over sqlmock

The classic trap, on the Go backend side with GORM, consists of testing the repository by mocking *sql.DB with sqlmock. It’s fast. It’s isolated. It’s also fragile.

The problem is simple. I’m not testing PostgreSQL. I’m testing the SQL that GORM generated, then pretending the database behaves as expected. As soon as a migration changes, a constraint is added, or a GORM version alters the produced SQL, the test can keep passing while the real database will fail.

I prefer testcontainers-go. I start a real PostgreSQL in a container, apply the migrations, then test the repository against the real database. The cost remains reasonable. A few extra seconds per package seems like a good trade-off for avoided database errors.

Basic pattern:

//go:build integration

func setupDB(t *testing.T) (*gorm.DB, func()) {
    ctx := context.Background()
    pg, _ := tcpostgres.Run(ctx, "postgres:16-alpine", ...)
    dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")
    m, _ := migrate.New("file://migrations", dsn)
    m.Up()
    db, _ := gorm.Open(gpostgres.Open(dsn), &gorm.Config{})
    return db, func() { ... }
}

The benefits are concrete:

  • Migrations are tested with golang-migrate, in the same order as in production.
  • Database errors are classified semantically, for instance with IsUniqueViolation(err), not by string comparison.
  • GORM is tested for real. I don’t discover at deployment time that sqlmock was telling me a story that was too clean.

The //go:build integration tag separates fast tests from integration tests. I keep go test ./... for fast unit tests, then go test -tags=integration ./... for the real database.

Structural choice 2: Strict MSW on the React side

On the frontend, I do the opposite of an application-level fetch mock. I mock at the network level with MSW.

MSW intercepts HTTP requests at the transport level, not at the business code level. The production code doesn’t see the mock. I can also reuse the same handlers in Node tests and in browser development via Service Worker. This limits the gaps between what I test and what I run locally.

The important line is this one:

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

With this option, any undeclared request fails the test. Without it, MSW sometimes lets a request pass to a real URL. I then end up with ghost APIs, green tests that don’t really test the expected path, and a false sense of security.

I also keep a default handler that replies with a clear error if I haven’t declared anything for the request.

export const handlers = [
  http.all('*', () =>
    HttpResponse.json(
      { error: 'no MSW handler declared for this request' },
      { status: 599 },
    ),
  ),
];

Each test then overrides the handlers with server.use(...). I prefer this explicit constraint over a permissive mock that hides omissions.

Structural choice 3: Pact for contract testing

Frontend and backend unit tests don’t see contract breaks between the two. E2E tests see them, but they cost more, take more time, and depend on a full infrastructure.

Pact fills this gap. The pattern is classic: consumer-driven contract testing.

  1. The consumer, here React, writes a test describing what it expects from the API.
  2. This test generates a JSON pact file.
  3. The provider, here Go, verifies that it satisfies this pact.

On the consumer side, I describe the expected interaction.

await provider
  .addInteraction()
  .given('a tariff for prefix 336 exists at rate 1500')
  .uponReceiving('a rate request for a French mobile number')
  .withRequest('POST', '/api/rate', builder => builder.jsonBody({
    duration_seconds: 120,
    destination: '+33612345678',
  }))
  .willRespondWith(200, builder => builder.jsonBody({
    cost_millicents: integer(3000),
    tariff_prefix: string('336'),
  }))
  .executeTest(async (mockServer) => {
    const result = await rateCall({...}, mockServer.url);
    expect(result.tariff_prefix).toBe('336');
  });

On the provider side, I verify the contract against the real API.

verifier.VerifyProvider(t, provider.VerifyRequest{
    ProviderBaseURL: "http://localhost:8181",
    PactFiles:       []string{pactFile},
    StateHandlers: models.StateHandlers{
        "a tariff for prefix 336 exists at rate 1500": func(setup bool, _ models.ProviderState) (models.ProviderStateResponse, error) {
            if setup { seedTariff(336, 1500) }
            return nil, nil
        },
    },
})

I keep the matchers as permissive as possible. integer(3000) rather than 3000, for instance. Pact is mostly useful for detecting contract breaks at build time, not for replacing everything else.

The boilerplate to fork

The full skeleton is in examples/billing-react-go/ of the public repo.

The domain is deliberately simple. A POST /api/rate endpoint calculates the cost of a phone call from a tariff stored in the database. It’s small enough to understand quickly, concrete enough to see the workflow for real.

I cover six layers.

  1. Unit + PBT Go in api/pricing/, with testify for table-driven tests and rapid for three properties: positivity, monotonicity, metamorphic.
  2. HTTP Handler Go in api/handlers/, with httptest and gin.Engine, plus an in-memory fakeLookup.
  3. DB Repository Go in api/repository/, with testcontainers-go, golang-migrate, and the //go:build integration tag.
  4. Pact Provider Go in api/pacts/, with pact-go and StateHandlers that seed the database.
  5. React Component in frontend/src/components/, with Vitest, RTL, and strict MSW.
  6. Pact Consumer React in frontend/src/api/, with pact-js V4 and permissive matchers.

I also keep a simple CI chain.

  • frontend-unit
  • backend-unit
  • backend-integration
  • mutation on modified packages
  • contract
  • e2e only on main

I don’t want a spectacular pipeline. I want a readable pipeline.

Local run:

git clone https://github.com/mwolff44/spec-to-tests
cd spec-to-tests/examples/billing-react-go

# Fast backend, without Docker
cd api && go mod tidy
go test -race ./pricing/... ./handlers/...

# Integration backend, with Docker
go test -race -tags=integration ./repository/...

# Frontend
cd ../frontend && pnpm install && pnpm test

# Cross-stack contract testing
pnpm test:pact
cd ../api && go test -tags=pact ./pacts/...

If I want to spin up the full stack, docker-compose.yml is there for that.

Three points to keep

1. I keep a simple test stack, but I map it cleanly. Vitest, RTL, MSW, and fast-check on the frontend; testify, httptest, testcontainers-go, and rapid on the backend; Pact cross-stack; Playwright for a few critical journeys.

2. Three structural choices keep the stack reliable: testcontainers-go over sqlmock, strict MSW with onUnhandledRequest: 'error', and Pact to detect contract breaks at build time.

3. AI can produce code fast. Reliability comes from the workflow, not from speed alone. The runnable boilerplate already exists; I can clone it and adapt it.

Next week, I will tackle the final article in the series. I’ll discuss the persistent plan.md, pre-commit hooks, and moving from plan to PR.


Further reading


This article is the sixth in a seven-part series. The previous article covered property-based testing. The next one will talk about plan.md, pre-commit hooks, and moving from plan to PRs.