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.jsBasic 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.