Skip to content

Testing

The Cargonerds backend is tested with real ABP integration tests: each test class boots the full ABP application in-process and runs it against real infrastructure started in Docker via Testcontainers — SQL Server (two databases), Redis, RabbitMQ and Azurite. Only a tiny set of services is mocked; everything else (EF Core, repositories, ABP managers, Hangfire, the unit-of-work pipeline) runs for real. Databases are reset between tests with Respawn.

The harness is built on ABP's AbpAsyncIntegratedTest<TStartupModule> base, xUnit v3 with the Microsoft Testing Platform, Shouldly assertions, and Moq for the mock boundary.

A running Docker daemon is required

The first test pulls and starts SQL Server, Redis, RabbitMQ and Azurite containers. Without a Docker daemon the test run fails at startup. See Prerequisites.

This is not a SQLite / in-memory setup

Despite a lingering Volo.Abp.EntityFrameworkCore.Sqlite dependency in the EF Core test project (a Pro-template leftover), tests run against SQL Server in a container. The provider is forced to SQL Server in the test base — see Gotchas.

Test projects

All test projects live under test/, target net10.0 and use RootNamespace=Cargonerds.

Project Type Scope
Cargonerds.Tests.Shared shared lib (no ABP module) DefaultTestDataContext, mock-registration helpers (ServiceCollectionExtensions), TestSkipReasons
Cargonerds.TestBase ABP module + harness CargonerdsTestBase<T>, CargonerdsTestBaseModule, CargonerdsTestConsts, DockerFixture, SharedTestContainers, the default data seeder, the fake principal accessor
Cargonerds.Domain.Tests integration CargonerdsDomainTestBase<T>, CargonerdsDomainTestModule, domain-layer tests (e.g. CountryLookupTests)
Cargonerds.Application.Tests integration CargonerdsApplicationTestBase<T>, CargonerdsApplicationTestModule, Samples/DefaultTestDataTests, Emailing/MailRedirectionServiceTests
Cargonerds.EntityFrameworkCore.Tests integration EF Core / repository tests, the xUnit collection + fixture plumbing
Cargonerds.ServiceDefaults.Tests plain unit ServiceDiscoveryConfigurationProviderTests — no containers, no ABP module, classic xUnit
Cargonerds.HttpApi.Client.ConsoleTestApp manual console app exercises the HTTP API client by hand (OutputType=Exe; not an automated test)

The Hub and Pricing modules ship their own test projects

modules/hub/test/ and modules/pricing/test/ mirror the same conventions (their own common.props points RunSettingsFilePath back at the repo-root test.runsettings and references the same Cargonerds.Tests.Shared).

Frameworks & versions

Package versions are managed centrally with Central Package ManagementManagePackageVersionsCentrally is set in common.props and every <PackageVersion> lives in Directory.Packages.props. The test projects therefore reference packages without a Version attribute.

Package Version Used by
xunit.v3 3.2.2 all ABP integration test projects
xunit + xunit.runner.visualstudio (classic) Cargonerds.ServiceDefaults.Tests only
Shouldly 4.3.0 assertions
Moq 4.20.72 the mock boundary (ConfigureMockServices)
NSubstitute 5.3.0 available; used by the test base project
Respawn 7.0.0 per-test database reset
Testcontainers.MsSql / .Redis / .RabbitMq / .Azurite 4.12.0 container lifecycle
Microsoft.NET.Test.Sdk 18.3.0 test SDK
Volo.Abp.TestBase and other ABP packages 10.1.1 ($(AbpVersion)) ABP integration base

Microsoft Testing Platform (MTP) and xUnit v3

common.props flips every test project into Microsoft Testing Platform mode and wires up the shared helper project:

common.props (excerpt)
<PropertyGroup Condition="'$(IsTestProject)' == 'true' and '$(RunSettingsFilePath)' == ''">
  <RunSettingsFilePath>$(MSBuildThisFileDirectory)test.runsettings</RunSettingsFilePath>
</PropertyGroup>

