Azure Service Bus Testing: Messages, Queues, and Topics in CI/CD

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.

Read more

Start now free