Skip to main content

Testing

Test Projects​

Test projects mirror the source structure:

  • Wayd.\{ServiceName\}.Domain.Tests - Domain logic tests
  • Wayd.\{ServiceName\}.Application.Tests - Handler and validator tests
  • Wayd.\{ServiceName\}.IntegrationTests - Run against a real SQL Server via Testcontainers
  • Wayd.Web.Api.Tests and Wayd.Web.Api.IntegrationTests - Controllers, request models, OpenAPI and MCP; the integration suite boots the real API
  • Wayd.Infrastructure.Tests and Wayd.Infrastructure.IntegrationTests - Persistence, messaging, auth, identity and connector modules
  • Wayd.Integrations.\{SystemName\}.Tests - External system clients
  • Wayd.Tools.DataGeneration.Cli.Tests - The data generation tool, which seeds through the import endpoints
  • Wayd.ArchitectureTests - Enforce Clean Architecture dependency rules (see Architecture tests)

Shared helpers live under Wayd.Common/tests/: Wayd.Tests.Shared (traits, TestingDateTimeProvider, the cross-cutting fakers, MockDbSetFactory), Wayd.TestData.Core (PrivateConstructorFaker<T>) and Wayd.Tests.Containers (the SQL Server container).

Unit vs integration​

Every test assembly is stamped with two traits automatically, so nothing needs tagging by hand:

  • Category says what kind of test it is. A *.IntegrationTests project is Integration; every other project is Unit.
  • Requires=Docker marks a project that references any Testcontainers* package. CI splits its two test runs on this same dependency, so the trait cannot drift from the job a test runs in.

The two usually agree, with one exception. Wayd.Integrations.AzureDevOps.IntegrationTests is Integration but has no Requires trait. It needs a live Azure DevOps org rather than a container, so it runs in CI's unit half and skips itself when no org is configured.

The Testcontainers match is on the prefix, not on Testcontainers.MsSql specifically: a suite reaching for a different module (Testcontainers.PostgreSql, .Redis, .RabbitMq) still needs Docker. Directory.Build.targets and .github/scripts/dotnet-test-projects.sh must stay in step on this.

Each integration project starts its own container, so the integration run pulls the SQL Server image once before any suite starts and runs at most two projects at a time. Set INTEGRATION_TEST_PARALLELISM to change the limit.

Sharing a container​

Every integration project shares one SQL Server container and one migrated schema across all of its test classes, via a collection fixture:

[CollectionDefinition(Name)]
public sealed class SqlServerTestCollection : ICollectionFixture<SqlServerDbContextFixture>
{
public const string Name = "SqlServer integration tests";
}

[Collection(SqlServerTestCollection.Name)]
public sealed class MyIntegrationTests(SqlServerDbContextFixture fixture) { }

Use [Collection(...)], never IClassFixture<T>. xUnit constructs a class fixture once per test class, so IClassFixture means a container start and schema migration for every class — which is what made one suite take six containers to run 44 tests.

The consequence is that tests share a database. Scope assertions to data the test itself created (a fresh Guid, a unique key) rather than assuming an empty schema or querying for "the only row".

Start a fixture's container with SqlServerTestContainer.Start() from Wayd.Tests.Containers, rather than building one yourself. It runs the image every suite shares, pinned once as SqlServerTestImage.Name. Wayd.Tests.Containers is referenced only by integration projects, so Wayd.Tests.Shared and the unit projects never depend on Testcontainers.

Authenticated HTTP requests​

Wayd.Web.Api.IntegrationTests boots the real API against its container, so a test can call an endpoint the way a client does. CreateAuthenticatedClient creates a local user holding exactly the permissions you name, signs them in through POST /api/auth/login, and returns an HttpClient that sends their token:

var user = await _factory.CreateAuthenticatedClient(
ApplicationPermission.NameFor(ApplicationAction.Import, ApplicationResource.StrategicThemes));

var response = await user.Client.PostAsync(route, content, TestContext.Current.CancellationToken);

Each call makes a new role and user, so one test's permissions never reach another's. Use it for anything that belongs to the HTTP layer: status codes, headers, model binding, and the permission attributes. Tests that only need a handler to run can still dispatch through IDispatcher in a scope.