<ItemGroup Condition="'$(IsTestProject)' == 'true'">
  <ProjectReference Include="$(MSBuildThisFileDirectory)\test\Cargonerds.Tests.Shared\Cargonerds.Tests.Shared.csproj" />
</ItemGroup>

<PropertyGroup Condition="'$(IsTestProject)' == 'true'">
  <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
</PropertyGroup>

IsTestProject is inferred automatically by Microsoft.NET.Test.Sdk. The consequences:

  • Each xUnit v3 test project builds a self-contained test executable (*.Tests.exe) rather than relying on the legacy VSTest host. This is the practical meaning of "xunit.v3 needs an exe + runner".
  • The repo-root test.runsettings is intentionally empty (<RunSettings></RunSettings>); it exists only so the RunSettingsFilePath property has a value.
  • Arguments after -- on the dotnet test command line are passed to the test executable, not to dotnet test (see Running tests).

Architecture of the harness

flowchart TD
    A["dotnet test (MTP)"] --> B["Test class : CargonerdsApplicationTestBase&lt;T&gt;"]
    B -->|"IClassFixture&lt;DockerFixture&gt;"| C["DockerFixture (one per class)"]
    C -->|"EnsureStartedAsync()"| D["SharedTestContainers (one per process)"]
    D --> E[("SQL Server")]
    D --> F[("Redis")]
    D --> G[("RabbitMQ")]
    D --> H[("Azurite")]
    C -->|"hub_test_xxx + spark_test_xxx"| E
    B -->|"BeforeAddApplicationAsync"| I["ABP app (real)<br/>+ Moq mock boundary"]
    I -->|"SeedData()"| J["IDataSeeder → DefaultTestDataSeedContributor"]
    B -->|"DisposeAsync()"| K["Respawn reset (both DBs)"]

There are two layers of sharing:

  • SharedTestContainers — process-wide. One SQL Server + Redis + RabbitMQ + Azurite is started once per test run, lazily, behind a SemaphoreSlim.
  • DockerFixture — per test class (via IClassFixture<DockerFixture>). Each class carves out its own pair of databases on the shared SQL Server, so classes run in parallel without colliding.

1. Container lifecycle — SharedTestContainers

test/Cargonerds.TestBase/SharedTestContainers.cs (namespace Cargonerds.Tests.Shared) starts the backing containers exactly once for the whole run:

SharedTestContainers.cs (excerpt)
_sql      = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-latest").Build();
_redis    = new RedisBuilder("redis:7.0").Build();
_rabbitMq = new RabbitMqBuilder("rabbitmq:3.11").Build();
_azurite  = new AzuriteBuilder("mcr.microsoft.com/azure-storage/azurite:3.33.0").Build();

await Task.WhenAll(_sql.StartAsync(), _redis.StartAsync(), _rabbitMq.StartAsync(), _azurite.StartAsync());

Per-database connection strings are derived by string-replacing the catalog name in the SQL Server connection string:

public static string SqlConnectionStringForDatabase(string databaseName) =>
    _sql.GetConnectionString().Replace("master", databaseName);

Containers are torn down by the Testcontainers resource reaper when the test process exits — there is no explicit teardown.

2. Per-class database isolation — DockerFixture

test/Cargonerds.TestBase/DockerFixture.cs runs once per test class. In InitializeAsync it:

  1. ensures the shared containers are up,
  2. builds two database connection strings using a 12-char random id — hub_test_<id> and spark_test_<id> (the Spark/Cargonerds one appends ;MultipleActiveResultSets=true),
  3. creates the Hub database from the model with EnsureCreatedAsync() (the Hub has no migrations inside Spark) and the Cargonerds database with MigrateAsync(),
  4. opens persistent SqlConnections and builds two Respawners.
DockerFixture.cs (excerpt)
HubDbConnectionString = SharedTestContainers.SqlConnectionStringForDatabase($"hub_test_{_id}");
CargonerdsDbConnectionString =
    SharedTestContainers.SqlConnectionStringForDatabase($"spark_test_{_id}") + ";MultipleActiveResultSets=true";

