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 Management — ManagePackageVersionsCentrally
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:
<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.runsettingsis intentionally empty (<RunSettings></RunSettings>); it exists only so theRunSettingsFilePathproperty has a value. - Arguments after
--on thedotnet testcommand line are passed to the test executable, not todotnet test(see Running tests).
Architecture of the harness¶
flowchart TD
A["dotnet test (MTP)"] --> B["Test class : CargonerdsApplicationTestBase<T>"]
B -->|"IClassFixture<DockerFixture>"| 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 aSemaphoreSlim.DockerFixture— per test class (viaIClassFixture<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:
_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:
- ensures the shared containers are up,
- builds two database connection strings using a 12-char random id —
hub_test_<id>andspark_test_<id>(the Spark/Cargonerds one appends;MultipleActiveResultSets=true), - creates the Hub database from the model with
EnsureCreatedAsync()(the Hub has no migrations inside Spark) and the Cargonerds database withMigrateAsync(), - opens persistent
SqlConnections and builds twoRespawners.
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:
SetAbpApplicationCreationOptionsAsync→options.UseAutofac()(Autofac is mandatory for the ABP container to build).BeforeAddApplicationAsync(IServiceCollection)builds configuration and injects the container wiring:
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:
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:
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:
[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:
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.)
Related pages¶
- ABP Patterns — modularity, DI, the building blocks the harness exercises
- Solution Architecture Overview — the layers and modules under test
- Aspire Integration — the service-discovery provider that
Cargonerds.ServiceDefaults.Testsunit-tests - Entity Framework Core — the
HubDbContext/CargonerdsDbContextsplit, global query filters, migrations - Background Jobs — disabled in tests via
AbpBackgroundJobOptions - Development Workflow — local pre-commit checks and CI
- Debugging — running the app locally
- CLI Commands —
dotnet testand related commands