Lokale cloud-emulators in .NET-integratietests: waar ze je voorliegen

Floci en Fakecloud trenden op Hacker News. Zo zet je Azurite en de Service Bus-emulator goed in je .NET-tests, en zo vang je af waar ze afwijken van Azure.

Jean-Pierre Broeders

Freelance .NET Developer

28 september 202614 min. leestijd
Lokale cloud-emulators in .NET-integratietests: waar ze je voorliegen

Afgelopen weekend stonden er twee lokale cloud-emulators tegelijk op de voorpagina van Hacker News. Floci belooft AWS, Azure, GCP en OCI lokaal te draaien "in milliseconds". Fakecloud richt zich op AWS en noemt zichzelf zonder omwegen "a local AWS cloud emulator for integration tests". Samen trokken ze ruim driehonderd punten en meer dan honderd reacties.

Die timing is geen toeval. Sinds 23 maart 2026 start het localstack/localstack-image niet meer zonder auth token. De community-editie is opgegaan in één image met een account erachter. Een Floci-commenter noemde dat "rug pulling the free tier", en dat is precies het gat waar deze nieuwe projecten in springen.

Ik schrijf vooral .NET op Azure, dus AWS-emulators raken mij zijdelings. Maar de discussie eronder gaat over iets wat ik bij elke klant tegenkom: hoeveel mag je een emulator geloven? Mijn antwoord is minder dan de meeste teams doen, en meer dan de sceptici in die threads beweren.

Wat er nu eigenlijk op tafel ligt

Floci heeft een aparte Azure-variant, floci-az, die volgens de site 28 services nabootst, waaronder Blob, Queue, Functions, Key Vault, Event Hubs en Service Bus. Het is MIT-gelicenseerd en claimt een koude start van 24 ms. Fakecloud is AGPL-3.0, dekt 105 AWS-services en claimt "100% conformance across all 3,932 implemented API operations".

Let op het woord implemented. Een commenter in de Fakecloud-thread draaide zijn eigen DynamoDB-conformancesuite en kwam uit op 583 fouten op 1279 tests. Dezelfde persoon zag DynamoDB Local zelf ook zo'n honderd van die tests falen. Welk project beter is, weet je daarmee nog steeds niet. Wel wat "conform" hier betekent: conform aan de tests die de makers zelf schreven.

Aan de Azure-kant heb je daarnaast gewoon de officiële emulators van Microsoft: Azurite voor Storage en sinds een tijd ook een Service Bus-emulator als Linux-container. Die gebruik ik zelf. En juist omdat Microsoft ze bouwt, is de documentatie eerlijk over de gaten. Dat is nuttiger dan welke benchmark-claim ook.

De Service Bus-emulator heeft een lijst met beperkingen. Lees hem

De overzichtspagina van de Service Bus-emulator somt het netjes op. Eén namespace. Maximaal 50 queues of topics. Tien gelijktijdige verbindingen. Berichten tot 256 KB. Een time-to-live van maximaal een uur. Geen partitioned entities, geen AMQP over WebSockets, geen Entra ID. En na een herstart van de container is alles weg.

Twee van die punten bijten in de praktijk harder dan de rest.

Die tien verbindingen, om te beginnen. xUnit draait testcollecties standaard parallel. Heb je twaalf testklassen die elk hun eigen ServiceBusClient openen, dan loop je tegen die grens aan en krijg je fouten die niets met je code te maken hebben. Microsoft schrijft er zelf bij dat de emulator bedoeld is voor "sequential testing use cases". Dat staat er niet voor niets.

Dan Entra ID. In productie authenticeer je met managed identity en DefaultAzureCredential. De emulator werkt alleen met een connection string. Elke test tegen de emulator slaat dus precies het stuk over waar ik in productie het vaakst problemen zie: een ontbrekende roltoewijzing, of een identity die op staging wel rechten heeft en op productie niet.

Dat maakt de emulator niet waardeloos. Het betekent dat je moet weten welke vraag je hem stelt.

Waar een emulator goed in is

