Testing
Read this when you add or change a Tadoku API test. HTTP golden cases are in HTTP end-to-end tests and chained scenarios in User journeys.
Principles
- Test meaningful behavior and risk, not every change. Add coverage where a regression would matter: production authentication and HTTP contracts, persistence and data integrity, and nontrivial domain rules.
- There is no test-per-method or test-per-change requirement. No new test is a valid choice for trivial, documentation-only, generated-output-only and test-removal changes.
- Do not add low-value tests: tautological assertions that mirror the implementation, trivial constant or schema equality checks, pass-through or error-wrapper assertions without distinct behavior, or coverage already provided at a shared boundary.
- Do not export internals, broaden visibility, or add dependencies or abstractions solely to enable such assertions. Broadening visibility so a test fixture builds a dependency the way production does is not such an assertion; record it in Import boundaries. When a test is explicitly removed, do not recreate equivalent coverage elsewhere unless asked.
- Prefer real-database repository tests plus HTTP E2Es over isolated
feature-service tests:
- Repository tests are highly recommended for query behavior, row mapping, constraints and persistence.
- HTTP E2Es cover feature-service orchestration and the HTTP contract. Do not add database-backed feature-service tests.
- Unit tests cover pure parameter validation and domain rules.
- Cover shared dependency failures and route ownership once, at their own boundaries, not again for every operation that uses them.
- Use Go's standard
testingpackage for new or rewritten tests: ordinary comparisons,t.Fatalffor failed prerequisites,t.Errorffor independent checks,t.Cleanupfor resource cleanup, anderrors.Isorerrors.Asfor errors. Do not add Testify, another assertion framework or a homegrown assertion DSL. Existing tests need no bulk rewrite; convert them when you rework their code or in a separately scoped mechanical change.
Running tests
bazel test //services/tadoku-api/... --test_output=errors
bazel test //services/tadoku-api/e2e:e2e_test --test_output=errors
bazel test //services/tadoku-api/e2e:e2e_test --test_filter=TestAuthentication
The first command runs everything, the second the HTTP E2Es and user journeys,
and the third one test function. Database and Valkey suites are cached like any
other test: a target reruns only when its inputs change. CI adds a digest of its
service containers and PostgreSQL settings to every test's cache key as
TADOKU_TEST_SERVICES, so changing a service image or setting reruns them all.
Locally the key includes the service URLs but not the server versions; pass
--nocache_test_results after changing your local services. The daily race
build reruns its targets uncached; see Race tests.
Tag a target no-cache only when its result depends on something outside its
inputs, such as property tests' fresh seeds.
Test infrastructure
Tests never skip. Missing or unsafe configuration fails before any connection. Never point tests at shared development or production services.
-
PostgreSQL:
TADOKU_TEST_POSTGRES_URLmust point to an explicit loopback port, databasepostgres, credentialspostgres:postgresand exactlysslmode=disable. Fixtures clone random disposable databases from a template holding the complete migration history. The first fixture on a server builds the template, named after the up-migration contents, while other test processes wait on an advisory lock; templates are retained and refuse connections. The admin pool is capped at two connections; owner and restricted application pools each allow four.testpostgres.Database.Poolis the owner pool for fixture reset and verification SQL.AppPooluses a separate randomly named per-database login with no superuser,bypassrls, ownership or role memberships. It has application DML, sequence usage and read-only migration metadata access. Owner-only baseline snapshots are not granted to it. Application-pool shutdown precedes database and role cleanup; partial setup also cleans up the owned role and reports cleanup failures.SeedTenantruns a fixture transaction bound to one test tenant through a second per-database login, capped at two connections. The login holds DML only on tables whose every row-level security policy is exactly thetadoku.tenantbinding, derived from the catalog, plus their sequences. It has nothing on the tenant registry, overrides,jobs, shared reference tables or migration metadata, and the same pool shutdown, role cleanup and partial-setup handling. It fails if the transaction ends bound to another tenant or to all tenants. -
PgBouncer: PostgreSQL transport tests require
TADOKU_TEST_PGBOUNCER_URLwith the same loopback, synthetic credential and database guard. Run PgBouncer 1.25.2 in transaction mode, with a wildcard database mapping to the disposable PostgreSQL instance, SCRAM authentication and nonzeromax_prepared_statements. The dedicated target exercises 300 goroutines, 12,000 tenant reads and 6,000 writes. Its test-only session-setting negative control must observe cross-client leaks. The normal and race CI workflows print both workload and negative-control results. -
Identity accounts: ordinary HTTP golden and journey resets seed native tenant-owned accounts after case SQL, preserving existing user/account state on primary-key conflicts. Cast and gateway fixtures obtain sessions through real login with test-cost bcrypt hashes; registration/password-policy workflows retain production cost 12. See Local account fixtures.
-
Valkey: raw Valkey and application lifecycle tests require
TADOKU_TEST_VALKEY_URLin the exact formredis://127.0.0.1:<port>(orlocalhost) for a disposable Valkey 9 service. They never flush the shared instance. Logical databases are reserved per suite: HTTP E2E shards use 15, 14, 12, 11 and 10, and the leaderboard cache fence and worker tests use 13. Unsharded HTTP E2Es use 15. Invalid or incomplete Bazel shard coordinates, including more than five shards, fail before opening fixtures. The HTTP E2E fixtures lease their database. Tests sharing database 13 use unique parsede2e/<id>tenants: the fence and cross-tenant tests create and delete only their named tenant keys; worker cache tests register a unique test tenant, persist it on jobs and delete only that tenant's matching keys. The worker's extra marker outside its tenant prefix also embeds the unique key and is deleted by exact name. No test sharing database 13 flushes it. The leased HTTP E2E database refuses pre-existing leaderboard, identity throttle or profile avatar keys. It resets only legacy/tenant-derived leaderboard patterns and tenant-derived identity login, registration and recovery and profile patterns while holding its lease, including leaderboard keys created by arbitrary test-tenant jobs.
HTTP shard coverage
The normal HTTP E2E target declares three native Bazel shards. Race builds use one E2E process while retaining the complete suite. Overall test-target concurrency still needs a runner-appropriate limit.
Use native Bazel shard_count to select top-level tests; do not add an
application test selector. Within a process, only scenario tests that meet the
parallel scenario rules call
t.Parallel, at most two at a time; cases that share fixture state stay serial.
--test_sharding_strategy=disabled runs the same target in one process for
comparison. Keep --nocache_test_results on measured runs.
Retain the complete successful test XML from an unfiltered unsharded run and
all shards of an equivalent run. Compare exact (classname, name) identities,
including parent tests and subtests, with
services/tadoku-api/e2e/check_shard_inventory.py:
python3 services/tadoku-api/e2e/check_shard_inventory.py \
--baseline "$EVIDENCE/unsharded/test.xml" \
--shards "$EVIDENCE"/sharded/shard_*/test.xml
Run python3 services/tadoku-api/e2e/check_shard_inventory_test.py to exercise
the checker's positive and negative controls.
The comparison fails on empty inventories, unsuccessful cases, duplicates, missing names or unexpected names. A case count alone cannot prove completeness. Use the same revision and no test filter for both runs. Preserve an inventory from before fixture changes as well, so review can establish that existing cases remain covered. Retain commands, revision, setup and resource samples alongside XML; CI artifacts must include successful shard XML as well as failures.
Limit concurrent test processes using Bazel's --local_test_jobs and the
runner's measured memory, CPU and database capacity. The number of declared
shards is not the number that must run simultaneously. Measure normal and race
builds separately; instrumentation changes memory and CPU demand. Do not infer
CI latency from a host with more available resources.
Repository and package tests
- Test code follows Go style, including arrange, act and assert grouping and one test case per table entry.
- Database tests in feature packages follow the feature package layout.
- Database helpers take contexts and return errors, with explicit
Closecleanup instead of depending ontesting.TB. Suite teardown preserves test failures and reports cleanup failures; partial setup also cleans up. - Direct repository, service and worker tests pass an explicit
tenant.WithKey(ctx, tenant.Production())context. HTTP E2Es receive the tenant through their signed fixture tokens. Fixture reset sets the canonical tenant transaction-locally before cleanup and seed inserts. - Raw owner-pool fixture inserts name the tenant column explicitly, including
every row and
insert ... selectprojection. Canonical fixtures usetadoku/prod; parent and child rows use the same tenant. A context value alone does not scope raw pool SQL. Reads through a restricted application role use the PostgreSQL executor with an explicit tenant context, preserving the same transaction-local scope as application calls. Never supply a session, role, database or pool startup tenant setting to make fixtures pass. - Repository and transaction tests keep their own independent databases and may
run in parallel. If local test targets contend with the provider fixtures,
use
--local_test_jobs=1to serialize targets while retaining all assertions. - Parallel worker tests create their fixtures after
t.Parallel, own their databases and provider keys, and stop every worker goroutine before fixture cleanup. Tests that change process-wide state, includingtimex.TheWorld,uuid.SetRand,jwt.TimeFuncor environment variables, and their parents stay serial. Bound in-process concurrency with-test.parallelas well as target concurrency when measuring against CPU, memory and connection limits. - Split a slow target whose tests must stay serial with native Bazel
shard_count; shards are separate processes, so process-wide state andt.Setenvstay safe. Tests that create real Flipt SDK clients stay serial within a process: concurrent clients race in the SDK's WebAssembly runtime. Before sharding, compare-test.vpass inventories of a sharded and an--test_sharding_strategy=disabledrun. - Keep the pool-closing failure test isolated. It opens a second pool on the shared DSN and closes it, without creating another migrated database.
- The
services/tadoku-api/infra/postgres/helper tests have their own setup inservices/tadoku-api/infra/postgres/README.md.
Property tests
- Write a property-based test with rapid when a rule must hold over a combinatorial input space and an independent model states the expected result. Keep example tests for the cases a reader should see. Never require random cases to reach an outcome; check each outcome a property must cover on a fixed case through the same assertions.
- Draw from small pools so values collide, and build them through the public constructors. Generate invalid input only when the property is about rejecting it. Rapid seeds, shrinks and prints counterexamples; do not do any of that by hand.
- Test a stateful component with
T.Repeat. Each action calls the real component, and the invariant compares its state with an in-memory model of the expected state. The model is an oracle, not a fake of a port; storage stays real. - A database property keeps one disposable database and deletes its rows at the start of each case.
- Every run draws a fresh seed. A target with property tests is tagged
no-cache, so CI never reuses its result, and passes-rapid.nofailfilein itsargs, because a fail file stays in the Bazel sandbox. Locally,--nocache_test_resultsreruns an unchanged target. Replay a failure with the seed it prints:bazel test <target> --test_filter=<Test> --test_arg=-rapid.seed=<seed>.--test_arg=-rapid.checks=<n>runs more cases and--test_arg=-test.shortfewer. Keep a counterexample worth reading as an example test.
Tenancy guard and isolation
services/tadoku-api/app/tenantlifecycle/ tests the owner command's
orchestration against disposable PostgreSQL and Valkey. Its complete
tenant-column inventory includes registry overrides; every ordinary owned table
has a nonempty teardown fixture. Canonical and another test tenant's complete
row snapshots remain unchanged. The Flipt HTTP fixture records resource
creation, while an injected outage verifies that the registry remains retryable.
Use TADOKU_TEST_POSTGRES_URL and TADOKU_TEST_VALKEY_URL for this suite, with
the same loopback guards as the API tests.
services/tadoku-api/infra/postgres/tenancy_schema_test.go migrates a
disposable database and checks every ordinary table for RLS, a non-null tenant
column, the strict transaction tenant default and its canonical policy.
Registry, shared reference, migration metadata and the helper's baseline tables
have their explicit exceptions. The guard checks shared reference policies and
rejects database/role startup tenant settings. A new table therefore fails the
guard until it has the required protections.
All HTTP E2E application repositories and the real journey worker use
AppPool; reset, fixture mutations and verify.sql keep Pool.
Scenario fixture seeds use SeedTenant.
Tests using testing/synctest create and close every database pool used in the
bubble inside that same bubble. Copy the fixture pool configuration to preserve
the owner or restricted application role, then use the new pools for writes,
worker services and verification SQL. Shared pools can open connections inside
the bubble and abort the process when their synchronization crosses its boundary.
Worker timer and retry scenarios advance virtual time while retaining real
PostgreSQL, SMTP and worker execution; keep SMTP listeners outside the bubble
and all database work inside its owned pools.
services/tadoku-api/e2e/tenant_isolation_test.go runs signed requests through
the production router with real JWT verification, PostgreSQL and Valkey. It
covers two-way log visibility, duplicate page slugs and synchronized identities,
shared-table write denial, base and branch job claims, and test-tenant deletion
including a replay chain without changing canonical rows. Pool tests also prove fresh and reused
unscoped connections fail closed. These checks cover database/context
isolation. The leaderboard Valkey tests also prove that a read without a
tenant writes no key.
Development seed SQL requires psql variables including tenant. Exercise
the files in scripts/dev/seed/ against a disposable migrated database,
with canonical and test keys, and retain the command and row/ID comparison.
If the test runner does not provide psql, attach that bounded manual run
as verification evidence rather than skipping it silently.