Skip to content

Add UnitTestEx.Aspire: .NET Aspire multi-host (inter-domain) testing support - #106

Merged
chullybun merged 43 commits into
mainfrom
chullybun-aspire-multi-host-testing
Sep 29, 2026
Merged

chullybun merged 43 commits into
mainfrom
chullybun-aspire-multi-host-testing

Conversation

@chullybun

@chullybun chullybun commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds UnitTestEx.Aspire, a second, opt-in testing tier alongside the existing WebApplicationFactory-based tier (ApiTester/FunctionTester/GenericTester, etc.). Where the existing tier hosts a single system in-process for deep intra-domain testing (DI replacement, mocked HttpClients), UnitTestEx.Aspire spins up an entire .NET Aspire AppHost - every project/container/executable resource it declares - as real, separately-hosted OS processes wired together with Aspire's actual service discovery, for proving genuine inter-domain interaction between services. Resolves #105.

See the README's Aspire multi-host testing section for full usage.

What's included

  • AspireTester/AspireTesterBase<TAppHost, TSelf> - launches a real AppHost (DistributedApplicationTestingBuilder under the hood), waits for resource readiness, and exposes Http(resourceName) for calling any hosted resource by name.
  • Compile-time tier boundary - a new TesterBaseCore holds only genuinely host-agnostic members; Tier 1's TesterBase keeps its full DI-replacement surface (ReplaceSingleton/ReplaceScoped/etc.) on top of it, while AspireTesterBase inherits TesterBaseCore directly. A Tier 2 tester simply doesn't have DI-replacement methods to call, rather than having them throw at runtime.
  • BeforeStart/AfterStart hooks - AspireTesterBase<TAppHost, TSelf>.BeforeStart(Func<DistributedApplication, Task>) runs after the DistributedApplication is built but before any resource starts (e.g. DB migrations/seeding against a pre-existing resource), paired with GetConnectionStringAsync for resolving connection strings at that point. AfterStart(Func<DistributedApplication, Task>) runs once all resources have started - the natural place for WaitForResourceAsync calls. OnHostStartUp() is sealed on AspireTesterBase in favour of these two hooks.
  • HttpMock - a shared IHttpMockClient/IHttpMockRequest/IHttpMockResponse interface is implemented by both Tier 1's MockHttpClient(Factory) and Tier 2's AspireHttpMockClient (backed by a real WireMock.Net resource), so the same fluent stubbing API - and the same YAML/JSON config schema via WithRequestsFromResourceAsync - works against either tier. Aspire.Hosting.HttpMock(this DistributedApplication, ...) exposes the same API directly against a started DistributedApplication with no AspireTester required, so an AppHost.cs can pre-seed default stubs for exploratory/manual runs.
  • Self-hosted (Docker-free) WireMock.Net pattern - UnitTestEx's recommended way to mock external dependencies in Tier 2: an ordinary Aspire project resource (not the official WireMock.Net.Aspire package's container resource), so no Docker/Podman is required. UnitTestEx.Aspire ships:
    • JsonElementComparerMatcher - a custom WireMock.Net IMatcher delegating JSON body matching to UnitTestEx's own JsonElementComparer, giving genuine semantic parity (dates/GUIDs/numbers) with Tier 1's JSON matching.
    • WireMockConsole.RunAsync - encapsulates all the self-hosted console boilerplate (port binding, settings, custom-matcher registration, graceful shutdown), reducing a consumer's mock host Program.cs to one line. Builds an AspireWireMockServerSettings (a plain, unsealed WireMockServerSettings subclass) and registers a WireMockRequestResponseLogger that logs every genuine stubbed request/response pair through the resource's own console output, giving parity with Tier 1's MockHttpClientHandler logging - both now have an independently configurable log level (AspireWireMockServerSettings.RequestResponseLogLevel, MockHttpClientFactory.LogLevel/UseLogLevel(LogLevel)), defaulting to Information and Debug respectively, or None to disable.
    • AddMockHostProject/WithMockHostEnvironment AppHost extensions - remove the run-mode/publish-mode null-handling boilerplate needed since a mock resource must never appear in a published deployment manifest.
  • Resource log observability - since Tier 2/3 resources run out-of-process, AspireTesterBase<TAppHost, TSelf> captures each resource's console log output and exposes it via:
    • Checkpoint(string reason)/Delay(TimeSpan, string? reason) - report anything logged since the last Checkpoint/Delay call (staying silent when there's nothing new), so background/hosted-service activity between assertions is still visible in test output. Delay no longer silently discards pre-existing backlog that arrives just before it starts - it now reports it under its own preceding section instead of losing it.
    • ErrorWhenLogContains(LogLevel, string[]? exclude, string[]? include) - a continuous, streaming check that fails the test the moment any resource logs at or above the given level (default Error, enabled by default); each call replaces rather than merges prior configuration.
    • AssertLogContains(string, string?)/AssertLogNotContains(string, string?) - one-shot assertions against every captured log entry so far, across all resources or scoped to one.
    • ResetLogs() - discards captured log entries/watermarks, for tests that deliberately reuse a host via a shared fixture.
    • Tier 1 gained the equivalent ExpectLogNotContains expectation, mirroring the existing ExpectLogContains.
  • tests/UnitTestEx.Aspire.MockHost - the copy-paste template project referenced by the README (not a shipped package - there's nothing to install).
  • tests/UnitTestEx.Aspire.AppHost plus parity test suites (UnitTestEx.Aspire.MSTest.Test/.NUnit.Test/.Xunit.Test) exercising both tiers across all three test frameworks.

Testing

  • dotnet build UnitTestEx.sln - 0 errors.
  • Full Tier 1 + Tier 2 test suites pass across MSTest, NUnit and Xunit (net8.0-net10.0 where applicable).

Co-authored-by: Copilot App 223556219+Copilot@users.noreply.github.com

Copilot AI lite review requested due to automatic review settings September 22, 2026 17:57
chullybun and others added 4 commits September 22, 2026 10:58
Assesses feasibility of supporting .NET Aspire's DistributedApplicationTestingBuilder
alongside the existing WebApplicationFactory-based intra-domain testing, as a new
opt-in UnitTestEx.Aspire package (net8.0+). Covers the two-tier model, WireMock.Net
as the cross-process equivalent of MockHttpClientFactory, per-component mocking
options, and the Playwright/UI extension point.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Eric Sibly <eric.sibly@avanade.com>
Adds section 9 analyzing which TesterBase<TSelf> fluent methods are safe
to reuse for a Tier 2 Aspire tester vs which would silently no-op (all
Replace*/Mock*/ReplaceHttpClientFactory funnel through the non-virtual
TesterBase.ConfigureServices). Proposes a minimal, backward-compatible
SupportsServiceConfiguration guard so these fail loudly instead of
silently doing nothing.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Eric Sibly <eric.sibly@avanade.com>
…ions

- Flip the proposed capability property from protected to a public virtual
  getter, so extension methods (UnitTestEx's own, companion packages, or
  consumers) can defensively check it, mirroring the existing public
  IsHostInstantiated flag.
- Audit existing TesterBase extension methods across the ServiceBus and
  Azure Functions companion packages: none touch DI, so none need
  guarding. Note that the IServiceCollection extensions in core
  ExtensionMethods.cs (ReplaceSingleton/Scoped/Transient/etc.) are
  automatically shielded already, since they can only run from inside a
  ConfigureServices callback that is now itself guarded.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Eric Sibly <eric.sibly@avanade.com>
)

Adds a public virtual SupportsServiceConfiguration property (defaults
to true) to TesterBase, and guards the internal ConfigureServices
funnel method to throw NotSupportedException when it is false.

This is Phase 1 of the phased plan tracked in #105 for supporting
.NET Aspire multi-host (inter-domain) testing alongside the existing
WebApplicationFactory-based single-host model. A future Aspire-based
tester does not run in-process, so DI service configuration/replacement
(ReplaceSingleton/ReplaceScoped/ReplaceTransient and their Keyed/Mock
variants, ReplaceHttpClientFactory) cannot be supported; without this
guard such calls would be silently queued and never applied. The
property is public so extension methods (in this repo or consumers)
can proactively check the capability.

- Add tests validating default behavior is unaffected, and that a
  tester overriding the property to false throws.
- Update CHANGELOG.md.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Eric Sibly <eric.sibly@avanade.com>
@chullybun
chullybun force-pushed the chullybun-aspire-multi-host-testing branch from 25c014d to 1f796fb Compare September 22, 2026 17:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The changelog version mismatch remains unresolved, along with the noted guard and exception-documentation follow-ups.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Low severity

Open (1)
What changed in this PR

Adds a backward-compatible TesterBase capability guard for unsupported service configuration in future Aspire multi-host testing.

Changes:

  • Adds SupportsServiceConfiguration and a fail-fast NotSupportedException.
  • Adds Xunit coverage for supported and guarded behavior.
  • Updates Aspire design documentation and the changelog.
File Summary Final review comments
tests/​UnitTestEx.Xunit.Test/​Other/​SupportsServiceConfigurationTest.cs Tests default and guarded behavior. No findings.
src/​UnitTestEx/​Abstractions/​TesterBase.cs Adds the capability property and configuration guard. Nit (2 votes): Include actionable alternatives in the exception. Nit (1 vote): Change “where” to “when” in the documentation.
docs/​design/​aspire-multi-host-testing.md Documents the Aspire testing design. Nit (1 vote): Clarify that direct configuration entry points remain outside the guard, or handle them.
CHANGELOG.md Records the enhancement. Moderate (1 vote): Resolve the v5.12.0 changelog entry versus the current 5.11.1 package version.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/UnitTestEx/Abstractions/TesterBase.cs Outdated
chullybun and others added 3 commits September 22, 2026 11:05
- Include actionable, Tier-2-appropriate guidance in the
  NotSupportedException thrown by TesterBase.ConfigureServices (point
  callers at configuring the resource's environment/configuration
  surface or mocking the HTTP boundary, e.g. WireMock.Net.Aspire),
  instead of just referencing the SupportsServiceConfiguration flag.
- Change "Thrown where" to "Thrown when" in the XML doc comment.
- Clarify in the design doc (section 9) that TestSetUp.ConfigureServices
  is a separate, tester-implementation-level entry point that is not,
  and does not need to be, routed through the new guard.
- Bump Common.targets Version to 5.12.0 to match the CHANGELOG.md entry
  added for this change, resolving the version mismatch flagged by
  review.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Eric Sibly <eric.sibly@avanade.com>
…esterBase<TSelf>

Re-examined the Phase 2 base-class decision after a direct challenge:
is TesterBase<TSelf> actually the right thing for AspireTesterBase to
inherit, or does it just produce a large surface of methods that read
right, compile fine, and blow up at runtime?

Verified against the real code:
- TesterBase<TSelf> contributes only DI-reach-in members (~25
  Replace*/Mock*/ReplaceHttpClientFactory overloads, ScopedType/Type,
  UseScopedTypeSetUp) - nothing else.
- HttpTesterBase (the class implementing Tier 1's entire HTTP
  request/response/assertion engine) already depends only on the
  non-generic TesterBase in its constructor, not TesterBase<TSelf>.
  It's a working example of exactly where the tier boundary sits.

Conclusion: AspireTesterBase should inherit the non-generic TesterBase
directly, not TesterBase<TSelf>. This is a compile-time-safe
alternative to guarding away DI methods at runtime - they simply won't
exist on the type.

Changes:
- Section 8: sketch updated to AspireTesterBase<TAppHost, TSelf> :
  TesterBase, with explicit throwing Services/Configuration overrides.
- Section 9: retitled and rewritten to explain the corrected
  inheritance decision, cite the HttpTesterBase evidence, and reframe
  SupportsServiceConfiguration as defense-in-depth rather than the
  primary safety mechanism for AspireTesterBase itself.
- Section 11: phasing updated to add extracting HttpTesterBase's
  HttpClient source (currently hard-coded to TestServer) behind a
  small seam as a prerequisite step before prototyping
  AspireTesterBase.

No code changes - design doc only. The already-shipped
SupportsServiceConfiguration guard on TesterBase remains valid and
useful as defense-in-depth for future extension methods.

Signed-off-by: Eric Sibly <eric.sibly@avanade.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… in design doc

Further discussion questioned whether inheriting the DI-heavy
TesterBase (or TesterBase<TSelf>) for a Tier 2 (Aspire) tester was
right at all, even with a runtime guard - proposed instead extracting
a new TesterBaseCore containing only the genuinely host-agnostic
members, with TesterBase staying as-is on top of it, and the new Tier
2 base inheriting TesterBaseCore directly.

This makes the runtime SupportsServiceConfiguration guard (shipped
earlier in this branch) unnecessary: its entire premise was "a Tier 2
tester will inherit ~25 DI-flavoured methods it can't honor, so make
them throw instead of silently no-op." Under the TesterBaseCore split
a Tier 2 tester never inherits those methods in the first place, so
there is nothing left to guard. Keeping the flag around would be
speculative infrastructure for an abandoned design.

Code changes:
- src/UnitTestEx/Abstractions/TesterBase.cs: removed the
  SupportsServiceConfiguration property and the guard clause in
  ConfigureServices; restored to its pre-guard shape.
- Removed tests/UnitTestEx.Xunit.Test/Other/SupportsServiceConfigurationTest.cs
  (tested only the reverted guard).
- CHANGELOG.md: removed the v5.12.0 entry (no functional change
  remains).
- Common.targets: reverted <Version> from 5.12.0 back to 5.11.1.

Design doc changes (docs/design/aspire-multi-host-testing.md):
- Section 8: AspireTesterBase sketch now inherits TesterBaseCore
  (not TesterBase), implementing a new IHttpClientSource interface
  instead of overriding Services/Configuration to throw - those
  members simply don't exist on the type.
- Section 9: rewritten to explain the TesterBaseCore extraction, the
  concrete member split (table cross-checked against every member on
  TesterBase today), the IHttpClientSource interface as the answer to
  "common but doesn't fit the base class", and why the previously
  shipped runtime guard is now unnecessary.
- Section 11: phasing updated to lead with the TesterBaseCore +
  IHttpClientSource extraction as the core-package prerequisite.
- TL;DR: updated to describe the compile-time (not runtime) tier
  boundary.

Verified: full solution builds; xUnit 60/60 pass (62 before, minus the
2 removed guard tests).

Signed-off-by: Eric Sibly <eric.sibly@avanade.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@chullybun chullybun changed the title feat: add TesterBase.SupportsServiceConfiguration guard (Phase 1 of #105) docs: Aspire multi-host testing design (guard approach reverted for TesterBaseCore split) Sep 22, 2026
chullybun and others added 17 commits September 22, 2026 14:00
…Phase 1 of #105)

Splits TesterBase into a new host-agnostic TesterBaseCore base class plus the
existing DI-specific TesterBase, so a future multi-host (e.g. Aspire) tester
can reuse the common testing engine without inheriting single-host/DI-only
capabilities it cannot support.

- Add TesterBaseCore (Abstractions) holding all host-agnostic members: logging,
  JSON serialization, SharedState, host-reset plumbing, pre/post-run actions,
  etc. TesterBase now inherits it and keeps only Services, Configuration,
  ConfigureServices, AdditionalConfiguration and CreateHttpRequest*.
- Add IHttpClientSource interface so HttpTesterBase-derived testers can build
  their HttpClient without a hardcoded TestServer; ApiTesterBase implements it.
  This is the reuse seam for a future Aspire-based tester.
- Widen the HTTP-response assertor/expectations chain (ExpectationsArranger,
  ExpectationsBase(T), AssertArgs, ErrorExpectations, ExceptionExpectations,
  HttpResponseMessageExpectations, LoggerExpectations, ValueExpectations,
  HttpResponseMessageAssertor(Base)(T), TesterExtensionsConfig.
  UpdateValueFromHttpResponseMessage) from TesterBase to TesterBaseCore.
- Rename the ResetHost() override hook to OnResetHost() in ApiTesterBase,
  FunctionTesterBase and GenericTesterCore to avoid a name clash with the new
  public TesterBaseCore.ResetHost().
- Breaking change: HttpTester, HttpTester<TValue>, ControllerTester<TController>
  and the HttpTesterBase* constructors now take (TesterBaseCore,
  IHttpClientSource) instead of (TesterBase, TestServer). Nothing in the test
  suite constructs these directly.
- Fix JsonElementComparerResult.ToString() to join differences with a literal
  '\n' instead of AppendLine()/Environment.NewLine, so its output is
  deterministic across platforms (was failing MSTest on Windows due to
  CRLF vs. the LF-only literals checked into the test file).

Signed-off-by: Eric Sibly <eric.sibly@avanade.com>

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- Remove unnecessary `new` on TesterBase.ResetHost(bool) (CS0109); the
  differing parameter list from TesterBaseCore.ResetHost() means there
  was nothing to hide.
- Fix XML doc `<see cref="TesterBase.X"/>` references (CS1574) that
  broke when SetUp, UserName, JsonSerializer, JsonComparerOptions,
  ResetHost(), OnHostStartUp, PreRunActions, PostRunBeforeExpectationsActions
  and PostRunAfterExpectationsActions moved to TesterBaseCore in Phase 1.
  Re-pointed each to TesterBaseCore.X in TesterBaseT.cs, HttpTesterBaseT.cs,
  HttpTesterBaseT2.cs, TesterArgs.cs and TestSetUp.cs.

Verified: full solution build is warning-free for these categories
(remaining NETSDK1138/CS8785 warnings are pre-existing net6.0 tooling
noise, unrelated to this change). Full test suite passes 100%
(xUnit 60/60, NUnit 115/115, MSTest 95/95 across net8/9/10).

Signed-off-by: Eric Sibly <eric.sibly@avanade.com>

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- New UnitTestEx.Aspire package with AspireTesterBase/AspireTester<TAppHost>/static
  AspireTester factory for testing .NET Aspire distributed applications end-to-end.
- Aspire test suite (Xunit/NUnit/MSTest) multi-targets net8.0/9.0/10.0, orchestrating
  the real tests/UnitTestEx.Api sample via a single AppHost pinned to net8.0 (Aspire's
  DCP orchestrator cannot launch a multi-targeted project resource without an explicit
  --framework arg; see microsoft/aspire#2962). Higher-TFM test project slots can safely
  reference the lower-TFM AppHost since NuGet ProjectReference TFM compatibility is
  asymmetric.
- HttpTesterBase.CreateRequest fix: honour the underlying HttpClient's BaseAddress so
  Aspire-issued clients (with resource-discovered base addresses) work correctly.
- CI: add explicit Aspire MSTest/NUnit/Xunit test steps (dotnet test auto-iterates all
  target frameworks in a multi-targeted test project without needing a manual loop).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…s a real readiness gate

A plain AddProject resource has no health check by default, so it's reported 'Healthy'
as soon as the OS process starts - not once Kestrel has actually bound its endpoint(s).
On a slower/cold CI machine this raced ahead of endpoint allocation, causing
'Endpoint '' for resource 'api' not found' when CreateHttpClient tried to resolve the
default https/http endpoint. WithHttpHealthCheck closes that race.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…g on launchSettings.json auto-detection

AddProject's implicit endpoint detection from launchSettings.json proved unreliable for a
multi-targeted referenced project (UnitTestEx.Api targets net8.0/9.0/10.0): it worked on
Windows locally but failed on Linux CI with 'Could not create HTTP health check for
resource api as no endpoint was found matching one of the specified names: https, http'.
Declaring a single explicit https endpoint (launchProfileName: null + WithHttpsEndpoint)
removes the dependency on that detection entirely and is deterministic across platforms.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…add timeout to WaitForResourceAsync

Linux CI does not fully support 'dotnet dev-certs https --trust', so the ASP.NET Core dev cert used by the
'api' resource's https endpoint is not OS-trusted there. Both AspireTesterBase.CreateHttpClient() and the
WithHttpHealthCheck probe resolve their HttpClient via the same DistributedApplication DI container's
IHttpClientFactory, so a failing TLS handshake would affect both - and with no cap on the health check's
retry loop plus no timeout on WaitForResourceAsync, this manifested as an indefinite hang rather than a
clean failure.

- AppHost.cs: ConfigureHttpClientDefaults with a permissive ServerCertificateCustomValidationCallback for
  this dev/test-only AppHost (never shipped).
- AspireTesterTest.cs (all 3 frameworks): pass a 60s timeout to WaitForResourceAsync so any future genuine
  problem fails fast instead of hanging.

Verified: all 9 combinations (Xunit/NUnit/MSTest x net8.0/9.0/10.0) pass locally against the real
UnitTestEx.Api.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…iting indefinitely

A resource that never becomes healthy should fail the test quickly, not hang it forever. Added
AspireTesterBase.DefaultWaitForResourceTimeout (60s) as the default when no timeout is passed;
callers can still opt into an indefinite wait via Timeout.InfiniteTimeSpan. Removed the now-redundant
explicit per-call timeouts from the 3 AspireTesterTest.cs files since the library default covers it.

Verified: all 9 combinations (Xunit/NUnit/MSTest x net8.0/9.0/10.0) pass locally.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ceTimeout doc comment

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
….Api reference

GetTargetPath is only invoked by Visual Studio's build engine (not dotnet build/CLI, which is why CI and
prior local dotnet build verifications never surfaced this). A multi-targeted project reference only exposes
GetTargetPath on its inner (single-TFM) build, not its outer cross-targeting wrapper, so VS fails to resolve
it unless a single TFM is pinned via SetTargetFramework - the standard, documented fix for referencing a
multi-targeted project from an AppHost (matches the framework already selected via --framework at runtime).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…-log relay in test output

The underlying DistributedApplication's own ILogger pipeline (Aspire.Hosting.* bootstrap messages, DCP
startup, and the console-log relay it forwards per-resource, e.g. '<AppHost>.Resources.<name>') was logging
at Information by default, drowning out UnitTestEx's own formatted request/response tester output (which is
written directly via Implementor.WriteLine and is unaffected by this).

