AzureMicrosoft.NETDevOps & InfrastructureQA & Testing

Schluss mit der geteilten Testdatenbank: Wegwerfbare SQL-Server-Container in der CI/CD

Eine geteilte Testdatenbank ist ein Flaky-Test-Generator. So machen wir es stattdessen — ein Linux-SQL-Server-Container, bei jedem Pipeline-Lauf frisch gestartet, auf Azure DevOps und GitHub Actions gleichermaßen.

Foto: Google Gemini · AI-generated

Die meisten Teams, die SQL-Server-Integrationstests in der CI ausführen, haben eines von zwei Problemen. Entweder läuft jeder Branch, jeder PR und jeder parallele Job gegen dieselbe geteilte Testdatenbank — und ein Test schlägt fehl, nicht weil der Code falsch ist, sondern weil in einer Tabelle noch Daten aus dem Lauf eines anderen Jobs standen, wo eigentlich nichts erwartet wurde. Oder das Team hat auf eine echte Datenbank ganz verzichtet und testet gegen ein Mock, einen In-Memory-Provider oder SQLite als Ersatz für SQL Server — ein flakiges, aber echtes Signal wird gegen ein sauberes, aber falsches getauscht.

Beides ist unnötig. Ein Linux-basierter SQL-Server-Container, bei jedem Pipeline-Lauf frisch gestartet und danach verworfen, löst beide Probleme gleichzeitig — und funktioniert identisch auf Azure DevOps und GitHub Actions, den beiden Plattformen, auf denen die meisten unserer Kunden arbeiten.

Warum die üblichen Abkürzungen nicht tragen

Ein Mock der Datenzugriffsschicht zeigt, dass Ihr Code die richtige Methode aufgerufen hat. Es zeigt nichts darüber, ob das SQL, das Ihr ORM erzeugt, tatsächlich korrekt ausgeführt wird — collation-abhängige Vergleiche, Locking- und Deadlock-Verhalten bei gleichzeitigen Schreibzugriffen, berechnete Spalten, Trigger-Nebeneffekte, die spezifische Art, wie SQL Server einen Ausführungsplan bewertet. SQLite ist eine gute Datenbank; sie ist kein Ersatz für das tatsächliche Verhalten von SQL Server, und Unterschiede zwischen beiden zeigen sich eher in der Produktion als in der CI — genau die falsche Reihenfolge.

Die andere gängige Antwort, LocalDB, hat ein härteres Hindernis: Es ist reines Windows-Tool. Jedes Team, das Linux-basierte gehostete Runner nutzt — die Standardoption, günstiger und schneller, sowohl bei Azure DevOps als auch bei GitHub Actions — kann es schlicht nicht verwenden. Das zwingt Teams entweder auf teurere Windows-Runner, nur um LocalDB nutzen zu können, oder in eine der oben genannten Mock-Lösungen. Ein containerisierter SQL Server umgeht diese Entscheidung komplett: Er läuft auf demselben Linux-Runner wie der Rest der Pipeline, kein separater Windows-Job nötig.

Wegwerfbar, parallel-sicher und jedes Mal identisch

Das Muster ist einfach: Der CI-Job startet einen mcr.microsoft.com/mssql/server-Container als Service neben dem Test-Job, spielt Migrationen ein, sobald der Container gesund ist, führt die Integrationstests aus und verwirft den Container, sobald der Job endet. Nichts bleibt zwischen den Läufen bestehen, also gibt es keine angesammelten Testdaten zum Zurücksetzen und keine “der Test von jemand anderem hat die geteilte Datenbank verunreinigt”-Flakiness — jeder Job bekommt seine eigene Instanz, sodass parallele Jobs und gleichzeitige PR-Builds sich überhaupt nicht in die Quere kommen. Und weil der Image-Tag fest gepinnt ist, testen jeder Entwickler und jeder Pipeline-Lauf gegen exakt denselben SQL-Server-Build — die Lücke zwischen “läuft bei mir lokal” und “läuft in der CI” schließt sich.

Wie das auf Azure DevOps und GitHub Actions aussieht

Auf Azure DevOps wird der Container als Service-Container auf Job-Ebene deklariert und per Name referenziert:

resources:
  containers:
    - container: mssql
      image: mcr.microsoft.com/mssql/server:2022-latest
      env:
        ACCEPT_EULA: "Y"
        MSSQL_SA_PASSWORD: $(SqlSaPassword)
      ports:
        - 1433:1433

jobs:
  - job: IntegrationTests
    services:
      mssql: mssql
    steps:
      - script: dotnet test --filter Category=Integration
        env:
          ConnectionStrings__Default: "Server=localhost,1433;Database=master;User Id=sa;Password=$(SqlSaPassword);TrustServerCertificate=True"

Auf GitHub Actions ist das Äquivalent ein services-Block auf dem Job, mit einem Health-Check, der den Test-Schritt erst freigibt, wenn SQL Server tatsächlich Verbindungen annimmt — nicht nur, sobald der Container gestartet ist:

jobs:
  integration-tests:
    runs-on: ubuntu-latest
    services:
      mssql:
        image: mcr.microsoft.com/mssql/server:2022-latest
        env:
          ACCEPT_EULA: "Y"
          MSSQL_SA_PASSWORD: ${{ secrets.SQL_SA_PASSWORD }}
        ports:
          - 1433:1433
        options: >-
          --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P $MSSQL_SA_PASSWORD -Q 'SELECT 1' -C || exit 1"
          --health-interval 10s --health-timeout 5s --health-retries 10
    steps:
      - uses: actions/checkout@v4
      - run: dotnet test --filter Category=Integration
        env:
          ConnectionStrings__Default: "Server=localhost,1433;Database=master;User Id=sa;Password=${{ secrets.SQL_SA_PASSWORD }};TrustServerCertificate=True"

Beides sind wenige Zeilen, die zu einer Pipeline hinzukommen, die meist ohnehin schon existiert. Keines davon braucht einen dauerhaften Testserver, den jemand bereitstellen, patchen oder verteidigen muss, wenn er mal wieder kaputt ist.

Wo sich das wirklich auszahlt

Der Gewinn liegt nicht nur in saubereren Testläufen — sondern darin, was aufhört zu passieren. Kein Montagmorgen-Triage mehr, welche Fehlschläge echt sind und welche nur Rauschen der geteilten Datenbank. Keine Windows-Runner-Minuten mehr, die einzig für LocalDB draufgehen. Keine Lücke mehr zwischen “lief lokal mit SQLite” und dem, was SQL Server in der Produktion tatsächlich tut. Für die Entwicklungsleitung bedeutet das weniger verschwendete Untersuchungsstunden pro Sprint; für Entwickler eine Testsuite, der man wieder vertrauen kann.

Das ist dieselbe Pipeline-Disziplin, die hinter dem Einkaufsportal steht, das wir gebaut haben und weiterhin betreiben — eine Azure-DevOps-CI/CD-Pipeline mit dedizierten Dev-, QA-, Test- und Live-Umgebungen, jede Änderung durchläuft denselben abgesicherten Pfad, bevor sie live geht. Wegwerfbare, containerisierte Testdatenbanken sind eine natürliche Erweiterung dieser Disziplin, kein Bruch damit.

Wenn Ihre Integrationstests aktuell gegen eine geteilte Datenbank, ein Mock oder gar nichts laufen, ist das ein Gespräch über DevOps und QA & Testing wert. Sagen Sie uns, wie Ihre Pipeline heute aussieht — wir sagen Ihnen ehrlich, was zuerst dran ist.