Edit this page on GitHub

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. publish posts to an existing topic and subscribe creates 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.json with a messaging-consumers subscription — 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.