Skip to content
bitzorcas
中EN

Tutorial

Writing Integration Tests and Behavior Parity: Testcontainers Hands-on

Leverage Testcontainers to spin up real SQL Server 2022 and Redis containers, write end-to-end integration tests for litigation matter intake slices, and assert 100% behavioral parity between EF Core and SqlSugar dual ORM adapters.

Last updated

In enterprise software engineering, a notorious paradox frequently arises: unit test coverage exceeds 90%, yet production releases regularly encounter catastrophic runtime failures.

The root cause lies in the excessive reliance on “mock objects.” Unit tests relying on in-memory mocks bypass real database engines entirely, leaving critical physical facts untested:

  • SQL dialect incompatibilities: Complex queries execute seamlessly in in-memory SQLite yet trigger full table scans or syntax errors in production SQL Server instances due to implicit type conversions;
  • Leaky multi-tenant filters: Mocked repositories fail to assert whether global query filters (WHERE TenantId = @TenantId) are actually evaluated by the query compiler;
  • Transaction and Outbox consistency: Mock tests cannot verify whether domain entity updates and CAP Transactional Outbox records commit atomically in the same local database transaction.

BitzOrcas.Modern mandates real containerized integration tests via Testcontainers as a deployment gate. By spinning up immutable, SHA-256 digest-pinned SQL Server 2022 and Redis containers during automated test execution, the suite verifies the full 10-tier pipeline, true atomic transactions, and 100% behavioral equivalence across dual ORM adapters.

Three-Dimensional Testing Pyramid

1. Pure Domain Unit Tests
Instant in-memory validation of aggregate root invariants (Zero external IO)

2. Testcontainers Real Container Integration Tests
Spin up real SQL Server containers, traversing HTTP -> 10-Tier Pipeline -> Physical DB

3. Dual-ORM Behavioral Parity Tests
Assert that SqlSugar and EF Core exhibit identical CRUD, soft-delete, and multi-tenant behavior

4. ArchUnitNET Architecture Fitness Functions
Enforce compile-time and CI gates preventing boundary degradation


Step 1: Constructing Platform-Parity Test Fixtures

In teams collaborating across macOS (Apple Silicon) and Linux workstations, unconstrained container instantiation frequently triggers CPU architecture drift.

Under tests/BitzOrcas.Integration.Tests/Infrastructure/, implement a test fixture pinned by cryptographic SHA-256 digest and platform architecture:

tests/BitzOrcas.Integration.Tests/Infrastructure/SqlServerTestContainer.cs
using DotNet.Testcontainers.Images;
using Testcontainers.MsSql;
using Xunit;
using ContainerPlatform = DotNet.Testcontainers.Images.Platform;
namespace BitzOrcas.Integration.Tests.Infrastructure;
/// <summary>
/// Integration test SQL Server container factory.
/// </summary>
internal static class SqlServerTestContainer
{
/// <summary>
/// Official immutable image digest for SQL Server 2022 CU.
/// Declaring linux/amd64 explicitly prevents Apple Silicon workstations from misinterpreting architecture as image drift.
/// </summary>
private const string ImmutableImageDigest =
"mcr.microsoft.com/mssql/server@sha256:e07b9699a2b749969f19d86563ceeea22bd3a69f7f1db85a8d1ac4bdaf0c6f56";
internal static MsSqlContainer Create()
=> new MsSqlBuilder(new DockerImage(ImmutableImageDigest, new ContainerPlatform("linux/amd64"))).Build();
}
/// <summary>
/// Shares a single SQL Server container lifecycle across a test class.
/// </summary>
public sealed class SqlServerContainerFixture : IAsyncLifetime
{
public MsSqlContainer Container { get; } = SqlServerTestContainer.Create();
public async Task InitializeAsync()
{
// Start container and execute internal readiness checks
await Container.StartAsync();
}
public Task DisposeAsync() => Container.DisposeAsync().AsTask();
}

Step 2: Implement End-to-End Slice Integration Tests

Leverage ASP.NET Core’s WebApplicationFactory<Program> to inject container connection strings into the API host and issue real HTTP requests:

tests/BitzOrcas.Integration.Tests/Legal/MatterIntakeIntegrationTests.cs
using System.Net;
using System.Net.Http.Json;
using BitzOrcas.Domain.Results;
using BitzOrcas.Integration.Tests.Infrastructure;
using BitzOrcas.Modules.Legal.Application.Commands.CreateMatterIntake;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Shouldly;
using Xunit;
public sealed class MatterIntakeIntegrationTests : IClassFixture<SqlServerContainerFixture>
{
private readonly SqlServerContainerFixture _fixture;
public MatterIntakeIntegrationTests(SqlServerContainerFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task CreateMatter_TraversingFullPipeline_ShouldPersistToDatabaseAndCapOutbox()
{
// 1. Override host configuration with active container connection string
var appFactory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
{
builder.UseSetting("ConnectionStrings:Default", _fixture.Container.GetConnectionString());
});
var client = appFactory.CreateClient();
// 2. Construct realistic litigation intake command payload
var command = new CreateMatterIntakeCommand(
MatterTitle: "Cross-Border Intellectual Property Injunction",
ClientName: "Leading Global Retail Corp.",
OpposingParty: "Offshore Infringing Distribution Network",
ClaimAmount: 8_800_000.00m,
Category: MatterCategory.CrossBorder);
// 3. Dispatch real HTTP POST request
var response = await client.PostAsJsonAsync("/api/legal/matters", command);
// 4. Machine assertions: assert HTTP 200 OK and valid matter ID
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<Result<string>>();
result.ShouldNotBeNull();
result.IsSuccess.ShouldBeTrue();
var matterId = result.Value;
matterId.ShouldNotBeNullOrWhiteSpace();
// 5. Query underlying physical database to assert persistence and tenancy
using var connection = new Microsoft.Data.SqlClient.SqlConnection(_fixture.Container.GetConnectionString());
await connection.OpenAsync();
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(1) FROM LegalMatterIntake WHERE Id = @Id AND IsDeleted = 0";
cmd.Parameters.AddWithValue("@Id", matterId);
var count = (int)(await cmd.ExecuteScalarAsync() ?? 0);
count.ShouldBe(1);
}
}

Step 3: Dual-ORM Behavioral Parity Testing

BitzOrcas.Modern supports SqlSugar and EF Core as co-equal production persistence adapters. To guarantee that switching adapters introduces zero behavioral regressions, we write automated parity tests:

tests/BitzOrcas.Integration.Tests/Persistence/OrmParityTests.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using BitzOrcas.Domain.Abstractions;
using BitzOrcas.Infrastructure.EfCore;
using BitzOrcas.Infrastructure.SqlSugar;
using BitzOrcas.Integration.Tests.Infrastructure;
using BitzOrcas.Modules.Legal.Domain;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
public sealed class OrmParityTests : IClassFixture<SqlServerContainerFixture>
{
private readonly SqlServerContainerFixture _fixture;
public OrmParityTests(SqlServerContainerFixture fixture)
{
_fixture = fixture;
}
[Theory]
[InlineData("SqlSugar")]
[InlineData("EFCore")]
public async Task BothOrmAdapters_MustEnforceSoftDeleteAndTenantFilterIdentically(string ormProvider)
{
// 1. Resolve command repository port for active provider
var repository = ResolveRepositoryForProvider<MatterIntake>(ormProvider, _fixture.Container.GetConnectionString());
var matterId = Guid.NewGuid().ToString("N");
var matter = MatterIntake.Create(
matterId,
$"CODE-{matterId[..6]}",
"Dual-ORM Parity Verification Case",
"Client Alpha",
"Opposing Beta",
100_000.00m,
tenantId: "1000001").GetValueOrThrow();
// 2. Persist aggregate
var saveResult = await repository.SaveAsync(matter, CancellationToken.None);
saveResult.IsSuccess.ShouldBeTrue();
// 3. Perform soft deletion
matter.Delete(deletedBy: "auditor_user");
await repository.SaveAsync(matter, CancellationToken.None);
// 4. Assert: standard tenant queries across both ORMs return NotFound
var queried = await repository.FindAsync(matterId, CancellationToken.None);
queried.IsFailure.ShouldBeTrue();
queried.Error.Code.ShouldBe("Repository.Aggregate.NotFound");
}
private static ICommandRepository<TEntity, string> ResolveRepositoryForProvider<TEntity>(string provider, string connectionString)
where TEntity : class
{
var services = new ServiceCollection();
services.AddLogging();
if (string.Equals(provider, "SqlSugar", StringComparison.OrdinalIgnoreCase))
{
services.AddBitzOrcasSqlSugar(options =>
{
options.ConnectionString = connectionString;
});
}
else
{
services.AddBitzOrcasEfCore(options =>
{
options.ConnectionString = connectionString;
});
}
var serviceProvider = services.BuildServiceProvider();
return serviceProvider.GetRequiredService<ICommandRepository<TEntity, string>>();
}
}

Core Engineering Quality Benefits

  1. Zero Environmental Drift: SHA-256 digest-pinned images guarantee that local workstations and CI test runners execute identical container runtimes.
  2. Confidence in Multi-ORM Deployments: Parity tests ensure business teams can seamlessly migrate between SqlSugar and EF Core without touching business logic.
  3. True Transactional Assertions: Testcontainers verifies local database transactions, unique index constraints, and atomic CAP Outbox commits against real relational storage.

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%