Added AspireTesterBase.MinimumLogLevel (defaults to Warning) which sets LoggerFilterOptions.MinLevel on the
testing builder before it is built; settable before first use if a consumer wants the extra verbosity back
(e.g. while diagnosing why a resource never became healthy).

Verified: all 9 combinations (Xunit/NUnit/MSTest x net8.0/9.0/10.0) pass locally with clean, focused output.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ction

Adds IHttpClientSource.GetRequestLogMessages(requestId) as an optional seam
(default returns null, preserving existing WAF-based behaviour unchanged)
that HttpTesterBase now prefers over TestSharedState-based correlation.

AspireTesterBase implements it by capturing each resource's own forwarded
logging (the '{ApplicationName}.Resources.{resourceName}' category Aspire
already relays to the AppHost) via a dedicated ILoggerProvider, independent
of the MinimumLogLevel console-noise filter. ResourceLoggerService.GetAllAsync/
WatchAsync are confirmed unpopulated under DistributedApplicationTestingBuilder,
so this capture provider is the reliable path.

Per-request correlation uses a captured-line-count window (snapshot before
send, diff after response) since there is no shared in-process request-id
correlation across a real, separate resource process.
…rBase style

Previously captured resource log lines only had ANSI codes and Aspire's own
'{lineNumber}: {timestamp}Z ' prefix stripped, discarding the embedded
timestamp entirely and leaving the raw two-physical-line console shape
('level: category[eventId]' header + indented message).