Een emulator beantwoordt de vraag: doet mijn code wat ik denk dat hij doet, als de dienst zich gedraagt zoals gedocumenteerd? Serialisatie van je berichten, de volgorde van je handlers, wat er gebeurt als je een bericht abandont en het na drie pogingen in de dead-letter queue belandt. Dat soort logica test je lokaal in seconden, zonder cloudrekening en zonder dat twee developers elkaars queue leeglezen.

Waar hij slecht in is: alles wat met identiteit, netwerk, quota van jouw tier en timing onder echte belasting te maken heeft. Dat zijn precies de dingen waar een HN-commenter over waarschuwde dat je "show-stopping differences" pas ziet als je naar productie gaat.

Dus schrijf je één testsuite die tegen allebei kan draaien.

De opzet: één fixture, twee doelen

Ik gebruik Testcontainers voor .NET met de modules Testcontainers.Azurite en Testcontainers.ServiceBus. Sinds versie 4.10 vraagt Testcontainers om een expliciet image in de constructor van de builder, en dat is terecht: een emulator die ongemerkt van gedrag wisselt omdat latest verschoof, is een flaky test die je zelf gebouwd hebt.

Eerst de configuratie voor de emulator. Entities maak je vooraf aan in JSON, want wijzigingen worden alleen bij opstarten ingelezen:

{
  "UserConfig": {
    "Namespaces": [
      {
        "Name": "sbemulatorns",
        "Queues": [
          {
            "Name": "orders",
            "Properties": {
              "DeadLetteringOnMessageExpiration": false,
              "DefaultMessageTimeToLive": "PT1H",
              "LockDuration": "PT1M",
              "MaxDeliveryCount": 3,
              "RequiresDuplicateDetection": false,
              "RequiresSession": false
            }
          }
        ],
        "Topics": []
      }
    ],
    "Logging": { "Type": "File" }
  }
}

Dan de fixture. Die start de emulator alleen als er geen echte namespace is opgegeven. Staat IT_SERVICEBUS_NAMESPACE wel gezet, dan praat dezelfde fixture met Azure via DefaultAzureCredential:

using Azure.Identity;
using Azure.Messaging.ServiceBus;
using Testcontainers.ServiceBus;
using Xunit;

public sealed class ServiceBusTargetFixture : IAsyncLifetime
{
    private readonly string? _realNamespace =
        Environment.GetEnvironmentVariable("IT_SERVICEBUS_NAMESPACE");

    private readonly ServiceBusContainer? _emulator;

    public ServiceBusTargetFixture()
    {
        if (_realNamespace is null)
        {
            // Pin op een concrete tag uit MCR zodra je dit in een echt project zet.
            _emulator = new ServiceBusBuilder(
                    "mcr.microsoft.com/azure-messaging/servicebus-emulator:latest")
                .WithAcceptLicenseAgreement(true)
                .WithConfig("servicebus-config.json")
                .Build();
        }
    }

    public bool IsRealAzure => _realNamespace is not null;

    public ServiceBusClient CreateClient() =>
        _realNamespace is not null
            ? new ServiceBusClient(_realNamespace, new DefaultAzureCredential())
            : new ServiceBusClient(_emulator!.GetConnectionString());

    public Task InitializeAsync() =>
        _emulator?.StartAsync() ?? Task.CompletedTask;

    public Task DisposeAsync() =>
        _emulator?.DisposeAsync().AsTask() ?? Task.CompletedTask;
}

[CollectionDefinition("servicebus")]
public sealed class ServiceBusCollection : ICollectionFixture<ServiceBusTargetFixture>;

Dit is xUnit v2-syntax. In v3 geeft IAsyncLifetime een ValueTask terug, de rest blijft gelijk.

Die CollectionDefinition doet echt iets. Alle Service Bus-tests delen daardoor één container en draaien binnen die collectie na elkaar. Dat houdt je ruim onder die grens van tien verbindingen, en je betaalt de opstarttijd van de emulator één keer in plaats van per testklasse.

Tests die de verschillen expliciet maken