using (var hubDbContext = new HubDbContext(hubContextOptions, null, null))
{
    // No migrations for the hub in spark, so create it directly from the model.
    await hubDbContext.Database.EnsureCreatedAsync();
}

using (var cargonerdsDbContext = new CargonerdsDbContext(cargonerdsDbContextOptions))
{
    await cargonerdsDbContext.Database.MigrateAsync();
}

CargonerdsRespawner = await Respawner.CreateAsync(
    CargonerdsConnection,
    new RespawnerOptions { TablesToIgnore = ["__EFMigrationsHistory"], WithReseed = true });

ResetAsync() resets both databases; DisposeAsync() is a no-op (databases die with the container).

3. Booting the ABP application — CargonerdsTestBase<T>

test/Cargonerds.TestBase/CargonerdsTestBase.cs is the heart of the harness. It inherits AbpAsyncIntegratedTest<TStartupModule> and implements IAsyncLifetime and IClassFixture<DockerFixture>:

public abstract class CargonerdsTestBase<TStartupModule>
    : AbpAsyncIntegratedTest<TStartupModule>,
        IAsyncLifetime,
        IClassFixture<DockerFixture>
    where TStartupModule : IAbpModule

It overrides the ABP startup hooks:

  • SetAbpApplicationCreationOptionsAsyncoptions.UseAutofac() (Autofac is mandatory for the ABP container to build).
  • BeforeAddApplicationAsync(IServiceCollection) builds configuration and injects the container wiring:
CargonerdsTestBase.BeforeAddApplicationAsync (excerpt)
var builder = new ConfigurationBuilder();
builder.AddJsonFile("appsettings.rohlig.json", false);   // copied in via project references
builder.AddJsonFile("appsettings.secrets.json", false);  // holds the ABP license code

builder.AddInMemoryCollection(new Dictionary<string, string?>
{
    { "ConnectionStrings:Default",     _fixture.CargonerdsDbConnectionString },
    { "ConnectionStrings:Hub",         _fixture.HubDbConnectionString },
    { "ConnectionStrings:BlobStorage", _fixture.AzuriteConnectionString },
    { "App:CargonerdsCustomerId",      CargonerdsTestConsts.CargonerdsCustomerId.ToString() },
    { "App:BookingNumberPrefix",       $"Booking-{CargonerdsTestConsts.CargonerdsCustomerId}" },
});
services.ReplaceConfiguration(builder.Build());

services.AddSingleton(MemoryCache);
services.AddHttpContextAccessor();
services.AddSingleton(SparkEnvironment.FromName("local"));   // forces the "local" Spark environment
services.AddAlwaysAllowAuthorization();                       // ABP: bypass permission checks
ConfigureMockServices(services);
ConfigureBackgroundJobs(services);
ConfigureSeeders(services);
services.Configure<AbpDbContextOptions>(o => o.Configure(c => c.UseSqlServer()));  // SQL Server, not SQLite

The public lifecycle entry point runs in this order:

public virtual async ValueTask InitializeAsync()
{
    await base.InitializeAsync();           // builds & starts the ABP app
    await SeedData();                       // IDataSeeder → DefaultTestDataSeedContributor
    ConfigureCurrentUser();                 // sets the Moq ICurrentUser
    ConfigureCurrentPrincipalAccessor();    // sets the Moq ICurrentPrincipalAccessor
}

public virtual async ValueTask DisposeAsync()
{
    // Do not dispose the fixture here; xunit owns it.
    await _fixture.ResetAsync();            // Respawn wipe after every test
}

The local Spark environment is the auth/feature shortcut

Registering SparkEnvironment.FromName("local") plus AddAlwaysAllowAuthorization() is how the test app runs without a real auth server or per-request user resolution. (There is no CargonerdsApiFactory, spark.local.user.json file, or dynamic-claims toggling in the test path today — see Gotchas.)

4. What is mocked vs. what runs for real

The mock boundary is deliberately tiny. ConfigureMockServices replaces just four services with Moq singletons; the rest of ABP and EF Core runs for real:

