The last part of the series about advanced patterns in Cosmos DB. Partition keys, Change Feed, multi-region, and ETags — all deserve real integration tests. Here we run them without touching an Azure account: with the Emulator, locally and in GitHub Actions.
Why Emulator and not mocks
Mocks on IRepository (interfaces, not concrete classes — NSubstitute cannot substitute classes with CosmosClient in the constructor) are perfect for unit tests on business logic. But the patterns in the series live in the Cosmos DB behavior:
- A mock doesn't tell you that a cross-partition query with
ORDER BYthrows an error without a composite index - A mock doesn't return 412 on stale ETag — so the retry from part 4 remains untested
- A mock doesn't validate that the Patch path has the correct casing
- A mock doesn't respect the atomicity of the transactional batch on the partition key
The Emulator is a real Cosmos DB (the same wire protocol, the same errors), run locally, free, with ephemeral data. Integration tests on it catch exactly the kind of bugs that unit tests cannot reach.
Local: Emulator in Docker
# The Linux Emulator, suitable for both local dev and CI
docker run --detach \
--name cosmos-emulator \
--publish 8081:8081 \
--publish 10250-10255:10250-10255 \
--env AZURE_COSMOS_EMULATOR_PARTITION_COUNT=10 \
--env AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE=false \
mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest
The endpoint is https://localhost:8081, and the key is the well-known public emulator key (the same for everyone, it is not a secret):
public static class EmulatorConfig
{
public const string Endpoint = "https://localhost:8081";
// The standard, public emulator key -- NOT a secret
public const string Key =
"C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
}
The certificate trap
The Emulator uses a self-signed certificate — the .NET SDK rejects it. For tests, two options: import the certificate into the trust store or (simpler and sufficient for CI) relax validation only in the test configuration, combined with Gateway mode:
public static CosmosClient CreateEmulatorClient() => new(
EmulatorConfig.Endpoint,
EmulatorConfig.Key,
new CosmosClientOptions
{
ConnectionMode = ConnectionMode.Gateway, // avoids direct ports
HttpClientFactory = () => new HttpClient(new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
}),
SerializerOptions = new CosmosSerializationOptions
{
PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
}
});
The name DangerousAcceptAnyServerCertificateValidator is intentionally scary — correctly: this code lives exclusively in the test project, never in production.
NUnit Fixture
A fixture that creates the database and containers once per run, with unique names for isolation between parallel runs:
[SetUpFixture]
public class CosmosTestFixture
{
public static CosmosClient Client { get; private set; } = default!;
public static Database Database { get; private set; } = default!;
public static Container Sessions { get; private set; } = default!;
[OneTimeSetUp]
public async Task GlobalSetupAsync()
{
Client = CreateEmulatorClient();
// Unique name -- parallel runs don't collide
var dbName = $"TestDb-{Guid.NewGuid():N}";
Database = await Client.CreateDatabaseIfNotExistsAsync(dbName);
Sessions = await Database.CreateContainerIfNotExistsAsync(
new ContainerProperties("sessions", "/tenantId"));
}
[OneTimeTearDown]
public async Task GlobalTeardownAsync()
{
await Database.DeleteAsync(); // complete cleanup
Client.Dispose();
}
}
And a test that validates exactly the pattern from part 4 — impossible with mocks:
[Test]
public async Task ReplaceWithStaleEtag_Throws412()
{
var session = new ChatSession
{
Id = Guid.NewGuid().ToString(),
TenantId = "tenant-1",
MessageCount = 0
};
var created = await CosmosTestFixture.Sessions
.CreateItemAsync(session, new PartitionKey("tenant-1"));
// Another "process" modifies the document
var fresh = created.Resource;
fresh.MessageCount = 1;
await CosmosTestFixture.Sessions.ReplaceItemAsync(
fresh, fresh.Id, new PartitionKey("tenant-1"));
// Writing with the initial (now stale) ETag must fail with 412
var stale = created.Resource;
stale.MessageCount = 99;
var ex = Assert.ThrowsAsync<CosmosException>(() =>
CosmosTestFixture.Sessions.ReplaceItemAsync(
stale, stale.Id, new PartitionKey("tenant-1"),
new ItemRequestOptions { IfMatchEtag = created.ETag }));
Assert.That(ex!.StatusCode, Is.EqualTo(HttpStatusCode.PreconditionFailed));
}
GitHub Actions: Emulator as a service container
# .github/workflows/integration-tests.yml
name: Integration Tests
on:
pull_request:
branches: [ main ]
jobs:
cosmos-integration:
runs-on: ubuntu-latest
services:
cosmos:
image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest
ports:
- 8081:8081
- 10250-10255:10250-10255
env:
AZURE_COSMOS_EMULATOR_PARTITION_COUNT: 10
AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE: false
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
# The Emulator starts SLOWLY -- wait for it explicitly
- name: Wait for emulator
run: |
for i in $(seq 1 60); do
if curl --insecure --silent https://localhost:8081/_explorer/emulator.pem > /dev/null; then
echo "Emulator ready after $i attempts"
exit 0
fi
sleep 5
done
echo "Emulator did not start in 5 minutes" && exit 1
- name: Run integration tests
run: dotnet test Tests/IntegrationTests \
--filter Category=CosmosIntegration \
--logger "trx;LogFileName=results.trx"
The details that make the difference between a green pipeline and a flaky one:
- Explicit wait is mandatory — the emulator needs 1-3 minutes to start; without polling, the first tests randomly fail with connection refused
- Separate category —
[Category("CosmosIntegration")]on integration tests; unit tests run on every push, integration on PRs (the emulator costs CI minutes) - Small PARTITION_COUNT — 10 is enough for tests; large values significantly slow startup
- No persistence — ephemeral data is exactly what you want in CI
Series conclusion
With this, the Cosmos DB patterns series is complete: partition key design that makes queries cheap, Change Feed as an event source, multi-region writes with conflict management, optimistic concurrency that prevents lost updates — and all automatically verifiable on every PR, with the Emulator in CI/CD.
Together with the previous series (Managed Identity, Key Vault, Container Apps, Service Bus), the cloud-native picture with Azure and .NET is almost complete. Next up is observability with Application Insights.
If you have questions or want to discuss how to apply these patterns in your project, write to me at contact@ludoprogramming.com.