Stage 7 · Master
Phase 2 — Organization Service
Integration Testing
Trade the fake repository for a real, disposable PostgreSQL container — and close the loop on the exact psql exercise the Repository Layer lesson left as manual homework.
What fakeRepository Cannot Prove, By Design
The Unit Testing lesson's fakeRepository never touches SQL — it proves OrganizationService's business rules are correct assuming the repository behaves as documented. It cannot prove the actual SQL in organization_postgres.go is correct, that the unique index really produces SQLSTATE 23505, or that a migration applies cleanly to a fresh database. Those require a real PostgreSQL instance, and Testcontainers-go provisions one automatically for the duration of a single test run.
//go:build integration Keeps This Test Out of the Default Run
A plain go test ./... must never silently try to launch a Docker container — a laptop without Docker running, or a restricted CI runner, would fail unrelated unit tests for an infrastructure reason that has nothing to do with the code under test. The //go:build integration tag at the top of this file excludes it from every ordinary test run; it only compiles and executes when a caller explicitly opts in.
//go:build integration
package repository_test
import (
"context"
"errors"
"os/exec"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"github.com/hoa-platform/backend/services/organization/internal/model"
"github.com/hoa-platform/backend/services/organization/internal/repository"
)
func TestOrganizationPostgres_Create_DuplicateSlug(t *testing.T) {
ctx := context.Background()
container, err := tcpostgres.Run(ctx, "postgres:16-alpine",
tcpostgres.WithDatabase("hoa_organization_test"),
tcpostgres.WithUsername("hoa"),
tcpostgres.WithPassword("hoa_test_password"),
testcontainers.WithWaitStrategy(
wait.ForListeningPort("5432/tcp").WithStartupTimeout(30*time.Second),
),
)
if err != nil {
t.Fatalf("starting postgres container: %v", err)
}
t.Cleanup(func() {
if err := container.Terminate(ctx); err != nil {
t.Logf("terminating container: %v", err)
}
})
dsn, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("building connection string: %v", err)
}
// migrate stays a local CLI tool, never a module dependency of this
// service, exactly as the Migrations lesson installed it — this test
// shells out to the identical binary a developer runs by hand. The
// container's DSN comes back scoped postgres://, so it is translated
// to pgx5:// the same way the Makefile's targets are: same
// connection string, different scheme, because that's what
// golang-migrate's pgx/v5 driver identifies itself by.
migrateDSN := "pgx5://" + strings.TrimPrefix(dsn, "postgres://")
migrate := exec.Command("migrate", "-path", "../../migrations", "-database", migrateDSN, "up")
if out, err := migrate.CombinedOutput(); err != nil {
t.Fatalf("running migrations: %v\n%s", err, out)
}
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connecting pool: %v", err)
}
t.Cleanup(pool.Close)
repo := repository.NewOrganizationPostgres(pool)
first := &model.Organization{
Name: "Integration Test Org", Slug: "integration-slug", RegistrationNumber: "REG-INT-1",
ContactEmail: "integration@test.test", AddressLine1: "1 Test St", City: "Austin",
State: "TX", PostalCode: "73301", Country: "USA", Status: model.StatusActive,
}
if err := repo.Create(ctx, first); err != nil {
t.Fatalf("first create: unexpected error: %v", err)
}
duplicate := &model.Organization{
Name: "Duplicate Slug Org", Slug: "integration-slug", RegistrationNumber: "REG-INT-2",
ContactEmail: "other@test.test", AddressLine1: "2 Test St", City: "Austin",
State: "TX", PostalCode: "73301", Country: "USA", Status: model.StatusActive,
}
err = repo.Create(ctx, duplicate)
if !errors.Is(err, repository.ErrDuplicateSlug) {
t.Fatalf("got %v, want repository.ErrDuplicateSlug", err)
}
}
This is the automated version of the Repository Layer lesson's manual psql exercise — the same SQLSTATE 23505 unique violation, now provoked and asserted by a test that runs in CI on every push instead of requiring a human at a psql prompt.
A Second Test Target, Deliberately Separate From make test
test-integration:
go test -C services/organization ./... -tags=integration -v
This target runs go test with an explicit -C flag rather than a plain go test ./... from the repository root — go.work has no root go.mod, so an unscoped root-level test invocation would fail before a single test even runs. The existing test target from the Makefile lesson remains completely unaffected, since -tags=integration only compiles files carrying that build tag.
Running the Container-Backed Suite by Hand First
==> starting container: postgres:16-alpine
==> test TestOrganizationPostgres_Create_DuplicateSlug
--- PASS: TestOrganizationPostgres_Create_DuplicateSlug (4.82s)
PASS
ok github.com/hoa-platform/backend/services/organization/internal/repository 4.912s
exec.Command("migrate", ...) resolves the migrate binary the same way a shell would: it must already be installed and discoverable on the runner's PATH, exactly as the Migrations lesson's go install left it locally. On a fresh CI runner, this step must run go install -tags 'pgx5' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1 before make test-integration — the CI/CD lesson later in this phase makes that step an explicit job, not an assumption.
Applied exercise
Confirm the build tag actually excludes this test from the ordinary suite
The lesson claims go test ./... never launches Docker unless -tags=integration is passed. Verify this directly rather than trusting the claim.
- With Docker Desktop (or your local Docker daemon) stopped, run go test ./internal/repository/... -v from inside services/organization and confirm no container-related error appears at all.
- Now run go test ./internal/repository/... -tags=integration -v with Docker still stopped and observe the container-startup failure this time.
- Restart Docker and re-run the same -tags=integration command to confirm it now passes.
Deliverable
A short transcript of all three runs, showing the tag's presence is what determines whether Docker is required at all.
Completion checks
- The first run (no tag, Docker stopped) completes without any Docker-related error.
- The second run (tag present, Docker stopped) fails specifically due to being unable to reach the Docker daemon, not a Go compilation error.
Why does organization_postgres_integration_test.go begin with //go:build integration instead of simply living alongside the other repository tests with no tag?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.