Frameworks and Libraries​

LibraryPurpose
xUnit v3 (xunit.v3)Test framework, on Microsoft.Testing.Platform
FluentAssertionsAssertion library, pinned to 7.1.0 in Directory.Packages.props
MoqMocking framework
Moq.AutoMockAutomatic dependency mocking
BogusFake data generation
NodaTime.TestingFakeClock, behind TestingDateTimeProvider
Testcontainers.MsSqlSQL Server for the integration suites
NetArchTest.RulesArchitecture rule validation

Test Naming​

Test projects: \{ProjectName\}.Tests

Test classes and methods should clearly describe the scenario being tested.

Test Structure​

These conventions keep the suite consistent and easy to scan. New tests should follow them.

One class per system-under-test, named after the file​

Each test class targets a single system-under-test (SUT), and the class name matches its file name. Do not merge tests for several SUTs (e.g. multiple command handlers) into one class — give each handler its own \{Handler\}Tests.cs containing a class of the same name.

Arrange / Act / Assert comments​

Every test method marks the three phases with // Arrange, // Act, and // Assert comments so the flow reads at a glance. When the setup needs context, append a note to the marker:

[Fact]
public async Task Handle_ShouldFail_WhenModelIsActive()
{
// Arrange — an active model cannot be deleted; the handler's guard should reject it
// and leave the row in place.
var model = _faker.AsActiveWsjf();
_dbContext.ScoringModels.Add(model);
var command = new DeleteScoringModelCommand(model.Id);

// Act
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);

// Assert
result.IsFailure.Should().BeTrue();
_dbContext.SaveChangesCallCount.Should().Be(0);
}

Cancellation tokens​

Pass TestContext.Current.CancellationToken to every method that accepts a CancellationToken — both the handler's Handle(...) call and any EF assertion queries (SingleAsync(...), AnyAsync(...), etc.). This is the dominant convention across the suite and clears the xUnit1051 analyzer warning. Avoid CancellationToken.None.

Fake Data​

Domain fakers generate realistic test data with Bogus. They live in each model's own test project, under a Data/ folder. Only the PrivateConstructorFaker<T> base class lives centrally, in Wayd.TestData.Core (namespace Wayd.TestData.Core). Test projects reach it transitively through their Wayd.Tests.Shared reference, so no extra project reference is needed.

Two intentional exceptions to the colocation rule — reuse these rather than duplicating or relocating them:

  • Cross-cutting fakers for types no single domain owns live in Wayd.Tests.Shared/Data/: FeatureFlagFaker, OidcProviderFaker, PersonalAccessTokenFaker, UserFaker.
  • Organization publishes its fakers from a dedicated Wayd.Organization.TestData project (TeamFaker, TeamMembershipFaker, and the rest), because several test projects consume them.
var faker = new ScoringModelFaker();
var model = faker.Generate();

Faker conventions​

  • Per-property With\{Property\} builder extensions, not a single WithData(...) with optional parameters. Each extension sets one RuleFor and returns the faker for chaining.
  • With\{Property\} means "set exactly this value." Do not skip null values inside the extension. If a nullable property needs to be cleared, WithDescription(null) or WithParentId(null) should set the generated model's property to null.
  • As... state helpers are for coherent domain states and may update several aligned properties together — e.g. AsActive() can set status, date range, and timestamps consistently. If an As... helper accepts optional parameters, it should call the relevant With\{Property\} method only when the optional value is supplied.
  • Composition helpers that build child collections through the aggregate's own methods (so invariants and ordering hold) are encouraged — e.g. AsProposedWith(scales, criteria, outputs).
  • Encapsulate shared fixtures as faker extensions, not a separate \{Thing\}TestData holder class. When several test classes need the same realistic entity, add a convenience extension that builds the canonical fixture (e.g. AsProposedWsjf() / AsActiveWsjf() on ScoringModelFaker) and call it from each test. Keep small one-off fixtures inline.

Fake DbContext​

