spanemuboost bootstraps Cloud Spanner Emulator and, experimentally, Spanner Omni for tests using testcontainers-go. Review the Spanner Omni software requirements before enabling the Omni path in local development or CI.
It inspired by autoConfigEmulator of:
This package doesn't have functionality of splitting statements and stripping comments. Consider to use memefish or helper packages.
func TestFoo(t *testing.T) {
env := spanemuboost.SetupEmulatorWithClients(t,
spanemuboost.WithSetupDDLs(ddls),
)
// env.Client, env.DatabaseClient, env.InstanceClient available
// cleanup is automatic via t.Cleanup
}For non-test usage (e.g. embedding the emulator in an application where the testing package is unavailable), see runnable examples on pkg.go.dev.
For many independent test or validation cases, prefer one shared backend runtime and one database per case. That is the recommended shape for the emulator, and it is even more important for Omni because each Omni runtime owns a memory-heavy container.
Use NewLazyRuntime plus SetupClients or OpenClients when cases should
share a runtime lazily. Use WithRandomDatabaseID() for per-case isolation. See
ExampleNewLazyRuntime_validationCases on
pkg.go.dev
for a validation-harness shape that composes shared and case-specific setup, and
separates setup failures from candidate statement results.
Random database IDs do not enable schema teardown on Clients.Close() by
default. The databases disappear when the runtime container is closed. For
long-lived shared runtimes, use ForceSchemaTeardown() or explicit cleanup if
database accumulation matters.
| Need | Entry point | Starts a new runtime container? |
|---|---|---|
| One test owns runtime and clients | SetupWithClients(t, backend, ...) |
Yes |
| Non-test code owns runtime and clients | RunWithClients(ctx, backend, ...) |
Yes |
Many tests share one runtime with testing.TB cleanup |
NewLazyRuntime(backend, ...) + SetupClients |
Once, on first use |
Many cases need explicit context.Context or manual client cleanup |
NewLazyRuntime(backend, ...) + OpenClients |
Once, on first use |
| Eager runtime startup with multiple databases | Run(ctx, backend, ...) + OpenClients |
Once, when Run is called |
Setup, Run, RunWithClients, and SetupWithClients with BackendOmni start a Spanner Omni single-server container and use the public Spanner gRPC API on port 15000 for database creation, DDL application, DML setup, and managed client creation. This path is intended for integration tests that want a real Omni runtime without depending on the emulator.
func TestOmni(t *testing.T) {
env := spanemuboost.SetupWithClients(t, spanemuboost.BackendOmni,
spanemuboost.WithRandomDatabaseID(),
spanemuboost.WithSetupDDLs([]string{
"CREATE TABLE tbl (pk STRING(MAX), col INT64) PRIMARY KEY (pk)",
}),
spanemuboost.WithSetupRawDMLs([]string{
"INSERT INTO tbl (pk, col) VALUES ('foo', 1)",
}),
)
err := env.Client.Single().Query(t.Context(), spanner.NewStatement(
"SELECT col FROM tbl WHERE pk = 'foo'",
)).Do(func(r *spanner.Row) error {
var col int64
return r.Column(0, &col)
})
if err != nil { t.Fatal(err) }
}For Omni, use databases as the normal isolation unit. Do not use random project
or instance IDs for ordinary test isolation; the single-server deployment uses
fixed project and instance IDs. Use WithRandomDatabaseID() and share the
runtime with NewLazyRuntime(BackendOmni, ...) when many cases are involved.
| Omni caveat | Detail |
|---|---|
| Experimental runtime | Omni support is newer than the emulator path and should be treated as integration-test-oriented |
| Primary endpoint | The main Spanner gRPC endpoint is 15000; the console remains separate |
| Resource use | Each started Omni runtime owns one Spanner Omni container; plan for roughly 4 GiB of memory per concurrently running Omni container |
| Recommended client config | Managed Omni clients force the RecommendedOmniClientConfig() transport defaults (DisableNativeMetrics and IsExperimentalHost) unless guardrails are disabled; the same helper remains the recommended base for external Go clients |
| Host and container prerequisites | Review the Spanner Omni software requirements before enabling Omni in local development or CI; see Omni runtime environments for local Colima and Podman notes |
| Guardrails | Known-invalid single-server Omni settings fail fast with human-readable errors; use DisableBackendGuardrails() only when testing a newer backend whose constraints may have changed |
The repository's Omni integration tests are gated by SPANEMUBOOST_ENABLE_OMNI_TESTS=1 so default test runs stay hermetic unless the environment is explicitly prepared for Omni.
Keep tests that start Omni runtimes serial unless the host has enough spare
memory for multiple Omni containers. spanemuboost does not impose a global
runtime lock; use go test -p=1 -parallel=1 or share a runtime with
NewLazyRuntime(BackendOmni, ...) when memory is tight.
When running through Podman and Testcontainers-Go does not auto-detect Podman
from DOCKER_HOST, set SPANEMUBOOST_TESTCONTAINERS_PROVIDER=podman for that
command or pass WithContainerProvider(testcontainers.ProviderPodman). The
environment variable affects all spanemuboost runtime containers, including the
default emulator backend. For rootful Podman machine with Ryuk enabled, the
relevant environment is:
env DOCKER_HOST=unix://<host Podman API socket> \
TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/run/podman/podman.sock \
TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED=true \
SPANEMUBOOST_TESTCONTAINERS_PROVIDER=podman \
SPANEMUBOOST_ENABLE_OMNI_TESTS=1 \
go test -p=1 -parallel=1 ./...Once a runtime is started, the shared client helpers are backend-neutral:
func TestSharedHelpers(t *testing.T) {
runtime := spanemuboost.Setup(t, spanemuboost.BackendOmni)
clients := spanemuboost.SetupClients(t, runtime,
spanemuboost.WithRandomDatabaseID(),
spanemuboost.WithSetupDDLs([]string{
"CREATE TABLE tbl (pk STRING(MAX)) PRIMARY KEY (pk)",
}),
)
_ = clients
}Starting Spanner Omni through testcontainers is slow because each runtime pulls
and boots a memory-heavy container. For local development and repeated test
runs, start Omni once with the spanemuboost CLI and attach clients to the
published endpoint:
spanemuboost serve omni --endpoint-file /tmp/omni-endpoint.json
spanemuboost stop --endpoint-file /tmp/omni-endpoint.jsonBy default, serve omni skips instance and database bootstrap so only the
built-in spanner-info database exists until clients create their own. Pass
--with-default-database to create the default emulator-database at serve
time (legacy behavior).
The endpoint file is owned by serve: it is written on startup (including the
serve process PID for lifecycle management) and removed on exit. Unset
SPANEMUBOOST_ENDPOINT_FILE after stopping the lifecycle manager.
In another shell:
export SPANEMUBOOST_ENDPOINT_FILE=/tmp/omni-endpoint.json
export SPANEMUBOOST_ENABLE_OMNI_TESTS=1
go test -p=1 -parallel=1 ./...Client code can use [NewLazyRuntimeFromEnvOrStart] to keep the existing testcontainers path while automatically attaching when endpoint env vars are set, or [NewAttachedRuntimeFromEnv] for explicit attachment:
runtime, err := spanemuboost.NewLazyRuntimeFromEnvOrStart(spanemuboost.BackendOmni)
if err != nil {
log.Fatal(err)
}
clients, err := spanemuboost.OpenClients(ctx, runtime,
spanemuboost.WithRandomDatabaseID(),
spanemuboost.WithSetupDDLs(ddls),
)| Variable | Purpose |
|---|---|
SPANEMUBOOST_ENDPOINT_FILE |
JSON file written by spanemuboost serve |
SPANEMUBOOST_OMNI_URI |
Direct Omni gRPC endpoint (host:port) |
SPANEMUBOOST_EMULATOR_URI |
Direct emulator gRPC endpoint (host:port) |
When attaching to Omni via SPANEMUBOOST_OMNI_URI, project and instance IDs
default to default. Non-default IDs require the running Omni instance to match
and are rejected by Omni guardrails unless callers use
DisableBackendGuardrails() on a programmatic runtime constructor.
[AttachedRuntime.Close] is a no-op because the lifecycle manager owns the
container. Stop the shared runtime with spanemuboost stop or by stopping the
serve process directly.
Run, RunWithClients, Setup, SetupWithClients, OpenClients, SetupClients, RuntimePlatform, and NewLazyRuntime work across emulator and Omni. This backend-neutral API surface is the primary stable entry point; only the BackendOmni backend and its specific behaviors are considered experimental. Omni does not add separate exported startup or client-opening helpers.
Use RuntimePlatform(ctx, runtime) when you want to surface the actual resolved container platform for a package-provided runtime handle without downcasting back to *Emulator. Depending on what metadata the underlying runtime exposes, that may be an os/arch string such as linux/amd64, a variant-qualified string such as linux/arm64/v8, or an OS-only value such as linux.
As recommended by the Cloud Spanner Emulator FAQ:
What is the recommended test setup? Use a single emulator process and create a Cloud Spanner instance within it. Since creating databases is cheap in the emulator, we recommend that each test bring up and tear down its own database. This ensures hermetic testing and allows the test suite to run tests in parallel if needed.
| Pattern | Emulator lifetime | Best for |
|---|---|---|
Lazy (NewLazyRuntime(BackendEmulator, ...) + SetupClients) |
First SetupClients call → TestMain cleanup |
Packages mixing emulator and non-emulator tests; skips startup when unused |
Eager (RunEmulator + SetupClients) |
TestMain start → TestMain cleanup |
All tests need the emulator; fail fast on startup errors |
Subtests (SetupEmulator + SetupClients) |
Parent test → t.Cleanup |
Related tests grouped under one function; supports t.Parallel() in subtests |
The emulator starts only when the first test calls SetupClients with the LazyRuntime. If go test -run TestUnit matches only tests that never use it, the container is never started.
var lazyRuntime = spanemuboost.NewLazyRuntime(
spanemuboost.BackendEmulator,
spanemuboost.EnableInstanceAutoConfigOnly(),
)
func TestMain(m *testing.M) { lazyRuntime.TestMain(m) }
func TestCreate(t *testing.T) {
clients := spanemuboost.SetupClients(t, lazyRuntime,
spanemuboost.WithRandomDatabaseID(),
spanemuboost.WithSetupDDLs(ddls),
)
// use clients.Client...
}
func TestUnit(t *testing.T) {
// Does NOT use lazyRuntime — emulator never starts
}NewLazyEmulator remains available as a backward-compatible wrapper around the
emulator backend, but NewLazyRuntime(BackendEmulator, ...) can cover the same
shared-runtime patterns while also extending naturally to Omni.
When you need emulator-specific helpers such as Container(), call
runtime := lazyRuntime.Setup(t) (or Get(ctx)) and type assert the result back
to *spanemuboost.Emulator for the BackendEmulator case.
testing.M does NOT implement testing.TB, so use RunEmulator directly in TestMain.
var emulator *spanemuboost.Emulator
func TestMain(m *testing.M) {
var err error
emulator, err = spanemuboost.RunEmulator(context.Background(),
spanemuboost.EnableInstanceAutoConfigOnly(),
)
if err != nil { log.Fatal(err) }
emulator.TestMain(m)
}
func TestCreate(t *testing.T) {
clients := spanemuboost.SetupClients(t, emulator,
spanemuboost.WithRandomDatabaseID(),
spanemuboost.WithSetupDDLs(ddls),
)
// use clients.Client...
}When tests are naturally related and don't need TestMain, you can share an emulator within subtests of a single parent test. Since each subtest creates its own database via WithRandomDatabaseID(), subtests can safely run in parallel with t.Parallel().
func TestSuite(t *testing.T) {
lazy := spanemuboost.NewLazyRuntime(
spanemuboost.BackendEmulator,
spanemuboost.EnableInstanceAutoConfigOnly(),
)
t.Cleanup(func() { _ = lazy.Close() })
runtime := lazy.Setup(t)
t.Run("test1", func(t *testing.T) {
t.Parallel()
clients := spanemuboost.SetupClients(t, runtime,
spanemuboost.WithRandomDatabaseID(),
spanemuboost.WithSetupDDLs(ddls),
)
// use clients.Client...
})
}For serial tests with code that reads SPANNER_EMULATOR_HOST directly:
func TestWithEnvVar(t *testing.T) {
lazy := spanemuboost.NewLazyRuntime(
spanemuboost.BackendEmulator,
spanemuboost.EnableInstanceAutoConfigOnly(),
)
t.Cleanup(func() { _ = lazy.Close() })
runtime := lazy.Setup(t)
t.Setenv("SPANNER_EMULATOR_HOST", runtime.URI())
// Code under test that reads SPANNER_EMULATOR_HOST directly
}| Caveat | Detail |
|---|---|
No t.Parallel() |
t.Setenv panics if the test or an ancestor called t.Parallel() |
| Process-global | The env var doesn't scale to concurrent tests |
Prefer ClientOptions() |
Pass runtime.ClientOptions() or clients directly when possible |