Practical Implementation: React + Go (Gin / GORM / PostgreSQL)
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.
| Layer | Main Tool | Speed | What I Test |
|---|---|---|---|
| Front, utils and formatters | Vitest | ms | Isolated business logic |
| Front, React components | Vitest + RTL | ms-s | Rendering and interactions |
| Front, views with API | Vitest + RTL + MSW | s | Components with mocked data |
| Front, logical invariants | fast-check | s | Parsing, calculation, state |
| Back, pure functions | testing + testify | µs-ms | Helpers, calculation, validation |
| Back, HTTP handlers | httptest + Gin | ms | Routing, validation, responses |
| Back, GORM repository | testcontainers-go + PostgreSQL | s | Real SQL, migrations, transactions |
| Back, logical invariants | rapid | s | Properties on pricing, routing, state |
| Cross-stack, API contract | Pact | s | Front-back compatibility without full E2E |
| Cross-stack, E2E | Playwright | s-min | Critical 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
sqlmockwas 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.
- The consumer, here React, writes a test describing what it expects from the API.
- This test generates a JSON pact file.
- 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.
- Unit + PBT Go in
api/pricing/, withtestifyfor table-driven tests andrapidfor three properties: positivity, monotonicity, metamorphic. - HTTP Handler Go in
api/handlers/, withhttptestandgin.Engine, plus an in-memoryfakeLookup. - DB Repository Go in
api/repository/, withtestcontainers-go,golang-migrate, and the//go:build integrationtag. - Pact Provider Go in
api/pacts/, withpact-goandStateHandlersthat seed the database. - React Component in
frontend/src/components/, with Vitest, RTL, and strict MSW. - Pact Consumer React in
frontend/src/api/, withpact-jsV4 and permissive matchers.
I also keep a simple CI chain.
frontend-unitbackend-unitbackend-integrationmutationon modified packagescontracte2eonly onmain
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
- testcontainers-go - golang.testcontainers.org
- golang-migrate - github.com/golang-migrate/migrate
- MSW (Mock Service Worker) - mswjs.io
- Pact documentation - docs.pact.io
- pact-js - github.com/pact-foundation/pact-js
- pact-go - github.com/pact-foundation/pact-go
- Vitest - vitest.dev
- React Testing Library - testing-library.com
- testify - github.com/stretchr/testify
- Gin - gin-gonic.com
- GORM - gorm.io
- fast-check (PBT TS) - fast-check.dev
- rapid (PBT Go) - github.com/flyingmutant/rapid
- Runnable React + Go + Pact demo - github.com/mwolff44/spec-to-tests (
examples/billing-react-go/)
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.