Azure Cosmos DB Testing: Emulator, SDK, and Integration Patterns

Azure Cosmos DB Testing: Emulator, SDK, and Integration Patterns

Azure Cosmos DB is a globally distributed, multi-model database with unique behavior around partition keys, request units (RU/s), consistency levels, and the change feed. Testing these behaviors requires both local emulation and integration tests against the real service. This guide covers both.

The Cosmos DB Emulator

Microsoft provides a Docker-based emulator that supports the SQL (Core) API:

# Pull emulator
docker pull mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest

# Start emulator
docker run -d \
  --name cosmos-emulator \
  -p 8081:8081 \
  -p 10251-10255:10251-10255 \
  -e AZURE_COSMOS_EMULATOR_PARTITION_COUNT=10 \
  -e AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE=true \
  mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest

# Emulator endpoint and key (fixed for emulator)
COSMOS_ENDPOINT="https://localhost:8081"
COSMOS_KEY="C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="

# Wait for emulator to be ready
timeout 120 bash -c '
  until curl -sk https://localhost:8081/_explorer/emulator.pem > /dev/null; do
    sleep 5
  done
'

Important: The emulator uses a self-signed certificate. In Node.js:

// Disable TLS verification for emulator only
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // Only in test environment!

Or pass the certificate:

# Get emulator certificate
curl -sk https://localhost:8081/_explorer/emulator.pem > cosmos-emulator.pem
NODE_EXTRA_CA_CERTS=cosmos-emulator.pem node tests/cosmos.test.js

Basic CRUD Testing

// cosmos.test.js
const { CosmosClient } = require('@azure/cosmos');

const isEmulator = !process.env.COSMOS_ENDPOINT?.includes('documents.azure.com');

const client = new CosmosClient({
  endpoint: process.env.COSMOS_ENDPOINT || 'https://localhost:8081',
  key: process.env.COSMOS_KEY || 'C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==',
  ...(isEmulator && { agent: new (require('https').Agent)({ rejectUnauthorized: false }) }),
});

const DB_NAME = 'testdb';
const CONTAINER_NAME = 'items';

beforeAll(async () => {
  const { database } = await client.databases.createIfNotExists({ id: DB_NAME });
  await database.containers.createIfNotExists({
    id: CONTAINER_NAME,
    partitionKey: { paths: ['/category'] },
    throughput: 400,
  });
});

afterAll(async () => {
  await client.database(DB_NAME).delete();
});

const container = () => client.database(DB_NAME).container(CONTAINER_NAME);

describe('CRUD operations', () => {
  test('creates an item', async () => {
    const item = {
      id: 'item-001',
      category: 'electronics',
      name: 'Laptop',
      price: 999.99,
    };
    
    const { resource, statusCode } = await container().items.create(item);
    
    expect(statusCode).toBe(201);
    expect(resource.id).toBe('item-001');
    expect(resource.category).toBe('electronics');
    expect(resource._etag).toBeTruthy(); // Cosmos adds system properties
    expect(resource._ts).toBeTruthy();
  });
  
  test('reads an item by id and partition key', async () => {
    const { resource } = await container()
      .item('item-001', 'electronics')
      .read();
    
    expect(resource.name).toBe('Laptop');
    expect(resource.price).toBe(999.99);
  });
  
  test('updates an item with optimistic concurrency', async () => {
    const { resource: existing } = await container()
      .item('item-001', 'electronics')
      .read();
    
    const updated = { ...existing, price: 849.99 };
    
    const { resource } = await container()
      .item('item-001', 'electronics')
      .replace(updated, {
        accessCondition: {
          type: 'IfMatch',
          condition: existing._etag, // Optimistic concurrency
        },
      });
    
    expect(resource.price).toBe(849.99);
    expect(resource._etag).not.toBe(existing._etag); // ETag changes on update
  });
  
  test('concurrent update fails with ETag mismatch', async () => {
    const { resource } = await container().item('item-001', 'electronics').read();
    
    // Simulate concurrent modification
    await container().item('item-001', 'electronics').replace({
      ...resource,
      price: 799.99,
    });
    
    // Now try to update with stale ETag
    await expect(
      container().item('item-001', 'electronics').replace(
        { ...resource, price: 699.99 },
        { accessCondition: { type: 'IfMatch', condition: resource._etag } }
      )
    ).rejects.toMatchObject({ code: 412 }); // Precondition Failed
  });
  
  test('deletes an item', async () => {
    await container().item('item-001', 'electronics').delete();
    
    const { statusCode } = await container()
      .item('item-001', 'electronics')
      .read()
      .catch(e => e);
    
    expect(statusCode).toBe(404);
  });
});

