Ultima parte din seria despre pattern-uri avansate în Cosmos DB. Partition keys, Change Feed, multi-region și ETags — toate merită teste de integrare reale. Aici le rulăm fără să atingem un cont Azure: cu Emulator, local și în GitHub Actions.
De ce Emulator și nu mocks
Mock-urile pe IRepository (interfețe, nu clase concrete — NSubstitute nu poate substitui clase cu CosmosClient în constructor) sunt perfecte pentru unit tests pe logica de business. Dar pattern-urile din serie trăiesc în comportamentul Cosmos DB:
- Un mock nu-ți spune că query-ul cross-partition cu
ORDER BYaruncă eroare fără composite index - Un mock nu returnează 412 la ETag stale — deci retry-ul din partea 4 rămâne netestat
- Un mock nu validează că path-ul de Patch are casing-ul corect
- Un mock nu respectă atomicitatea transactional batch-ului pe partition key
Emulator-ul e Cosmos DB real (același wire protocol, aceleași erori), rulat local, gratuit, cu date efemere. Testele de integrare pe el prind exact clasa de bug-uri pe care unit tests nu o pot atinge.
Local: Emulator în Docker
# Emulator-ul Linux, potrivit si pentru dev local si pentru 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
Endpoint-ul e https://localhost:8081, iar cheia e binecunoscuta cheie publică de emulator (aceeași pentru toată lumea, nu e un secret):
public static class EmulatorConfig
{
public const string Endpoint = "https://localhost:8081";
// Cheia standard, publica, a emulatorului -- NU e un secret
public const string Key =
"C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
}
Capcana certificatului
Emulator-ul folosește certificat self-signed — SDK-ul .NET îl respinge. Pentru teste, două opțiuni: imporți certificatul în trust store sau (mai simplu și suficient pentru CI) relaxezi validarea doar în configurația de test, combinat cu Gateway mode:
public static CosmosClient CreateEmulatorClient() => new(
EmulatorConfig.Endpoint,
EmulatorConfig.Key,
new CosmosClientOptions
{
ConnectionMode = ConnectionMode.Gateway, // evita porturile directe
HttpClientFactory = () => new HttpClient(new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
}),
SerializerOptions = new CosmosSerializationOptions
{
PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
}
});
Numele DangerousAcceptAnyServerCertificateValidator e intenționat înfricoșător — corect: acest cod stă exclusiv în proiectul de teste, niciodată în cel de producție.
Fixture NUnit
O fixture care creează baza și containerele o dată per rulare, cu nume unice pentru izolare între rulări paralele:
[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();
// Nume unic -- rulari paralele nu se calca
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(); // curatenie completa
Client.Dispose();
}
}
Și un test care validează exact pattern-ul din partea 4 — imposibil cu 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"));
// Alt "proces" modifica documentul
var fresh = created.Resource;
fresh.MessageCount = 1;
await CosmosTestFixture.Sessions.ReplaceItemAsync(
fresh, fresh.Id, new PartitionKey("tenant-1"));
// Scrierea cu ETag-ul initial (acum stale) trebuie sa esueze cu 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 ca 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"
# Emulator-ul porneste LENT -- asteapta-l explicit
- 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 dupa $i incercari"
exit 0
fi
sleep 5
done
echo "Emulator nu a pornit in 5 minute" && exit 1
- name: Run integration tests
run: dotnet test Tests/IntegrationTests \
--filter Category=CosmosIntegration \
--logger "trx;LogFileName=results.trx"
Detaliile care fac diferența între un pipeline verde și unul flaky:
- Wait-ul explicit e obligatoriu — emulator-ul are nevoie de 1-3 minute la pornire; fără polling, primele teste pică aleator cu connection refused
- Categorie separată —
[Category("CosmosIntegration")]pe testele de integrare; unit tests rulează pe fiecare push, integrarea pe PR-uri (emulator-ul costă minute de CI) - PARTITION_COUNT mic — 10 e suficient pentru teste; valori mari lungesc pornirea semnificativ
- Fără persistență — datele efemere sunt exact ce vrei în CI
Încheierea seriei
Cu asta, seria Cosmos DB patterns e completă: partition key design care face queries ieftine, Change Feed ca sursă de evenimente, multi-region writes cu conflictele gestionate, optimistic concurrency care previne lost updates — și toate verificabile automat, pe fiecare PR, cu Emulator-ul în CI/CD.
Împreună cu seriile anterioare (Managed Identity, Key Vault, Container Apps, Service Bus), tabloul cloud-native cu Azure și .NET e aproape complet. Urmează observabilitatea cu Application Insights.
Dacă ai întrebări sau vrei să discuți cum aplici aceste pattern-uri în proiectul tău, scrie-mi la contact@ludoprogramming.com.