The entire mock boundary
protected virtual void ConfigureMockServices(IServiceCollection context)
{
    context.ReplaceWithSingletonMock<ICurrentPrincipalAccessor>();
    context.ReplaceWithSingletonMock<ICurrentUser>();
    context.ReplaceWithSingletonMock<IAppCache>();
    context.ReplaceWithSingletonMock<ICw1EAdapter>();
}

The Moq registration helpers live in Cargonerds.Tests.Shared/ServiceCollectionExtensions.cs. Each helper registers both the Mock<T> and its .Object, so a test can pull the Mock<T> back out of DI to set up or verify calls:

public static IServiceCollection ReplaceWithSingletonMock<T>(this IServiceCollection services) where T : class
    => services.RemoveAll<T>().AddSingletonMockOf<T>();

public static IServiceCollection AddSingletonMockOf<T>(this IServiceCollection services,
    MockBehavior mockBehavior = MockBehavior.Loose) where T : class
    => services
        .AddSingleton(_ => new Mock<T>(mockBehavior))
        .AddSingleton(provider => provider.GetRequiredService<Mock<T>>().Object);

ConfigureCurrentUser sets the Moq ICurrentUser Id/Email/UserName from DefaultTestDataContext.User. ConfigureCurrentPrincipalAccessor populates the Moq ICurrentPrincipalAccessor.Principal with a ClaimsPrincipal carrying AbpClaimTypes.UserId, AbpClaimTypes.UserName, AbpClaimTypes.Email, and an IdentityPermissions.OrganizationUnits.Default claim holding the seeded organization-unit id.

5. Seeding baseline data

SeedData() calls ABP's IDataSeeder with a DataSeedContext carrying the DefaultTestDataContext under the DefaultTestDataSeedContributor.DefaultTestDataKey ("DefaultTestData") property:

await GetRequiredService<IDataSeeder>()
    .SeedAsync(new DataSeedContext()
        .WithProperty(DefaultTestDataSeedContributor.DefaultTestDataKey, DefaultTestDataContext));

DefaultTestDataSeedContributor (test/Cargonerds.TestBase/DataSeeder/) is an IDataSeedContributor that writes the baseline identity: it inserts the customer and organization via HubDbContext, creates the organization unit via OrganizationUnitManager, links an OrganizationUnitOrgCode, creates the user via IdentityUserManager, and adds the user to the OU.

The seeded graph (DefaultTestDataContext) is: a CargonerdsCustomer, an Organization, an OrganizationUnit, and an IdentityUser.

6. Background jobs

ConfigureBackgroundJobs disables job execution and uses Hangfire's in-memory storage so tests never hit a real job store (see Background Jobs):

services.Configure<AbpBackgroundJobOptions>(o => o.IsJobExecutionEnabled = false);
services.AddHangfire(o => o.UseInMemoryStorage());
services.Configure<AbpHangfireOptions>(o =>
    o.ServerOptions = new BackgroundJobServerOptions { Queues = [""] });

7. EF Core test module options

CargonerdsEntityFrameworkCoreTestModule (test/Cargonerds.EntityFrameworkCore.Tests/EntityFrameworkCore/) depends on CargonerdsEntityFrameworkCoreModule and turns off the dynamic/static feature, permission and text-template stores so tests don't persist framework metadata to the database:

Configure<FeatureManagementOptions>(o => { o.SaveStaticFeaturesToDatabase = false; o.IsDynamicFeatureStoreEnabled = false; });
Configure<PermissionManagementOptions>(o => { o.SaveStaticPermissionsToDatabase = false; o.IsDynamicPermissionStoreEnabled = false; });
Configure<TextTemplateManagementOptions>(o => { o.SaveStaticTemplatesToDatabase = false; o.IsDynamicTemplateStoreEnabled = false; });

The ABP module chain

Every test layer has its own ABP module with [DependsOn(...)]. The TStartupModule generic you pass to CargonerdsTestBase<T> decides which real modules load.

[DependsOn(
    typeof(AbpAutofacModule),
    typeof(AbpTestBaseModule),
    typeof(AbpAuthorizationModule),
    typeof(AbpBackgroundJobsAbstractionsModule))]
