Testing Guide
This guide covers the .NET backend suites, the frontend Vitest suites, code coverage, the containerised Docker test stack, and how everything runs in CI. Browser end-to-end testing has its own page: E2E Testing.
Backend Tests
Always use the test script
Run the .NET suites through ./scripts/run-tests.sh, never bare dotnet test:
./scripts/run-tests.sh # the fast suites in api/Wallow.slnx; integration EXCLUDED
./scripts/run-tests.sh integration # ONLY Category=Integration, solution-wide (needs Docker)
./scripts/run-tests.sh all # both, in one run (needs Docker)
./scripts/run-tests.sh identity # one module
The script logs each assembly to a TRX file (--logger "trx;LogFilePrefix=results" into a
temp directory), parses those TRX files, and prints structured per-assembly pass/fail/skip
counts plus the names of individual failed tests. It exits with the underlying dotnet test
exit code.
Why bare dotnet test is banned: the script passes
--settings api/tests/coverage.runsettings automatically. That runsettings file excludes
generated code — EF Core migrations, LoggerMessage.g.cs, and friends — which would otherwise
be counted as uncovered lines and deflate every coverage number. Coverage exclusions live
only in api/tests/coverage.runsettings; never duplicate them into a project file or a
CI command line.
Integration tests are opt-in, and the script says so
Every invocation except integration and all appends
--filter "Category!=E2E&Category!=Integration", because that tier needs live infrastructure
(Testcontainers, hence Docker). Those runs print SCOPE: fast suites only beside their totals and
close with an INTEGRATION TESTS DID NOT RUN banner naming the two commands that do run them —
a green total from a bare run is not evidence that the integration guards passed.
integration and all select by category across api/Wallow.slnx, not by project, because
integration tests live in seven assemblies. Wallow.Api.Tests is the one that matters most:
HandlerCodegenTests compiles every discovered Wolverine handler and is the only guard that a
handler dependency the codegen cannot inline-construct fails in the suite rather than in a
dead-letter queue.
A run that executes zero tests reports RESULT: FAIL and exits nonzero — a selector that matches
nothing is a broken selector, not a pass.
Module shorthands
identity, storage, notifications, announcements, inquiries, branding, apikeys,
api, arch (or architecture), seeder, migrations, shared, kernel, integration,
all. Shorthands are matched case-insensitively.
A second argument — integration or all — narrows that tier to whatever the first argument
selected, so you can iterate on one assembly's integration tests without running the other six:
./scripts/run-tests.sh api integration # just Wallow.Api.Tests' Category=Integration tests
./scripts/run-tests.sh storage integration # just Storage's Testcontainers suites
./scripts/run-tests.sh api/tests/Wallow.Api.Tests all # a path works as the first argument too
Any other second argument is rejected with exit code 2 rather than silently ignored.
Anything the script does not recognise as a shorthand is passed through as a project path, so
./scripts/run-tests.sh api/tests/Wallow.Api.Tests works too.
Test Tiers
| Tier | Purpose | Infrastructure | Location |
|---|---|---|---|
| Unit | Individual components in isolation | None | api/tests/Modules/{Module}/Wallow.{Module}.Tests/ |
| Integration | API endpoints against real databases | Docker (auto-managed via Testcontainers) | api/tests/Wallow.Api.Tests/, api/tests/Modules/Identity/Wallow.Identity.IntegrationTests/ |
| Architecture | Layer dependencies and module isolation | None (reflection-based) | api/tests/Wallow.Architecture.Tests/ |
| Component (frontend) | One React component in a real browser | Headless Chromium | apps/*/src/**/*.test.tsx |
| E2E | Complete user journeys in the browser | Running app + API | apps/wallow-auth/e2e/, apps/wallow-web/e2e/ |
The E2E tier is Playwright (@playwright/test) and lives in the React apps, not in the .NET
solution. Additional .NET test projects: Wallow.Shared.Kernel.Tests,
Wallow.Shared.Infrastructure.Tests, Wallow.AppHost.Tests, Wallow.MigrationService.Tests,
Wallow.SeederService.Tests, and the Benchmarks/ projects.
Test Frameworks
| Package | Purpose |
|---|---|
| xUnit | Test framework |
| AwesomeAssertions | Fluent assertions |
| NSubstitute | Mocking |
| Testcontainers | Docker-based integration testing |
| NetArchTest | Architecture rule validation |
| Bogus | Fake data generation |
Test Project Structure
api/tests/
├── coverage.runsettings # Coverage config — the single source of exclusions
├── Directory.Build.props
├── Benchmarks/ # BenchmarkDotNet projects
├── Wallow.Tests.Common/ # Shared test infrastructure
├── Wallow.Api.Tests/ # API integration tests
├── Wallow.AppHost.Tests/ # Aspire host wiring
├── Wallow.Architecture.Tests/ # Architecture enforcement
├── Wallow.MigrationService.Tests/
├── Wallow.SeederService.Tests/
├── Wallow.Shared.Kernel.Tests/
├── Wallow.Shared.Infrastructure.Tests/
└── Modules/
└── {Module}/
└── Wallow.{Module}.Tests/
├── Domain/
├── Application/
└── Infrastructure/
Naming Convention
Use the pattern Method_Scenario_ExpectedResult:
[Fact]
public async Task Handle_WithValidCommand_CreatesInvoice() { ... }
[Fact]
public async Task Handle_WithDuplicateNumber_ReturnsFailure() { ... }
Unit Test Patterns
Handler tests
Mock dependencies with NSubstitute, test through the public Handle method, assert on the
Result return value, and verify repository/bus interactions.
Validator tests
Use FluentValidation's TestValidate extension to assert on specific property errors.
Domain entity tests
Test entity behaviour through factory methods and state transitions. Assert on the domain events raised.
Integration Tests
WallowApiFactory
Extends WebApplicationFactory<Program> and manages Testcontainers for PostgreSQL and Valkey.
It replaces authentication with TestAuthHandler and sets a fixed tenant context. No manual
Docker setup is needed — the factory drives the container lifecycle via IAsyncLifetime.
Keep the container images aligned with the compose stacks: postgres:18-alpine and
valkey/valkey:8-alpine.
Collection fixtures
Use ICollectionFixture<WallowApiFactory> (not IClassFixture) so containers are shared
across test classes:
[Collection(nameof(WallowTestCollection))]
public class InvoiceTests(WallowApiFactory factory) : WallowIntegrationTestBase(factory)
{
}
Authentication
Tests use TestAuthHandler to bypass real OAuth2. Generate a test token with
JwtTokenHelper.GenerateToken(userId).
Architecture Tests
NetArchTest enforces the design rules on every run:
- The Domain layer takes no dependency on Application, Infrastructure, or EF Core.
- No module references another module directly — only via
Shared.Contracts. - All entities are sealed.
- Modules are discovered dynamically by scanning for
Wallow.*.Domain.dll, so no manual registration is needed.
Frontend Tests
pnpm test (which is turbo run test, i.e. vitest run per package, topologically ordered and
cached in .turbo/) drives the frontend suites. Vitest runs a two-project split, configured by the shared createVitestProjects
preset in packages/testing and wired up by each app's vitest.config.ts:
| Project | Includes | Runtime |
|---|---|---|
| node | src/**/*.test.ts, plus every src/**/*.ssr.test.tsx |
Node |
| browser | src/**/*.test.tsx minus the *.ssr.test.tsx specs |
Headless Chromium |
The browser project uses the Vitest playwright() factory provider from
@vitest/browser-playwright with headless: true and a single chromium instance;
vitest-browser-react supplies render and the locator API, and assertions come from
@vitest/expect locator matchers.
jsdom, happy-dom, and jest are banned in this repo. Anything that touches the DOM —
rendering a component, reading layout, focus, or computed styles — runs in a real browser.
Do not add a // @vitest-environment jsdom pragma or a jsdom/happy-dom dependency; either one
regresses the suite off real-browser fidelity.
The *.ssr.test.tsx suffix exists for specs that render through react-dom/server
(renderToString) or assert a route's beforeLoad redirect, and never mount a live DOM.
Routing those into Chromium buys nothing and costs real per-test browser overhead. It is a
naming convention rather than a per-app list precisely so a new SSR spec lands on the node
project the moment it is created — the config needs no edit. (createVitestProjects still
accepts an explicit nodeTsxSpecs array, which replaces the convention; no package in this
repo uses it.)
Playwright E2E specs are deliberately kept out of Vitest: the Vitest include globs are
scoped to src/**, while Playwright specs live only in e2e/ (or wallow-web's
e2e-cross-app/).
Code Coverage
Coverage is collected automatically by ./scripts/run-tests.sh using
api/tests/coverage.runsettings. The format is Cobertura, the include filter is
[Wallow.*]*, test assemblies are excluded (IncludeTestAssembly=false), and SourceLink is
enabled.
Exclusions
Excluded by assembly/type filter:
- EF Core migrations (
*.Migrations.*) ProgramandStartupclasses- Module registration extensions (
*.Extensions.*Module*) and*WallowModules - Assembly info (
*AssemblyInfo) - Test and benchmark assemblies (
Wallow.Tests.Common,Wallow.Benchmarks,*.Tests,*.IntegrationTests) System.Runtime.CompilerServices.*
Excluded by file:
**/Migrations/**/*.cs- Generated sources:
**/Logging.g.cs,**/LoggerMessage.g.cs,**/RegexGenerator.g.cs,**/*.generated.cs - Design-time and factory classes:
**/DesignTimeTenantContext.cs,**/*DbContextFactory.cs
Excluded by attribute: CompilerGeneratedAttribute, ExcludeFromCodeCoverageAttribute.
Viewing coverage locally
# Run tests — coverage is collected automatically
./scripts/run-tests.sh
# Install the report generator (one-time)
dotnet tool install -g dotnet-reportgenerator-globaltool
# Generate an HTML report
reportgenerator \
-reports:"**/coverage.cobertura.xml" \
-targetdir:"coverage-report" \
-reporttypes:Html
open coverage-report/index.html
Docker Test Stack
docker/docker-compose.test.yml is a self-contained compose file, separate from the
development docker-compose.yml. It brings up infrastructure plus the API and both React apps
on ports distinct from the dev environment, so both stacks can run at once.
Drive it with
./scripts/e2e.sh, not by hand. A baredocker compose -f docker/docker-compose.test.yml upfails: thewallow-migrations,wallow-seeder, andwallow-apiservices declare animage:with nobuild:block, so they expect prebuilt:testimages that a plainupnever produces.scripts/e2e.shpublishes those images (viadotnet publish /t:PublishContainer) before bringing the stack up. See E2E Testing.
Infrastructure services
| Service | Image | Default host port | Purpose |
|---|---|---|---|
postgres |
postgres:18-alpine |
5442 | Database (dev uses 5432) |
valkey |
valkey/valkey:8-alpine |
6389 | Cache (dev uses 6379) |
mailpit |
axllent/mailpit:v1.22 |
8035 (UI), 1035 (SMTP) | Email capture — the passwordless and reset-password E2E specs read mail back over its HTTP API |
garage |
wallow-garage:test |
3910, 3913 | S3-compatible storage. Built from docker/images/garage — the same image the dev stack builds, just tagged :test instead of :v2.2.0, with test-only credentials and bucket passed as env. |
Migration and seed services
| Service | Image | Purpose |
|---|---|---|
wallow-migrations |
wallow-migrations:test |
Applies EF migrations, then exits. Gates on postgres being healthy. |
wallow-seeder |
wallow-seeder:test |
Seeds roles, scopes, the admin, and OIDC clients from api/seed.json. Gates on wallow-migrations completing successfully, and overrides the wallow-web-client redirect URIs for the test port (http://localhost:${E2E_WEB_PORT:-5053}/bff/callback). The compose default seeds the admin, but ./scripts/e2e.sh blanks Admin__Email for the first pass so the first-run-setup journey creates it, then re-runs the seeder with it restored. |
Application services
| Service | Image | Default host port | Purpose |
|---|---|---|---|
wallow-api |
wallow-api:test |
5050 | API server |
wallow-auth |
wallow-auth-react:test |
5051 | Auth app (TanStack Start; a pure same-origin reverse proxy to the API) |
wallow-web |
wallow-web-react:test |
5053 | Web app (TanStack Start dashboard + BFF) |
bff-example |
wallow-bff-example:test |
3003 | SDK BFF reference host, authenticating as the bff-example-client. Not started by CI — the CI job brings up only wallow-auth and its transitive dependencies. |
scripts/e2e.sh overrides every host port (and the image tag) per run so concurrent runs stay
isolated — see docker/.env.example for the E2E_* knobs (Wallow-joo0).
The three Node services build from apps/wallow-auth/Dockerfile and apps/wallow-web/Dockerfile
with the repo root as build context, so the workspace:* dependencies resolve. The image
tags are deliberately distinct from the deleted Blazor apps' tags so a stale image can never be
silently reused.
OIDC configuration
The test compose file splits browser-facing URLs from container-to-container ones. Ports below are
the classic defaults (${E2E_API_PORT:-5050}); scripts/e2e.sh substitutes a per-run port
everywhere they appear:
- API:
OpenIddict__Issuerishttp://localhost:5050, so tokens match the URL the browser sees. - Auth:
wallow-authis a pure reverse proxy holding no session. It reads exactly three env vars —PORT,HOST, andWALLOW_API_INTERNAL_URL(http://wallow-api:8080). - Web: the
wallow-webBFF usesOIDC_ISSUER: http://localhost:5050for browser redirects andOIDC_METADATA_URL: http://host.docker.internal:5050/.well-known/openid-configurationfor container-side discovery. Sessions live in Valkey so logout truly revokes.
On Linux, add the hosts entry that host.docker.internal needs:
echo "127.0.0.1 host.docker.internal" | sudo tee -a /etc/hosts
CI
Tests run in GitHub Actions via .github/workflows/ci.yml. The workflow triggers on
pull requests targeting main only — there is no push trigger and no image-publishing
job in this workflow.
| Job | Depends on | What it does |
|---|---|---|
changes |
— | Classifies the PR's paths with dorny/paths-filter into three outputs — code (everything except docs/** and **/*.md), dotnet (api/**, global.json, scripts/e2e.sh, ci.yml) and js (apps/**, packages/**, the workspace manifests, scripts/fork-smoke.sh, ci.yml). Every gate below reads these. |
build |
changes |
Runs when code is true. Restores, builds api/Wallow.slnx in Release, and runs dotnet format --verify-no-changes. Caches the build output for the downstream jobs. |
unit-tests |
changes, build |
Runs when dotnet is true. dotnet test --filter "Category!=Integration&Category!=E2E" with --settings api/tests/coverage.runsettings. Uploads the coverage-unit artifact. |
integration-tests |
changes, build |
Runs when dotnet is true. dotnet test --filter "Category=Integration". PostgreSQL comes from a GitHub Actions service container; Valkey is started with a docker run step and polled until it answers PING. Uploads the coverage-integration artifact. |
cross-tenant-tests |
changes, build |
Runs when dotnet is true. dotnet test --filter "Category=CrossTenant" against the same Postgres service container and docker run Valkey. This is the tenant-isolation gate; it does not upload coverage. |
docker-images-app |
build |
Publishes the API, migration, and seeder container images plus the wallow-auth-react / wallow-web-react Docker builds, for both linux-x64 and linux-arm64, then caches them as a tarball. |
docker-images-infra |
build |
Builds the garage image via docker compose -f docker/docker-compose.test.yml build garage and the Postgres replica image, then caches them. |
e2e-tests |
docker-images-app, docker-images-infra |
Loads the cached images, installs Chromium, and runs ./scripts/e2e.sh with E2E_SKIP_IMAGE_BUILD=1, E2E_UP_SERVICE=wallow-auth. That one script runs all three Playwright suites — wallow-auth, wallow-web, and the cross-app suite (both its first-party and external-origin specs) — against ports it allocates for this run (Wallow-joo0). Uploads the playwright-report-wallow-auth and playwright-report-wallow-web artifacts. |
fork-smoke |
changes |
Runs when js is true — it needs no .NET output, so it runs alongside the build rather than after it. Runs ./scripts/fork-smoke.sh outside the checkout: packs packages/sdk and packages/styles and builds a scratch app against the tarballs, proving an out-of-workspace consumer can install them. |
merge-coverage |
unit-tests, integration-tests |
Merges the two coverage artifacts with ReportGenerator, enforces the coverage threshold, and uploads the coverage-report artifact. |
Jobs skip when their inputs are untouched. A docs-only PR fails the code filter, so build
skips and docker-images-* / e2e-tests inherit that skip through needs: build — the whole
pipeline stands down. A JS-only PR skips the three .NET test jobs (and merge-coverage with them,
since its always() guard degrades to skipped when both producers skip) while build, the image
jobs and e2e-tests still run, because e2e boots the full stack from this PR's images. An
api-only PR skips fork-smoke alone. GitHub reports an if:-skipped job as passing, so required
status checks keep working.
The frontend gate is a different workflow. .github/workflows/js.yml has a single build job
that runs pnpm lint, lint:tests, lint:manifests, lint:deps, lint:env, format:check,
turbo run build typecheck test, and check:exports — the same set pnpm check runs locally.
ci.yml does not run any frontend unit tests.
Because docker-images-app prebuilds and caches the :test images, the e2e-tests job sets
E2E_SKIP_IMAGE_BUILD=1 and loads them instead of rebuilding. That knob suppresses both halves
of the runner's image work — the dotnet publish of the API/migration/seeder images and compose's
--build of the services with a build block — which is why a local run, where it is unset, always
builds against the current tree. bff-example is the one image no job caches, so compose builds it
either way. Setting E2E_BASE_URL makes Playwright drive the containerised wallow-auth app on
its allocated port (classic default :5051) directly rather than booting a local dev server.
Coverage threshold
merge-coverage extracts the merged line-rate from Cobertura.xml and fails the job — and
the pipeline — if line coverage is below 90%. If neither test job produced a coverage
artifact, the job emits a warning and skips reporting rather than failing.
Reading CI results
- Test results appear in the GitHub Actions job logs.
- On E2E failure, download the
playwright-report-wallow-authartifact for the HTML report, traces, and screenshots (retained 5 days). - The merged coverage report is the
coverage-reportartifact (retained 30 days).
Best Practices
- Keep each test independent; never rely on another test's state.
- Use
IAsyncLifetimefor async setup and teardown. - Clear domain events after entity setup:
entity.ClearDomainEvents(). - Test behaviour through public interfaces, not internal state.
- Use builders for complex entities, Bogus for random data, and constants for shared IDs.
- Keep Testcontainers images aligned with the compose files (
postgres:18-alpine,valkey/valkey:8-alpine).