Now parses and reassembles each logical entry (header + one or more message/
continuation lines) into the same '{timestamp} {level}: {message} [{category}]'
format that Tier 1's LoggerBase already produces, re-using Aspire's own
embedded timestamp (converted to local time) rather than the capture time.

Also fixes a real bug uncovered along the way: an entry only completed once
a subsequent header line arrived to terminate it, so the most recently
logged entry in any given request/wait window (often the interesting one)
was silently dropped. Pending entries are now force-flushed before each
buffer read (and on provider Dispose as a final safety net).
…ier 2 testers

Reason(string) writes an arbitrary context note ('REASON >' block) to the
test output, for both TesterBase<TSelf> (single in-process host) and
AspireTesterBase<TAppHost, TSelf> (multi-host).

WaitAndLog(string reason, TimeSpan duration) writes a '====' delimited
block (reason + timeout), waits for the duration, then reports whatever was
logged during that window under the same 'LOGGING >' heading used elsewhere
- useful when a test needs to wait on background/inter-resource activity
that isn't tied to a specific HTTP request/response.

The 'what counts as elapsed log output' behaviour is supplied via a new
protected virtual TesterBaseCore.DrainElapsedLogMessages():
- Tier 1 default: any SharedState logging not attributed to a specific HTTP
  request (i.e. background/hosted service logging).
