Skip to main content

Integration Tests

Emulator-backed integration tests are the standard path for exercising Spanner repository semantics, Pub/Sub publish and delivery round-trips, and Firebase token flows. Mocked database unit tests still cover pure logic, but they are not sufficient for repository families or service-boundary behavior.

This runbook covers:

  • the Bazel tag convention that distinguishes integration tests from unit tests
  • how to run the suite locally
  • the nightly CI job that runs the suite against an emulator once per day

Test-Tag Convention

Every emulator-backed integration test must carry the Bazel-level manual tag on its gateway_kt_test target:

gateway_kt_test(
name = "customer_repository_spanner_integration_test",
tags = ["manual"],
# ...
)

Optional additional tags already in use:

  • critical-path for tests that also run under the PR critical-path gate
  • cert for certification-lane tests excluded from the nightly manual suite

manual is load-bearing for two reasons. It keeps the test out of bazel test //... on laptops without a running emulator, and the nightly workflow resolves the manual-test set with bazel query.

There is no repo-wide integration Bazel tag today. Some suites use JUnit-level @Tag("integration") for JUnit Platform filtering inside a single gateway_kt_test target, but that tag does not drive Bazel target selection.

When SPANNER_EMULATOR_HOST is unset, each test should use Assumptions.assumeTrue(...) to skip cleanly. Tests extending SpannerEmulatorTestBase inherit this behavior from //libs/spanner-test-harness.

Run Locally

Start the Spanner emulator:

bash infra/scripts/start-spanner-emulator.sh

For the full local stack, use:

./scripts/dev.sh services

Run the whole nightly manual suite:

bazel test $(bazel query \
'tests(//services/... + //libs/...) intersect attr(tags, "manual", //services/... + //libs/...) except attr(tags, "cert", //services/... + //libs/...)') \
--local_test_jobs=1

--test_tag_filters=manual //... alone does not select manual targets because Bazel excludes manual targets from wildcard expansion before tag filters run. Use the bazel query ... | bazel test shape above.

Run one repository family in isolation:

bazel test //services/processing:customer_repository_spanner_integration_test

Stop the emulator:

pkill -f 'spanner_emulator_main' || true

Shared Harness

New Spanner-backed tests should extend com.myriad.gateway.spannertestharness.SpannerEmulatorTestBase from //libs/spanner-test-harness. The base class owns:

  • PGAdapter lifecycle with one port per test
  • schema bootstrap through SQLDelight
  • per-test truncation of user tables
  • a shared GatewayDatabase handle

Processing keeps seed helpers in SpannerRepositoryIntegrationHarness for tests that need preloaded organization, location, or credential rows. Other services use the shared base directly.

Nightly CI

.github/workflows/integration-nightly.yml runs every day at 07:00 UTC:

  1. Starts cloud-spanner-emulator through infra/scripts/start-spanner-emulator.sh.
  2. Resolves manual-tagged test targets with bazel query, excluding cert-tagged certification tests.
  3. Runs them serialized with --local_test_jobs=1.
  4. Uploads bazel-testlogs/**/test.log as a run artifact.
  5. Opens or updates a nightly-failure issue when the suite fails.

A separate nightly workflow runs the Android SDK consumer-smoke target. It is intentionally independent so SDK-surface regressions are visible as their own failure signal.

Adding A Repository Family

  1. Add the integration test under services/{service}/src/test/kotlin/.../integration/, extending SpannerEmulatorTestBase.
  2. Wire it into services/{service}/BUILD.bazel with gateway_kt_test and tags = ["manual"]. Add critical-path only when it must run in the PR critical-path gate.
  3. Run it locally against the emulator.
  4. Let the nightly workflow pick it up automatically.

Known Limitations

None currently. Two ON CONFLICT queries in the schema previously did not parse through Spanner PGAdapter:

  • libs/schema/.../ProvisioningJobStep.sq used a null-filtered unique index as the conflict target -- fixed by replacing the ON CONFLICT (job_id, step) DO UPDATE with an update-then-insert in ProvisioningJobStepRepository.upsert (same pattern as WebhookEventRepository.insertIfAbsent).
  • libs/schema/.../CheckoutSessionPromotion.sq used target-less ON CONFLICT DO NOTHING -- fixed by naming the (session_id, promotion_code_id) PRIMARY KEY columns explicitly as the arbiter.

Both are covered by emulator-backed tests (ProvisioningJobRepositorySpannerIntegrationTest.provisioningJobStepUpsertInsertsThenUpdatesSameJobIdAndStep and CheckoutSessionRepositorySpannerIntegrationTest.checkoutSessionPromotionInsertIsIdempotentOnConflict).

The same null-filtered-index restriction also applies to idempotency_keys (IdempotencyKey.sq) -- its bare ON CONFLICT DO NOTHING was fixed the same way, by naming the (scope, key, client_id) PRIMARY KEY columns explicitly.