Het meeste van je suite schrijf je gewoon tegen CreateClient() en het maakt niet uit waar hij heen wijst. Maar een paar tests wil je juist schrijven op de plekken waar emulator en cloud kunnen uiteenlopen. Een voorbeeld:

[Collection("servicebus")]
public sealed class OrderQueueContractTests(ServiceBusTargetFixture target)
{
    [Fact]
    public async Task Message_above_256kb_is_rejected()
    {
        await using var client = target.CreateClient();
        await using var sender = client.CreateSender("orders");

        var oversized = new ServiceBusMessage(new byte[300 * 1024]);

        var ex = await Assert.ThrowsAsync<ServiceBusException>(
            () => sender.SendMessageAsync(oversized));

        Assert.Equal(ServiceBusFailureReason.MessageSizeExceeded, ex.Reason);
    }

    [Fact]
    public async Task Failed_message_lands_in_dead_letter_after_max_deliveries()
    {
        await using var client = target.CreateClient();
        await using var sender = client.CreateSender("orders");
        await using var receiver = client.CreateReceiver("orders");

        var id = Guid.NewGuid().ToString();
        await sender.SendMessageAsync(new ServiceBusMessage("boom") { MessageId = id });

        for (var attempt = 0; attempt < 3; attempt++)
        {
            var msg = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(10));
            Assert.NotNull(msg);
            await receiver.AbandonMessageAsync(msg);
        }

        await using var dlq = client.CreateReceiver(
            "orders",
            new ServiceBusReceiverOptions { SubQueue = SubQueue.DeadLetter });

        var dead = await dlq.ReceiveMessageAsync(TimeSpan.FromSeconds(10));
        Assert.Equal(id, dead?.MessageId);
        await dlq.CompleteMessageAsync(dead!);
    }
}

De eerste test lijkt triviaal, maar hij legt een aanname vast. De grens van 256 KB geldt voor de emulator en voor de Standard-tier. Draait je echte namespace op Premium, dan faalt deze test 's nachts, en dat wil je ook. Dan weet je dat je code ergens leunt op een limiet die in productie anders is. Liever een rode nightly dan een consumer die in productie ineens berichten van 5 MB binnenkrijgt die niemand verwachtte.

De tweede test is het soort gedrag waar de emulator juist goed in is. Hier wil ik hem gewoon in elke pull request zien draaien.

Geen if (isEmulator) in je productiecode

De verleiding bij emulators is dat je code gaat vertakken: als we lokaal draaien, doe dit, anders dat. Dat is de snelste weg naar code die alleen in productie getest wordt. Laat je applicatie alleen naar de vorm van de configuratie kijken:

using Azure.Identity;
using Microsoft.Extensions.Azure;

builder.Services.AddAzureClients(clients =>
{
    var sb = builder.Configuration.GetSection("ServiceBus");

    if (!string.IsNullOrEmpty(sb["ConnectionString"]))
    {
        // Lokaal en in tests: emulator-connection string uit user secrets of env.
        clients.AddServiceBusClient(sb["ConnectionString"]);
    }
    else
    {
        // Azure: namespace + managed identity.
        clients.AddServiceBusClientWithNamespace(sb["FullyQualifiedNamespace"]);
    }

    clients.UseCredential(new DefaultAzureCredential());
});

Er staat nergens "emulator" in. Er staat alleen: heb ik een connection string of een namespace. Wie in productie per ongeluk een connection string meegeeft, ziet dat terug in de configuratie en niet in een verborgen codepad.

De pipeline: emulator bij elke PR, Azure elke nacht

In GitHub Actions splits ik het in twee jobs. Pull requests draaien tegen de emulator, want die draait gewoon in Docker op de runner. 's Nachts draait dezelfde suite tegen een echte, goedkope Standard-namespace die alleen voor integratietests bestaat. Inloggen gaat via OIDC, zonder opgeslagen client secret:

name: integration-tests

on:
  pull_request:
  schedule:
    - cron: "0 3 * * *"

permissions:
  id-token: write
  contents: read

