Running the cloud-broker emulators locally
The GCP Pub/Sub and Azure Service Bus backends ship guarded end-to-end suites that run against the vendors’ own local emulators. Neither needs a cloud account, a credential, or a billing profile.
Both suites skip when their environment variable is absent, so an ordinary pnpm run test leaves
them unexercised. Set the endpoint variables before selecting the messaging member. They are not
wired into CI — see “Why not CI” below. The Node runner does not use Deno permission grants; use
disposable local emulators and pass only the environment required.
GCP Pub/Sub
Google ships the emulator inside the gcloud CLI image. The @google-cloud/pubsub SDK honours
PUBSUB_EMULATOR_HOST natively and skips authentication entirely when it is set.
docker run -d --name he-pubsub -p 8085:8085 \
gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators \
gcloud beta emulators pubsub start --project=he-test --host-port=0.0.0.0:8085
PUBSUB_EMULATOR_HOST=127.0.0.1:8085 PUBSUB_PROJECT_ID=he-test \
pnpm run test packages/messaging-plugin
docker rm -f he-pubsub
Use 127.0.0.1:8085 for the local emulator. The Node suite runs through Vitest, so the former Deno
DNS permission restriction does not apply.
Repeated runs share one emulator instance. The suite suffixes its own topics per run, and since
M101b a subscription with no queue is named per topic (messaging-consumers.<topic>), so a second
consecutive run needs no restart. The restart this paragraph used to advise was masking a defect,
not emulator statefulness: Pub/Sub subscription names are project-global, and the old single default
messaging-consumers attached the second run’s rr.req.<topic> responder to the first run’s
(deleted) topic, so its request timed out. The suite now passes twice against one instance, and
reverting the per-topic default reproduces that timeout on the second run.
What it proves that a fake cannot: delivery through the real gRPC streaming pull into the
on('message') bridge; that a handler throw reaches the platform as a nack and produces a genuine
redelivery; and that the RPC reply inbox’s topic.createSubscription / subscription.delete() pair
behaves as the design assumes — asserted by listing subscriptions on the reply topic while the
broker is up and again after stop().
Azure Service Bus
Microsoft’s emulator is config-driven and needs a SQL Edge sidecar. Entities come from a mounted
Config.json; the emulator creates nothing at runtime.
The emulator’s own AMQP port is 5672, which is also RabbitMQ’s — and CI’s RabbitMQ service, or a
local container serving RABBITMQ_URL for the M70c outage suites, already holds it. Publish the
emulator on 5673 and name that port in the endpoint; UseDevelopmentEmulator=true accepts one.
docker network create he-sbnet
docker run -d --name he-sqledge --network he-sbnet \
-e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD='<strong-password>' \
mcr.microsoft.com/azure-sql-edge:latest
# Wait for readiness rather than guessing: the emulator fails to start against a
# SQL Edge that is not yet accepting connections.
until docker logs he-sqledge 2>&1 | grep -q 'Recovery is complete'; do sleep 2; done
docker run -d --name he-sb --network he-sbnet -p 5673:5672 \
-v "$PWD/docs/fixtures/servicebus-emulator-config.json:/ServiceBus_Emulator/ConfigFiles/Config.json" \
-e ACCEPT_EULA=Y -e SQL_SERVER=he-sqledge -e MSSQL_SA_PASSWORD='<strong-password>' \
mcr.microsoft.com/azure-messaging/servicebus-emulator:latest
until docker logs he-sb 2>&1 | grep -q 'Application started'; do sleep 2; done
SERVICEBUS_CONNECTION_STRING='Endpoint=sb://localhost:5673;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;' \
pnpm run test packages/messaging-plugin
docker rm -f he-sb he-sqledge && docker network rm he-sbnet
The outage suite (packages/messaging-plugin/test/integration/service-bus-outage-real.test.ts,
M95b §3.3) drives the broker through a real docker stop/start and asserts the 2×2 the v0.6.0
health claim turned on: up while running, down while stopped, up again after restart — and
that the two states get DIFFERENT answers. Its publishes are load-bearing, not setup: against this
emulator the management probe resolves unknown in both states, so the data-plane evidence window
is the only signal, and each health answer is preceded by the publish that populates it. Unlike the
e2e suite, this one restarts he-sb itself and leaves it running, so it is repeatable —
measured, two consecutive runs against a 21-minute-old container both pass with no restart between
them. That is the difference from the e2e suite below, whose RPC step is what fails on a second run
against a persistent emulator. This suite is deliberately local-only for the two reasons that do
apply: the image is large, and the emulator has no administration endpoint, so the management probe
can never succeed there (see the health-indicator note below) — and test/apps-gate.test.ts asserts
that absence so it reads as a recorded decision rather than a gap.
What it proves that a fake cannot: that createReceiver(topicName, subscriptionName) with
autoCompleteMessages: false really hands settlement to the receiver, that completeMessage and
abandonMessage reach the service (an abandon produces a real redelivery), and that the AMQP
receiver-link teardown works against a live connection.
Two emulator behaviours shaped the suite, and both are properties of the broker rather than defects. A Service Bus subscription accrues every message published to its topic whether or not a receiver is attached, so each case takes its own topic — sharing one lets an earlier case’s message arrive in a later one. And competing receivers on a single subscription share messages between them, so each case stops its app before the next starts.
The emulator supports no management operations.
ServiceBusAdministrationClient.createSubscription fails against it with a RestError, so RPC
cannot be round-tripped there — the reply inbox needs a per-instance subscription. That limitation
is itself useful: it is the one place the inbox’s failure path can be driven against a real broker,
and the suite asserts it surfaces ReplyInboxUnavailableError naming the reply topic and the
Manage right. Service Bus RPC remains unverified against real Azure.
The same limitation reaches the health indicator, and it is worth knowing before you read one.
ServiceBusBroker’s reachability probe is an administration call, and the emulator’s administration
endpoint has no TLS listener — the SDK throws RestError/ECONNRESET after about six seconds. So
against the emulator the probe can never succeed, and the indicator reports
{ "status": "up", "data": { "broker": "service-bus", "reachable": "unknown" } }
unknown rather than down: a probe that never reached the namespace has learned nothing about it,
so it must not drain a replica whose data plane is publishing fine. Before this was corrected (V5-2)
the same configuration answered down and /ready returned 503 while publish returned 200. A
namespace that positively answers unhealthy — a 404 for one that has been deleted, say — is still
reported down, because that is an answer rather than a silence.
Against the emulator specifically, the MANAGEMENT probe cannot tell a running broker from a
stopped one. Measured: with the container stopped it also reports up/unknown, because there is
no administration endpoint to answer either way. This is a property of the emulator, not of a real
namespace, where a Manage-capable credential succeeds and a send/listen-only one is refused with a
401 that counts as reachable.
Since M95b the indicator does discriminate here, and that is what the outage suite asserts. The
broker records the outcome of every real publish — the data plane — in an evidence window
reachability() consults before the management probe, so a stopped emulator resolves false and
/ready answers 503. This paragraph previously ended “do not use the emulator to exercise health
transitions”; that was true of the management probe alone and is now false of the indicator, so the
guidance is: drive health transitions through real publishes, never by polling /health alone.
A poll-only test learns nothing here, because with an empty evidence window every answer still falls
through to the management probe — which is exactly why the outage suite’s publishes are
load-bearing. A publish against a stopped broker also holds the request for the SDK’s full retry
budget — shorten it with retryOptions (X28-6) if a test needs to fail fast.
AWS SQS
packages/queue-plugin/test/e2e/sqs-elasticmq.test.ts runs against ElasticMQ and is wired into
CI. See .github/workflows/ci.yml.
docker run -d --name he-elasticmq -p 9324:9324 softwaremill/elasticmq-native:1.7.1
SQS_ENDPOINT_URL=http://localhost:9324 pnpm run test packages/queue-plugin
docker rm -f he-elasticmq
Why not CI
ElasticMQ is one container with no credentials and no licence, so it earns its place in the
workflow. The other two do not, for different reasons: the Pub/Sub emulator image carries the whole
gcloud SDK, and the Service Bus emulator needs a EULA acceptance plus a SQL Edge sidecar and a
mounted config file — two multi-container additions for backends whose logic is a thin translation
over a pure, fully unit-tested adapter. Running them locally before a release is the intended
workflow.
A scaffolded workspace on an emulator
setu new acme --workspace --transport pubsub (or --transport service-bus) wires every member to
the transport and emits a Compose stack that starts the emulator beside them, so this needs no
docker run of its own:
docker compose -f docker/compose.yaml up -d # emulator + every member
Neither arm needs a credential. Each reads its connection value from the environment
(PUBSUB_PROJECT_ID, SERVICE_BUS_CONNECTION_STRING) and falls back to the vendor’s documented
local-emulator setting, which is what makes an unconfigured workspace work.
Two operational facts a developer cannot guess, both carried in the generated README:
- Pub/Sub does not create topics.
publishposts to an existing topic andsubscribecreates only the subscription, so create each topic first —curl -X PUT $PUBSUB_EMULATOR_HOST/v1/projects/$PUBSUB_PROJECT_ID/topics/<name>. - The Service Bus emulator creates no entities at all. Every topic must be declared in the
generated
docker/servicebus-config.jsonwith amessaging-consumerssubscription — the broker’s own default consumer group — before the container starts.
Both paths were verified end to end: two generated members, one publishing and one receiving, over
each real emulator. A stale subscription is worth knowing about on the Pub/Sub side: one bound to a
deleted topic silently swallows delivery, because subscribe treats ALREADY_EXISTS as success and
then opens a subscription attached to nothing. Reset the emulator between unrelated runs.