Stage 7 · Master
Testing Strategy
Integration Tests with Testcontainers
Container-backed integration tests are specified now for the first service databases, even though the current scaffold has no schemas beyond /healthz.
Why This Lesson Is a Specification
No Meridian service persists data yet. apps/identity-service, apps/community-service, and apps/billing-service each still stop at an empty internal/domain/domain.go and a /healthz route. That means there is no present-tense repository package to integration-test today. This lesson therefore defines the integration-test shape that should be adopted the moment the first service owns migrations and a repository layer.
The examples below describe the first real Postgres-backed test harness for Meridian. They are grounded in the actual workspace layout, Go 1.26 modules, and multi-tenant data model, but they do not claim that repository packages or migrations already exist in the current scaffold.
Where Testcontainers Fits
| Question | Unit test | Testcontainers integration test |
|---|---|---|
| Does config.Env fall back correctly? | Yes | No need |
| Does gateway config map env vars correctly? | Yes | No need |
| Does an identity-service repository enforce WHERE tenant_id = $1? | Not credibly | Yes |
| Does invoice generation survive real Postgres constraints and transactions? | Not credibly | Yes |
The first data-owning services will need real Postgres tests because shared-schema multi-tenancy is enforced at the query boundary, not by good intentions. A missing tenant_id predicate, a uniqueness constraint that is scoped incorrectly, or a transaction that mishandles invoice/payment updates are all failures that mocks can hide. Disposable containerized Postgres keeps the feedback loop local while still exercising the real engine.
The Postgres Harness Shape
apps/identity-service/internal/testutil/postgres/postgres.gocreateResponsibility: Starts a disposable Postgres container, returns a DSN or pgx pool, and tears the container down with t.Cleanup.
Why now: Container lifecycle and readiness waiting should be written once per service module, not re-implemented in every repository test.
Connects to: Matches the apps/<service>/internal/... layout established by the cmd/internal conventions lesson.
apps/identity-service/migrations/*.sqlcreateResponsibility: Defines the real tenant/user/membership schema that the harness applies before each test run.
Why now: Integration tests should fail on broken migrations for the same reason production would fail on them.
Connects to: Builds on the shared-schema tenant_id model documented in docs/architecture/services.md.
apps/identity-service/internal/repository/postgres/membership_repository_integration_test.gocreateResponsibility: Seeds two tenants and proves that tenant-scoped lookups, inserts, and role queries never bleed across the boundary.
Why now: Identity-service is the natural first owner of tenant, user, and membership data, so it is also the natural first owner of tenant-scoped database tests.
Connects to: Uses the same tenancy vocabulary carried in libs/platform/tenancy.
Tenant-Aware Integration Cases
The first repository integration tests should always seed at least two tenants. A single-tenant fixture proves almost nothing about isolation. In identity-service that means memberships for two different HOA tenants; in community-service it means residents, units, and complaints split across tenants; in billing-service it means invoices and payments scoped by tenant plus resident or unit. The important assertion is not only that the expected row exists, but that the foreign tenant's row is invisible under the same query shape.
The Decision
- Testcontainers is reserved for the first real repository and migration tests, not for code that can already be trusted with unit tests.
- Every service that owns tenant-scoped tables should seed at least two tenants in its integration suite.
- The harness lives inside the owning service module's internal/testutil tree so the module stays independently testable.
- Until a service has migrations and repositories, the only honest concrete tests remain the unit tests over libs/platform and internal/config.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.