jobs:
  emulator:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "10.0.x"
      - run: dotnet test tests/Integration

  real-azure:
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    environment: integration-azure
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "10.0.x"
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - run: dotnet test tests/Integration
        env:
          IT_SERVICEBUS_NAMESPACE: sb-myapp-it.servicebus.windows.net

DefaultAzureCredential pikt na azure/login de Azure CLI-sessie op. De service principal krijgt alleen de rol "Azure Service Bus Data Owner" op die ene testnamespace. De nightly test daarmee meteen ook het RBAC-pad dat de emulator helemaal overslaat.

Een Standard-namespace kost een paar tientjes per maand plus een fractie per miljoen operaties. Voor de meeste klanten die ik zie is dat minder dan één uur debuggen aan een productie-incident dat de emulator nooit had kunnen vangen.

Wat ik uit die HN-threads meeneem

Een Floci-commenter schreef dat lokale stubs "a lifesaver" zijn "when you're a one-person shop". Daar kan ik als freelancer alleen maar mee instemmen. Ik wil niet voor elke feature-branch cloudresources aanmaken, en ik wil al helemaal niet dat een klant een rekening krijgt omdat mijn tests een queue vol liepen.

Maar de scherpste opmerking kwam uit de Fakecloud-discussie: als je het echte ding vervangt door een nep-ding, leer je niets over het echte ding. Dat is overdreven, want je leert wel degelijk iets over je eigen code. Het klopt wel voor alles wat de emulator zelf verzint.

En dan het LocalStack-verhaal. Een tool waar honderden CI-pipelines stilletjes van afhingen, vraagt nu een account. Wie de emulator diep in zijn testcode heeft verweven, met eigen API's en eigen asserties, zit vast. Wie hem achter een fixture heeft gezet die ook tegen de echte dienst kan praten, wisselt van emulator met één regel. Of zet hem uit en draait alles tegen Azure. Die ontkoppeling is het echte ontwerp. De keuze tussen Floci, Azurite of iets anders is daarna een detail.

Veelgestelde vragen over cloud-emulators in .NET-tests

Kan ik Floci gebruiken in plaats van Azurite en de Service Bus-emulator van Microsoft? Technisch kan dat zodra floci-az de SDK-calls ondersteunt die jij gebruikt, want de Azure SDK praat met een connection string en maakt niet uit wat erachter zit. Voor Storage en Service Bus zou ik voorlopig bij de officiële emulators blijven, omdat Microsoft daar de afwijkingen documenteert, en Floci pas inzetten voor services waar Microsoft geen emulator voor levert.

Waarom falen mijn Service Bus-tests willekeurig als ze parallel draaien? De emulator staat standaard maar tien gelijktijdige verbindingen toe en is bedoeld voor sequentieel testen. Zet alle Service Bus-tests in één xUnit-collectie met een gedeelde fixture, hergebruik clients waar dat kan en ruim senders en receivers op met await using.

Hoe test ik managed identity en RBAC als de emulator alleen connection strings kent? Dat kan niet tegen de emulator, daarom hoort er een run tegen een echte namespace bij. Een nightly job met OIDC-login en een service principal die alleen de benodigde data-rol op een aparte testnamespace heeft, test precies het autorisatiepad dat lokaal ontbreekt.

Moet ik het emulator-image pinnen of mag latest? Pin het. Een emulator die tussen twee runs van versie wisselt, geeft je testfouten die niets met jouw wijziging te maken hebben, en Testcontainers vraagt sinds versie 4.10 niet voor niets om een expliciet image. Werk de tag bewust bij via Dependabot of Renovate, zodat een gedragswijziging een eigen PR krijgt.

Verder lezen


Conclusie: Gebruik een lokale emulator voor wat hij goed kan, snelle feedback op je eigen berichtenlogica, en laat dezelfde tests 's nachts tegen echte Azure draaien. Zet de emulator achter één fixture die ook de echte dienst aankan, want dan pas je bij de volgende licentiewissel of het volgende gat in de conformance alleen je configuratie aan.

Bronnen: Floci · Fakecloud · Service Bus-emulator overzicht (Microsoft Learn) · Hacker News-discussie Floci · Hacker News-discussie Fakecloud.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op