Photo: Google Gemini · AI-generated
Most teams running SQL Server integration tests in CI share one of two problems. Either every branch, every PR, and every parallel job runs against the same shared test database — and a test fails not because the code is wrong, but because another job’s leftover data was still sitting in a table it expected to be empty. Or the team gave up on a real database entirely and tests run against a mock, an in-memory provider, or SQLite standing in for SQL Server — trading a flaky-but-real signal for a clean-but-fake one.
Neither is necessary. A Linux-based SQL Server container, started fresh for every pipeline run and thrown away when it finishes, solves both problems at once — and it works identically on Azure DevOps and GitHub Actions, the two platforms most of our clients run on.
Why the usual shortcuts don’t hold up
Mocking the data access layer tells you your code called the right method. It tells you nothing about whether the SQL your ORM generates actually executes correctly — collation-sensitive comparisons, locking and deadlock behaviour under concurrent writes, computed columns, trigger side effects, the specific way SQL Server evaluates a query plan. SQLite is a fine database; it is not a substitute for SQL Server’s actual behaviour, and differences between them have a way of surfacing in production instead of in CI, which is exactly backwards.
The other common answer, LocalDB, has a harder blocker: it’s Windows-only. Any team using Linux-based hosted runners — the default, cheaper, faster option on both Azure DevOps and GitHub Actions — simply cannot use it. That pushes teams either onto costlier Windows runners for the sole purpose of running LocalDB, or into one of the mocking approaches above. A containerized SQL Server sidesteps the choice entirely: it runs on the same Linux runner as the rest of the pipeline, no separate Windows job required.
Disposable, parallel-safe, and identical every time
The pattern is simple: the CI job starts a mcr.microsoft.com/mssql/server container as a service alongside the test job, applies migrations against it once it’s healthy, runs the integration suite, and discards the container when the job ends. Nothing persists between runs, so there’s no accumulated test data to reset and no “someone else’s test polluted the shared database” flakiness — every job gets its own instance, so parallel jobs and concurrent PR builds don’t contend with each other at all. And because the image tag is pinned, every developer and every pipeline run is testing against the exact same SQL Server build, closing the gap between “works on my machine” and “works in CI.”
What this looks like on Azure DevOps and GitHub Actions
On Azure DevOps, the container is declared as a job-level service container and referenced by name:
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"
On GitHub Actions, the equivalent is a services block on the job, with a health check gating the test step until SQL Server is actually accepting connections — not just that the container has started:
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"
Both are a few lines added to a pipeline that likely already exists. Neither requires a persistent test server for anyone to provision, patch, or argue over who broke it this week.
Where this earns its keep
The payoff isn’t just cleaner test runs — it’s what stops happening. No more Monday-morning triage of which failures are real and which are shared-database noise. No more Windows-runner minutes spent solely to get LocalDB. No more “it passed locally” gaps between a developer’s SQLite-backed test run and what SQL Server actually does in production. For engineering leadership, that’s fewer wasted investigation-hours per sprint; for developers, it’s a test suite worth trusting again.
This is the same pipeline discipline behind the Purchasing Portal we built and still run — an Azure DevOps CI/CD pipeline with dedicated dev, QA, test, and live environments, every change moving through the same gated path before it reaches production. Disposable, containerized test databases are a natural extension of that discipline, not a departure from it.
If your integration tests are running against a shared database, a mock, or nothing at all, that’s a DevOps and QA & Testing conversation worth having. Tell us what your pipeline looks like today — we’ll tell you honestly what’s worth fixing first.
