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
The BuildBuddy Nightly manual integration action 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. - Gives each target an isolated emulator database and runs targets serially with test-result caching disabled.
- Uploads a sanitized target/result summary. BuildBuddy retains native Bazel test logs on the invocation.
- Posts a commit status that the GitHub reporter converts into a
nightly-failureissue when the suite fails.
The separate BuildBuddy Android consumer smoke schedule validates the public
Android and NearPay artifacts so SDK-surface regressions retain an independent
failure signal and artifact bundle.
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 scheduled BuildBuddy action 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.