- Tier 2 (Aspire) override: combines each resource's captured logging
  (via ResourceLogCaptureProvider) across ALL resources since the last
  drain, each line prefixed with its owning resource name, since background
  activity may occur in any resource.
It set LoggerFilterOptions.MinLevel on the AppHost's testing builder, but unlike Tier 1 testers (which
call ClearProviders() before adding their own capture provider, leaving MinLevel as the sole governing
filter), the Aspire AppHost keeps its own default console logger active. That provider's effective level
is governed by Aspire's own internal logging configuration, not by our MinLevel fallback rule, so setting
this property had no observable effect on the console noise it was meant to control.

The exemption rule that always lets our own ResourceLogCaptureProvider see everything (regardless of the
AppHost's own console noise level) is retained.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…t (Tier 1 and Aspire)

Tier 1 (UnitTestEx.*.Test): Reason() writes the expected 'REASON >' block; WaitAndLog() blocks for
approximately the requested duration, surfaces logging performed via the host's DI-resolved ILogger
during the wait window under 'LOGGING >', and correctly excludes stale logging that occurred before the
wait started ('None.').

Aspire (UnitTestEx.Aspire.*.Test): a new Reason_And_WaitAndLog_AggregatesResourceLogs test verifies the
same behaviour plus the Aspire-specific DrainElapsedLogMessages override - a background request against
the 'api' resource fired mid-wait is captured and prefixed with '[api]' in the aggregated LOGGING > output.

A small SpyTestFrameworkImplementor (duplicated per test project, matching this repo's established
per-framework test file convention) wraps the tester's own implementor so output still reaches the test
framework while also being captured for assertions.

Verified: full Tier 1 suites (63/118/98 Xunit/NUnit/MSTest) and full Aspire net10.0 suites (3/3 each,
9/9 total) all pass; full solution build is clean (0 errors, no new warnings).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ost console noise

- Suppress Aspire AppHost's default ILoggerProvider console output by default
  (opt-in via new EnableHostDiagnostics()); prevents raw AppHost/DCP/resource
  noise from duplicating UnitTestEx's own curated log output.
- Fix genuine log-duplication bug: unify the per-request (.Http().Run()) and
  multi-resource (Wait()) log drains onto a single shared watermark so a
  resource log line is reported exactly once, never twice.
- Merge the resource-name attribution into the existing category bracket,
  e.g. '[UnitTestEx.Api.Controllers.PersonController (api)]', instead of a
  separate trailing '[api]' bracket.
- Rename WaitAndLog(reason, duration) to Wait(reason = null, duration = null)
  across TesterBaseCore/TesterBaseT/AspireTesterBase; add
  TesterBaseCore.DefaultWaitDuration (1s) used when duration is omitted.
- Default reason text changed to 'No reason specified' when none supplied.
- Simplify WAIT output to a single 'WAIT (duration) >' line with dashed
  dividers (removed '====' clutter).
- Rename ReasonAndWaitAndLogTest -> ReasonAndWaitTest (Xunit/NUnit/MSTest)
  and update all call sites/assertions accordingly.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- Fix WireMock.Net scenario state-machine bug in WithSequenceAsync (SetStateTo:
  null resets scenario to start rather than leaving state unchanged).
- Redesign sequence semantics to exactly mirror Tier 1's MockHttpClientRequest:
  sequence length is an exact invocation count, Times() disallowed alongside
  WithSequenceAsync, and overrunning the sequence fails loudly (synthetic
  guard mapping returns 500, VerifyAsync throws MockHttpClientException).
- Add JsonResource equivalents (WithJsonResourceBody/Async/WithJsonResource)
  across AspireHttpMockRequest/Response/ResponseSequenceItem, matching Tier 1
  parity, plus [StringSyntax(Json)] on raw JSON string parameters.
- Add pathsToIgnore support to WithJsonBody via WireMock.Net's native
  JsonPartialMatcher (strips named properties from the match pattern and
  switches matcher), for cases like an unpredictable ETag.
- Add end-to-end tests covering sequence exhaustion and pathsToIgnore
  (positive + negative match) against a real WireMock.Net container.
- Document the finalized sequence and pathsToIgnore semantics, including
  divergences from Tier 1, in the design doc.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
chullybun and others added 7 commits September 23, 2026 17:13
Introduces IHttpMockClient/IHttpMockRequest/IHttpMockRequestBody/IHttpMockResponse/
IHttpMockResponseSequence/IHttpMockResponseSequenceItem/IHttpMockedRequest so
test-authoring code can be written once and used identically against Tier 1's
in-process Moq-based MockHttpClient* and Tier 2/3's out-of-process WireMock.Net-based
AspireHttpMock*. Each interface exposes a minimal abstract core, with convenience
overloads provided as default interface methods composed from that core.

Retrofits both MockHttpClient* and AspireHttpMock* to implement the shared interfaces
(async-wrapping Tier 1's native synchronous members for compatibility - non-breaking).

Fixes a real cross-tier semantic gap surfaced by the retrofit: MockHttpClientRequest's
WithAnyBody() previously required a non-null body and so could never match a body-less
request (e.g. GET), unlike Aspire's true-wildcard behavior and the interface's documented
contract. Tier 1 now matches with or without a body, aligning both tiers; updated
UriAndAnyBody/DefaultHttpClient tests across the Xunit/NUnit/MSTest projects accordingly.

Adds HttpMockInterfaceTest.cs (Tier 1) exercising every DIM extra via interface-typed
variables, and HttpMock_InterfaceCoreMembers_TierSpecificAdaptations (Aspire) exercising
the hand-written, tier-specific core-member adaptations. Adds HttpMockSharedConfig.cs and
a HttpMock_SharedInterface_ConfiguresIdenticallyAcrossTiers proof test on both tiers.

Updates docs/design/aspire-multi-host-testing.md with a new section documenting the
shared interfaces and the WithAnyBody() alignment rationale.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds a new 'Aspire multi-host testing' section covering: when to reach for
Tier 2 (AspireTester) over the existing in-process Tier 1 testers, a basic
Http() usage example, and a worked example of mocking multiple external
dependencies (email/auth/erp) each as their own isolated AddWireMock(...)
container resource via HttpMock(resourceName) - highlighting that the fluent
stubbing API is shared with Tier 1's MockHttpClientFactory via the IHttpMock*
interfaces. Also updates the intro TOC and Examples section.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The design was captured in the tracking issue, so docs/design/aspire-multi-host-testing.md
(and the now-empty docs/ folder) is removed. All in-code XML doc / comment references to
it (AspireHttpMockClient, AspireTesterBase, IHttpMockClient, AspireTesterTest) are rephrased
to drop the dangling pointer while keeping the surrounding explanation intact.

Also adds Aspire's own official guidance as a verbatim quote to the README's new 'Aspire
multi-host testing' section, reinforcing that the two-tier split (WebApplicationFactory for
single-project/in-memory/mocked-dependency testing vs. AspireTester for genuine multi-host
interaction) isn't a UnitTestEx-specific compromise.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…L/JSON config gap between tiers

- New internal HttpMockResourceConfig helper parses the same mock.unittestex YAML/JSON
  schema as Tier 1's native MockHttpClient.WithRequestsFromResource, but is implemented
  purely against IHttpMockClient/IHttpMockRequest/IHttpMockResponse/IHttpMockResponseSequenceItem,
  so it works identically against Tier 2/3's AspireHttpMockClient without either tier
  reimplementing the parsing.
- Added two IHttpMockClient DIMs: WithRequestsFromResourceAsync<TAssembly> and
  WithRequestsFromResourceAsync(string, Assembly?, CancellationToken).
- Tier 1's existing native WithRequestsFromResource is left completely unchanged to avoid
  any regression risk; this is purely additive.
- One intentional, documented behavioral difference: where a request entry omits body
  entirely, the shared loader matches any body (WithAnyBody) rather than Tier 1's native
  'no body at all' semantics, since the shared interface has no way to express the latter.
- Added Xunit test coverage on Tier 1 (HttpMockInterfaceTest) and Tier 2/3 (AspireTesterTest)
  with matching embedded YAML resources.
- Documented the new capability in the README's YAML/JSON configuration and Aspire
  multi-host testing sections.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Introduced UnitTestEx.Aspire to support .NET Aspire multi-host testing with a familiar API. Added AspireHttpClientMock for HttpClient mocking. Updated Common.targets to version 5.12.0.
…cher, and Aspire AppHost helpers

- Renamed JsonComparerMatcher to JsonElementComparerMatcher for UnitTestEx naming
  consistency; fixed it to honor configured JsonElementComparerOptions instead of
  always using JsonElementComparer.Default.
- Added WireMockConsole.RunAsync to UnitTestEx.Aspire, encapsulating the self-hosted
  WireMock.Net console boilerplate (PORT env var, settings, custom-matcher
  registration, graceful shutdown) so a consumer's MockHost Program.cs is a one-liner.
- Added UnitTestExAspireExtensions (AddMockHostProject / WithMockHostEnvironment) to
  remove the run-mode/publish-mode null-handling boilerplate from AppHost projects
  wiring up self-hosted mock resources.
- Added tests/UnitTestEx.Aspire.MockHost as the copy-paste template project referenced
  by the README, using the above helpers; added it to the solution under /tests.
- Fixed a latent null-argument risk in the sample AppHost (WithEnvironment received a
  null EndpointReference in publish mode) via the new WithMockHostEnvironment helper.
- Made Humanizer package references PrivateAssets to avoid leaking them downstream.
- Renamed WaitAndLog/Reason test coverage to Delay-based naming (ReasonAndDelayTest)
  for consistency with the Delay/Wait unification.
- Updated README's Aspire multi-host testing section and CHANGELOG to match.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@chullybun chullybun changed the title docs: Aspire multi-host testing design (guard approach reverted for TesterBaseCore split) Add UnitTestEx.Aspire: .NET Aspire multi-host (inter-domain) testing support Sep 24, 2026
chullybun and others added 10 commits September 24, 2026 15:54
… request

ApiTesterBase.CreateHttpClient routed through GetTestServer() -> HostExecutionWrapper()
-> SharedState.Reset() on every call. Since CreateHttpClient is invoked by
HttpTesterBase.SendAsync per-request (i.e. potentially after an Expect*()-style call,
such as CoreEx's ExpectEvents(), has already registered request-scoped state keyed by
the HttpTester's RequestId), that state was silently wiped immediately before the
request was sent - causing correctly-matched expectations to be reported as failed.

CreateHttpClient now resolves the WebApplicationFactory's TestServer directly, without
going through the resetting wrapper. GetTestServer() itself is untouched (still resets)
for its own construction-time callers (Controller<T>(), Http(), Http<TResponse>()),
where the reset-before-use semantics are correct.

Added RequestStateDataTest as regression coverage: registers request-scoped state,
confirms it is still present at post-run. Verified it fails against the prior
behavior and passes with the fix.

Also:
- Documented that Playwright can drive Tier 2/3 Aspire resources directly (README
  footnote only - no new API, per discussion on issue #105).
- Added UnitTestEx.Aspire to nuget-publish.ps1's project list.
- Reformatted WithMockHostEnvironment's signature (no functional change).
- Folded the fix into the CHANGELOG's existing (unreleased) v5.12.0 entry, since the
  regression was introduced by this same branch's work.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ric subclass

Mirrors the existing TesterBase / TesterBase<TSelf> pattern so extension
methods can target AspireTesterBase without needing TAppHost/TSelf.

- AspireTesterBase (non-generic) now sits directly on TesterBaseCore and
  holds everything not dependent on TAppHost/TSelf: GetDistributedApplicationAsync
  (public), HttpMock, Http(), WaitForResourceAsync, HostDiagnosticsEnabled,
  DrainElapsedLogMessages, ResourceLogCaptureProvider/ResourceLogger/
  ResourceHttpClientSource, and the DisposeAsync/DisposeAsyncCore pair.
- AspireTesterBase<TAppHost, TSelf> (new file AspireTesterBaseT.cs) keeps
  only the fluent (TSelf-returning) configuration methods and the
  TAppHost-dependent CreateBuilderAsync() override.
- Added ConfigureBuilderActions (protected property) and
  IsDistributedApplicationBuilding (protected guard) on the base so the
  generic subclass's WithResourceEnvironment/EnableHostDiagnostics can
  still enforce their pre-build-only invariant.
- Fixed dangling XML doc <see cref> references left pointing at members
  that moved to the other class as part of the split.

Verified: full solution build (0 errors) and UnitTestEx.Aspire.Xunit.Test
15/15 passing on net8.0/net9.0/net10.0.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… JsonElementComparerMatcher to HttpMock namespace

- Renamed TesterBase/AspireTesterBase's Reason() to Checkpoint(), which now also flushes
  (writes) any elapsed log messages since the last Checkpoint()/Delay() call, mirroring
  Delay() minus the actual wait. Both Checkpoint() and Delay() now stay silent (no
  'LOGGING >' section) when there is nothing new to report, instead of writing 'None.'.
- Added AspireTesterBase.WaitForResourceAsync(string[], TimeSpan?) to wait for multiple
  named resources concurrently; converted 4 existing sequential-wait call sites in the
  Xunit Aspire test suite to use it.
- Moved JsonElementComparerMatcher (+ its exception type) from the UnitTestEx.Aspire
  namespace into UnitTestEx.Aspire.HttpMock, since it is tightly coupled to the HttpMock
  feature (registered by WireMockConsole, consumed by AspireHttpMockRequest).
- Renamed ReasonAndDelayTest.cs/class to CheckpointAndDelayTest across all three Tier 1
  test projects.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…itForResourceAsync(string[])

- CHANGELOG.md: describe the final Checkpoint()/Delay()/WaitForResourceAsync(string[]) API
  under v5.12.0, since the earlier Reason()->Checkpoint() rename and the CreateHttpClient
  SharedState-reset bug were both introduced and resolved entirely within this unreleased
  PR and were never part of a shipped release.
- AspireTesterBase.WaitForResourceAsync(string[], TimeSpan?) now de-duplicates the supplied
  resource names before waiting, so a caller passing the same resource twice does not wait
  on it concurrently more than once.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…tart

BeforeStart(Func<DistributedApplication, Task>) registers a callback invoked
after the DistributedApplication is built but before any resource is
started, closing the gap where GetDistributedApplicationAsync() atomically
builds and starts in one step. This enables use cases such as running
database migrations/seeding or clearing a cache against a pre-existing
(e.g. AddConnectionString) resource before dependent project resources come
online, avoiding a race between test setup and Aspire resource start-up.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Aspire's own DistributedApplication.GetConnectionStringAsync (and
GetEndpoint/CreateHttpClient) testing extensions throw
InvalidOperationException ('The application must be started before
resolving endpoints or connection strings') until the host's
IHostApplicationLifetime.ApplicationStarted has fired - which has not
happened yet inside a BeforeStart callback (invoked after BuildAsync but
before StartAsync).

GetConnectionStringAsync resolves a resource's connection string directly
against the DistributedApplicationModel instead, bypassing that guard. This
works for resources whose connection string does not depend on a
dynamically-allocated endpoint - e.g. a static, externally-hosted dependency
added via the AppHost's own AddConnectionString(name) - which is exactly the
primary BeforeStart use case (running migrations/seeding against such a
resource before dependent project resources start).

Also corrects the BeforeStart XML docs, which previously (and incorrectly)
suggested calling app.GetConnectionStringAsync(name) directly.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… extension methods

- AfterStart mirrors BeforeStart: runs after app.StartAsync() succeeds, before
  the tester returns the app; a throwing callback aborts start-up and disposes
  the app, same as BeforeStart.
- OnHostStartUp() is now sealed on AspireTesterBase - consumers should use
  BeforeStart/AfterStart instead of the single-host-oriented hook.
- WaitForResourceAsync(DistributedApplication, ...) moved from static methods
  on AspireTesterBase to extension methods on UnitTestExAspireExtensions
  (Aspire.Hosting namespace) for discoverability alongside AddProject etc.
- Corrected BeforeStart/AfterStart doc wording: reentrant calls into
  GetDistributedApplicationAsync() deadlock/hang, they do not recursively
  kick off a second build.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…eding

Exposes the same fluent AspireHttpMockClient API as AspireTesterBase.HttpMock,
but directly against a started DistributedApplication with no AspireTester
required. Lets an AppHost.cs itself pre-seed default request/response stubs
(after 'await app.StartAsync()') so an exploratory/manual run of the AppHost -
outside of any UnitTestEx test - does not fail against an un-stubbed external
dependency.

- New Aspire.Hosting.HttpMock(this DistributedApplication, string, string?,
  JsonElementComparerOptions?) extension method in UnitTestExAspireExtensions.
- New Xunit test proving it configures the same underlying WireMock.Net
  resource as the tester's own HttpMock.
- README: new 'Pre-seeding stubs from AppHost.cs' subsection under Aspire
  multi-host testing.
- CHANGELOG updated.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- AspireTesterBase<TAppHost, TSelf>.ErrorWhenLogContains(LogLevel, exclude, include) registers a
  continuous, streaming check that fails a test the moment any resource logs at/above a given
  LogLevel (default Error), checked whenever log activity is drained (Checkpoint/Delay/Http).
  Enabled by default at LogLevel.Error; pass LogLevel.None to opt out. Supports wildcard exclude
  (known noise, always wins) and include (narrows what is checked) patterns; each call fully
  replaces the prior configuration rather than merging with it.
- Added ExpectLogNotContains expectation (Tier 1), mirroring ExpectLogContains, to confirm log
  output does not contain specified text.
- Added Person/test/log/{level} test-support endpoint to the sample API to exercise the above.
- Updated README and CHANGELOG for both features.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Delay() previously pre-drained and discarded any backlog captured
before it started, to avoid misattributing it to the delay's own
window. For Aspire testers this is the only mechanism resource
console logs ever reach test output, so the discard caused genuine,
permanent loss of log lines that were still visible to
AssertLogContains (a raw, watermark-bypassing scan) but never
printed by any later Checkpoint()/Delay().

WriteDelay now reports this backlog via WriteElapsedLogMessages()
under its own preceding LOGGING > section (ahead of the delay's own
DELAY (...) > marker), instead of silently dropping it via
DrainElapsedLogMessages(). Silent when there is nothing to report.

Updated the Tier 1 CheckpointAndDelayTest mirrors (Xunit/MSTest/NUnit)
to assert the corrected behavior.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved critical API-compatibility and concurrent log-drain findings, along with additional behavior issues, require changes before approval.

Review effort: Lite
Findings: 6 High severity

Open (6)
Resolved since last review (1)

Comment thread src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs Outdated
Comment thread src/UnitTestEx/Abstractions/TesterBaseCore.cs
Comment thread src/UnitTestEx/Abstractions/TesterBaseT.cs
Comment thread src/UnitTestEx/Abstractions/TesterExtensionsConfig.cs
Comment thread src/UnitTestEx/AspNetCore/HttpTesterBase.cs
Comment thread src/UnitTestEx/AspNetCore/HttpTesterT.cs
DrainElapsedLogMessages and ResourceHttpClientSource.GetNewResourceLogMessagesAsync each derived their returned entries via buffer.Skip(lastCount).ToArray() but then separately read
buffer.Count to advance the watermark. A resource log enqueued between the two reads could advance the watermark past an entry that was never included in newEntries, silently and
permanently skipping it from all future drains/checks.

Both now take a single buffer.ToArray() snapshot and derive newEntries and the advanced watermark from that same array, so the watermark can never advance past anything that wasn't
also returned/checked.

Addresses automated PR review feedback on AspireTesterBase.cs.
@chullybun
chullybun merged commit ae3ca42 into main Sep 29, 2026
3 of 5 checks passed
@chullybun
chullybun deleted the chullybun-aspire-multi-host-testing branch September 29, 2026 20:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support .NET Aspire multi-host (inter-domain) testing alongside WebApplicationFactory

2 participants