Fake DbContext implementations are available for each application area, in the Application test project's Infrastructure/ folder (FakeWaydDbContext and FakeImportDbContext in Wayd.Common.Application.Tests, FakeWorkDbContext, FakeProjectPortfolioManagementDbContext, and one for each other module with Application tests), backing each DbSet with an in-memory list so handlers can be tested without a database. They track SaveChangesCallCount, which assertions use to verify a handler saved (or didn't) as expected. Combine with NullLogger<T>.Instance for the handler's logger.

private readonly FakeWaydDbContext _dbContext = new();
private readonly Mock<ICurrentUser> _currentUser = new();
private readonly TestingDateTimeProvider _dateTimeProvider = new(new FakeClock(Instant.FromUtc(2026, 1, 15, 9, 30)));
private readonly ScoringModelFaker _faker = new();

private DeleteScoringModelCommandHandler CreateHandler() =>
new(_dbContext, _currentUser.Object, _dateTimeProvider, NullLogger<DeleteScoringModelCommandHandler>.Instance);

A fake runs queries as LINQ-to-Objects, so it cannot catch what only a real provider does: a missing .Include (the fake's navigations are whatever the test set), a projection that does not translate to SQL, or one save that should span two interfaces. A change that depends on any of these needs a test in the module's *.IntegrationTests project.

Running Tests​

# All tests (both halves; the integration suites need Docker running)
dotnet test Wayd.slnx

# Only the tests that need no Docker — what CI's unit run covers
./.github/scripts/dotnet-test-projects.sh unit

# Only the Testcontainers suites (Requires=Docker) — what CI's integration run covers
./.github/scripts/dotnet-test-projects.sh integration

# Specific project
dotnet test "Wayd.Services/Wayd.Work/tests/Wayd.Work.Application.Tests/Wayd.Work.Application.Tests.csproj"

# By class name, within one project
dotnet test "<project>" --filter "FullyQualifiedName~ProjectServiceTests"

# By category, within one project (--filter-trait "Category=Unit" is the native xUnit form)
dotnet test "<project>" --filter "Category=Unit"

# Architecture tests
dotnet test Wayd.ArchitectureTests/Wayd.ArchitectureTests.csproj

Tests run on Microsoft.Testing.Platform (MTP), opted into by global.json; VSTest is not used. Each test project is its own executable, and any option dotnet test does not recognise is passed through to it, so run dotnet test "<project>" --help for the full list (filters, --coverlet, --hangdump, reports).

A test module that runs zero tests fails with exit code 8. A filter therefore fails on every project it matches nothing in, so a filtered run across the whole solution fails. Filter one project at a time, or use the split script above.

Coverage​

COLLECT_COVERAGE=true ./.github/scripts/dotnet-test-projects.sh <unit|integration> collects Cobertura coverage with coverlet (--coverlet) into TestResults/coverage.cobertura.<timestamp>.xml at the repo root. The include and exclude rules live in the root testconfig.json, which every test project gets next to its assembly as <Assembly>.testconfig.json. A project can replace it with its own testconfig.json; Wayd.ArchitectureTests does, to exclude everything, because reflecting over instrumented assemblies throws TypeLoadException.

Test traits​

Four independent axes are available for selecting tests:

TraitValuesMeaning
CategoryUnit, IntegrationWhat kind of test it is. Applied automatically per assembly — do not set by hand.
RequiresDockerInfrastructure it cannot run without; absent when it needs none. Applied automatically per assembly — do not set by hand.
SuiteSmokeA curated fast pass. Untagged tests are the default regression body.
AreaPPM, Security, …What it covers, cutting across projects.

Values live as constants in Wayd.Tests.Shared (TestCategories, TestRequirements, TestSuites, TestAreas). Use them rather than string literals: in a filter that combines several conditions, a typo in one of them silently runs fewer tests instead of failing.

Frontend Tests​

cd Wayd.Web/src/wayd.web.reactclient
npm test # watch-friendly local run
npm run test:ci # what CI runs on a PR
npm run test:coverage # what CI runs on a merge to main

Uses Jest with React Testing Library.

Coverage instrumentation costs roughly four times the test run itself, so PRs run without it and only the main-branch build collects it. Both the .NET and client coverage numbers are published to that run's summary page.