public class CargonerdsTestBaseModule : AbpModule { }

The layered test bases are thin wrappers that fix the startup module:

Test base Startup module
CargonerdsApplicationTestBase<T> (generic — usually CargonerdsApplicationTestModule)
CargonerdsEntityFrameworkCoreTestBase CargonerdsEntityFrameworkCoreTestModule

Writing a test

A test class derives from the appropriate layer base, takes the DockerFixture in its primary constructor, and forwards it to the base. Use GetRequiredService<T>() to resolve anything from the real ABP container, and Shouldly to assert. Here is the real Samples/DefaultTestDataTests:

test/Cargonerds.Application.Tests/Samples/DefaultTestDataTests.cs
public class DefaultTestDataTests(DockerFixture fixture)
    : CargonerdsApplicationTestBase<CargonerdsApplicationTestModule>(fixture)
{
    [Fact]
    public async Task DefaultTestDateSeed()
    {
        (await GetRequiredService<IOrganizationUnitRepository>()
            .FindAsync(DefaultTestDataContext.OrganizationUnit.Id))
            .Id.ShouldBe(DefaultTestDataContext.OrganizationUnit.Id);

        (await GetRequiredService<IIdentityUserRepository>()
            .FindAsync(DefaultTestDataContext.User.Id))
            .Id.ShouldBe(DefaultTestDataContext.User.Id);

        GetRequiredService<WhiteLabelingOptions>().AppName.ShouldBe("Realtime");
    }
}

Repository access must be inside a unit of work

Resolving a repository and querying outside a unit of work can hit a disposed-DbContext error. The base exposes WithUnitOfWorkAsync(...) overloads that open a DI scope, begin an IUnitOfWorkManager unit of work, run your delegate, and CompleteAsync:

protected virtual async Task<TResult> WithUnitOfWorkAsync<TResult>(
    AbpUnitOfWorkOptions options, Func<Task<TResult>> func)
{
    using var scope = ServiceProvider.CreateScope();
    var uowManager = scope.ServiceProvider.GetRequiredService<IUnitOfWorkManager>();
    using var uow = uowManager.Begin(options);
    var result = await func();
    await uow.CompleteAsync();
    return result;
}

Skipping a flaky test

A shared reason string lives in Cargonerds.Tests.Shared/TestSkipReasons.cs for temporarily disabling tests:

[Fact(Skip = TestSkipReasons.Misconfiguration)]
public async Task SomethingThatNeedsEnvFix() { /* ... */ }

Parallelism: class fixture vs. collection

The primary pattern is IClassFixture<DockerFixture> — one fixture (and DB pair) per class, classes running in parallel, no [Collection] attribute needed. Separately, an xUnit collection named "Cargonerds collection" exists for tests that need to be serialized:

CargonerdsEntityFrameworkCoreCollection.cs
[CollectionDefinition(CargonerdsTestConsts.CollectionDefinitionName)]   // "Cargonerds collection"
public class CargonerdsEntityFrameworkCoreCollection
    : ICollectionFixture<CargonerdsEntityFrameworkCoreFixture> { }

Apply [Collection(CargonerdsTestConsts.CollectionDefinitionName)] to opt a test class into that serialized collection.

The non-ABP unit project

Cargonerds.ServiceDefaults.Tests is the outlier: classic xUnit + xunit.runner.visualstudio, no common.props import, no containers, no ABP module. It unit-tests ServiceDiscoveryConfigurationProviderTests — the provider that resolves {token} placeholders (e.g. {auth}, {api}, {redis}, hyphenated {db-migrator}) in appsettings from Aspire services:* endpoints / connection strings, preferring https over http. See Aspire Integration.

Running tests

The MTP invocation passes runner arguments after --:

# whole suite (this is the exact CI command)
dotnet test "Cargonerds.All.sln" --configuration Release --no-build --nologo -- --ignore-exit-code 8

# whole suite, local, quiet
dotnet build "Cargonerds.All.sln" > /dev/null 2>&1 \
  && dotnet test "Cargonerds.All.sln" --no-build --nologo -- --ignore-exit-code 8