Testing Partition Key Design

Good partition key testing catches hot partition issues before production:

describe('Partition key behavior', () => {
  test('items in different partitions are isolated', async () => {
    // Create items in two partitions
    await container().items.create({ id: 'p1-item', category: 'electronics', price: 100 });
    await container().items.create({ id: 'p2-item', category: 'books', price: 20 });
    
    // Query within partition — should only return that partition's items
    const { resources: electronicsItems } = await container().items.query({
      query: 'SELECT * FROM c WHERE c.category = @category',
      parameters: [{ name: '@category', value: 'electronics' }],
    }).fetchAll();
    
    expect(electronicsItems.every(i => i.category === 'electronics')).toBe(true);
    expect(electronicsItems.find(i => i.category === 'books')).toBeUndefined();
  });
  
  test('cross-partition query returns all items', async () => {
    const { resources } = await container().items.query(
      'SELECT * FROM c',
      { partitionKey: undefined } // Cross-partition
    ).fetchAll();
    
    expect(resources.length).toBeGreaterThanOrEqual(2);
    const categories = new Set(resources.map(r => r.category));
    expect(categories.has('electronics')).toBe(true);
    expect(categories.has('books')).toBe(true);
  });
  
  test('item read without partition key throws', async () => {
    // Reading without partition key requires knowing the partition key
    const result = await container()
      .item('p1-item') // No partition key provided
      .read()
      .catch(e => e);
    
    // Cosmos requires partition key for point reads
    expect(result.code === 400 || result.message).toBeTruthy();
  });
});

Testing Queries

describe('Query operations', () => {
  beforeAll(async () => {
    // Seed test data
    const items = [
      { id: 'q1', category: 'electronics', price: 100, inStock: true },
      { id: 'q2', category: 'electronics', price: 200, inStock: false },
      { id: 'q3', category: 'electronics', price: 300, inStock: true },
    ];
    
    await Promise.all(items.map(item => container().items.upsert(item)));
  });
  
  test('filters with WHERE clause', async () => {
    const { resources } = await container().items.query({
      query: 'SELECT * FROM c WHERE c.category = @cat AND c.inStock = true',
      parameters: [{ name: '@cat', value: 'electronics' }],
    }).fetchAll();
    
    expect(resources.every(r => r.inStock === true)).toBe(true);
    expect(resources).toHaveLength(2);
  });
  
  test('aggregate queries work', async () => {
    const { resources } = await container().items.query({
      query: 'SELECT VALUE AVG(c.price) FROM c WHERE c.category = @cat',
      parameters: [{ name: '@cat', value: 'electronics' }],
    }).fetchAll();
    
    expect(resources[0]).toBe(200); // (100 + 200 + 300) / 3
  });
  
  test('pagination works correctly', async () => {
    const queryIterator = container().items.query(
      'SELECT * FROM c WHERE c.category = "electronics" ORDER BY c.price',
      { maxItemCount: 2 }
    );
    
    const firstPage = await queryIterator.fetchNext();
    expect(firstPage.resources).toHaveLength(2);
    expect(firstPage.hasMoreResults).toBe(true);
    
    const secondPage = await queryIterator.fetchNext();
    expect(secondPage.resources).toHaveLength(1);
    expect(secondPage.hasMoreResults).toBe(false);
    
    // First page should have lowest prices
    expect(firstPage.resources[0].price).toBe(100);
    expect(firstPage.resources[1].price).toBe(200);
  });
});

Testing the Change Feed

