Azure Service Bus Testing: Messages, Queues, and Topics in CI/CD
Azure Service Bus is Microsoft's enterprise messaging platform for decoupled, reliable communication between services. Testing it properly means verifying message routing, dead-letter behavior, session ordering, and the retry policies that protect your system when consumers fail. This guide covers how to test Service Bus locally, in CI, and against real Azure namespaces.
Local Testing with the Azure Service Bus Emulator
Azure provides an official Docker-based Service Bus emulator for local and CI testing:
# Pull the emulator
docker pull mcr.microsoft.com/azure-messaging/servicebus-emulator:latest
# Config file — required
cat > config.json << 'EOF'
{
"UserConfig": {
"Namespaces": [
{
"Name": "test-namespace",
"Queues": [
{ "Name": "orders-queue" },
{ "Name": "orders-deadletter", "Properties": { "MaxDeliveryCount": 3 } }
],
"Topics": [
{
"Name": "order-events",
"Subscriptions": [
{ "Name": "email-service" },
{ "Name": "inventory-service" }
]
}
]
}
],
"Logging": { "Type": "File" }
}
}
EOF
# Start emulator
docker run -d \
--name servicebus-emulator \
-p 5671:5671 \
-e ACCEPT_EULA=Y \
-v $(pwd)/config.json:/ServiceBus_Emulator/ConfigFiles/Config.json \
mcr.microsoft.com/azure-messaging/servicebus-emulator:latest
# Connection string for emulator
CONNECTION_STRING="Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true"Testing Queue Message Flow
// queue.test.js
const { ServiceBusClient } = require('@azure/service-bus');
const CONNECTION_STRING = process.env.SERVICE_BUS_CONNECTION_STRING ||
'Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true';
const QUEUE_NAME = 'orders-queue';
let client;
beforeAll(() => {
client = new ServiceBusClient(CONNECTION_STRING);
});
afterAll(async () => {
await client.close();
});
describe('Queue basic operations', () => {
test('sends and receives a message', async () => {
const sender = client.createSender(QUEUE_NAME);
const receiver = client.createReceiver(QUEUE_NAME);
const testMessage = {
body: { orderId: 'order-123', amount: 99.99 },
contentType: 'application/json',
subject: 'NewOrder',
};
await sender.sendMessages(testMessage);
await sender.close();
const messages = await receiver.receiveMessages(1, { maxWaitTimeInMs: 5000 });
expect(messages).toHaveLength(1);
expect(messages[0].body.orderId).toBe('order-123');
expect(messages[0].subject).toBe('NewOrder');
await receiver.completeMessage(messages[0]);
await receiver.close();
});
test('message properties are preserved', async () => {
const sender = client.createSender(QUEUE_NAME);
const receiver = client.createReceiver(QUEUE_NAME);
const testMessage = {
body: { data: 'test' },
applicationProperties: {
region: 'us-east-1',
priority: 'high',
version: 2,
},
messageId: 'msg-unique-id-456',
correlationId: 'correlation-789',
};
await sender.sendMessages(testMessage);
await sender.close();
const [received] = await receiver.receiveMessages(1, { maxWaitTimeInMs: 5000 });
expect(received.applicationProperties.region).toBe('us-east-1');
expect(received.applicationProperties.priority).toBe('high');
expect(received.messageId).toBe('msg-unique-id-456');
expect(received.correlationId).toBe('correlation-789');
await receiver.completeMessage(received);
await receiver.close();
});
test('batch sending works correctly', async () => {
const sender = client.createSender(QUEUE_NAME);
const receiver = client.createReceiver(QUEUE_NAME);
const batch = await sender.createMessageBatch();
for (let i = 0; i < 5; i++) {
batch.tryAddMessage({ body: { index: i } });
}
await sender.sendMessages(batch);
await sender.close();
const messages = await receiver.receiveMessages(10, { maxWaitTimeInMs: 5000 });
expect(messages.length).toBe(5);
for (const msg of messages) {
await receiver.completeMessage(msg);
}
await receiver.close();
});
});Testing Dead Letter Queue Behavior
describe('Dead letter queue', () => {
test('messages exceed max delivery count go to dead letter', async () => {
// Use a queue configured with MaxDeliveryCount=3
const RETRY_QUEUE = 'orders-deadletter';
const DLQ_PATH = `${RETRY_QUEUE}/$deadletterqueue`;
const sender = client.createSender(RETRY_QUEUE);
const receiver = client.createReceiver(RETRY_QUEUE);
const dlqReceiver = client.createReceiver(RETRY_QUEUE, {
subQueueType: 'deadLetter',
});
// Send a message
await sender.sendMessages({ body: { willFail: true } });
await sender.close();
// Abandon the message 3 times (max delivery count)
for (let attempt = 0; attempt < 3; attempt++) {
const [msg] = await receiver.receiveMessages(1, { maxWaitTimeInMs: 5000 });
if (!msg) break;
await receiver.abandonMessage(msg);
}
await receiver.close();
// Message should now be in DLQ
const dlqMessages = await dlqReceiver.receiveMessages(1, { maxWaitTimeInMs: 10000 });
expect(dlqMessages).toHaveLength(1);
expect(dlqMessages[0].deadLetterReason).toBeTruthy();
await dlqReceiver.completeMessage(dlqMessages[0]);
await dlqReceiver.close();
});
test('explicitly dead-lettered message has reason', async () => {
const sender = client.createSender(QUEUE_NAME);
const receiver = client.createReceiver(QUEUE_NAME);
const dlqReceiver = client.createReceiver(QUEUE_NAME, {
subQueueType: 'deadLetter',
});
await sender.sendMessages({ body: { invalid: 'schema' } });
await sender.close();
const [msg] = await receiver.receiveMessages(1, { maxWaitTimeInMs: 5000 });
// Explicitly dead-letter with reason
await receiver.deadLetterMessage(msg, {
deadLetterReason: 'InvalidSchema',
deadLetterErrorDescription: 'Message body does not match expected schema',
});
await receiver.close();
const [dlqMsg] = await dlqReceiver.receiveMessages(1, { maxWaitTimeInMs: 5000 });
expect(dlqMsg.deadLetterReason).toBe('InvalidSchema');
expect(dlqMsg.deadLetterErrorDescription).toContain('schema');
await dlqReceiver.completeMessage(dlqMsg);
await dlqReceiver.close();
});
});Testing Topic Subscriptions
describe('Topic and subscriptions', () => {
const TOPIC_NAME = 'order-events';
test('message reaches all subscriptions', async () => {
const sender = client.createSender(TOPIC_NAME);
const emailReceiver = client.createReceiver(TOPIC_NAME, 'email-service');
const inventoryReceiver = client.createReceiver(TOPIC_NAME, 'inventory-service');
await sender.sendMessages({
body: { orderId: 'order-456', event: 'OrderPlaced' },
subject: 'OrderPlaced',
});
await sender.close();
// Both subscriptions should receive the message
const [emailMsg] = await emailReceiver.receiveMessages(1, { maxWaitTimeInMs: 5000 });
const [inventoryMsg] = await inventoryReceiver.receiveMessages(1, { maxWaitTimeInMs: 5000 });
expect(emailMsg.body.orderId).toBe('order-456');
expect(inventoryMsg.body.orderId).toBe('order-456');
await emailReceiver.completeMessage(emailMsg);
await inventoryReceiver.completeMessage(inventoryMsg);
await emailReceiver.close();
await inventoryReceiver.close();
});
});Testing Scheduled Messages
describe('Scheduled messages', () => {
test('message delivered after scheduled time', async () => {
const sender = client.createSender(QUEUE_NAME);
const receiver = client.createReceiver(QUEUE_NAME);
const scheduledTime = new Date(Date.now() + 3000); // 3 seconds from now
const sequenceNumber = await sender.scheduleMessages(
{ body: { scheduled: true } },
scheduledTime
);
await sender.close();
// Should NOT be available immediately
const earlyMessages = await receiver.receiveMessages(1, { maxWaitTimeInMs: 1000 });
expect(earlyMessages).toHaveLength(0);
// Wait for scheduled time
await new Promise(resolve => setTimeout(resolve, 4000));
// Should be available now
const messages = await receiver.receiveMessages(1, { maxWaitTimeInMs: 5000 });
expect(messages).toHaveLength(1);
expect(messages[0].body.scheduled).toBe(true);
await receiver.completeMessage(messages[0]);
await receiver.close();
}, 15000);
test('scheduled message can be cancelled', async () => {
const sender = client.createSender(QUEUE_NAME);
const receiver = client.createReceiver(QUEUE_NAME);
const scheduledTime = new Date(Date.now() + 10000); // 10 seconds
const sequenceNumber = await sender.scheduleMessages(
{ body: { toBeCancelled: true } },
scheduledTime
);
// Cancel before delivery
await sender.cancelScheduledMessages(sequenceNumber);
await sender.close();
// Wait past the original scheduled time
await new Promise(resolve => setTimeout(resolve, 2000));
const messages = await receiver.receiveMessages(1, { maxWaitTimeInMs: 1000 });
expect(messages).toHaveLength(0);
await receiver.close();
}, 15000);
});CI Pipeline Configuration
# .github/workflows/service-bus-tests.yml
name: Service Bus Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start Service Bus Emulator
run: |
docker run -d \
--name servicebus-emulator \
-p 5671:5671 \
-e ACCEPT_EULA=Y \
-v ${{ github.workspace }}/servicebus-config.json:/ServiceBus_Emulator/ConfigFiles/Config.json \
mcr.microsoft.com/azure-messaging/servicebus-emulator:latest
- name: Wait for emulator
run: |
timeout 60 bash -c '
until curl -s http://localhost:5671 > /dev/null 2>&1 || \
docker logs servicebus-emulator 2>&1 | grep -q "Emulator Service started"; do
sleep 2
done
'
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Run Service Bus tests
run: npm test -- --testPathPattern="service-bus"
env:
SERVICE_BUS_CONNECTION_STRING: "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true"Testing Against Real Azure (Integration Tests)
For tests that need the real Service Bus (testing premium features, network rules, etc.):
// Use connection string from environment
const isCI = process.env.CI === 'true';
const hasRealServiceBus = !!process.env.AZURE_SERVICE_BUS_CONNECTION_STRING;
const describeIntegration = (hasRealServiceBus ? describe : describe.skip);
describeIntegration('Integration tests (real Azure)', () => {
test('message survives Service Bus restart', async () => {
// Only run against real Azure
const client = new ServiceBusClient(process.env.AZURE_SERVICE_BUS_CONNECTION_STRING);
const sender = client.createSender('orders-queue');
await sender.sendMessages({ body: { persist: true } });
await sender.close();
// Verify message is in queue (check Active message count via management API)
const { ServiceBusAdministrationClient } = require('@azure/service-bus');
const adminClient = new ServiceBusAdministrationClient(
process.env.AZURE_SERVICE_BUS_CONNECTION_STRING
);
const properties = await adminClient.getQueueRuntimeProperties('orders-queue');
expect(properties.activeMessageCount).toBeGreaterThan(0);
await client.close();
});
});Common Testing Pitfalls
Message ordering: Service Bus queues guarantee FIFO only with sessions enabled. Without sessions, don't assert ordering:
// Wrong — assumes FIFO without sessions
const messages = await receiver.receiveMessages(5, { maxWaitTimeInMs: 5000 });
expect(messages[0].body.index).toBe(0); // May fail
// Right — verify all messages received, check order separately
const messages = await receiver.receiveMessages(5, { maxWaitTimeInMs: 5000 });
const indices = messages.map(m => m.body.index).sort((a, b) => a - b);
expect(indices).toEqual([0, 1, 2, 3, 4]);Test isolation: Always clean up queue messages between tests. Use unique message IDs or correlation IDs to filter:
const testRunId = `test-${Date.now()}`;
await sender.sendMessages({
body: { data: 'test' },
applicationProperties: { testRunId },
});
const messages = await receiver.receiveMessages(10, { maxWaitTimeInMs: 5000 });
const myMessages = messages.filter(
m => m.applicationProperties?.testRunId === testRunId
);Emulator limitations: The emulator doesn't support all Service Bus Premium features (network isolation, geo-replication, large message support). Use conditional test skipping for features that require real Azure.