# a single project
dotnet test test/Cargonerds.Application.Tests

# filter by name
dotnet test --filter "FullyQualifiedName~DefaultTestDataTests"

--ignore-exit-code 8 is required for the full solution

Under MTP, exit code 8 means "no tests ran". Cargonerds.All.sln contains projects that have no tests (and the console test app), so without -- --ignore-exit-code 8 the run reports failure. Note that --ignore-exit-code is a runner flag, so it must come after --.

Frontend tests

The Next.js realtime app uses Vitest:

cd frontend/realtime
npm run test

CI

Pull-request validation runs in .github/workflows/pr-validation.yml on ubuntu-latest. The build-dotnet job restores, builds (Release), runs CSharpier formatting checks, verifies the build produced no uncommitted files, then runs:

dotnet test "Cargonerds.All.sln" --configuration Release --no-build --nologo -- --ignore-exit-code 8

A parallel build-frontend job builds the Next.js app and checks Prettier formatting. Each step uses continue-on-error and a final step aggregates failures, so one red step does not mask another. See Development Workflow for the local pre-commit checks that mirror these.

Gotchas

Two different customer ids are in play

CargonerdsTestConsts.CargonerdsCustomerId is a Guid.NewGuid() used for the App:* config values in BeforeAddApplicationAsync. DockerFixture seeds and filters with its own static readonly Guid SharedCargonerdsCustomerId — also a Guid.NewGuid(), but shared across all test classes on purpose.

Do not make the customer id per-class

DockerFixture.SharedCargonerdsCustomerId is static deliberately. The Hub customer-owned global query filter (CargonerdsCustomerId == appConfig.CargonerdsCustomerId) is baked into EF Core's process-wide cached model by whichever parallel host builds it first. If each class used a different id, every other host would still filter by the first host's id. Isolation comes from the per-class database, not the id. (See Org filtering via global query filters.)

SQLite is a red herring

CargonerdsEntityFrameworkCoreTestModule depends on AbpEntityFrameworkCoreSqliteModule and the EF test project references Volo.Abp.EntityFrameworkCore.Sqlite, but CargonerdsTestBase.BeforeAddApplicationAsync overrides the provider with c.UseSqlServer() and supplies real container connection strings. Tests run against SQL Server.

FakeCurrentPrincipalAccessor is defined but unused by the default base

test/Cargonerds.TestBase/Security/FakeCurrentPrincipalAccessor.cs exists (marked [Dependency(ReplaceServices = true)], returning a hard-coded admin@abp.io principal), but the default base instead registers a Moq ICurrentPrincipalAccessor and populates it in ConfigureCurrentPrincipalAccessor. The fake is dead code unless a derived test opts in.

Respawn timing

DisposeAsync resets both databases after each test (with WithReseed = true), and the base's InitializeAsync re-seeds before each test — so every test gets a fresh baseline. The doc-comment in DockerFixture.cs that says "Respawner is gone / no per-test wipe" is stale; the live Respawner fields and ResetAsync() are authoritative.

appsettings.rohlig.json is loaded by name but lives elsewhere

It is added in BeforeAddApplicationAsync but physically lives at src/Cargonerds.AppHost/appsettings.rohlig.json; it reaches the test output directory via the transitive Cargonerds.EntityFrameworkCore project reference. If a test project stops referencing that chain, the file goes missing at runtime.

appsettings.secrets.json carries the ABP license code

test/Cargonerds.TestBase/appsettings.secrets.json contains a single AbpLicenseCode and is copied CopyToOutputDirectory=Always. It is required for the ABP app to boot in tests.

No CargonerdsApiFactory / spark.local.user.json / dynamic-claims toggle on main

These concepts (a WebApplicationFactory-style host, a spark.local.user.json user file, or IsDynamicClaimsEnabled toggling) are not present in the current main test code. The closest real artifacts are SparkEnvironment.FromName("local") and AddAlwaysAllowAuthorization() in the test base. (An empty Cargonerds.Integration.Tests folder exists on disk with build output only — no source, and it is not part of Cargonerds.All.sln.)