describe('Change feed', () => {
  test('change feed captures new items', async () => {
    const changes = [];
    
    // Start change feed iterator
    const iterator = container().items.getChangeFeedIterator({
      changeFeedStartFrom: 'Now',
    });
    
    // Insert items
    await container().items.create({ id: 'cf-1', category: 'test-cf', value: 1 });
    await container().items.create({ id: 'cf-2', category: 'test-cf', value: 2 });
    
    // Poll for changes
    let attempts = 0;
    while (changes.length < 2 && attempts < 10) {
      const page = await iterator.readNext();
      changes.push(...(page.result || []));
      attempts++;
      if (changes.length < 2) {
        await new Promise(resolve => setTimeout(resolve, 500));
      }
    }
    
    const testChanges = changes.filter(c => c.category === 'test-cf');
    expect(testChanges.length).toBeGreaterThanOrEqual(2);
    
    const ids = testChanges.map(c => c.id);
    expect(ids).toContain('cf-1');
    expect(ids).toContain('cf-2');
  }, 30000);
  
  test('change feed captures updates', async () => {
    await container().items.upsert({ id: 'cf-3', category: 'test-cf', value: 3 });
    
    const iterator = container().items.getChangeFeedIterator({
      changeFeedStartFrom: 'Now',
    });
    
    // Update the item
    await container().items.upsert({ id: 'cf-3', category: 'test-cf', value: 999 });
    
    const page = await iterator.readNext();
    const updated = page.result?.find(c => c.id === 'cf-3');
    
    expect(updated?.value).toBe(999);
  }, 15000);
});

Testing Stored Procedures

describe('Stored procedures', () => {
  beforeAll(async () => {
    // Register stored procedure
    await container().scripts.storedProcedures.create({
      id: 'bulkInsert',
      body: function bulkInsert(items) {
        var context = getContext();
        var collection = context.getCollection();
        var count = 0;
        
        function insertNext() {
          if (count >= items.length) {
            context.getResponse().setBody(count);
            return;
          }
          
          var accepted = collection.createDocument(
            collection.getSelfLink(),
            items[count],
            function(err) {
              if (err) throw err;
              count++;
              insertNext();
            }
          );
          
          if (!accepted) throw new Error('Collection rate limit exceeded');
        }
        
        insertNext();
      }.toString(),
    }).catch(() => {}); // Ignore if already exists
  });
  
  test('stored procedure executes in partition', async () => {
    const items = [
      { id: 'sp-1', category: 'sp-test', value: 1 },
      { id: 'sp-2', category: 'sp-test', value: 2 },
    ];
    
    const { resource: count } = await container().scripts
      .storedProcedure('bulkInsert')
      .execute('sp-test', [items]); // Partition key as first arg
    
    expect(count).toBe(2);
    
    const { resources } = await container().items.query(
      'SELECT * FROM c WHERE c.category = "sp-test"'
    ).fetchAll();
    
    expect(resources).toHaveLength(2);
  });
});

CI Configuration

name: Cosmos DB Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Start Cosmos Emulator
        run: |
          docker run -d \
            --name cosmos \
            -p 8081:8081 \
            -p 10251-10255:10251-10255 \
            -e AZURE_COSMOS_EMULATOR_PARTITION_COUNT=10 \
            mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest
      
      - name: Wait for Cosmos Emulator
        run: |
          timeout 120 bash -c '
            until curl -sk https://localhost:8081 > /dev/null 2>&1; do
              echo "Waiting for Cosmos emulator..."
              sleep 5
            done
          '
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run Cosmos DB tests
        run: npm test -- --testPathPattern="cosmos"
        env:
          COSMOS_ENDPOINT: https://localhost:8081
          COSMOS_KEY: "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
          NODE_TLS_REJECT_UNAUTHORIZED: "0"

Common Pitfalls

Emulator startup time: The Cosmos emulator takes 30–90 seconds to be fully ready. Don't just check if port 8081 responds — wait for the healthcheck endpoint or the /dbs endpoint to respond correctly.

Partition key in delete: Every point operation (read, replace, delete) requires the partition key value. Forgetting it causes confusing 400 errors.

RU throttling in emulator: The emulator still enforces throughput limits. If your tests insert many items rapidly, you may see 429 responses. Add retry logic or use upsert instead of batch creates where possible.

Cross-partition queries are expensive: In tests, cross-partition queries are fine. In production, they use more RUs. Design test data to exercise both single-partition (point reads) and cross-partition scenarios.

Read more

Start now free