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-pathfor tests that also run under the PR critical-path gatecertfor 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
GatewayDatabasehandle
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:
- Starts
cloud-spanner-emulatorthroughinfra/scripts/start-spanner-emulator.sh. - Resolves
manual-tagged test targets withbazel query, excludingcert-tagged certification tests. - Runs them serialized with
--local_test_jobs=1. - Uploads
bazel-testlogs/**/test.logas a run artifact. - Opens or updates a
nightly-failureissue 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
- Add the integration test under
services/{service}/src/test/kotlin/.../integration/, extendingSpannerEmulatorTestBase. - Wire it into
services/{service}/BUILD.bazelwithgateway_kt_testandtags = ["manual"]. Addcritical-pathonly when it must run in the PR critical-path gate. - Run it locally against the emulator.
- 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.sqused a null-filtered unique index as the conflict target -- fixed by replacing theON CONFLICT (job_id, step) DO UPDATEwith an update-then-insert inProvisioningJobStepRepository.upsert(same pattern asWebhookEventRepository.insertIfAbsent).libs/schema/.../CheckoutSessionPromotion.sqused target-lessON 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.