RO EN

Cosmos DB patterns (5) — Emulator în CI/CD

Cosmos DB patterns (5) — Emulator în CI/CD ✨ Imagine generată cu AI
Doru Bulubașa
31 iulie 2026
45 vizualizări

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 BY aruncă 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.