From 47de9603bb346b24153353ea965d90dc1544c7c6 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 10:14:36 -0700 Subject: [PATCH 01/43] docs: add design note on Aspire multi-host testing support 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 --- docs/design/aspire-multi-host-testing.md | 229 +++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 docs/design/aspire-multi-host-testing.md diff --git a/docs/design/aspire-multi-host-testing.md b/docs/design/aspire-multi-host-testing.md new file mode 100644 index 0000000..668d12d --- /dev/null +++ b/docs/design/aspire-multi-host-testing.md @@ -0,0 +1,229 @@ +# Design note: supporting .NET Aspire multi-host testing alongside `WebApplicationFactory` + +## TL;DR + +Yes, it's possible — but it's **two separate tiers**, not one API extended to cover both: + +| | Tier 1 — intra-domain (today) | Tier 2 — inter-domain (proposed) | +|---|---|---| +| Package | `UnitTestEx` (existing) | new `UnitTestEx.Aspire` | +| Underlying host | `WebApplicationFactory` + in-memory `TestServer` | `Aspire.Hosting.Testing.DistributedApplicationTestingBuilder` | +| Process model | Single in-memory process | Real, separate OS processes per resource | +| Transport | In-memory short-circuit (no sockets) | Real network / service discovery | +| DI reach-in | Full — `ConfigureServices`, `ReplaceScoped`, `ReplaceSingleton` | None — DI containers are in other processes | +| Use it when... | You want deep, per-component control of **one** service in isolation | You want to exercise the **real** interaction between two or more services | + +This isn't a UnitTestEx-specific compromise — it matches Aspire's own guidance verbatim: + +> "If your goal is to test a single project in isolation, run components in-memory, or mock external +> dependencies, consider using `WebApplicationFactory` instead." +> — [aspire.dev/testing/overview](https://aspire.dev/testing/overview/) + +So the plan is to keep Tier 1 exactly as-is, and add Tier 2 as a new, separately-versioned, opt-in +package — picking the tier per test based on what you're actually trying to prove, not blending them. + +## 1. The current model (Tier 1), recapped + +`ApiTesterBase` (`src/UnitTestEx/AspNetCore/ApiTesterBase.cs`) wraps a +`WebApplicationFactory`: + +- One process, one in-memory `TestServer` — `Server.CreateClient()`/`TestServer` has no real socket. +- `ConfigureServices` runs inside the *same* DI container the app under test uses, so tests can + `ReplaceScoped`/`ReplaceSingleton` any component (repository, clock, feature flag, etc.) with a test + double, per test, with zero ceremony. +- Outbound `HttpClient` calls are intercepted via `MockHttpClientFactory` + (`src/UnitTestEx/Mocking/MockHttpClientFactory.cs`), which replaces the singleton + `IHttpClientFactory` in that same DI container with a Moq-backed fake. Requests are matched + (method/URL/headers/body) and canned responses returned — entirely in-process, no real HTTP. +- `appsettings.unittest.json` + `AdditionalConfiguration` layer test-only configuration in. + +This is fast (no process spin-up, no network), fully deterministic, and gives complete component-level +control — but it can only ever exercise **one** service's pipeline at a time. Anything the service calls +outside itself (another domain's API, a payment gateway) is necessarily a mock, never the real thing. + +## 2. Why Aspire changes the rules + +`Aspire.Hosting.Testing`'s `DistributedApplicationTestingBuilder` starts the **entire AppHost** — +every project, container, and executable resource it declares — as **separate real processes**, +wired together with Aspire's real service discovery, exactly as they'd run in production: + +```csharp +var appHost = await DistributedApplicationTestingBuilder.CreateAsync(); +await using var app = await appHost.BuildAsync(); +await app.StartAsync(); + +await app.ResourceNotifications.WaitForResourceHealthyAsync("shopping", cts.Token); +using var httpClient = app.CreateHttpClient("shopping"); +``` + +Consequences that matter for test design: + +- **No cross-process DI.** You cannot `ConfigureServices`/`ReplaceScoped` into another process's + container. Aspire's own docs are explicit about this: *"Because Aspire tests run services in separate + processes, you can't inject services directly through dependency injection. However, you can + influence application behavior through environment variables or configuration."* Overrides go through + `appHost.CreateResourceBuilder("name").WithEnvironment(...)`, or `WithExplicitStart` + and config-conditional resource registration in the AppHost itself — not through an in-memory DI hook. +- **Real network.** Requests actually leave one process's socket and arrive at another's Kestrel + listener — real serialization, real middleware pipeline, real service-discovery resolution. +- **Higher cost.** Process start-up, health-check waits, and (for container resources) Docker pulls make + these tests orders of magnitude slower than an in-memory `TestServer` round-trip. + +None of this is a limitation to "work around" — it's precisely what makes Tier 2 valuable: it's the only +way to prove that two real services actually talk to each other correctly. + +## 3. The two-tier model — pick one per test, don't force a hybrid + +- **Tier 1 stays exactly as it is.** If the point of a test is "does the Shopping service correctly + handle a `ProductPriceChanged` event, with the repository/clock/feature-flag mocked out precisely" — + that's a single-service, deep-DI-control test. Use `ApiTesterBase` directly. No amount of Aspire + wrapping replaces this, because the DI reach-in it relies on simply isn't available across process + boundaries. +- **Tier 2 (new `UnitTestEx.Aspire`) is for the case where the point of the test *is* the real + interaction between two or more domains.** If you actually want to prove Shopping's real HTTP call to + Products resolves correctly, hits the real routing/model binding/middleware in Products, and gets a + real response back — that can only be proven by running both as real processes. +- A hybrid (Tier-1-hosted service running alongside Tier-2 resources, with Aspire's resolved endpoints + fed into a `WebApplicationFactory`'s configuration) is *technically* possible, but it's a narrow, + advanced escape hatch — not something to design the mainline API around. Keep it out of scope for the + initial `UnitTestEx.Aspire` package; document it later only if a real need shows up. + +## 4. Worked scenario: Shopping + Products calling an external mail/payment gateway + +This is the shape of test the user described, mapped onto the model above: + +1. **Shopping and Products are the "key internals" under test** → run both as real Aspire project + resources under Tier 2. A request into Shopping that triggers a real call into Products exercises + the actual contract between them — the thing Tier 1 can never prove. +2. **The mail/payment gateway is a true external 3rd-party boundary**, not something you own or want to + hit for real in a test → swap it for a **`WireMock.Net.Aspire`** resource in the AppHost, and point + Shopping's/Products' configuration at the WireMock endpoint instead of the real one: + + ```csharp + var gateway = builder.AddWireMock("payment-gateway"); // WireMock.Net.Aspire resource + + builder.AddProject("shopping") + .WithReference(gateway) + .WithEnvironment("PaymentGateway__BaseUrl", gateway.GetEndpoint("http")); + ``` + + In the test, stub the gateway's responses through WireMock's server API (real HTTP, matched by + route/method/body) before asserting on Shopping's/Products' behavior. + +## 5. Is WireMock the equivalent of UnitTestEx's `MockHttpClientFactory`? + +**Conceptually, yes** — both let you declare "when a request matching X comes in, respond with Y" and +verify the expected calls happened. **Mechanically, they're different tools solving the same intent at +different layers**: + +| | `MockHttpClientFactory` (Tier 1) | WireMock.Net (Tier 2) | +|---|---|---| +| What it replaces | `IHttpClientFactory` inside one process's DI container | Nothing — it's a real HTTP server | +| Reachable from | Only the process it's registered in | Any process on the network — that's the whole point | +| Transport | In-memory (`DelegatingHandler`), no socket | Real HTTP, in-proc host or container | +| Works across Aspire processes? | No | Yes | + +So for Tier 2, WireMock.Net (via the official `WireMock.Net.Aspire` package) *is* the cross-process +counterpart of what `MockHttpClientFactory` does in-process for Tier 1. To keep the authoring experience +consistent, `UnitTestEx.Aspire` should ship a thin fluent wrapper over WireMock.Net's stub API, mirroring +the existing `MockHttpClient`/`MockHttpClientRequest`/`MockHttpClientResponse` builder syntax as closely +as possible, so switching between tiers doesn't mean learning a new mocking DSL. + +## 6. Mocking individual components case-by-case across processes + +Since there's no cross-process DI, "mock this one component in that other service" has to be solved +without reaching into its container. Options, in order of preference: + +1. **Environment-variable/config-driven test hooks baked into each project's own `Program.cs`** — e.g. a + convention like the existing `appsettings.unittest.json`, where a project opts in to swapping a + component when a specific config value/env var is set. This is a per-project responsibility (a + documented convention UnitTestEx can recommend), not something injected externally. +2. **Swap the whole resource for a stub** — if you don't need the real implementation at all, replace it + in the AppHost with a WireMock resource or a minimal stand-in project, exactly as in the gateway + example above. +3. **Use Tier 1 directly** — if what you actually need is deep DI control of one specific service, that's + a sign the test's real goal is single-service isolation, not multi-host integration. Write it as a + Tier 1 test instead of contorting Tier 2 to fake DI reach-in it was never designed to have. + +## 7. The Playwright/UI angle + +If the AppHost grows to include a web frontend (Blazor, React, etc.), Tier 2 resources are real, +reachable URLs — so [Playwright can drive them directly](https://learn.microsoft.com/dotnet/aspire/fundamentals/testing/web-testing/playwright/), +an officially documented Microsoft pattern, not something UnitTestEx needs to build: + +```csharp +await app.ResourceNotifications.WaitForResourceHealthyAsync("frontend", cts.Token); +var frontendUrl = app.CreateHttpClient("frontend").BaseAddress; + +await using var playwright = await Playwright.CreateAsync(); +await using var browser = await playwright.Chromium.LaunchAsync(); +var page = await browser.NewPageAsync(); +await page.GotoAsync(frontendUrl!.ToString()); +``` + +This is a natural extension of Tier 2, not a third tier: the AppHost already orchestrates the frontend as +a real process, so a real browser can just point at it. Contrast with Tier 1, where `TestServer` has no +real socket — Playwright has nothing to connect to. `UnitTestEx.Aspire` doesn't need its own Playwright +wrapper; a documentation section showing this pattern (resource → real URL → Playwright) is enough. + +## 8. Sketch of the new `UnitTestEx.Aspire` package surface + +Illustrative only — not implemented by this doc: + +```csharp +public abstract class AspireTesterBase : TesterBase, IAsyncDisposable + where TAppHost : class + where TSelf : AspireTesterBase +{ + // Builds + starts the DistributedApplicationTestingBuilder for TAppHost on first access, + // mirroring ApiTesterBase's lazy GetWebApplicationFactory() pattern. + protected Task GetDistributedApplicationAsync(); + + // Config/env overrides applied before BuildAsync (the only override surface available + // across process boundaries). + public TSelf WithResourceEnvironment(string resourceName, string key, string value); + + // Waits for a resource to be healthy/running before issuing requests against it. + public Task WaitForResourceAsync(string resourceName, TimeSpan? timeout = null); + + // Resource-scoped Http()/Http() wired to the resource's real HttpClient, reusing the + // existing HttpTester/HttpTester fluent assertion API for consistency with Tier 1. + public HttpTester Http(string resourceName); + public HttpTester Http(string resourceName); +} +``` + +A WireMock resource plugs into the same model as just another named resource: stub it via the thin +wrapper described in section 5, then assert against the real domain resource's behavior via +`Http(resourceName)` as usual. + +## 9. Versioning and CI impact + +- Aspire requires **.NET 8+**. `UnitTestEx.Aspire` would target `net8.0;net9.0;net10.0` only — it cannot + support `net6.0`/`net7.0` the way the core `UnitTestEx` package currently does. + `Aspire.Hosting.Testing` is the driving dependency. +- The test project needs a `` to the AppHost project — `DistributedApplicationTestingBuilder.CreateAsync()` + requires a `Projects.*`-namespace type that's only source-generated for referenced AppHost projects. + This is a structural requirement on consumers, not something UnitTestEx can abstract away. +- CI runners need **Docker** available for any container-backed resource (WireMock container variant, + service bus/DB emulators). Tier 1 has no such requirement today. +- **Startup latency** is much higher — process spin-up and health-check waits per resource, vs. an + in-memory `TestServer`. Expect Tier 2 suites to run substantially slower and probably to be a smaller, + more targeted subset of the overall test suite than Tier 1 suites. +- **Log/trace capture** across multiple real processes is harder than today's single in-process + `ILoggerProvider` capture (`LoggerProvider.CreateLogger(...)` in `ApiTesterBase`) — Aspire's dashboard/ + OTLP pipeline is the natural place to look, rather than trying to replicate today's single-process log + capture across processes. + +## 10. Recommendation + +Ship this as a new, separately-versioned, **opt-in** `UnitTestEx.Aspire` package, following the same +companion-package pattern already established by `UnitTestEx.Azure.Functions` and +`UnitTestEx.Azure.ServiceBus` — the core `UnitTestEx` package and `ApiTesterBase`/`MockHttpClientFactory` +are untouched either way. Suggested phasing for follow-up work: + +1. This design doc (done). +2. Prototype `AspireTesterBase`/`DistributedTesterBase` with resource-scoped `Http()`/`Http()`, + `WaitForResourceAsync`, and environment-override helpers. +3. Thin WireMock.Net wrapper mirroring `MockHttpClient`'s authoring syntax. +4. Documentation for the Playwright/UI pattern (section 7) — no new code required, just guidance. From cbe9363750a61f1f0d66440ef9f570921676cda8 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 10:37:17 -0700 Subject: [PATCH 02/43] docs: address TesterBase capability gap for Aspire testers Adds section 9 analyzing which TesterBase 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 --- docs/design/aspire-multi-host-testing.md | 95 ++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 6 deletions(-) diff --git a/docs/design/aspire-multi-host-testing.md b/docs/design/aspire-multi-host-testing.md index 668d12d..931e001 100644 --- a/docs/design/aspire-multi-host-testing.md +++ b/docs/design/aspire-multi-host-testing.md @@ -22,6 +22,13 @@ This isn't a UnitTestEx-specific compromise — it matches Aspire's own guidance So the plan is to keep Tier 1 exactly as-is, and add Tier 2 as a new, separately-versioned, opt-in package — picking the tier per test based on what you're actually trying to prove, not blending them. +One important wrinkle: `TesterBase` is the shared base a lot of fluent extension methods hang off +today, and most of its DI-flavoured methods (`ReplaceSingleton`, `MockScoped`, `ScopedType`, etc.) would +otherwise **silently no-op** rather than fail if called on a Tier 2 tester, because they queue into a +list that only Tier 1's `WebApplicationFactory` wiring ever plays back. Section 9 proposes a small, +additive, backward-compatible guard (`SupportsServiceConfiguration`) to turn that into a clear, +immediate `NotSupportedException` instead. + ## 1. The current model (Tier 1), recapped `ApiTesterBase` (`src/UnitTestEx/AspNetCore/ApiTesterBase.cs`) wraps a @@ -197,7 +204,79 @@ A WireMock resource plugs into the same model as just another named resource: st wrapper described in section 5, then assert against the real domain resource's behavior via `Http(resourceName)` as usual. -## 9. Versioning and CI impact +## 9. `TesterBase` reuse: why `AspireTesterBase` can't just inherit it unchanged + +`TesterBase` (`src/UnitTestEx/Abstractions/TesterBaseT.cs`) is the type a large number of fluent +extension methods hang off (`ReplaceSingleton`/`Scoped`/`Transient` + `Keyed`/`Mock` variants, +`ReplaceHttpClientFactory`, `ScopedType`, `Type`, `UseUser`, `UseJsonSerializer`, `Delay`, ...). If +`AspireTesterBase` simply extended it, most of these would still compile — some fully valid, some +subtly, silently broken. Cataloguing what's actually in play: + +**Host-agnostic — reusable as-is, no change needed:** +- `UserName`/`UseUser`/`WithUser` — this doesn't touch DI at all; it feeds + `TestSetUp.OnBeforeHttpRequestMessageSendAsync`/`OnBeforeHttpRequestSendAsync`, a hook that mutates the + outgoing `HttpRequestMessage`/`HttpRequest` before it's sent (e.g. to attach an OAuth token) — just as + meaningful for a real cross-resource HTTP call in Tier 2 as for an in-memory one in Tier 1. +- `UseJsonSerializer`/`JsonComparerOptions`/`CreateJsonComparer` — test-side JSON handling, independent + of the host model. +- `Delay`, `ResetHost()` (`abstract`, each tier already implements its own semantics), + `OnHostStart`/`OnHostStartUp` (`OnHostStartUp` is already `protected virtual`) — can be reinterpreted + as "run after the resource becomes healthy" rather than "run after the in-process host starts." + +**Already correctly guarded today — no framework change needed:** +- `Services` and `Configuration` are already `abstract`; `AspireTesterBase` just needs to override them + to `throw new NotSupportedException(...)`. +- `ScopedType`/`Type` (6 overloads) call `Services.CreateScope()` **synchronously at + the call site** (via the existing `HostExecutionWrapper`) — so once `Services` throws, these already + fail immediately and correctly. Nothing to add here. + +**The actual gap:** every `Replace*`/`Mock*`/`ReplaceHttpClientFactory` method (~25 overloads across +singleton/scoped/transient/keyed variants) funnels through one method — +`TesterBase.ConfigureServices(Action, bool)` — which is `protected` but **not +virtual**. A subclass has no way to intercept or reject it. Left unchanged, calling e.g. +`.ReplaceSingleton(...)` on an `AspireTesterBase` would compile fine, silently queue into an +internal list, and then simply never run — because nothing in an Aspire-hosted flow ever calls +`AddConfiguredServices(IServiceCollection)` (there's no in-process `IServiceCollection` to configure). +**That's a silent no-op, not a compile error or a runtime exception** — the test looks like it wired in +a mock, passes, and the mock was never applied. That's a worse failure mode than an explicit exception. + +**Proposed fix — one new virtual property, one guard clause, fully backward-compatible:** + +```csharp +// TesterBase — new; defaults to true, so every existing Tier 1 tester needs zero changes. +protected virtual bool SupportsServiceConfiguration => true; + +protected void ConfigureServices(Action configureServices, bool autoResetHost = true) +{ + if (!SupportsServiceConfiguration) + throw new NotSupportedException( + $"{GetType().Name} does not support in-process service configuration/replacement because its " + + "underlying host runs as a separate real process. Configure the resource itself instead " + + "(environment variables/config passed at AppHost build time), or swap in a WireMock resource " + + "for HTTP boundaries."); + + lock (SyncRoot) + { + if (autoResetHost) + ResetHost(false); + + _configureServices.Add(configureServices); + } +} +``` + +`AspireTesterBase` sets `SupportsServiceConfiguration => false` and implements the already-abstract +`Services`/`Configuration` to throw the same way. Because every DI-flavoured fluent method already +funnels through this one guarded method, all ~25 of them fail loudly and immediately at the call site, +with a message pointing at the real Tier 2 alternative — no need to touch each method individually. + +This is intentionally a single boolean switch, not a `[Flags]` capability enum — given how centralized +`ConfigureServices` already is and how few other members are actually host-model-sensitive (see above), +a richer capability model would be speculative complexity today. If further, more granular gaps emerge +once `UnitTestEx.Aspire` is actually built, a `TesterCapabilities` flags enum can replace the single bool +then, without another breaking change (the guarded call site stays the same shape). + +## 10. Versioning and CI impact - Aspire requires **.NET 8+**. `UnitTestEx.Aspire` would target `net8.0;net9.0;net10.0` only — it cannot support `net6.0`/`net7.0` the way the core `UnitTestEx` package currently does. @@ -215,15 +294,19 @@ wrapper described in section 5, then assert against the real domain resource's b OTLP pipeline is the natural place to look, rather than trying to replicate today's single-process log capture across processes. -## 10. Recommendation +## 11. Recommendation Ship this as a new, separately-versioned, **opt-in** `UnitTestEx.Aspire` package, following the same companion-package pattern already established by `UnitTestEx.Azure.Functions` and `UnitTestEx.Azure.ServiceBus` — the core `UnitTestEx` package and `ApiTesterBase`/`MockHttpClientFactory` -are untouched either way. Suggested phasing for follow-up work: +are untouched either way. The one small, backward-compatible change needed in core `UnitTestEx` is the +`SupportsServiceConfiguration` guard from section 9 — everything else is additive. Suggested phasing for +follow-up work: 1. This design doc (done). -2. Prototype `AspireTesterBase`/`DistributedTesterBase` with resource-scoped `Http()`/`Http()`, +2. Add the `SupportsServiceConfiguration` guard to `TesterBase`/`TesterBase` (section 9) — a + small, additive, backward-compatible core change, landed and released independently of the rest. +3. Prototype `AspireTesterBase`/`DistributedTesterBase` with resource-scoped `Http()`/`Http()`, `WaitForResourceAsync`, and environment-override helpers. -3. Thin WireMock.Net wrapper mirroring `MockHttpClient`'s authoring syntax. -4. Documentation for the Playwright/UI pattern (section 7) — no new code required, just guidance. +4. Thin WireMock.Net wrapper mirroring `MockHttpClient`'s authoring syntax. +5. Documentation for the Playwright/UI pattern (section 7) — no new code required, just guidance. From b87c11b34685fb0aedefe6176a26af37794cd135 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 10:47:35 -0700 Subject: [PATCH 03/43] docs: make SupportsServiceConfiguration public, audit existing extensions - 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 --- docs/design/aspire-multi-host-testing.md | 48 +++++++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/design/aspire-multi-host-testing.md b/docs/design/aspire-multi-host-testing.md index 931e001..827bf95 100644 --- a/docs/design/aspire-multi-host-testing.md +++ b/docs/design/aspire-multi-host-testing.md @@ -240,11 +240,13 @@ internal list, and then simply never run — because nothing in an Aspire-hosted **That's a silent no-op, not a compile error or a runtime exception** — the test looks like it wired in a mock, passes, and the mock was never applied. That's a worse failure mode than an explicit exception. -**Proposed fix — one new virtual property, one guard clause, fully backward-compatible:** +**Proposed fix — one new public virtual property, one guard clause, fully backward-compatible:** ```csharp -// TesterBase — new; defaults to true, so every existing Tier 1 tester needs zero changes. -protected virtual bool SupportsServiceConfiguration => true; +// TesterBase — public getter (read-only, virtual) so extension methods — UnitTestEx's own, a companion +// package's, or a consumer's — can defensively check the capability themselves, not just rely on +// catching the exception. Defaults to true, so every existing Tier 1 tester needs zero changes. +public virtual bool SupportsServiceConfiguration => true; protected void ConfigureServices(Action configureServices, bool autoResetHost = true) { @@ -265,7 +267,18 @@ protected void ConfigureServices(Action configureServices, b } ``` -`AspireTesterBase` sets `SupportsServiceConfiguration => false` and implements the already-abstract +Making the getter `public` rather than `protected` matters because `TesterBase`'s whole design +point is being a hook for extension methods (many of `UnitTestEx`'s own fluent methods are already +written as extensions elsewhere in the codebase, and consumers/companion packages write their own). +A `protected` flag is invisible to any of those — they'd have no way to guard themselves and would just +propagate whatever exception the guarded core method throws (or, worse, do the DI-touching work +*themselves* without ever routing through `ConfigureServices`, bypassing the guard entirely). A public +getter lets any extension method written against `TesterBase` check +`tester.SupportsServiceConfiguration` up front and either skip the operation, throw its own +tier-appropriate message, or offer a fallback — the same pattern already used for the public +`IsHostInstantiated` flag on `TesterBase`. + +`AspireTesterBase` overrides `SupportsServiceConfiguration => false` and implements the already-abstract `Services`/`Configuration` to throw the same way. Because every DI-flavoured fluent method already funnels through this one guarded method, all ~25 of them fail loudly and immediately at the call site, with a message pointing at the real Tier 2 alternative — no need to touch each method individually. @@ -273,8 +286,31 @@ with a message pointing at the real Tier 2 alternative — no need to touch each This is intentionally a single boolean switch, not a `[Flags]` capability enum — given how centralized `ConfigureServices` already is and how few other members are actually host-model-sensitive (see above), a richer capability model would be speculative complexity today. If further, more granular gaps emerge -once `UnitTestEx.Aspire` is actually built, a `TesterCapabilities` flags enum can replace the single bool -then, without another breaking change (the guarded call site stays the same shape). +once `UnitTestEx.Aspire` is actually built, a `TesterCapabilities` flags enum can replace the single +public property then, without another breaking change (the guarded call site stays the same shape). + +**Existing extension methods audited too, not just core members.** `TesterBase`/`TesterBase` is +also the hook for extension methods defined outside `UnitTestEx` itself, so the companion packages were +checked as well: + +- `UnitTestEx.Azure.ServiceBus/ExtensionMethods.cs` (`CreateServiceBusMessageFromValue`, + `CreateServiceBusMessage*`, etc.) and `UnitTestEx.Azure.Functions/ExtensionMethods.cs` + (`CreateWebJobsServiceBusMessageActions`, etc.) both extend `TesterBase` — but neither touches DI at + all; they only read `tester.JsonSerializer`/`tester.Implementor` to build message payloads/assertors. + Both are host-agnostic already and need no guarding. +- `UnitTestEx/ExtensionMethods.cs`'s `ReplaceSingleton`/`ReplaceScoped`/`ReplaceTransient`/`Keyed*`/ + `Remove`/`RemoveKeyed` are extensions on `IServiceCollection`, not on `TesterBase` — they can only ever + be called from inside a `ConfigureServices(sc => ...)` callback delegate. Since that callback is only + ever queued (and only ever played back) through the now-guarded `TesterBase.ConfigureServices`, these + are automatically shielded for free: for a Tier 2 tester the callback is never queued in the first + place (the guard throws before it can be), so these `IServiceCollection` extensions are simply never + invoked. No separate check needed inside them. + +The public `SupportsServiceConfiguration` getter still matters for *future* extension methods — +particularly anything a `UnitTestEx.Aspire` package itself, or a consumer, might add directly against +`TesterBase` that touches DI without going through the existing `ConfigureServices` funnel. Any +such method should check `tester.SupportsServiceConfiguration` itself rather than assume the funnel will +catch it, exactly because today's audit shows the funnel is the only enforcement point. ## 10. Versioning and CI impact From 1f796fbe1526c9870160bc46a27bf394f076c387 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 10:56:26 -0700 Subject: [PATCH 04/43] feat: add TesterBase.SupportsServiceConfiguration guard (Phase 1 of #105) 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 --- CHANGELOG.md | 3 ++ src/UnitTestEx/Abstractions/TesterBase.cs | 13 +++++ .../Other/SupportsServiceConfigurationTest.cs | 47 +++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 tests/UnitTestEx.Xunit.Test/Other/SupportsServiceConfigurationTest.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index bd55491..f04c993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ Represents the **NuGet** versions. +## v5.12.0 +- *Enhancement:* Added `TesterBase.SupportsServiceConfiguration` (defaults to `true`) to enable testers whose underlying host does not run in-process (for example, a future multi-host/distributed application tester) to opt out of in-process DI service configuration/replacement. Where overridden to `false`, the DI-related fluent methods (e.g. `ReplaceSingleton`/`ReplaceScoped`/`ReplaceTransient` and their `Keyed`/`Mock` variants, `ReplaceHttpClientFactory`) now throw a `NotSupportedException` instead of silently queuing configuration that would never be applied. + ## v5.11.1 - *Fixed:* Wrapped `Assembly.LoadFrom` in `TestSetUp` with try-catch to log exceptions and prevent setup failures. diff --git a/src/UnitTestEx/Abstractions/TesterBase.cs b/src/UnitTestEx/Abstractions/TesterBase.cs index c90e0f0..0d03f83 100644 --- a/src/UnitTestEx/Abstractions/TesterBase.cs +++ b/src/UnitTestEx/Abstractions/TesterBase.cs @@ -100,6 +100,15 @@ public TesterBase(TestFrameworkImplementor implementor) /// The host can be reset by invoking . public bool IsHostInstantiated { get; internal set; } + /// + /// Indicates whether the underlying host supports in-process service configuration/replacement (see ). + /// + /// Defaults to true. Testers whose underlying host does not run in-process (for example, one that orchestrates real, separate processes) should override this to return + /// false, and should also have their / implementations throw a for the same reason. + /// This is exposed publicly (rather than protected) so that extension methods - whether defined by UnitTestEx itself, a companion package, or a consumer - can defensively check + /// the capability themselves before attempting an in-process service configuration/replacement operation, rather than relying solely on the being thrown. + public virtual bool SupportsServiceConfiguration => true; + /// /// Gets the synchronization object where synchronized access is required. /// @@ -226,8 +235,12 @@ protected void OnHostStart(Action start, bool autoResetHost = true) /// A delegate for configuring . /// Indicates whether to automatically (passing false) when configuring the services. /// This can be called multiple times prior to the underlying host being instantiated. Internally, the is queued and then played in order when the host is initially instantiated. + /// Thrown where is false. protected void ConfigureServices(Action configureServices, bool autoResetHost = true) { + if (!SupportsServiceConfiguration) + throw new NotSupportedException($"{GetType().Name} does not support in-process service configuration/replacement because its underlying host does not run in-process (see {nameof(SupportsServiceConfiguration)})."); + lock (SyncRoot) { if (autoResetHost) diff --git a/tests/UnitTestEx.Xunit.Test/Other/SupportsServiceConfigurationTest.cs b/tests/UnitTestEx.Xunit.Test/Other/SupportsServiceConfigurationTest.cs new file mode 100644 index 0000000..a4105e5 --- /dev/null +++ b/tests/UnitTestEx.Xunit.Test/Other/SupportsServiceConfigurationTest.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System; +using UnitTestEx.Abstractions; +using Xunit; + +namespace UnitTestEx.Xunit.Test.Other +{ + public class SupportsServiceConfigurationTest + { + [Fact] + public void Default_Is_True_And_ConfigureServices_Succeeds() + { + using var tester = new NoDiTester(); + Assert.True(tester.SupportsServiceConfiguration); + + // Should not throw as service configuration is supported by default. + tester.ReplaceScoped(_ => throw new NotImplementedException()); + } + + [Fact] + public void Unsupported_ConfigureServices_Throws_NotSupportedException() + { + using var tester = new NoDiTester(supportsServiceConfiguration: false); + Assert.False(tester.SupportsServiceConfiguration); + + var ex = Assert.Throws(() => tester.ReplaceScoped(_ => throw new NotImplementedException())); + Assert.Contains(nameof(NoDiTester.SupportsServiceConfiguration), ex.Message); + } + + /// + /// A minimal that does not run a real host; used purely to validate guarding behaviour. + /// + private sealed class NoDiTester(bool supportsServiceConfiguration = true) : TesterBase(TestFrameworkImplementor.Null), IDisposable + { + public override bool SupportsServiceConfiguration { get; } = supportsServiceConfiguration; + + public override IServiceProvider Services => throw new NotSupportedException($"{nameof(Services)} is not supported as {nameof(SupportsServiceConfiguration)} is false."); + + public override IConfiguration Configuration => throw new NotSupportedException($"{nameof(Configuration)} is not supported as {nameof(SupportsServiceConfiguration)} is false."); + + protected override void ResetHost() { } + + public void Dispose() { } + } + } +} From 26a3d1885f8cbf4595423dc13f29f27731cdf130 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 11:05:28 -0700 Subject: [PATCH 05/43] fix: address Copilot review feedback on PR #106 - 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 --- Common.targets | 2 +- docs/design/aspire-multi-host-testing.md | 20 +++++++++++++++++--- src/UnitTestEx/Abstractions/TesterBase.cs | 7 +++++-- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Common.targets b/Common.targets index 0ea044b..4b8740c 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 5.11.1 + 5.12.0 preview Avanade Avanade diff --git a/docs/design/aspire-multi-host-testing.md b/docs/design/aspire-multi-host-testing.md index 827bf95..f05d828 100644 --- a/docs/design/aspire-multi-host-testing.md +++ b/docs/design/aspire-multi-host-testing.md @@ -253,9 +253,10 @@ protected void ConfigureServices(Action configureServices, b if (!SupportsServiceConfiguration) throw new NotSupportedException( $"{GetType().Name} does not support in-process service configuration/replacement because its " + - "underlying host runs as a separate real process. Configure the resource itself instead " + - "(environment variables/config passed at AppHost build time), or swap in a WireMock resource " + - "for HTTP boundaries."); + $"underlying host does not run in-process (see {nameof(SupportsServiceConfiguration)}). " + + $"Instead, configure the target resource through its supported environment/configuration surface " + + $"(e.g. an Aspire resource builder's 'WithEnvironment'/'WithReference'), or mock its external HTTP " + + $"dependencies at the resource boundary (e.g. using WireMock.Net.Aspire) rather than in-process."); lock (SyncRoot) { @@ -312,6 +313,19 @@ particularly anything a `UnitTestEx.Aspire` package itself, or a consumer, might such method should check `tester.SupportsServiceConfiguration` itself rather than assume the funnel will catch it, exactly because today's audit shows the funnel is the only enforcement point. +**One direct configuration entry point deliberately sits outside the guard: `TestSetUp.ConfigureServices`.** +This is a global `Action?` delegate (set once on `TestSetUp`/`TestSetUp.Default`, +not per-tester) that each *concrete* Tier 1 tester's own host-building code invokes directly — +e.g. `ApiTesterBase`/`GenericTesterCore` both call `SetUp.ConfigureServices?.Invoke(sc)` immediately +before `AddConfiguredServices(sc)` while constructing their `IServiceCollection`. It never routes through +`TesterBase.ConfigureServices`, so the new guard clause does not — and cannot — see it. This is safe by +construction rather than by the guard: an `AspireTesterBase`'s host-building code has no in-process +`IServiceCollection` to build in the first place, so it simply would never call +`SetUp.ConfigureServices?.Invoke(...)` at all — there is nothing to guard because there is no call site +to guard. This is called out explicitly so it isn't mistaken for a gap the `SupportsServiceConfiguration` +guard is responsible for closing; it is a different, tester-implementation-level entry point, and each +future Tier 2 tester implementation is simply responsible for not invoking it. + ## 10. Versioning and CI impact - Aspire requires **.NET 8+**. `UnitTestEx.Aspire` would target `net8.0;net9.0;net10.0` only — it cannot diff --git a/src/UnitTestEx/Abstractions/TesterBase.cs b/src/UnitTestEx/Abstractions/TesterBase.cs index 0d03f83..7689a53 100644 --- a/src/UnitTestEx/Abstractions/TesterBase.cs +++ b/src/UnitTestEx/Abstractions/TesterBase.cs @@ -235,11 +235,14 @@ protected void OnHostStart(Action start, bool autoResetHost = true) /// A delegate for configuring . /// Indicates whether to automatically (passing false) when configuring the services. /// This can be called multiple times prior to the underlying host being instantiated. Internally, the is queued and then played in order when the host is initially instantiated. - /// Thrown where is false. + /// Thrown when is false. protected void ConfigureServices(Action configureServices, bool autoResetHost = true) { if (!SupportsServiceConfiguration) - throw new NotSupportedException($"{GetType().Name} does not support in-process service configuration/replacement because its underlying host does not run in-process (see {nameof(SupportsServiceConfiguration)})."); + throw new NotSupportedException( + $"{GetType().Name} does not support in-process service configuration/replacement because its underlying host does not run in-process (see {nameof(SupportsServiceConfiguration)}). " + + $"Instead, configure the target resource through its supported environment/configuration surface (e.g. an Aspire resource builder's 'WithEnvironment'/'WithReference'), " + + $"or mock its external HTTP dependencies at the resource boundary (e.g. using WireMock.Net.Aspire) rather than in-process."); lock (SyncRoot) { From 320c9758a0ad4339aaefc11d27069b784c0d191a Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 11:16:15 -0700 Subject: [PATCH 06/43] docs: revise design doc - AspireTesterBase inherits TesterBase, not TesterBase Re-examined the Phase 2 base-class decision after a direct challenge: is TesterBase 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 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. It's a working example of exactly where the tier boundary sits. Conclusion: AspireTesterBase should inherit the non-generic TesterBase directly, not TesterBase. 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 : 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 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/aspire-multi-host-testing.md | 179 ++++++++++++++++------- 1 file changed, 124 insertions(+), 55 deletions(-) diff --git a/docs/design/aspire-multi-host-testing.md b/docs/design/aspire-multi-host-testing.md index f05d828..375b502 100644 --- a/docs/design/aspire-multi-host-testing.md +++ b/docs/design/aspire-multi-host-testing.md @@ -175,10 +175,12 @@ wrapper; a documentation section showing this pattern (resource → real URL → ## 8. Sketch of the new `UnitTestEx.Aspire` package surface -Illustrative only — not implemented by this doc: +Illustrative only — not implemented by this doc. **Revised per section 9's inheritance analysis:** +`AspireTesterBase` extends the non-generic `TesterBase`, *not* `TesterBase` — see section 9 for +why. ```csharp -public abstract class AspireTesterBase : TesterBase, IAsyncDisposable +public abstract class AspireTesterBase : TesterBase, IAsyncDisposable where TAppHost : class where TSelf : AspireTesterBase { @@ -194,9 +196,17 @@ public abstract class AspireTesterBase : TesterBase, IAs public Task WaitForResourceAsync(string resourceName, TimeSpan? timeout = null); // Resource-scoped Http()/Http() wired to the resource's real HttpClient, reusing the - // existing HttpTester/HttpTester fluent assertion API for consistency with Tier 1. + // existing HttpTester/HttpTester fluent assertion API for consistency with Tier 1 — made + // possible because HttpTesterBase already only depends on the non-generic TesterBase (see + // section 9), not on anything DI-related from TesterBase. public HttpTester Http(string resourceName); public HttpTester Http(string resourceName); + + // The two members TesterBase declares abstract; honestly unsupported for a multi-process tester. + public override IServiceProvider Services => + throw new NotSupportedException("AspireTesterBase has no single in-process IServiceProvider; each resource owns its own DI container. Use Http(resourceName) or configure the resource directly instead."); + public override IConfiguration Configuration => + throw new NotSupportedException("AspireTesterBase has no single in-process IConfiguration; configure/override the resource's own environment/configuration before it starts instead."); } ``` @@ -204,43 +214,95 @@ A WireMock resource plugs into the same model as just another named resource: st wrapper described in section 5, then assert against the real domain resource's behavior via `Http(resourceName)` as usual. -## 9. `TesterBase` reuse: why `AspireTesterBase` can't just inherit it unchanged - -`TesterBase` (`src/UnitTestEx/Abstractions/TesterBaseT.cs`) is the type a large number of fluent -extension methods hang off (`ReplaceSingleton`/`Scoped`/`Transient` + `Keyed`/`Mock` variants, -`ReplaceHttpClientFactory`, `ScopedType`, `Type`, `UseUser`, `UseJsonSerializer`, `Delay`, ...). If -`AspireTesterBase` simply extended it, most of these would still compile — some fully valid, some -subtly, silently broken. Cataloguing what's actually in play: - -**Host-agnostic — reusable as-is, no change needed:** -- `UserName`/`UseUser`/`WithUser` — this doesn't touch DI at all; it feeds - `TestSetUp.OnBeforeHttpRequestMessageSendAsync`/`OnBeforeHttpRequestSendAsync`, a hook that mutates the - outgoing `HttpRequestMessage`/`HttpRequest` before it's sent (e.g. to attach an OAuth token) — just as - meaningful for a real cross-resource HTTP call in Tier 2 as for an in-memory one in Tier 1. -- `UseJsonSerializer`/`JsonComparerOptions`/`CreateJsonComparer` — test-side JSON handling, independent - of the host model. -- `Delay`, `ResetHost()` (`abstract`, each tier already implements its own semantics), - `OnHostStart`/`OnHostStartUp` (`OnHostStartUp` is already `protected virtual`) — can be reinterpreted - as "run after the resource becomes healthy" rather than "run after the in-process host starts." - -**Already correctly guarded today — no framework change needed:** -- `Services` and `Configuration` are already `abstract`; `AspireTesterBase` just needs to override them - to `throw new NotSupportedException(...)`. -- `ScopedType`/`Type` (6 overloads) call `Services.CreateScope()` **synchronously at - the call site** (via the existing `HostExecutionWrapper`) — so once `Services` throws, these already - fail immediately and correctly. Nothing to add here. - -**The actual gap:** every `Replace*`/`Mock*`/`ReplaceHttpClientFactory` method (~25 overloads across -singleton/scoped/transient/keyed variants) funnels through one method — -`TesterBase.ConfigureServices(Action, bool)` — which is `protected` but **not -virtual**. A subclass has no way to intercept or reject it. Left unchanged, calling e.g. -`.ReplaceSingleton(...)` on an `AspireTesterBase` would compile fine, silently queue into an -internal list, and then simply never run — because nothing in an Aspire-hosted flow ever calls -`AddConfiguredServices(IServiceCollection)` (there's no in-process `IServiceCollection` to configure). -**That's a silent no-op, not a compile error or a runtime exception** — the test looks like it wired in -a mock, passes, and the mock was never applied. That's a worse failure mode than an explicit exception. - -**Proposed fix — one new public virtual property, one guard clause, fully backward-compatible:** +## 9. Why `AspireTesterBase` inherits `TesterBase`, not `TesterBase` + +**Revised conclusion, superseding an earlier draft of this section.** The first draft of this doc (and +the sketch in section 8) had `AspireTesterBase : TesterBase` — i.e. reuse the +*generic* base and guard away the parts that don't apply. On reflection that's the wrong shape: it's a +Liskov substitution violation waiting to happen. `TesterBase` (`src/UnitTestEx/Abstractions/TesterBaseT.cs`) +exists for exactly one reason — **in-process DI reach-in** — and it is *only* that: `ReplaceSingleton`/ +`Scoped`/`Transient` + `Keyed`/`Mock` variants, `ReplaceHttpClientFactory`, `ScopedType`, +`Type` (~30 members total, all DI-flavoured). None of it applies to a multi-process Tier 2 +tester, full stop. Inheriting it just to guard ~30 members with a runtime `NotSupportedException` means +every one of those methods *looks* like a valid, IntelliSense-suggested thing to call on an +`AspireTesterBase` — compiles clean, reads clean — and then blows up at test-run time. That's a bad +authoring experience for a large surface, and the guard from section 8 (now shipped) only mitigates it; +it doesn't remove it. + +**What's actually shared between the tiers, concretely, not hypothetically:** the evidence is +`HttpTesterBase` (`src/UnitTestEx/AspNetCore/HttpTesterBase.cs`) — the class that already implements +Tier 1's entire HTTP request/response/assertion engine (request+response logging, JSON (de)serialization, +the `Expectations`/`Assertors` pipeline, per-request log capture correlated via `SharedState`/`RequestId`, +the `OnBeforeHttpRequestMessageSendAsync` user-impersonation hook). It takes an `Owner` typed as the +*non-generic* `TesterBase` in its constructor — **not** `TesterBase` — and never touches +`Services`, `Configuration`, `ConfigureServices`, or any `Replace*`/`ScopedType`/`Type` member. It's +composed alongside a `TesterBase`-derived `ApiTesterBase` today (which supplies the `TestServer`), +but it doesn't need the DI-heavy base itself. That's a real, working example — not a guess — of exactly +where the tier boundary actually sits: **`TesterBase` (non-generic) is the true shared capability set; +`TesterBase` is Tier-1-only.** + +**What `TesterBase` (non-generic) actually gives you, and why every item is genuinely host-agnostic:** +- `Implementor`, `LoggerProvider`, `SharedState` — test-framework output plumbing and the log-capture/ + request-correlation mechanism `HttpTesterBase` relies on. +- `JsonSerializer`/`UseJsonSerializer`, `JsonComparerOptions`/`CreateJsonComparer` — test-side JSON + handling, independent of the host model. +- `UserName`/`UseUser`/`WithUser` — feeds `TestSetUp.OnBeforeHttpRequestMessageSendAsync`, a hook that + mutates the outgoing `HttpRequestMessage` before it's sent (e.g. to attach an OAuth token) — just as + meaningful for a real cross-resource HTTP call in Tier 2 as an in-memory one in Tier 1. +- `SetUp` (`TestSetUp`), `Delay`, `ExecutePreRunActions`/`ExecutePostRun*Actions`, + `OnHostStart`/`OnHostStartUp` — pipeline hooks `HttpTesterBase.SendAsync` already calls today; + "host start" can be reinterpreted as "resource becomes healthy" for Tier 2. +- `ResetHost()` (`abstract`) — each tier already implements its own semantics; Tier 2's will look more + like "tear down and rebuild the `DistributedApplication`" than Tier 1's in-place reset, but the shape + (an abstract hook the base calls when needed) still fits. +- `Services`/`Configuration` (`abstract`) and `SupportsServiceConfiguration`/`ConfigureServices` (the + guard shipped in this repo already) — `AspireTesterBase` still needs to override the two abstract + members and still benefits from the guard as defense-in-depth (see below), but critically it does + **not** need to inherit the ~30 concrete DI-flavoured methods to do so. + +**What this means for `HttpTesterBase` itself (a small, real follow-up, not hand-waving):** today it's +hard-coded to `TestServer` (`CreateHttpClient() => new(new HttpDelegatingHandler(this, +TestServer.CreateHandler())) { BaseAddress = TestServer.BaseAddress }`). To let `AspireTesterBase.Http(resourceName)` +reuse the same engine, `HttpTesterBase`/its `HttpTester` subclasses need their `HttpClient` source +extracted behind a small seam (e.g. an abstract/virtual `CreateHttpClient()`, or a constructor taking a +`Func` instead of a `TestServer` directly) so Tier 2 can supply `app.CreateHttpClient(resourceName)` +in place of `TestServer.CreateHandler()`. Everything else in `HttpTesterBase` — logging, JSON, expectations, +the user-impersonation hook — needs no change at all. + +**So, concretely, for Phase 3 (prototyping `AspireTesterBase`):** +1. `AspireTesterBase : TesterBase` (non-generic) — see the revised section 8 sketch. + It gets zero `Replace*`/`ScopedType`/`Type` members: not guarded away, simply never inherited. A + consumer reading its IntelliSense sees only members that actually work. +2. Override `Services`/`Configuration` honestly (`throw new NotSupportedException(...)`, pointing at the + real per-resource alternative) — this is a deliberate, single, well-documented override of two members + already declared `abstract`, not a smell; it's exactly what an abstract member is for. +3. Extract `HttpTesterBase`'s `HttpClient` source behind a small seam so `Http(resourceName)` can reuse + the entire existing HTTP assertion engine unchanged, fed from `app.CreateHttpClient(resourceName)` + instead of `TestServer`. +4. The `SupportsServiceConfiguration` guard (already shipped in core `TesterBase`) remains valuable as + defense-in-depth — not for `AspireTesterBase` itself (which never has the DI methods to call), but for + *other* extension methods written directly against the non-generic `TesterBase` (by this repo, a + companion package, or a consumer) that might reach into DI without going through the funnel. See below + for the one existing entry point that still sits outside it. + +Cataloguing the rest of what's on `TesterBase` for completeness, so nothing is assumed away: +`ScopedType`/`Type` (6 overloads) call `Services.CreateScope()` synchronously at the +call site — they'd already fail correctly once `Services` throws, if they were ever inherited, but under +the revised design they simply aren't present on `AspireTesterBase` at all, which is strictly better than +"present but throws." + +**The actual gap the guard closes (still true, still shipped):** every `Replace*`/`Mock*`/ +`ReplaceHttpClientFactory` method (~25 overloads) funnels through one method — +`TesterBase.ConfigureServices(Action, bool)` — which was `protected` but **not +virtual** before this repo's change. A subclass had no way to intercept or reject it. Left unguarded, +calling e.g. `.ReplaceSingleton(...)` on any future non-in-process `TesterBase`-derived type would +compile fine, silently queue into an internal list, and then simply never run — because nothing in a +non-in-process flow ever calls `AddConfiguredServices(IServiceCollection)`. **That's a silent no-op, not +a compile error or a runtime exception** — worse than an explicit exception. The guard fixes that for any +type that ends up with those methods in scope; the revised `AspireTesterBase` design avoids needing to +rely on it at all by simply not inheriting them, but the guard is still the right belt-and-braces fix at +the `TesterBase` level, in case a future extension method (companion package or consumer) adds its own +`Replace*`-shaped helper directly against `TesterBase`. ```csharp // TesterBase — public getter (read-only, virtual) so extension methods — UnitTestEx's own, a companion @@ -279,10 +341,12 @@ getter lets any extension method written against `TesterBase` check tier-appropriate message, or offer a fallback — the same pattern already used for the public `IsHostInstantiated` flag on `TesterBase`. -`AspireTesterBase` overrides `SupportsServiceConfiguration => false` and implements the already-abstract -`Services`/`Configuration` to throw the same way. Because every DI-flavoured fluent method already -funnels through this one guarded method, all ~25 of them fail loudly and immediately at the call site, -with a message pointing at the real Tier 2 alternative — no need to touch each method individually. +`AspireTesterBase` doesn't even need to override `SupportsServiceConfiguration` — under the revised +design (section 9 above) it never inherits `TesterBase` or its ~25 DI-flavoured fluent methods in +the first place, so there's no funnel call site to guard for it specifically. It still implements the +already-abstract `Services`/`Configuration` to throw. The guard earns its keep for any *other* +`TesterBase`-derived type (present or future) that does end up with DI-touching methods in scope without +going through the intended flow. This is intentionally a single boolean switch, not a `[Flags]` capability enum — given how centralized `ConfigureServices` already is and how few other members are actually host-model-sensitive (see above), @@ -309,9 +373,9 @@ checked as well: The public `SupportsServiceConfiguration` getter still matters for *future* extension methods — particularly anything a `UnitTestEx.Aspire` package itself, or a consumer, might add directly against -`TesterBase` that touches DI without going through the existing `ConfigureServices` funnel. Any -such method should check `tester.SupportsServiceConfiguration` itself rather than assume the funnel will -catch it, exactly because today's audit shows the funnel is the only enforcement point. +`TesterBase` that touches DI without going through the existing `ConfigureServices` funnel. Any such +method should check `tester.SupportsServiceConfiguration` itself rather than assume the funnel will catch +it, exactly because today's audit shows the funnel is the only enforcement point. **One direct configuration entry point deliberately sits outside the guard: `TestSetUp.ConfigureServices`.** This is a global `Action?` delegate (set once on `TestSetUp`/`TestSetUp.Default`, @@ -349,14 +413,19 @@ future Tier 2 tester implementation is simply responsible for not invoking it. Ship this as a new, separately-versioned, **opt-in** `UnitTestEx.Aspire` package, following the same companion-package pattern already established by `UnitTestEx.Azure.Functions` and `UnitTestEx.Azure.ServiceBus` — the core `UnitTestEx` package and `ApiTesterBase`/`MockHttpClientFactory` -are untouched either way. The one small, backward-compatible change needed in core `UnitTestEx` is the -`SupportsServiceConfiguration` guard from section 9 — everything else is additive. Suggested phasing for -follow-up work: +are untouched either way. The small, backward-compatible changes needed in core `UnitTestEx` are the +`SupportsServiceConfiguration` guard (already shipped) and the `HttpTesterBase` `HttpClient`-source +extraction (section 9) — everything else is additive. Suggested phasing for follow-up work: 1. This design doc (done). -2. Add the `SupportsServiceConfiguration` guard to `TesterBase`/`TesterBase` (section 9) — a - small, additive, backward-compatible core change, landed and released independently of the rest. -3. Prototype `AspireTesterBase`/`DistributedTesterBase` with resource-scoped `Http()`/`Http()`, +2. Add the `SupportsServiceConfiguration` guard to `TesterBase` (section 9) — a small, additive, + backward-compatible core change, landed and released independently of the rest (done). +3. Extract `HttpTesterBase`'s hard-coded `TestServer`/`HttpClient` construction behind a small seam + (section 9) so it can be fed from either `TestServer` (Tier 1, unchanged) or + `app.CreateHttpClient(resourceName)` (Tier 2) — a small, additive, backward-compatible core change, + ideally landed and released independently too, ahead of the rest. +4. Prototype `AspireTesterBase : TesterBase` (non-generic — see revised section 8/9) + with resource-scoped `Http()`/`Http()` (reusing the extracted engine from step 3), `WaitForResourceAsync`, and environment-override helpers. -4. Thin WireMock.Net wrapper mirroring `MockHttpClient`'s authoring syntax. -5. Documentation for the Playwright/UI pattern (section 7) — no new code required, just guidance. +5. Thin WireMock.Net wrapper mirroring `MockHttpClient`'s authoring syntax. +6. Documentation for the Playwright/UI pattern (section 7) — no new code required, just guidance. From 707b3f3b2972311cb8016a2c1ec26f444e44a7f9 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 11:29:34 -0700 Subject: [PATCH 07/43] Revert SupportsServiceConfiguration guard; adopt TesterBaseCore split in design doc Further discussion questioned whether inheriting the DI-heavy TesterBase (or TesterBase) 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 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 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 - Common.targets | 2 +- docs/design/aspire-multi-host-testing.md | 338 ++++++++---------- src/UnitTestEx/Abstractions/TesterBase.cs | 16 - .../Other/SupportsServiceConfigurationTest.cs | 47 --- 5 files changed, 145 insertions(+), 261 deletions(-) delete mode 100644 tests/UnitTestEx.Xunit.Test/Other/SupportsServiceConfigurationTest.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f04c993..bd55491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,6 @@ Represents the **NuGet** versions. -## v5.12.0 -- *Enhancement:* Added `TesterBase.SupportsServiceConfiguration` (defaults to `true`) to enable testers whose underlying host does not run in-process (for example, a future multi-host/distributed application tester) to opt out of in-process DI service configuration/replacement. Where overridden to `false`, the DI-related fluent methods (e.g. `ReplaceSingleton`/`ReplaceScoped`/`ReplaceTransient` and their `Keyed`/`Mock` variants, `ReplaceHttpClientFactory`) now throw a `NotSupportedException` instead of silently queuing configuration that would never be applied. - ## v5.11.1 - *Fixed:* Wrapped `Assembly.LoadFrom` in `TestSetUp` with try-catch to log exceptions and prevent setup failures. diff --git a/Common.targets b/Common.targets index 4b8740c..0ea044b 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 5.12.0 + 5.11.1 preview Avanade Avanade diff --git a/docs/design/aspire-multi-host-testing.md b/docs/design/aspire-multi-host-testing.md index 375b502..8c962b2 100644 --- a/docs/design/aspire-multi-host-testing.md +++ b/docs/design/aspire-multi-host-testing.md @@ -23,11 +23,13 @@ So the plan is to keep Tier 1 exactly as-is, and add Tier 2 as a new, separately package — picking the tier per test based on what you're actually trying to prove, not blending them. One important wrinkle: `TesterBase` is the shared base a lot of fluent extension methods hang off -today, and most of its DI-flavoured methods (`ReplaceSingleton`, `MockScoped`, `ScopedType`, etc.) would -otherwise **silently no-op** rather than fail if called on a Tier 2 tester, because they queue into a -list that only Tier 1's `WebApplicationFactory` wiring ever plays back. Section 9 proposes a small, -additive, backward-compatible guard (`SupportsServiceConfiguration`) to turn that into a clear, -immediate `NotSupportedException` instead. +today, and its DI-flavoured methods (`ReplaceSingleton`, `MockScoped`, `ScopedType`, etc.) would be +awkward — even dangerous — on a Tier 2 tester if it simply inherited that surface: they'd compile, look +valid, and either silently no-op or need a runtime guard to catch them. Section 9 works through this and +lands on a **compile-time** fix instead of a runtime one: extract a new `TesterBaseCore` holding only the +genuinely host-agnostic members, have today's `TesterBase` inherit it unchanged (Tier 1's shape doesn't +move), and have the new Tier 2 base inherit `TesterBaseCore` directly — so the DI-flavoured methods +simply don't exist on a Tier 2 tester, rather than existing and throwing. ## 1. The current model (Tier 1), recapped @@ -175,12 +177,11 @@ wrapper; a documentation section showing this pattern (resource → real URL → ## 8. Sketch of the new `UnitTestEx.Aspire` package surface -Illustrative only — not implemented by this doc. **Revised per section 9's inheritance analysis:** -`AspireTesterBase` extends the non-generic `TesterBase`, *not* `TesterBase` — see section 9 for -why. +Illustrative only — not implemented by this doc. **Revised per section 9's analysis:** `AspireTesterBase` +extends the new `TesterBaseCore` (not `TesterBase`, and not `TesterBase`) — see section 9 for why. ```csharp -public abstract class AspireTesterBase : TesterBase, IAsyncDisposable +public abstract class AspireTesterBase : TesterBaseCore, IAsyncDisposable where TAppHost : class where TSelf : AspireTesterBase { @@ -197,198 +198,143 @@ public abstract class AspireTesterBase : TesterBase, IAsyncDisp // Resource-scoped Http()/Http() wired to the resource's real HttpClient, reusing the // existing HttpTester/HttpTester fluent assertion API for consistency with Tier 1 — made - // possible because HttpTesterBase already only depends on the non-generic TesterBase (see - // section 9), not on anything DI-related from TesterBase. + // possible because HttpTesterBase depends only on TesterBaseCore plus the new IHttpClientSource + // seam (see section 9), not on anything DI-related from TesterBase/TesterBase. public HttpTester Http(string resourceName); public HttpTester Http(string resourceName); - // The two members TesterBase declares abstract; honestly unsupported for a multi-process tester. - public override IServiceProvider Services => - throw new NotSupportedException("AspireTesterBase has no single in-process IServiceProvider; each resource owns its own DI container. Use Http(resourceName) or configure the resource directly instead."); - public override IConfiguration Configuration => - throw new NotSupportedException("AspireTesterBase has no single in-process IConfiguration; configure/override the resource's own environment/configuration before it starts instead."); + // Implements the new seam so HttpTesterBase can source an HttpClient for a named resource + // instead of a TestServer (see section 9). + HttpClient IHttpClientSource.CreateHttpClient(string? name) => + GetDistributedApplicationAsync().GetAwaiter().GetResult().CreateHttpClient(name ?? throw new ArgumentNullException(nameof(name))); } ``` +Note what's conspicuously *absent* here: no `Services`, no `Configuration`, no `NotSupportedException` +overrides at all. `AspireTesterBase` never inherits those members in the first place (they live on +`TesterBase`, which it doesn't extend) — so there's nothing to throw away or guard. A consumer's +IntelliSense on an `AspireTesterBase`-derived tester simply doesn't show `Services`/`Configuration`/ +`ReplaceSingleton`/etc. — the tier boundary is enforced by the compiler, not by a runtime exception. + A WireMock resource plugs into the same model as just another named resource: stub it via the thin wrapper described in section 5, then assert against the real domain resource's behavior via `Http(resourceName)` as usual. -## 9. Why `AspireTesterBase` inherits `TesterBase`, not `TesterBase` - -**Revised conclusion, superseding an earlier draft of this section.** The first draft of this doc (and -the sketch in section 8) had `AspireTesterBase : TesterBase` — i.e. reuse the -*generic* base and guard away the parts that don't apply. On reflection that's the wrong shape: it's a -Liskov substitution violation waiting to happen. `TesterBase` (`src/UnitTestEx/Abstractions/TesterBaseT.cs`) -exists for exactly one reason — **in-process DI reach-in** — and it is *only* that: `ReplaceSingleton`/ -`Scoped`/`Transient` + `Keyed`/`Mock` variants, `ReplaceHttpClientFactory`, `ScopedType`, -`Type` (~30 members total, all DI-flavoured). None of it applies to a multi-process Tier 2 -tester, full stop. Inheriting it just to guard ~30 members with a runtime `NotSupportedException` means -every one of those methods *looks* like a valid, IntelliSense-suggested thing to call on an -`AspireTesterBase` — compiles clean, reads clean — and then blows up at test-run time. That's a bad -authoring experience for a large surface, and the guard from section 8 (now shipped) only mitigates it; -it doesn't remove it. +## 9. `TesterBaseCore`: extracting the genuinely shared base + +**Revised conclusion, superseding two earlier drafts of this section.** The doc has now gone through two +iterations on this point, each closer to the truth: +1. First draft: `AspireTesterBase : TesterBase` — reuse the generic DI-heavy base + and guard away the parts that don't apply. Wrong — see point 2. +2. Second draft: `AspireTesterBase : TesterBase` (non-generic) — better, but still meant overriding the + two abstract `Services`/`Configuration` members to throw, and still relied on a runtime + `SupportsServiceConfiguration` guard (shipped in this repo, now reverted — see below) as a safety net + for anything else on `TesterBase` that might reach into DI. +3. **This draft:** extract a new abstract `TesterBaseCore`, containing only the members that are + genuinely host-agnostic. `TesterBase` (unchanged in shape from before this doc's changes) inherits it. + `AspireTesterBase` inherits it directly, **skipping `TesterBase` entirely**. This isn't just "one fewer + level of inheritance" — it means `AspireTesterBase` never has `Services`, `Configuration`, + `ConfigureServices`, or any DI-flavoured member to override, guard, or explain away. There's nothing to + throw because there's nothing to call. The tier boundary is enforced by what the compiler will let you + write, full stop — not by a runtime flag someone has to remember to check. + +**Why this is better than the runtime guard, concretely:** the guard's entire premise was "a Tier 2 +tester will end up with ~25 DI-flavoured methods it can't honor, so let's make them throw a clear +exception instead of silently no-opping." That premise assumed Tier 2 *would* inherit those methods. Once +`AspireTesterBase` simply doesn't — because it never extends the class that declares them — the premise +disappears, and with it the reason for the guard to exist. Keeping a public `SupportsServiceConfiguration` +property and a runtime check in `ConfigureServices` around after that would be exactly the kind of +speculative infrastructure the rest of this doc argues against: a flag that, in every real subclass that +will ever exist, can only ever be `true`. **The guard has been reverted from `TesterBase`** (it shipped +in this branch's PR before this revision; removing it is a clean, zero-impact change since the PR was +never released). **What's actually shared between the tiers, concretely, not hypothetically:** the evidence is `HttpTesterBase` (`src/UnitTestEx/AspNetCore/HttpTesterBase.cs`) — the class that already implements Tier 1's entire HTTP request/response/assertion engine (request+response logging, JSON (de)serialization, the `Expectations`/`Assertors` pipeline, per-request log capture correlated via `SharedState`/`RequestId`, -the `OnBeforeHttpRequestMessageSendAsync` user-impersonation hook). It takes an `Owner` typed as the -*non-generic* `TesterBase` in its constructor — **not** `TesterBase` — and never touches -`Services`, `Configuration`, `ConfigureServices`, or any `Replace*`/`ScopedType`/`Type` member. It's -composed alongside a `TesterBase`-derived `ApiTesterBase` today (which supplies the `TestServer`), -but it doesn't need the DI-heavy base itself. That's a real, working example — not a guess — of exactly -where the tier boundary actually sits: **`TesterBase` (non-generic) is the true shared capability set; -`TesterBase` is Tier-1-only.** - -**What `TesterBase` (non-generic) actually gives you, and why every item is genuinely host-agnostic:** -- `Implementor`, `LoggerProvider`, `SharedState` — test-framework output plumbing and the log-capture/ - request-correlation mechanism `HttpTesterBase` relies on. -- `JsonSerializer`/`UseJsonSerializer`, `JsonComparerOptions`/`CreateJsonComparer` — test-side JSON - handling, independent of the host model. -- `UserName`/`UseUser`/`WithUser` — feeds `TestSetUp.OnBeforeHttpRequestMessageSendAsync`, a hook that - mutates the outgoing `HttpRequestMessage` before it's sent (e.g. to attach an OAuth token) — just as - meaningful for a real cross-resource HTTP call in Tier 2 as an in-memory one in Tier 1. -- `SetUp` (`TestSetUp`), `Delay`, `ExecutePreRunActions`/`ExecutePostRun*Actions`, - `OnHostStart`/`OnHostStartUp` — pipeline hooks `HttpTesterBase.SendAsync` already calls today; - "host start" can be reinterpreted as "resource becomes healthy" for Tier 2. -- `ResetHost()` (`abstract`) — each tier already implements its own semantics; Tier 2's will look more - like "tear down and rebuild the `DistributedApplication`" than Tier 1's in-place reset, but the shape - (an abstract hook the base calls when needed) still fits. -- `Services`/`Configuration` (`abstract`) and `SupportsServiceConfiguration`/`ConfigureServices` (the - guard shipped in this repo already) — `AspireTesterBase` still needs to override the two abstract - members and still benefits from the guard as defense-in-depth (see below), but critically it does - **not** need to inherit the ~30 concrete DI-flavoured methods to do so. - -**What this means for `HttpTesterBase` itself (a small, real follow-up, not hand-waving):** today it's -hard-coded to `TestServer` (`CreateHttpClient() => new(new HttpDelegatingHandler(this, -TestServer.CreateHandler())) { BaseAddress = TestServer.BaseAddress }`). To let `AspireTesterBase.Http(resourceName)` -reuse the same engine, `HttpTesterBase`/its `HttpTester` subclasses need their `HttpClient` source -extracted behind a small seam (e.g. an abstract/virtual `CreateHttpClient()`, or a constructor taking a -`Func` instead of a `TestServer` directly) so Tier 2 can supply `app.CreateHttpClient(resourceName)` -in place of `TestServer.CreateHandler()`. Everything else in `HttpTesterBase` — logging, JSON, expectations, -the user-impersonation hook — needs no change at all. - -**So, concretely, for Phase 3 (prototyping `AspireTesterBase`):** -1. `AspireTesterBase : TesterBase` (non-generic) — see the revised section 8 sketch. - It gets zero `Replace*`/`ScopedType`/`Type` members: not guarded away, simply never inherited. A - consumer reading its IntelliSense sees only members that actually work. -2. Override `Services`/`Configuration` honestly (`throw new NotSupportedException(...)`, pointing at the - real per-resource alternative) — this is a deliberate, single, well-documented override of two members - already declared `abstract`, not a smell; it's exactly what an abstract member is for. -3. Extract `HttpTesterBase`'s `HttpClient` source behind a small seam so `Http(resourceName)` can reuse - the entire existing HTTP assertion engine unchanged, fed from `app.CreateHttpClient(resourceName)` - instead of `TestServer`. -4. The `SupportsServiceConfiguration` guard (already shipped in core `TesterBase`) remains valuable as - defense-in-depth — not for `AspireTesterBase` itself (which never has the DI methods to call), but for - *other* extension methods written directly against the non-generic `TesterBase` (by this repo, a - companion package, or a consumer) that might reach into DI without going through the funnel. See below - for the one existing entry point that still sits outside it. - -Cataloguing the rest of what's on `TesterBase` for completeness, so nothing is assumed away: -`ScopedType`/`Type` (6 overloads) call `Services.CreateScope()` synchronously at the -call site — they'd already fail correctly once `Services` throws, if they were ever inherited, but under -the revised design they simply aren't present on `AspireTesterBase` at all, which is strictly better than -"present but throws." - -**The actual gap the guard closes (still true, still shipped):** every `Replace*`/`Mock*`/ -`ReplaceHttpClientFactory` method (~25 overloads) funnels through one method — -`TesterBase.ConfigureServices(Action, bool)` — which was `protected` but **not -virtual** before this repo's change. A subclass had no way to intercept or reject it. Left unguarded, -calling e.g. `.ReplaceSingleton(...)` on any future non-in-process `TesterBase`-derived type would -compile fine, silently queue into an internal list, and then simply never run — because nothing in a -non-in-process flow ever calls `AddConfiguredServices(IServiceCollection)`. **That's a silent no-op, not -a compile error or a runtime exception** — worse than an explicit exception. The guard fixes that for any -type that ends up with those methods in scope; the revised `AspireTesterBase` design avoids needing to -rely on it at all by simply not inheriting them, but the guard is still the right belt-and-braces fix at -the `TesterBase` level, in case a future extension method (companion package or consumer) adds its own -`Replace*`-shaped helper directly against `TesterBase`. +the `OnBeforeHttpRequestMessageSendAsync` user-impersonation hook). It takes an `Owner` typed as +`TesterBase` today, but never touches `Services`, `Configuration`, `ConfigureServices`, or any +`Replace*`/`ScopedType`/`Type` member — everything it actually uses is a genuinely host-agnostic member. +That's real, working evidence — not a guess — of exactly where the tier boundary sits. + +**`TesterBaseCore` — the proposed member split, cross-checked against every member currently on +`TesterBase`:** + +| Stays agnostic → moves to `TesterBaseCore` | Stays DI-specific → stays on `TesterBase` | +|---|---| +| `Implementor`, `LoggerProvider`, `SharedState`, `SetUp` | `Configuration` (`abstract`) | +| `UserName`/`AdditionalConfiguration` (user-impersonation, config overrides) | `Services` (`abstract`) | +| `JsonSerializer`, `JsonComparerOptions`, `CreateJsonComparer()` | `ConfigureServices(Action, bool)` | +| `IsHostInstantiated`, `SyncRoot`, `ResetHost()` (abstract hook) | `AddConfiguredServices(IServiceCollection)` | +| `OnHostStart`/`OnHostStartUp` | the backing `_configureServices` list | +| `PreRunActions`/`PostRunBeforeExpectationsActions`/`PostRunAfterExpectationsActions`/`PostRunActions` + `Execute*Actions` | | +| `ReplaceTestFrameworkImplementor`, `LogHttpResponseMessage` | | +| all `CreateHttpRequest*`/`CreateJsonHttpRequest*` overloads (pure request-DTO builders, no DI touched at all) | | + +One mechanical wrinkle: today's public `ResetHost(bool resetConfiguredServices = false)` both resets +`IsHostInstantiated` (agnostic) *and* clears `_configureServices` (DI-specific) in one method. Splitting +the class means splitting this too: `TesterBaseCore` keeps a parameterless `ResetHost()` that resets +`IsHostInstantiated` and calls the abstract reset hook; `TesterBase` keeps the existing +`ResetHost(bool resetConfiguredServices)` overload, which additionally clears `_configureServices` before +calling `base.ResetHost()`. Existing Tier 1 callers see no change — same overload, same behavior. + +**The `IHttpClientSource` interface — the concrete answer to "what's common but doesn't fit the base +class":** `HttpTesterBase.CreateHttpClient()` is hard-coded to `TestServer.CreateHandler()` today. Rather +than adding a Tier-2-aware branch inside `HttpTesterBase` itself, or a virtual method that only makes +sense for one tier, the cleaner seam is a small interface: ```csharp -// TesterBase — public getter (read-only, virtual) so extension methods — UnitTestEx's own, a companion -// package's, or a consumer's — can defensively check the capability themselves, not just rely on -// catching the exception. Defaults to true, so every existing Tier 1 tester needs zero changes. -public virtual bool SupportsServiceConfiguration => true; - -protected void ConfigureServices(Action configureServices, bool autoResetHost = true) +public interface IHttpClientSource { - if (!SupportsServiceConfiguration) - throw new NotSupportedException( - $"{GetType().Name} does not support in-process service configuration/replacement because its " + - $"underlying host does not run in-process (see {nameof(SupportsServiceConfiguration)}). " + - $"Instead, configure the target resource through its supported environment/configuration surface " + - $"(e.g. an Aspire resource builder's 'WithEnvironment'/'WithReference'), or mock its external HTTP " + - $"dependencies at the resource boundary (e.g. using WireMock.Net.Aspire) rather than in-process."); - - lock (SyncRoot) - { - if (autoResetHost) - ResetHost(false); - - _configureServices.Add(configureServices); - } + // name is the resource/client name for Tier 2 (e.g. Aspire's app.CreateHttpClient(name)) and + // ignored (or unused) for Tier 1, which always has exactly one TestServer to source from. + HttpClient CreateHttpClient(string? name = null); } ``` -Making the getter `public` rather than `protected` matters because `TesterBase`'s whole design -point is being a hook for extension methods (many of `UnitTestEx`'s own fluent methods are already -written as extensions elsewhere in the codebase, and consumers/companion packages write their own). -A `protected` flag is invisible to any of those — they'd have no way to guard themselves and would just -propagate whatever exception the guarded core method throws (or, worse, do the DI-touching work -*themselves* without ever routing through `ConfigureServices`, bypassing the guard entirely). A public -getter lets any extension method written against `TesterBase` check -`tester.SupportsServiceConfiguration` up front and either skip the operation, throw its own -tier-appropriate message, or offer a fallback — the same pattern already used for the public -`IsHostInstantiated` flag on `TesterBase`. - -`AspireTesterBase` doesn't even need to override `SupportsServiceConfiguration` — under the revised -design (section 9 above) it never inherits `TesterBase` or its ~25 DI-flavoured fluent methods in -the first place, so there's no funnel call site to guard for it specifically. It still implements the -already-abstract `Services`/`Configuration` to throw. The guard earns its keep for any *other* -`TesterBase`-derived type (present or future) that does end up with DI-touching methods in scope without -going through the intended flow. - -This is intentionally a single boolean switch, not a `[Flags]` capability enum — given how centralized -`ConfigureServices` already is and how few other members are actually host-model-sensitive (see above), -a richer capability model would be speculative complexity today. If further, more granular gaps emerge -once `UnitTestEx.Aspire` is actually built, a `TesterCapabilities` flags enum can replace the single -public property then, without another breaking change (the guarded call site stays the same shape). - -**Existing extension methods audited too, not just core members.** `TesterBase`/`TesterBase` is -also the hook for extension methods defined outside `UnitTestEx` itself, so the companion packages were -checked as well: - -- `UnitTestEx.Azure.ServiceBus/ExtensionMethods.cs` (`CreateServiceBusMessageFromValue`, - `CreateServiceBusMessage*`, etc.) and `UnitTestEx.Azure.Functions/ExtensionMethods.cs` - (`CreateWebJobsServiceBusMessageActions`, etc.) both extend `TesterBase` — but neither touches DI at - all; they only read `tester.JsonSerializer`/`tester.Implementor` to build message payloads/assertors. - Both are host-agnostic already and need no guarding. -- `UnitTestEx/ExtensionMethods.cs`'s `ReplaceSingleton`/`ReplaceScoped`/`ReplaceTransient`/`Keyed*`/ - `Remove`/`RemoveKeyed` are extensions on `IServiceCollection`, not on `TesterBase` — they can only ever - be called from inside a `ConfigureServices(sc => ...)` callback delegate. Since that callback is only - ever queued (and only ever played back) through the now-guarded `TesterBase.ConfigureServices`, these - are automatically shielded for free: for a Tier 2 tester the callback is never queued in the first - place (the guard throws before it can be), so these `IServiceCollection` extensions are simply never - invoked. No separate check needed inside them. - -The public `SupportsServiceConfiguration` getter still matters for *future* extension methods — -particularly anything a `UnitTestEx.Aspire` package itself, or a consumer, might add directly against -`TesterBase` that touches DI without going through the existing `ConfigureServices` funnel. Any such -method should check `tester.SupportsServiceConfiguration` itself rather than assume the funnel will catch -it, exactly because today's audit shows the funnel is the only enforcement point. - -**One direct configuration entry point deliberately sits outside the guard: `TestSetUp.ConfigureServices`.** -This is a global `Action?` delegate (set once on `TestSetUp`/`TestSetUp.Default`, -not per-tester) that each *concrete* Tier 1 tester's own host-building code invokes directly — -e.g. `ApiTesterBase`/`GenericTesterCore` both call `SetUp.ConfigureServices?.Invoke(sc)` immediately -before `AddConfiguredServices(sc)` while constructing their `IServiceCollection`. It never routes through -`TesterBase.ConfigureServices`, so the new guard clause does not — and cannot — see it. This is safe by -construction rather than by the guard: an `AspireTesterBase`'s host-building code has no in-process -`IServiceCollection` to build in the first place, so it simply would never call -`SetUp.ConfigureServices?.Invoke(...)` at all — there is nothing to guard because there is no call site -to guard. This is called out explicitly so it isn't mistaken for a gap the `SupportsServiceConfiguration` -guard is responsible for closing; it is a different, tester-implementation-level entry point, and each -future Tier 2 tester implementation is simply responsible for not invoking it. +`ApiTesterBase` (Tier 1) implements it by wrapping its existing `TestServer`; `AspireTesterBase` (Tier 2) +implements it by wrapping `app.CreateHttpClient(name)` (see the section 8 sketch). `HttpTesterBase` takes +an `IHttpClientSource` instead of reaching for a `TestServer` field directly, and calls +`Source.CreateHttpClient(resourceName)` where it currently calls `TestServer.CreateHandler()`. This is +exactly the pattern the discussion raised: a capability that's genuinely shared in *concept* between the +tiers, but whose *implementation* is unavoidably tier-specific, doesn't belong crammed onto the base +class (as a field/virtual method that only half the hierarchy can sensibly implement) — it belongs on a +small, focused interface that each tier's concrete tester implements on its own terms, and that +composed/consumed code (`HttpTesterBase`, future extension methods) depends on instead of a concrete +base type. Everything else in `HttpTesterBase` — logging, JSON, expectations, the user-impersonation +hook — needs no change at all; only the `HttpClient`-sourcing line moves behind this interface. + +**So, concretely, for Phase 3 (prototyping `AspireTesterBase`):** +1. Extract `TesterBaseCore` per the table above; `TesterBase : TesterBaseCore` (Tier 1's public shape is + unchanged — this is a pure "extract base class" refactor, source- and binary-compatible for existing + consumers). +2. Introduce `IHttpClientSource`; have `ApiTesterBase` implement it (wrapping its existing `TestServer`) + and have `HttpTesterBase` depend on the interface instead of a concrete `TestServer` field. +3. `AspireTesterBase : TesterBaseCore, IHttpClientSource, IAsyncDisposable` — see the + revised section 8 sketch. Zero `Services`/`Configuration`/`Replace*`/`ScopedType`/`Type` members: not + guarded away, simply never inherited. +4. `Http(resourceName)`/`Http(resourceName)` reuse `HttpTesterBase`/`HttpTester`/`HttpTester` + unchanged, fed via `AspireTesterBase`'s `IHttpClientSource` implementation. + +**Existing extension methods, re-audited against the new split.** `UnitTestEx.Azure.ServiceBus`'s and +`UnitTestEx.Azure.Functions`' extension methods on `TesterBase` only read `JsonSerializer`/`Implementor` +— both now on `TesterBaseCore` — so they're unaffected either way. `UnitTestEx/ExtensionMethods.cs`'s +`ReplaceSingleton`/`ReplaceScoped`/etc. are extensions on `IServiceCollection`, only ever invoked from +inside a `ConfigureServices(sc => ...)` callback — a callback that, for `AspireTesterBase`, is never +queued in the first place because `ConfigureServices` doesn't exist on the type it's calling through. +No runtime check needed anywhere in this chain. + +**`TestSetUp.ConfigureServices` — the one direct configuration entry point, now trivially out of reach +rather than merely unguarded.** This is a global `Action?` delegate (set once on +`TestSetUp`/`TestSetUp.Default`, not per-tester) that each *concrete* Tier 1 tester's own host-building +code invokes directly — e.g. `ApiTesterBase`/`GenericTesterCore` both call +`SetUp.ConfigureServices?.Invoke(sc)` immediately before `AddConfiguredServices(sc)` while constructing +their `IServiceCollection`. Under the previous (guard-based) draft this was called out as "safe by +construction, not by the guard, because there's no in-process `IServiceCollection` to build." Under this +draft that's even more clearly true: `AspireTesterBase`'s host-building code is written against +`TesterBaseCore`, which doesn't have `AddConfiguredServices`/`SetUp.ConfigureServices` plumbing to call in +the first place — there is no call site to reach, guarded or otherwise. ## 10. Versioning and CI impact @@ -413,19 +359,23 @@ future Tier 2 tester implementation is simply responsible for not invoking it. Ship this as a new, separately-versioned, **opt-in** `UnitTestEx.Aspire` package, following the same companion-package pattern already established by `UnitTestEx.Azure.Functions` and `UnitTestEx.Azure.ServiceBus` — the core `UnitTestEx` package and `ApiTesterBase`/`MockHttpClientFactory` -are untouched either way. The small, backward-compatible changes needed in core `UnitTestEx` are the -`SupportsServiceConfiguration` guard (already shipped) and the `HttpTesterBase` `HttpClient`-source -extraction (section 9) — everything else is additive. Suggested phasing for follow-up work: +are untouched in observable behavior either way. The small, backward-compatible changes needed in core +`UnitTestEx` are the `TesterBaseCore` extraction and the new `IHttpClientSource` interface (section 9) — +everything else is additive. Suggested phasing for follow-up work: 1. This design doc (done). -2. Add the `SupportsServiceConfiguration` guard to `TesterBase` (section 9) — a small, additive, - backward-compatible core change, landed and released independently of the rest (done). -3. Extract `HttpTesterBase`'s hard-coded `TestServer`/`HttpClient` construction behind a small seam - (section 9) so it can be fed from either `TestServer` (Tier 1, unchanged) or - `app.CreateHttpClient(resourceName)` (Tier 2) — a small, additive, backward-compatible core change, - ideally landed and released independently too, ahead of the rest. -4. Prototype `AspireTesterBase : TesterBase` (non-generic — see revised section 8/9) - with resource-scoped `Http()`/`Http()` (reusing the extracted engine from step 3), - `WaitForResourceAsync`, and environment-override helpers. -5. Thin WireMock.Net wrapper mirroring `MockHttpClient`'s authoring syntax. -6. Documentation for the Playwright/UI pattern (section 7) — no new code required, just guidance. +2. Extract `TesterBaseCore` from `TesterBase` per the member split in section 9 — a small, + backward-compatible "extract base class" refactor (Tier 1's public shape is unchanged); introduce + `IHttpClientSource` and have `ApiTesterBase`/`HttpTesterBase` implement/depend on it instead of a + hard-coded `TestServer` field. Land and release this independently, ahead of the rest. + + *(Note: an earlier draft of this doc shipped a runtime `SupportsServiceConfiguration` guard on + `TesterBase` as a first step instead. That guard has been reverted — the `TesterBaseCore` split + makes it structurally unnecessary: `AspireTesterBase` never inherits the DI-flavoured members it + would have guarded, so there was nothing left for it to protect.)* +3. Prototype `AspireTesterBase : TesterBaseCore` (see revised section 8/9) with + resource-scoped `Http()`/`Http()` (reusing the existing HTTP engine via `IHttpClientSource` from + step 2), `WaitForResourceAsync`, and environment-override helpers. +4. Thin WireMock.Net wrapper mirroring `MockHttpClient`'s authoring syntax. +5. Documentation for the Playwright/UI pattern (section 7) — no new code required, just guidance. + diff --git a/src/UnitTestEx/Abstractions/TesterBase.cs b/src/UnitTestEx/Abstractions/TesterBase.cs index 7689a53..c90e0f0 100644 --- a/src/UnitTestEx/Abstractions/TesterBase.cs +++ b/src/UnitTestEx/Abstractions/TesterBase.cs @@ -100,15 +100,6 @@ public TesterBase(TestFrameworkImplementor implementor) /// The host can be reset by invoking . public bool IsHostInstantiated { get; internal set; } - /// - /// Indicates whether the underlying host supports in-process service configuration/replacement (see ). - /// - /// Defaults to true. Testers whose underlying host does not run in-process (for example, one that orchestrates real, separate processes) should override this to return - /// false, and should also have their / implementations throw a for the same reason. - /// This is exposed publicly (rather than protected) so that extension methods - whether defined by UnitTestEx itself, a companion package, or a consumer - can defensively check - /// the capability themselves before attempting an in-process service configuration/replacement operation, rather than relying solely on the being thrown. - public virtual bool SupportsServiceConfiguration => true; - /// /// Gets the synchronization object where synchronized access is required. /// @@ -235,15 +226,8 @@ protected void OnHostStart(Action start, bool autoResetHost = true) /// A delegate for configuring . /// Indicates whether to automatically (passing false) when configuring the services. /// This can be called multiple times prior to the underlying host being instantiated. Internally, the is queued and then played in order when the host is initially instantiated. - /// Thrown when is false. protected void ConfigureServices(Action configureServices, bool autoResetHost = true) { - if (!SupportsServiceConfiguration) - throw new NotSupportedException( - $"{GetType().Name} does not support in-process service configuration/replacement because its underlying host does not run in-process (see {nameof(SupportsServiceConfiguration)}). " + - $"Instead, configure the target resource through its supported environment/configuration surface (e.g. an Aspire resource builder's 'WithEnvironment'/'WithReference'), " + - $"or mock its external HTTP dependencies at the resource boundary (e.g. using WireMock.Net.Aspire) rather than in-process."); - lock (SyncRoot) { if (autoResetHost) diff --git a/tests/UnitTestEx.Xunit.Test/Other/SupportsServiceConfigurationTest.cs b/tests/UnitTestEx.Xunit.Test/Other/SupportsServiceConfigurationTest.cs deleted file mode 100644 index a4105e5..0000000 --- a/tests/UnitTestEx.Xunit.Test/Other/SupportsServiceConfigurationTest.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using System; -using UnitTestEx.Abstractions; -using Xunit; - -namespace UnitTestEx.Xunit.Test.Other -{ - public class SupportsServiceConfigurationTest - { - [Fact] - public void Default_Is_True_And_ConfigureServices_Succeeds() - { - using var tester = new NoDiTester(); - Assert.True(tester.SupportsServiceConfiguration); - - // Should not throw as service configuration is supported by default. - tester.ReplaceScoped(_ => throw new NotImplementedException()); - } - - [Fact] - public void Unsupported_ConfigureServices_Throws_NotSupportedException() - { - using var tester = new NoDiTester(supportsServiceConfiguration: false); - Assert.False(tester.SupportsServiceConfiguration); - - var ex = Assert.Throws(() => tester.ReplaceScoped(_ => throw new NotImplementedException())); - Assert.Contains(nameof(NoDiTester.SupportsServiceConfiguration), ex.Message); - } - - /// - /// A minimal that does not run a real host; used purely to validate guarding behaviour. - /// - private sealed class NoDiTester(bool supportsServiceConfiguration = true) : TesterBase(TestFrameworkImplementor.Null), IDisposable - { - public override bool SupportsServiceConfiguration { get; } = supportsServiceConfiguration; - - public override IServiceProvider Services => throw new NotSupportedException($"{nameof(Services)} is not supported as {nameof(SupportsServiceConfiguration)} is false."); - - public override IConfiguration Configuration => throw new NotSupportedException($"{nameof(Configuration)} is not supported as {nameof(SupportsServiceConfiguration)} is false."); - - protected override void ResetHost() { } - - public void Dispose() { } - } - } -} From ce2e11da7789466f8158f413203625b7b9af445b Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 14:00:28 -0700 Subject: [PATCH 08/43] feat: extract TesterBaseCore for host-agnostic testing capabilities (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, ControllerTester 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 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Azure/Functions/FunctionTesterBase.cs | 2 +- .../Abstractions/IHttpClientSource.cs | 22 ++ src/UnitTestEx/Abstractions/TesterBase.cs | 274 +--------------- src/UnitTestEx/Abstractions/TesterBaseCore.cs | 297 ++++++++++++++++++ .../Abstractions/TesterExtensionsConfig.cs | 4 +- src/UnitTestEx/AspNetCore/ApiTesterBase.cs | 14 +- src/UnitTestEx/AspNetCore/ControllerTester.cs | 7 +- src/UnitTestEx/AspNetCore/HttpTester.cs | 7 +- src/UnitTestEx/AspNetCore/HttpTesterBase.cs | 92 +++--- src/UnitTestEx/AspNetCore/HttpTesterBaseT.cs | 7 +- src/UnitTestEx/AspNetCore/HttpTesterBaseT2.cs | 7 +- src/UnitTestEx/AspNetCore/HttpTesterT.cs | 7 +- .../Assertors/HttpResponseMessageAssertor.cs | 8 +- .../HttpResponseMessageAssertorBase.cs | 10 +- .../HttpResponseMessageAssertorBaseT.cs | 6 +- .../Assertors/HttpResponseMessageAssertorT.cs | 10 +- src/UnitTestEx/Expectations/AssertArgs.cs | 14 +- .../Expectations/ErrorExpectations.cs | 6 +- .../Expectations/ExceptionExpectations.cs | 6 +- .../Expectations/ExpectationsArranger.cs | 10 +- .../Expectations/ExpectationsBase.cs | 10 +- .../Expectations/ExpectationsBaseT.cs | 6 +- .../HttpResponseMessageExpectations.cs | 6 +- .../Expectations/LoggerExpectations.cs | 6 +- .../Expectations/ValueExpectations.cs | 6 +- src/UnitTestEx/Generic/GenericTesterCore.cs | 2 +- .../Json/JsonElementComparerResult.cs | 4 +- 27 files changed, 450 insertions(+), 400 deletions(-) create mode 100644 src/UnitTestEx/Abstractions/IHttpClientSource.cs create mode 100644 src/UnitTestEx/Abstractions/TesterBaseCore.cs diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs index 4fec2b4..0c62861 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs @@ -188,7 +188,7 @@ private IHost GetHost() } /// - protected override void ResetHost() + protected override void OnResetHost() { lock (SyncRoot) { diff --git a/src/UnitTestEx/Abstractions/IHttpClientSource.cs b/src/UnitTestEx/Abstractions/IHttpClientSource.cs new file mode 100644 index 0000000..3b32262 --- /dev/null +++ b/src/UnitTestEx/Abstractions/IHttpClientSource.cs @@ -0,0 +1,22 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System.Net.Http; + +namespace UnitTestEx.Abstractions +{ + /// + /// Enables the creation of an that targets a given test host/resource, independent of the underlying hosting model. + /// + /// This is the seam that allows the -based testers (see , and + /// ) to be reused across both a single in-process host (e.g. , backed by a + /// TestServer) and a multi-host distributed application (e.g. a companion Aspire tester, backed by DistributedApplication.CreateHttpClient). + public interface IHttpClientSource + { + /// + /// Creates a new . + /// + /// The optional name used to select the target resource/endpoint; ignored by single-host implementations that only ever have the one target. + /// The . + HttpClient CreateHttpClient(string? name = null); + } +} diff --git a/src/UnitTestEx/Abstractions/TesterBase.cs b/src/UnitTestEx/Abstractions/TesterBase.cs index c90e0f0..dffe2bd 100644 --- a/src/UnitTestEx/Abstractions/TesterBase.cs +++ b/src/UnitTestEx/Abstractions/TesterBase.cs @@ -6,7 +6,6 @@ using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; @@ -15,105 +14,25 @@ using System.Reflection; using System.Text; using System.Threading; -using UnitTestEx.Expectations; using UnitTestEx.Json; -using UnitTestEx.Logging; namespace UnitTestEx.Abstractions { /// - /// Provides the common/core base unit-testing capabilities. + /// Provides the common single in-process host (dependency injection (DI) enabled) unit-testing capabilities. /// - public abstract class TesterBase + /// This extends the host-agnostic to add the DI-specific capabilities (, , ) + /// that only make sense where there is a single host with a single DI container. A multi-host distributed application tester (e.g. a companion Aspire tester) should instead inherit directly. + public abstract class TesterBase : TesterBaseCore { - private string? _userName; private readonly List> _configureServices = []; private IEnumerable>? _additionalConfiguration; - private readonly List _hostStart = []; - - /// - /// Static constructor. - /// - static TesterBase() - { - TestSetUp.Force(); - - try - { - var fi = new FileInfo(Path.Combine(Environment.CurrentDirectory, "appsettings.unittest.json")); - if (!fi.Exists) - return; - - var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(fi.FullName)); - if (json.RootElement.TryGetProperty("DefaultJsonSerializer", out var je) && je.ValueKind == System.Text.Json.JsonValueKind.String) - TestSetUp.Default.JsonSerializer = (IJsonSerializer)Activator.CreateInstance(Type.GetType(je.GetString()!)!)!; - } - catch (Exception ex) - { - // Swallow and carry on; none of this logic should impact execution. - System.Diagnostics.Debug.WriteLine($"UnitTestEx attempted to read, then load (if specified) the 'DefaultJsonSerializer' from, 'appsettings.unittest.json': {ex}."); - } - } - - /// - /// Gets the default JSON media type names used for JSON serialization/deserialization. - /// - public static string[] JsonMediaTypeNames { get; set; } = [MediaTypeNames.Application.Json, "application/json-patch+json", "application/problem+json", "application/merge-patch+json"]; /// /// Initializes a new instance of the class. /// /// The . - public TesterBase(TestFrameworkImplementor implementor) - { - Implementor = implementor ?? throw new ArgumentNullException(nameof(implementor)); - LoggerProvider = new SharedStateLoggerProvider(SharedState); - SetUp = TestSetUp.Default.Clone(); - JsonSerializer = SetUp.JsonSerializer; - JsonComparerOptions = SetUp.JsonComparerOptions; - } - - /// - /// Gets the . - /// - public TestFrameworkImplementor Implementor { get; private set; } - - /// - /// Gets the . - /// - public SharedStateLoggerProvider LoggerProvider { get; } - - /// - /// Gets the . - /// - public TestSharedState SharedState { get; } = new TestSharedState(); - - /// - /// Gets the configured . - /// - /// Defaults to . - public TestSetUp SetUp { get; internal set; } - - /// - /// Indicates whether the underlying host has been instantiated. - /// - /// The host can be reset by invoking . - public bool IsHostInstantiated { get; internal set; } - - /// - /// Gets the synchronization object where synchronized access is required. - /// - protected object SyncRoot { get; } = new object(); - - /// - /// Gets the test user name. - /// - /// Defaults to . - public string UserName - { - get => _userName ?? SetUp.DefaultUserName; - protected set => _userName = value; - } + public TesterBase(TestFrameworkImplementor implementor) : base(implementor) { } /// /// Gets the additional configuration used at host initialization (see ). @@ -132,91 +51,28 @@ protected set /// Gets the from the underlying host. /// /// The . - /// Accessing the may result in the underlying host being instantiated (see ) where applicable which may result in errors unless a subsequent is performed. + /// Accessing the may result in the underlying host being instantiated (see ) where applicable which may result in errors unless a subsequent is performed. public abstract IConfiguration Configuration { get; } /// /// Gets the from the underlying host. /// /// The . - /// Accessing the may result in the underlying host being instantiated (see ) where applicable which may result in errors unless a subsequent is performed. + /// Accessing the may result in the underlying host being instantiated (see ) where applicable which may result in errors unless a subsequent is performed. public abstract IServiceProvider Services { get; } - /// - /// Gets the not from the underlying host. - /// - /// Defaults to . To change the use the method. This does not use the - /// instance from the underlying host as a different serializer may be required or may not have been configured. - public IJsonSerializer JsonSerializer { get; internal set; } - - /// - /// Gets the not from the underlying host. - /// - /// Defaults to . To change the use the method. This does not use the - /// instance from the underlying host as a different serializer may be required or may not have been configured. - public JsonElementComparerOptions JsonComparerOptions { get; internal set; } - - /// - /// Creates a using the configured and . - /// - /// A new instance. - public JsonElementComparer CreateJsonComparer() - { - var options = JsonComparerOptions.Clone(); - options.JsonSerializer ??= JsonSerializer; - return new JsonElementComparer(options); - } - /// /// Resets the underlying host to instantiate a new instance. /// /// Indicates whether to reset the previously configured services and start-ups. - public void ResetHost(bool resetConfiguredServices = false) + public new void ResetHost(bool resetConfiguredServices = false) { lock (SyncRoot) { - IsHostInstantiated = false; if (resetConfiguredServices) _configureServices.Clear(); - ResetHost(); - } - } - - /// - /// Resets the underlying host to instantiate a new instance. - /// - protected abstract void ResetHost(); - - /// - /// Enables opportunity to execute logic immediately after the underlying host has been started. - /// - /// Where overriding ensure the base is invoked first to avoid unintended side-effects as will invoke the registered . - /// Note: a host lifetime can span one or more tests so this should not be used for per-test set-up/configuration. Equally, a will result in a new host instantiation on first access. - protected virtual void OnHostStartUp() - { - foreach (var start in _hostStart) - { - start(); - } - } - - /// - /// Provides an opportunity to execute logic immediately after the underlying host has been started. - /// - /// A start . - /// Indicates whether to automatically (passing false) when configuring the services. - /// This can be called multiple times prior to the underlying host being instantiated. - /// See . - protected void OnHostStart(Action start, bool autoResetHost = true) - { - lock (SyncRoot) - { - if (autoResetHost) - ResetHost(false); - - _hostStart.Add(start); - + base.ResetHost(); } } @@ -254,118 +110,6 @@ protected void AddConfiguredServices(IServiceCollection services) IsHostInstantiated = true; } - /// - /// Gets the list of pre-run actions to be executed before the underlying test Run occurs. - /// - protected List> PreRunActions { get; } = []; - - /// - /// Gets the list of post-run actions to be executed after the underlying test Run occurs (before ). - /// - protected List> PostRunBeforeExpectationsActions { get; } = []; - - /// - /// Gets the list of post-run actions to be executed after the underlying test Run occurs (after ). - /// - protected List> PostRunAfterExpectationsActions { get; } = []; - - /// - /// Gets the list of post-run actions to be executed after the underlying test Run occurs (always executed regardless of result to enable the likes of clean-up etc.). - /// - protected List> PostRunActions { get; } = []; - - /// - /// Executes the pre-run actions before the underlying test Run occurs. - /// - /// The tester instance. - internal void ExecutePreRunActions(IExpectations tester) - { - foreach (var action in PreRunActions) - action(tester); - } - - /// - /// Executes the post-run actions after the underlying test Run occurs (before ). - /// - /// The tester instance. - internal void ExecutePostRunBeforeExpectationsActions(IExpectations tester) - { - foreach (var action in PostRunBeforeExpectationsActions) - action(tester); - } - - /// - /// Executes the post-run actions after the underlying test Run occurs (before ). - /// - /// The tester instance. - internal void ExecutePostRunAfterExpectationsActions(IExpectations tester) - { - foreach (var action in PostRunAfterExpectationsActions) - action(tester); - } - - /// - /// Executes the post-run actions after the underlying test Run occurs (always executed regardless of result to enable the likes of clean-up etc.). - /// - /// The tester instance. - internal void ExecutePostRunActions(IExpectations tester) - { - foreach (var action in PostRunActions) - action(tester); - } - - /// - /// Replaces the with the specified . - /// - /// The new . - public void ReplaceTestFrameworkImplementor(TestFrameworkImplementor implementor) - { - Implementor = implementor ?? throw new ArgumentNullException(nameof(implementor)); - } - - /// - /// Logs the . - /// - /// The . - /// The optional . - internal void LogHttpResponseMessage(HttpResponseMessage res, Stopwatch? sw) - { - Implementor.WriteLine(""); - Implementor.WriteLine($"RESPONSE >"); - Implementor.WriteLine($"HttpStatusCode: {res.StatusCode} ({(int)res.StatusCode})"); - Implementor.WriteLine($"Elapsed (ms): {(sw == null ? "none" : sw.Elapsed.TotalMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture))}"); - - var hdrs = res.Headers?.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - Implementor.WriteLine($"Headers: {(hdrs == null || hdrs.Length == 0 ? "none" : "")}"); - if (hdrs != null && hdrs.Length > 0) - { - foreach (var hdr in hdrs) - { - Implementor.WriteLine($" {hdr}"); - } - } - - object? jo = null; - var content = res.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - if (!string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(res.Content?.Headers?.ContentType?.MediaType) && JsonMediaTypeNames.Contains(res.Content.Headers.ContentType.MediaType)) - { - try - { - jo = JsonSerializer.Deserialize(content); - } - catch (Exception) { /* This is being swallowed by design. */ } - } - - var txt = $"Content: [{res.Content?.Headers?.ContentType?.MediaType ?? "none"}]"; - if (jo != null) - { - Implementor.WriteLine(txt); - Implementor.WriteLine(JsonSerializer.Serialize(jo, JsonWriteFormat.Indented)); - } - else - Implementor.WriteLine($"{txt} {(string.IsNullOrEmpty(content) ? "none" : content)}"); - } - #region CreateHttpRequest /// diff --git a/src/UnitTestEx/Abstractions/TesterBaseCore.cs b/src/UnitTestEx/Abstractions/TesterBaseCore.cs new file mode 100644 index 0000000..4b89d66 --- /dev/null +++ b/src/UnitTestEx/Abstractions/TesterBaseCore.cs @@ -0,0 +1,297 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Mime; +using UnitTestEx.Expectations; +using UnitTestEx.Json; +using UnitTestEx.Logging; + +namespace UnitTestEx.Abstractions +{ + /// + /// Provides the host-agnostic unit-testing capabilities that are common to every tester, irrespective of whether the underlying test target is a single in-process host + /// (see ) or a multi-host distributed application (e.g. .NET Aspire). + /// + /// This intentionally does not expose any dependency injection (DI) related capabilities (e.g. Services, Configuration, ConfigureServices) as these are meaningful only + /// where there is a single host with a single DI container; see for these capabilities. Where a capability is common in spirit, but cannot be expressed here without a DI + /// dependency, it should be exposed via an interface (e.g. ) that each tier can implement appropriately. + public abstract class TesterBaseCore + { + private readonly List _hostStart = []; + + /// + /// Static constructor. + /// + static TesterBaseCore() + { + TestSetUp.Force(); + + try + { + var fi = new FileInfo(Path.Combine(Environment.CurrentDirectory, "appsettings.unittest.json")); + if (!fi.Exists) + return; + + var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(fi.FullName)); + if (json.RootElement.TryGetProperty("DefaultJsonSerializer", out var je) && je.ValueKind == System.Text.Json.JsonValueKind.String) + TestSetUp.Default.JsonSerializer = (IJsonSerializer)Activator.CreateInstance(Type.GetType(je.GetString()!)!)!; + } + catch (Exception ex) + { + // Swallow and carry on; none of this logic should impact execution. + System.Diagnostics.Debug.WriteLine($"UnitTestEx attempted to read, then load (if specified) the 'DefaultJsonSerializer' from, 'appsettings.unittest.json': {ex}."); + } + } + + /// + /// Gets the default JSON media type names used for JSON serialization/deserialization. + /// + public static string[] JsonMediaTypeNames { get; set; } = [MediaTypeNames.Application.Json, "application/json-patch+json", "application/problem+json", "application/merge-patch+json"]; + + /// + /// Initializes a new instance of the class. + /// + /// The . + protected TesterBaseCore(TestFrameworkImplementor implementor) + { + Implementor = implementor ?? throw new ArgumentNullException(nameof(implementor)); + LoggerProvider = new SharedStateLoggerProvider(SharedState); + SetUp = TestSetUp.Default.Clone(); + JsonSerializer = SetUp.JsonSerializer; + JsonComparerOptions = SetUp.JsonComparerOptions; + } + + /// + /// Gets the . + /// + public TestFrameworkImplementor Implementor { get; private set; } + + /// + /// Gets the . + /// + public SharedStateLoggerProvider LoggerProvider { get; } + + /// + /// Gets the . + /// + public TestSharedState SharedState { get; } = new TestSharedState(); + + /// + /// Gets the configured . + /// + /// Defaults to . + public TestSetUp SetUp { get; internal set; } + + /// + /// Indicates whether the underlying host has been instantiated. + /// + /// The host can be reset by invoking (or, where supported, ). + public bool IsHostInstantiated { get; internal set; } + + /// + /// Gets the synchronization object where synchronized access is required. + /// + protected object SyncRoot { get; } = new object(); + + /// + /// Gets the test user name. + /// + /// Defaults to . + public string UserName + { + get => _userName ?? SetUp.DefaultUserName; + protected set => _userName = value; + } + + private string? _userName; + + /// + /// Gets the not from the underlying host. + /// + /// Defaults to . This does not use the instance from the underlying host as a different serializer may be required or may not have been configured. + public IJsonSerializer JsonSerializer { get; internal set; } + + /// + /// Gets the not from the underlying host. + /// + /// Defaults to . This does not use the instance from the underlying host as a different serializer may be required or may not have been configured. + public JsonElementComparerOptions JsonComparerOptions { get; internal set; } + + /// + /// Creates a using the configured and . + /// + /// A new instance. + public JsonElementComparer CreateJsonComparer() + { + var options = JsonComparerOptions.Clone(); + options.JsonSerializer ??= JsonSerializer; + return new JsonElementComparer(options); + } + + /// + /// Resets the underlying host to instantiate a new instance. + /// + public void ResetHost() + { + lock (SyncRoot) + { + IsHostInstantiated = false; + OnResetHost(); + } + } + + /// + /// Resets the underlying host to instantiate a new instance. + /// + protected abstract void OnResetHost(); + + /// + /// Enables opportunity to execute logic immediately after the underlying host has been started. + /// + /// Where overriding ensure the base is invoked first to avoid unintended side-effects as will invoke the registered . + /// Note: a host lifetime can span one or more tests so this should not be used for per-test set-up/configuration. Equally, a will result in a new host instantiation on first access. + protected virtual void OnHostStartUp() + { + foreach (var start in _hostStart) + { + start(); + } + } + + /// + /// Provides an opportunity to execute logic immediately after the underlying host has been started. + /// + /// A start . + /// Indicates whether to automatically when configuring the services. + /// This can be called multiple times prior to the underlying host being instantiated. + /// See . + protected void OnHostStart(Action start, bool autoResetHost = true) + { + lock (SyncRoot) + { + if (autoResetHost) + ResetHost(); + + _hostStart.Add(start); + } + } + + /// + /// Gets the list of pre-run actions to be executed before the underlying test Run occurs. + /// + protected List> PreRunActions { get; } = []; + + /// + /// Gets the list of post-run actions to be executed after the underlying test Run occurs (before ). + /// + protected List> PostRunBeforeExpectationsActions { get; } = []; + + /// + /// Gets the list of post-run actions to be executed after the underlying test Run occurs (after ). + /// + protected List> PostRunAfterExpectationsActions { get; } = []; + + /// + /// Gets the list of post-run actions to be executed after the underlying test Run occurs (always executed regardless of result to enable the likes of clean-up etc.). + /// + protected List> PostRunActions { get; } = []; + + /// + /// Executes the pre-run actions before the underlying test Run occurs. + /// + /// The tester instance. + internal void ExecutePreRunActions(IExpectations tester) + { + foreach (var action in PreRunActions) + action(tester); + } + + /// + /// Executes the post-run actions after the underlying test Run occurs (before ). + /// + /// The tester instance. + internal void ExecutePostRunBeforeExpectationsActions(IExpectations tester) + { + foreach (var action in PostRunBeforeExpectationsActions) + action(tester); + } + + /// + /// Executes the post-run actions after the underlying test Run occurs (before ). + /// + /// The tester instance. + internal void ExecutePostRunAfterExpectationsActions(IExpectations tester) + { + foreach (var action in PostRunAfterExpectationsActions) + action(tester); + } + + /// + /// Executes the post-run actions after the underlying test Run occurs (always executed regardless of result to enable the likes of clean-up etc.). + /// + /// The tester instance. + internal void ExecutePostRunActions(IExpectations tester) + { + foreach (var action in PostRunActions) + action(tester); + } + + /// + /// Replaces the with the specified . + /// + /// The new . + public void ReplaceTestFrameworkImplementor(TestFrameworkImplementor implementor) + { + Implementor = implementor ?? throw new ArgumentNullException(nameof(implementor)); + } + + /// + /// Logs the . + /// + /// The . + /// The optional . + internal void LogHttpResponseMessage(HttpResponseMessage res, Stopwatch? sw) + { + Implementor.WriteLine(""); + Implementor.WriteLine($"RESPONSE >"); + Implementor.WriteLine($"HttpStatusCode: {res.StatusCode} ({(int)res.StatusCode})"); + Implementor.WriteLine($"Elapsed (ms): {(sw == null ? "none" : sw.Elapsed.TotalMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture))}"); + + var hdrs = res.Headers?.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + Implementor.WriteLine($"Headers: {(hdrs == null || hdrs.Length == 0 ? "none" : "")}"); + if (hdrs != null && hdrs.Length > 0) + { + foreach (var hdr in hdrs) + { + Implementor.WriteLine($" {hdr}"); + } + } + + object? jo = null; + var content = res.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + if (!string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(res.Content?.Headers?.ContentType?.MediaType) && JsonMediaTypeNames.Contains(res.Content.Headers.ContentType.MediaType)) + { + try + { + jo = JsonSerializer.Deserialize(content); + } + catch (Exception) { /* This is being swallowed by design. */ } + } + + var txt = $"Content: [{res.Content?.Headers?.ContentType?.MediaType ?? "none"}]"; + if (jo != null) + { + Implementor.WriteLine(txt); + Implementor.WriteLine(JsonSerializer.Serialize(jo, JsonWriteFormat.Indented)); + } + else + Implementor.WriteLine($"{txt} {(string.IsNullOrEmpty(content) ? "none" : content)}"); + } + } +} diff --git a/src/UnitTestEx/Abstractions/TesterExtensionsConfig.cs b/src/UnitTestEx/Abstractions/TesterExtensionsConfig.cs index 592ebd3..5d4b049 100644 --- a/src/UnitTestEx/Abstractions/TesterExtensionsConfig.cs +++ b/src/UnitTestEx/Abstractions/TesterExtensionsConfig.cs @@ -31,11 +31,11 @@ public virtual void ConfigureServices(TesterBase owner, IServiceCollection servi /// Updates the from the (where applicable). /// /// The value . - /// The owning . + /// The owning . /// The . /// The deserialized value (override where applicable). /// The value will have already been deserialized. - public virtual void UpdateValueFromHttpResponseMessage(TesterBase owner, HttpResponseMessage response, ref TValue? value) { } + public virtual void UpdateValueFromHttpResponseMessage(TesterBaseCore owner, HttpResponseMessage response, ref TValue? value) { } /// /// Updates the from the (where applicable). diff --git a/src/UnitTestEx/AspNetCore/ApiTesterBase.cs b/src/UnitTestEx/AspNetCore/ApiTesterBase.cs index c039f2c..7f84f33 100644 --- a/src/UnitTestEx/AspNetCore/ApiTesterBase.cs +++ b/src/UnitTestEx/AspNetCore/ApiTesterBase.cs @@ -24,7 +24,7 @@ namespace UnitTestEx.AspNetCore /// /// The API startup . /// The to support inheriting fluent-style method-chaining. - public abstract class ApiTesterBase : TesterBase, IDisposable where TEntryPoint : class where TSelf : ApiTesterBase + public abstract class ApiTesterBase : TesterBase, IHttpClientSource, IDisposable where TEntryPoint : class where TSelf : ApiTesterBase { private bool _disposed; private WebApplicationFactory? _waf; @@ -95,7 +95,7 @@ protected WebApplicationFactory GetWebApplicationFactory() } /// - protected override void ResetHost() + protected override void OnResetHost() { lock (SyncRoot) { @@ -146,20 +146,20 @@ protected override void ResetHost() /// /// The API Controller . /// The . - public ControllerTester Controller() where TController : ControllerBase => new(this, GetTestServer()); + public ControllerTester Controller() where TController : ControllerBase => new(this, this); /// /// Enables a test to be sent to the underlying . /// /// The . - public HttpTester Http() => new(this, GetTestServer()); + public HttpTester Http() => new(this, this); /// /// Enables a test to be sent to the underlying with an expected response value . /// /// The response value . /// The . - public HttpTester Http() => new(this, GetTestServer()); + public HttpTester Http() => new(this, this); /// /// Gets the underlying . @@ -167,6 +167,10 @@ protected override void ResetHost() /// The . public TestServer GetTestServer() => HostExecutionWrapper(() => GetWebApplicationFactory().Server); + /// + public HttpClient CreateHttpClient(string? name = null) => new(GetTestServer().CreateHandler()) { BaseAddress = GetTestServer().BaseAddress }; + + /// /// Sets the content root to be relative to the solution directory (i.e. the directory containing the .sln file). /// diff --git a/src/UnitTestEx/AspNetCore/ControllerTester.cs b/src/UnitTestEx/AspNetCore/ControllerTester.cs index 4910102..a3a8dac 100644 --- a/src/UnitTestEx/AspNetCore/ControllerTester.cs +++ b/src/UnitTestEx/AspNetCore/ControllerTester.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; -using Microsoft.AspNetCore.TestHost; using System; using System.Collections; using System.Collections.Generic; @@ -21,9 +20,9 @@ namespace UnitTestEx.AspNetCore /// Enables the testing of a operation. /// /// The . - /// The owning . - /// The . - public class ControllerTester(TesterBase owner, TestServer testServer) : HttpTesterBase>(owner, testServer) where TController : ControllerBase + /// The owning . + /// The . + public class ControllerTester(TesterBaseCore owner, IHttpClientSource httpClientSource) : HttpTesterBase>(owner, httpClientSource) where TController : ControllerBase { /// /// Provides the HTTP request body option. diff --git a/src/UnitTestEx/AspNetCore/HttpTester.cs b/src/UnitTestEx/AspNetCore/HttpTester.cs index 807593a..eaace4c 100644 --- a/src/UnitTestEx/AspNetCore/HttpTester.cs +++ b/src/UnitTestEx/AspNetCore/HttpTester.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx -using Microsoft.AspNetCore.TestHost; using System; using System.Diagnostics.CodeAnalysis; using System.Net.Http; @@ -14,9 +13,9 @@ namespace UnitTestEx.AspNetCore /// /// Provides send testing. /// - /// The owning . - /// The . - public class HttpTester(TesterBase owner, TestServer testServer) : HttpTesterBase(owner, testServer) + /// The owning . + /// The . + public class HttpTester(TesterBaseCore owner, IHttpClientSource httpClientSource) : HttpTesterBase(owner, httpClientSource) { /// /// Runs the test by sending an with no body. diff --git a/src/UnitTestEx/AspNetCore/HttpTesterBase.cs b/src/UnitTestEx/AspNetCore/HttpTesterBase.cs index 0eb43c6..bd97c9e 100644 --- a/src/UnitTestEx/AspNetCore/HttpTesterBase.cs +++ b/src/UnitTestEx/AspNetCore/HttpTesterBase.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx -using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; @@ -33,24 +32,24 @@ public abstract class HttpTesterBase : IExpectations /// /// Initializes a new class. /// - /// The owning . - /// The . - public HttpTesterBase(TesterBase owner, TestServer testServer) + /// The owning . + /// The . + public HttpTesterBase(TesterBaseCore owner, IHttpClientSource httpClientSource) { Owner = owner ?? throw new ArgumentNullException(nameof(owner)); - TestServer = testServer ?? throw new ArgumentNullException(nameof(testServer)); + HttpClientSource = httpClientSource ?? throw new ArgumentNullException(nameof(httpClientSource)); UserName = Owner.UserName; } /// - /// Gets the owning . + /// Gets the owning . /// - public TesterBase Owner { get; } + public TesterBaseCore Owner { get; } /// - /// Gets the underlying . + /// Gets the used to create the underlying . /// - public TestServer TestServer { get; } + public IHttpClientSource HttpClientSource { get; } /// /// Gets the . @@ -63,7 +62,7 @@ public HttpTesterBase(TesterBase owner, TestServer testServer) public IJsonSerializer JsonSerializer => Owner.JsonSerializer; /// - /// Gets or sets the test user name (defaults to ). + /// Gets or sets the test user name (defaults to ). /// public string? UserName { get; protected set; } @@ -95,7 +94,7 @@ protected async Task SendAsync(HttpMethod httpMetho { Owner.ExecutePreRunActions(this); using var client = CreateHttpClient(); - var res = await new TypedHttpClient(client, JsonSerializer).SendAsync(httpMethod, requestUri, requestModifier).ConfigureAwait(false); + var res = await new TypedHttpClient(this, client).SendAsync(httpMethod, requestUri, requestModifier).ConfigureAwait(false); await Task.Delay(TestSetUp.TaskDelayMilliseconds).ConfigureAwait(false); Owner.ExecutePostRunBeforeExpectationsActions(this); await AssertExpectationsAsync(res).ConfigureAwait(false); @@ -131,7 +130,7 @@ protected async Task SendAsync(HttpMethod httpMetho Owner.LoggerProvider.CreateLogger("ApiTester").LogWarning("A payload within a GET request message has no defined semantics; sending a payload body on a GET request might cause some existing implementations to reject the request (see https://www.rfc-editor.org/rfc/rfc7231)."); using var client = CreateHttpClient(); - var res = await new TypedHttpClient(client, JsonSerializer).SendAsync(httpMethod, requestUri, content, contentType, requestModifier).ConfigureAwait(false); + var res = await new TypedHttpClient(this, client).SendAsync(httpMethod, requestUri, content, contentType, requestModifier).ConfigureAwait(false); await Task.Delay(TestSetUp.TaskDelayMilliseconds).ConfigureAwait(false); Owner.ExecutePostRunBeforeExpectationsActions(this); await AssertExpectationsAsync(res).ConfigureAwait(false); @@ -166,7 +165,7 @@ protected async Task SendAsync(HttpMethod httpMetho Owner.LoggerProvider.CreateLogger("ApiTester").LogWarning("A payload within a GET request message has no defined semantics; sending a payload body on a GET request might cause some existing implementations to reject the request (see https://www.rfc-editor.org/rfc/rfc7231)."); using var client = CreateHttpClient(); - var res = await new TypedHttpClient(client, JsonSerializer).SendAsync(httpMethod, requestUri, value, requestModifier).ConfigureAwait(false); + var res = await new TypedHttpClient(this, client).SendAsync(httpMethod, requestUri, value, requestModifier).ConfigureAwait(false); await Task.Delay(TestSetUp.TaskDelayMilliseconds).ConfigureAwait(false); Owner.ExecutePostRunBeforeExpectationsActions(this); await AssertExpectationsAsync(res).ConfigureAwait(false); @@ -181,51 +180,20 @@ protected async Task SendAsync(HttpMethod httpMetho } /// - /// Creates an for the that logs the request and response to the test output. + /// Creates an (via the ) that logs the request and response to the test output. /// /// The . - public HttpClient CreateHttpClient() => new(new HttpDelegatingHandler(this, TestServer.CreateHandler())) { BaseAddress = TestServer.BaseAddress }; - - /// - /// Orchestrates the HTTP request send including logging and . - /// - /// The . - /// The inner . - public class HttpDelegatingHandler(HttpTesterBase httpTester, HttpMessageHandler innerHandler) : DelegatingHandler(innerHandler) - { - private readonly HttpTesterBase _httpTester = httpTester; - - /// - protected async override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - TestSetUp.LogAutoSetUpOutputs(_httpTester.Owner.Implementor); - - if (_httpTester.Owner.SetUp.OnBeforeHttpRequestMessageSendAsync != null) - await _httpTester.Owner.SetUp.OnBeforeHttpRequestMessageSendAsync(request, _httpTester.UserName, cancellationToken); - - request.Headers.Add(RequestIdName, _httpTester.RequestId); - - _httpTester.LogRequest(request); - var sw = Stopwatch.StartNew(); - var res = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); - sw.Stop(); - - await Task.Delay(0, cancellationToken).ConfigureAwait(false); - _httpTester.LastLogs = _httpTester.Owner.SharedState.GetLoggerMessages(_httpTester.RequestId); - _httpTester.LogResponse(res, sw, _httpTester.LastLogs); - return res; - } - } + public HttpClient CreateHttpClient() => HttpClientSource.CreateHttpClient(); /// /// Provides the requisite sending capabilities. /// + /// The owning . /// The . - /// The . - public class TypedHttpClient(HttpClient client, IJsonSerializer jsonSerializer) + public class TypedHttpClient(HttpTesterBase httpTester, HttpClient client) { + private readonly HttpTesterBase _httpTester = httpTester ?? throw new ArgumentNullException(nameof(httpTester)); private readonly HttpClient _client = client ?? throw new ArgumentNullException(nameof(client)); - private readonly IJsonSerializer _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); /// /// Sends with no content. @@ -255,10 +223,30 @@ public async Task SendAsync(HttpMethod method, [StringSynta #else public async Task SendAsync(HttpMethod method, string? requestUri, object? value, Action? requestModifier) #endif - => await SendAsync(CreateRequest(method, requestUri ?? "", new StringContent(_jsonSerializer.Serialize(value), Encoding.UTF8, MediaTypeNames.Application.Json), requestModifier), default).ConfigureAwait(false); + => await SendAsync(CreateRequest(method, requestUri ?? "", new StringContent(_httpTester.JsonSerializer.Serialize(value), Encoding.UTF8, MediaTypeNames.Application.Json), requestModifier), default).ConfigureAwait(false); - /// - private Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => _client.SendAsync(request, cancellationToken); + /// + /// Orchestrates the HTTP request send including logging and . + /// + private async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + TestSetUp.LogAutoSetUpOutputs(_httpTester.Owner.Implementor); + + if (_httpTester.Owner.SetUp.OnBeforeHttpRequestMessageSendAsync != null) + await _httpTester.Owner.SetUp.OnBeforeHttpRequestMessageSendAsync(request, _httpTester.UserName, cancellationToken); + + request.Headers.Add(RequestIdName, _httpTester.RequestId); + + _httpTester.LogRequest(request); + var sw = Stopwatch.StartNew(); + var res = await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); + sw.Stop(); + + await Task.Delay(0, cancellationToken).ConfigureAwait(false); + _httpTester.LastLogs = _httpTester.Owner.SharedState.GetLoggerMessages(_httpTester.RequestId); + _httpTester.LogResponse(res, sw, _httpTester.LastLogs); + return res; + } /// /// Create the request. diff --git a/src/UnitTestEx/AspNetCore/HttpTesterBaseT.cs b/src/UnitTestEx/AspNetCore/HttpTesterBaseT.cs index 245fa53..a643e8d 100644 --- a/src/UnitTestEx/AspNetCore/HttpTesterBaseT.cs +++ b/src/UnitTestEx/AspNetCore/HttpTesterBaseT.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx -using Microsoft.AspNetCore.TestHost; using System; using System.Net.Http; using System.Threading.Tasks; @@ -18,9 +17,9 @@ public abstract class HttpTesterBase : HttpTesterBase, IHttpResponseMessa /// /// Initializes a new class. /// - /// The owning . - /// The . - public HttpTesterBase(TesterBase owner, TestServer testServer) : base(owner, testServer) => ExpectationsArranger = new ExpectationsArranger(owner, (TSelf)this); + /// The owning . + /// The . + public HttpTesterBase(TesterBaseCore owner, IHttpClientSource httpClientSource) : base(owner, httpClientSource) => ExpectationsArranger = new ExpectationsArranger(owner, (TSelf)this); /// /// Gets the . diff --git a/src/UnitTestEx/AspNetCore/HttpTesterBaseT2.cs b/src/UnitTestEx/AspNetCore/HttpTesterBaseT2.cs index 8c1bba9..674905e 100644 --- a/src/UnitTestEx/AspNetCore/HttpTesterBaseT2.cs +++ b/src/UnitTestEx/AspNetCore/HttpTesterBaseT2.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx -using Microsoft.AspNetCore.TestHost; using System; using System.Net.Http; using System.Threading.Tasks; @@ -19,9 +18,9 @@ public abstract class HttpTesterBase : HttpTesterBase, IHttpRespo /// /// Initializes a new class. /// - /// The owning . - /// The . - public HttpTesterBase(TesterBase owner, TestServer testServer) : base(owner, testServer) => ExpectationsArranger = new ExpectationsArranger(owner, (TSelf)this); + /// The owning . + /// The . + public HttpTesterBase(TesterBaseCore owner, IHttpClientSource httpClientSource) : base(owner, httpClientSource) => ExpectationsArranger = new ExpectationsArranger(owner, (TSelf)this); /// /// Gets the . diff --git a/src/UnitTestEx/AspNetCore/HttpTesterT.cs b/src/UnitTestEx/AspNetCore/HttpTesterT.cs index 51f456d..5db7743 100644 --- a/src/UnitTestEx/AspNetCore/HttpTesterT.cs +++ b/src/UnitTestEx/AspNetCore/HttpTesterT.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx -using Microsoft.AspNetCore.TestHost; using System; using System.Diagnostics.CodeAnalysis; using System.Net.Http; @@ -15,9 +14,9 @@ namespace UnitTestEx.AspNetCore /// Provides send testing with an expected response value. /// /// The expected response value . - /// The . - /// The . - public class HttpTester(TesterBase owner, TestServer testServer) : HttpTesterBase>(owner, testServer) + /// The . + /// The . + public class HttpTester(TesterBaseCore owner, IHttpClientSource httpClientSource) : HttpTesterBase>(owner, httpClientSource) { /// /// Runs the test by sending an with no body. diff --git a/src/UnitTestEx/Assertors/HttpResponseMessageAssertor.cs b/src/UnitTestEx/Assertors/HttpResponseMessageAssertor.cs index 946e581..068e70b 100644 --- a/src/UnitTestEx/Assertors/HttpResponseMessageAssertor.cs +++ b/src/UnitTestEx/Assertors/HttpResponseMessageAssertor.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using Microsoft.Net.Http.Headers; using System; @@ -13,10 +13,10 @@ namespace UnitTestEx.Assertors /// /// Represents the test assert helper. /// - /// The owning . + /// The owning . /// The log messages captured during execution. /// The . - public class HttpResponseMessageAssertor(TesterBase owner, IEnumerable? logs, HttpResponseMessage response) : HttpResponseMessageAssertorBase(owner, logs, response) + public class HttpResponseMessageAssertor(TesterBaseCore owner, IEnumerable? logs, HttpResponseMessage response) : HttpResponseMessageAssertorBase(owner, logs, response) { /// /// Asserts the the matches the . @@ -82,7 +82,7 @@ public HttpResponseMessageAssertor AssertValue(TValue? expectedValue, pa return this; } - if (!string.IsNullOrEmpty(Response.Content.Headers?.ContentType?.MediaType) && TesterBase.JsonMediaTypeNames.Contains(Response.Content.Headers.ContentType.MediaType!)) + if (!string.IsNullOrEmpty(Response.Content.Headers?.ContentType?.MediaType) && TesterBaseCore.JsonMediaTypeNames.Contains(Response.Content.Headers.ContentType.MediaType!)) { var json = Response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); if (expectedValue == null) diff --git a/src/UnitTestEx/Assertors/HttpResponseMessageAssertorBase.cs b/src/UnitTestEx/Assertors/HttpResponseMessageAssertorBase.cs index 15bbf1c..3b0f31e 100644 --- a/src/UnitTestEx/Assertors/HttpResponseMessageAssertorBase.cs +++ b/src/UnitTestEx/Assertors/HttpResponseMessageAssertorBase.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using System; using System.Collections.Generic; @@ -15,15 +15,15 @@ namespace UnitTestEx.Assertors /// /// Initializes a new instance of the class. /// - /// The owning . + /// The owning . /// The log messages captured during execution. /// The . - public abstract class HttpResponseMessageAssertorBase(TesterBase owner, IEnumerable? logs, HttpResponseMessage response) + public abstract class HttpResponseMessageAssertorBase(TesterBaseCore owner, IEnumerable? logs, HttpResponseMessage response) { /// - /// Gets the owning . + /// Gets the owning . /// - public TesterBase Owner { get; } = owner ?? throw new ArgumentNullException(nameof(owner)); + public TesterBaseCore Owner { get; } = owner ?? throw new ArgumentNullException(nameof(owner)); /// /// Gets the log messages captured during execution. diff --git a/src/UnitTestEx/Assertors/HttpResponseMessageAssertorBaseT.cs b/src/UnitTestEx/Assertors/HttpResponseMessageAssertorBaseT.cs index 1c54f2b..61dcc95 100644 --- a/src/UnitTestEx/Assertors/HttpResponseMessageAssertorBaseT.cs +++ b/src/UnitTestEx/Assertors/HttpResponseMessageAssertorBaseT.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using Microsoft.AspNetCore.Http; using Microsoft.Net.Http.Headers; @@ -16,10 +16,10 @@ namespace UnitTestEx.Assertors /// /// Provides the base test assert helper capabilities. /// - /// The owning . + /// The owning . /// The log messages captured during execution. /// The . - public abstract class HttpResponseMessageAssertorBase(TesterBase owner, IEnumerable? logs, HttpResponseMessage response) : HttpResponseMessageAssertorBase(owner, logs, response) where TSelf : HttpResponseMessageAssertorBase + public abstract class HttpResponseMessageAssertorBase(TesterBaseCore owner, IEnumerable? logs, HttpResponseMessage response) : HttpResponseMessageAssertorBase(owner, logs, response) where TSelf : HttpResponseMessageAssertorBase { /// /// Asserts that the has a successful value between 200 and 299. diff --git a/src/UnitTestEx/Assertors/HttpResponseMessageAssertorT.cs b/src/UnitTestEx/Assertors/HttpResponseMessageAssertorT.cs index 60e307f..8bd4fab 100644 --- a/src/UnitTestEx/Assertors/HttpResponseMessageAssertorT.cs +++ b/src/UnitTestEx/Assertors/HttpResponseMessageAssertorT.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using Microsoft.Net.Http.Headers; using System; @@ -11,10 +11,10 @@ namespace UnitTestEx.Assertors /// /// Represents the test assert helper with a specified response . /// - /// The owning . + /// The owning . /// The log messages captured during execution. /// The . - public class HttpResponseMessageAssertor(TesterBase owner, IEnumerable? logs, HttpResponseMessage response) : HttpResponseMessageAssertorBase>(owner, logs, response) + public class HttpResponseMessageAssertor(TesterBaseCore owner, IEnumerable? logs, HttpResponseMessage response) : HttpResponseMessageAssertorBase>(owner, logs, response) { private TValue? _value; private bool _valueIsDeserialized; @@ -22,11 +22,11 @@ public class HttpResponseMessageAssertor(TesterBase owner, IEnumerable /// Initializes a new instance of the class. /// - /// The owning . + /// The owning . /// The log messages captured during execution. /// The value already deserialized. /// The . - public HttpResponseMessageAssertor(TesterBase owner, IEnumerable? logs, TValue value, HttpResponseMessage response) : this(owner, logs, response) + public HttpResponseMessageAssertor(TesterBaseCore owner, IEnumerable? logs, TValue value, HttpResponseMessage response) : this(owner, logs, response) { _value = value; _valueIsDeserialized = true; diff --git a/src/UnitTestEx/Expectations/AssertArgs.cs b/src/UnitTestEx/Expectations/AssertArgs.cs index f902318..102dfd8 100644 --- a/src/UnitTestEx/Expectations/AssertArgs.cs +++ b/src/UnitTestEx/Expectations/AssertArgs.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using System; using System.Collections.Generic; @@ -18,11 +18,11 @@ public class AssertArgs /// /// Initializes a new instance of the class. /// - /// The owning . + /// The owning . /// The JSON paths to ignore where comparing the result value. /// The . /// The logs captured. - internal AssertArgs(TesterBase tester, IEnumerable pathsToIgnore, Exception? exception, IEnumerable? logs) + internal AssertArgs(TesterBaseCore tester, IEnumerable pathsToIgnore, Exception? exception, IEnumerable? logs) { Tester = tester ?? throw new ArgumentNullException(nameof(tester)); PathsToIgnore = pathsToIgnore; @@ -33,21 +33,21 @@ internal AssertArgs(TesterBase tester, IEnumerable pathsToIgnore, Except /// /// Initializes a new instance of the class with a . /// - /// The owning . + /// The owning . /// The JSON paths to ignore where comparing the result value. /// The . /// The logs captured. /// The resulting value. - internal AssertArgs(TesterBase tester, IEnumerable pathsToIgnore, Exception? exception, IEnumerable? logs, object? value) : this(tester, pathsToIgnore, exception, logs) + internal AssertArgs(TesterBaseCore tester, IEnumerable pathsToIgnore, Exception? exception, IEnumerable? logs, object? value) : this(tester, pathsToIgnore, exception, logs) { _hasValue = true; _value = value; } /// - /// Gets the owning . + /// Gets the owning . /// - public TesterBase Tester { get; } + public TesterBaseCore Tester { get; } /// /// Gets the that was the result of the test. diff --git a/src/UnitTestEx/Expectations/ErrorExpectations.cs b/src/UnitTestEx/Expectations/ErrorExpectations.cs index 261ed10..c8ebc2c 100644 --- a/src/UnitTestEx/Expectations/ErrorExpectations.cs +++ b/src/UnitTestEx/Expectations/ErrorExpectations.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using System; using System.Collections.Generic; @@ -12,9 +12,9 @@ namespace UnitTestEx.Expectations /// /// Provides error expectations. /// - /// The owning . + /// The owning . /// The initiating tester. - public class ErrorExpectations(TesterBase owner, TTester tester) : ExpectationsBase(owner, tester) + public class ErrorExpectations(TesterBaseCore owner, TTester tester) : ExpectationsBase(owner, tester) { /// public override string Title => "Error expectations"; diff --git a/src/UnitTestEx/Expectations/ExceptionExpectations.cs b/src/UnitTestEx/Expectations/ExceptionExpectations.cs index 580814c..69c9bfd 100644 --- a/src/UnitTestEx/Expectations/ExceptionExpectations.cs +++ b/src/UnitTestEx/Expectations/ExceptionExpectations.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using System; using System.Net.Http; @@ -10,9 +10,9 @@ namespace UnitTestEx.Expectations /// /// Provides expectations. /// - /// The owning . + /// The owning . /// The initiating tester. - public class ExceptionExpectations(TesterBase owner, TTester tester) : ExpectationsBase(owner, tester) + public class ExceptionExpectations(TesterBaseCore owner, TTester tester) : ExpectationsBase(owner, tester) { /// public override string Title => "Exception expectations"; diff --git a/src/UnitTestEx/Expectations/ExpectationsArranger.cs b/src/UnitTestEx/Expectations/ExpectationsArranger.cs index 75efee0..cd9c189 100644 --- a/src/UnitTestEx/Expectations/ExpectationsArranger.cs +++ b/src/UnitTestEx/Expectations/ExpectationsArranger.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using System; using System.Collections.Generic; @@ -14,16 +14,16 @@ namespace UnitTestEx.Expectations /// /// The type. /// An will after execution; as such, the configuration is not intended to be reused more than once. - /// The owning . + /// The owning . /// The initiating tester. - public class ExpectationsArranger(TesterBase owner, TTester tester) + public class ExpectationsArranger(TesterBaseCore owner, TTester tester) { private readonly Dictionary> _expectations = []; /// - /// Gets the owning . + /// Gets the owning . /// - public TesterBase Owner { get; } = owner ?? throw new ArgumentNullException(nameof(owner)); + public TesterBaseCore Owner { get; } = owner ?? throw new ArgumentNullException(nameof(owner)); /// /// Gets the initiating tester. diff --git a/src/UnitTestEx/Expectations/ExpectationsBase.cs b/src/UnitTestEx/Expectations/ExpectationsBase.cs index 5b9c95e..916c4d6 100644 --- a/src/UnitTestEx/Expectations/ExpectationsBase.cs +++ b/src/UnitTestEx/Expectations/ExpectationsBase.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using System; using System.Collections.Generic; @@ -10,15 +10,15 @@ namespace UnitTestEx.Expectations /// /// Provides the base Expectations implementation. /// - /// The owning - public abstract class ExpectationsBase(TesterBase owner) + /// The owning + public abstract class ExpectationsBase(TesterBaseCore owner) { private List>>? _extendedActions; /// - /// Gets the owning . + /// Gets the owning . /// - public TesterBase Owner { get; } = owner ?? throw new ArgumentNullException(nameof(owner)); + public TesterBaseCore Owner { get; } = owner ?? throw new ArgumentNullException(nameof(owner)); /// /// Gets or sets the title used in the assertion output. diff --git a/src/UnitTestEx/Expectations/ExpectationsBaseT.cs b/src/UnitTestEx/Expectations/ExpectationsBaseT.cs index b14f376..cc8805a 100644 --- a/src/UnitTestEx/Expectations/ExpectationsBaseT.cs +++ b/src/UnitTestEx/Expectations/ExpectationsBaseT.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using System; using System.Threading.Tasks; @@ -9,9 +9,9 @@ namespace UnitTestEx.Expectations /// /// Provides the base Expectations implementation. /// - /// The owning + /// The owning /// The initiating tester. - public abstract class ExpectationsBase(TesterBase owner, TTester tester) : ExpectationsBase(owner) + public abstract class ExpectationsBase(TesterBaseCore owner, TTester tester) : ExpectationsBase(owner) { /// /// Gets the initiating tester. diff --git a/src/UnitTestEx/Expectations/HttpResponseMessageExpectations.cs b/src/UnitTestEx/Expectations/HttpResponseMessageExpectations.cs index a5acf58..dd50ef4 100644 --- a/src/UnitTestEx/Expectations/HttpResponseMessageExpectations.cs +++ b/src/UnitTestEx/Expectations/HttpResponseMessageExpectations.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using System; using System.Net; @@ -11,9 +11,9 @@ namespace UnitTestEx.Expectations /// /// Provides expectations. /// - /// The owning . + /// The owning . /// The initiating tester. - public class HttpResponseMessageExpectations(TesterBase owner, TTester tester) : ExpectationsBase(owner, tester) + public class HttpResponseMessageExpectations(TesterBaseCore owner, TTester tester) : ExpectationsBase(owner, tester) { private HttpStatusCode? _httpStatusCode; diff --git a/src/UnitTestEx/Expectations/LoggerExpectations.cs b/src/UnitTestEx/Expectations/LoggerExpectations.cs index 7610fd4..32a0d29 100644 --- a/src/UnitTestEx/Expectations/LoggerExpectations.cs +++ b/src/UnitTestEx/Expectations/LoggerExpectations.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using Microsoft.Extensions.Logging; using System; @@ -12,9 +12,9 @@ namespace UnitTestEx.Expectations /// /// Provides log expectations. /// - /// The owning . + /// The owning . /// The initiating tester. - public class LoggerExpectations(TesterBase owner, TTester tester) : ExpectationsBase(owner, tester) + public class LoggerExpectations(TesterBaseCore owner, TTester tester) : ExpectationsBase(owner, tester) { private readonly List _expectTexts = []; diff --git a/src/UnitTestEx/Expectations/ValueExpectations.cs b/src/UnitTestEx/Expectations/ValueExpectations.cs index 114e158..4d5bde2 100644 --- a/src/UnitTestEx/Expectations/ValueExpectations.cs +++ b/src/UnitTestEx/Expectations/ValueExpectations.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using System; using System.Linq; @@ -12,9 +12,9 @@ namespace UnitTestEx.Expectations /// /// Provides value expectations. /// - /// The owning . + /// The owning . /// The initiating tester. - public class ValueExpectations(TesterBase owner, TTester tester) : ExpectationsBase(owner, tester) + public class ValueExpectations(TesterBaseCore owner, TTester tester) : ExpectationsBase(owner, tester) { private const string NullJson = "null"; private Func? _json; diff --git a/src/UnitTestEx/Generic/GenericTesterCore.cs b/src/UnitTestEx/Generic/GenericTesterCore.cs index 24fa4fe..15dde90 100644 --- a/src/UnitTestEx/Generic/GenericTesterCore.cs +++ b/src/UnitTestEx/Generic/GenericTesterCore.cs @@ -147,7 +147,7 @@ private IHost GetHost() } /// - protected override void ResetHost() + protected override void OnResetHost() { lock (SyncRoot) { diff --git a/src/UnitTestEx/Json/JsonElementComparerResult.cs b/src/UnitTestEx/Json/JsonElementComparerResult.cs index e09a7d9..afea426 100644 --- a/src/UnitTestEx/Json/JsonElementComparerResult.cs +++ b/src/UnitTestEx/Json/JsonElementComparerResult.cs @@ -90,14 +90,14 @@ public override string ToString() foreach (var d in _differences!) { if (sb.Length > 0) - sb.AppendLine(); + sb.Append('\n'); sb.Append(d.ToString()); } if (IsMaxDifferencesFound) { - sb.AppendLine(); + sb.Append('\n'); sb.Append($"Maximum difference count of '{MaxDifferences}' found; comparison stopped."); } From 3b831b7b53fa13ba9158a959c26c5ce40f174dd4 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 14:09:54 -0700 Subject: [PATCH 09/43] Fix compiler warnings introduced by TesterBaseCore extraction - Remove unnecessary `new` on TesterBase.ResetHost(bool) (CS0109); the differing parameter list from TesterBaseCore.ResetHost() means there was nothing to hide. - Fix XML doc `` 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 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/UnitTestEx/Abstractions/TesterBase.cs | 4 ++-- src/UnitTestEx/Abstractions/TesterBaseT.cs | 24 +++++++++---------- src/UnitTestEx/AspNetCore/HttpTesterBaseT.cs | 6 ++--- src/UnitTestEx/AspNetCore/HttpTesterBaseT2.cs | 6 ++--- src/UnitTestEx/TestSetUp.cs | 6 ++--- src/UnitTestEx/TesterArgs.cs | 2 +- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/UnitTestEx/Abstractions/TesterBase.cs b/src/UnitTestEx/Abstractions/TesterBase.cs index dffe2bd..be53a30 100644 --- a/src/UnitTestEx/Abstractions/TesterBase.cs +++ b/src/UnitTestEx/Abstractions/TesterBase.cs @@ -65,7 +65,7 @@ protected set /// Resets the underlying host to instantiate a new instance. /// /// Indicates whether to reset the previously configured services and start-ups. - public new void ResetHost(bool resetConfiguredServices = false) + public void ResetHost(bool resetConfiguredServices = false) { lock (SyncRoot) { @@ -96,7 +96,7 @@ protected void ConfigureServices(Action configureServices, b /// /// Adds the previously to the . /// - /// It is recommended that this is performed within a to ensure thread-safety. + /// It is recommended that this is performed within a to ensure thread-safety. protected void AddConfiguredServices(IServiceCollection services) { if (IsHostInstantiated) diff --git a/src/UnitTestEx/Abstractions/TesterBaseT.cs b/src/UnitTestEx/Abstractions/TesterBaseT.cs index d88e470..4d0798c 100644 --- a/src/UnitTestEx/Abstractions/TesterBaseT.cs +++ b/src/UnitTestEx/Abstractions/TesterBaseT.cs @@ -28,11 +28,11 @@ public abstract class TesterBase : TesterBase where TSelf : TesterBase UseSetUp(TestSetUp.Default); /// - /// Replaces the by cloning the and will . + /// Replaces the by cloning the and will . /// /// The /// The to support fluent-style method-chaining. - /// Updates the and from the . + /// Updates the and from the . /// As the host is reset it is recommended that the is performed early so as to not inadvertently override earlier configurations. public TSelf UseSetUp(TestSetUp setUp) { @@ -48,9 +48,9 @@ public TSelf UseSetUp(TestSetUp setUp) } /// - /// Updates (replaces) the default test . + /// Updates (replaces) the default test . /// - /// The test user name (a null value will reset to ). + /// The test user name (a null value will reset to ). /// The to support fluent-style method-chaining. public TSelf UseUser(string? userName) { @@ -59,9 +59,9 @@ public TSelf UseUser(string? userName) } /// - /// Updates (replaces) the default test . + /// Updates (replaces) the default test . /// - /// The test user identifier (a null value will reset to ). + /// The test user identifier (a null value will reset to ). /// The to support fluent-style method-chaining. /// The is required for the conversion to take place. public TSelf UseUser(object? userIdentifier) @@ -76,7 +76,7 @@ public TSelf UseUser(object? userIdentifier) } /// - /// Updates the used by the itself, not the underlying executing host which should be configured separately. + /// Updates the used by the itself, not the underlying executing host which should be configured separately. /// /// The . /// The to support fluent-style method-chaining. @@ -87,11 +87,11 @@ public TSelf UseJsonSerializer(IJsonSerializer jsonSerializer) } /// - /// Updates the used by the itself, not the underlying executing host which should be configured separately. + /// Updates the used by the itself, not the underlying executing host which should be configured separately. /// /// The . /// The to support fluent-style method-chaining. - /// Where the is null then the will be used. + /// Where the is null then the will be used. public TSelf UseJsonComparerOptions(JsonElementComparerOptions options) { JsonComparerOptions = options ?? throw new ArgumentNullException(nameof(options)); @@ -103,7 +103,7 @@ public TSelf UseJsonComparerOptions(JsonElementComparerOptions options) /// /// The additional configuration key/value pairs. /// The to support fluent-style method-chaining. - /// Usage will result in a . + /// Usage will result in a . public TSelf UseAdditionalConfiguration(IEnumerable>? additionalConfiguration) { AdditionalConfiguration = additionalConfiguration; @@ -116,7 +116,7 @@ public TSelf UseAdditionalConfiguration(IEnumerableThe additional configuration key. /// The additional configuration value. /// The to support fluent-style method-chaining. - /// Usage will result in a . + /// Usage will result in a . public TSelf UseAdditionalConfiguration(string key, string? value) => UseAdditionalConfiguration([new KeyValuePair(key, value)]); /// @@ -160,7 +160,7 @@ public TSelf UseScopedTypeSetUp(Func setupAsync) /// A start . /// Indicates whether to automatically (passing false) when configuring the services. /// This can be called multiple times prior to the underlying host being instantiated. - /// See . + /// See . public new TSelf OnHostStart(Action start, bool autoResetHost = true) { base.OnHostStart(start, autoResetHost); diff --git a/src/UnitTestEx/AspNetCore/HttpTesterBaseT.cs b/src/UnitTestEx/AspNetCore/HttpTesterBaseT.cs index a643e8d..52c6718 100644 --- a/src/UnitTestEx/AspNetCore/HttpTesterBaseT.cs +++ b/src/UnitTestEx/AspNetCore/HttpTesterBaseT.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using System; using System.Net.Http; @@ -27,7 +27,7 @@ public abstract class HttpTesterBase : HttpTesterBase, IHttpResponseMessa public ExpectationsArranger ExpectationsArranger { get; } /// - /// Sets (overrides) the test user name (defaults to ). + /// Sets (overrides) the test user name (defaults to ). /// /// The test user name. /// The instance to support fluent-style method-chaining. @@ -38,7 +38,7 @@ public TSelf WithUser(string? userName) } /// - /// Sets (overrides) the test user name (defaults to ). + /// Sets (overrides) the test user name (defaults to ). /// /// The test user identifier. /// The instance to support fluent-style method-chaining. diff --git a/src/UnitTestEx/AspNetCore/HttpTesterBaseT2.cs b/src/UnitTestEx/AspNetCore/HttpTesterBaseT2.cs index 674905e..f00de7f 100644 --- a/src/UnitTestEx/AspNetCore/HttpTesterBaseT2.cs +++ b/src/UnitTestEx/AspNetCore/HttpTesterBaseT2.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using System; using System.Net.Http; @@ -28,7 +28,7 @@ public abstract class HttpTesterBase : HttpTesterBase, IHttpRespo public ExpectationsArranger ExpectationsArranger { get; } /// - /// Sets (overrides) the test user name (defaults to ). + /// Sets (overrides) the test user name (defaults to ). /// /// The test user name. /// The instance to support fluent-style method-chaining. @@ -39,7 +39,7 @@ public TSelf WithUser(string? userName) } /// - /// Sets (overrides) the test user name (defaults to ). + /// Sets (overrides) the test user name (defaults to ). /// /// The test user identifier. /// The instance to support fluent-style method-chaining. diff --git a/src/UnitTestEx/TestSetUp.cs b/src/UnitTestEx/TestSetUp.cs index 24a5b9c..bf4e61b 100644 --- a/src/UnitTestEx/TestSetUp.cs +++ b/src/UnitTestEx/TestSetUp.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; @@ -189,13 +189,13 @@ public static void LogAutoSetUpOutputs(TestFrameworkImplementor implementor) /// /// Gets or sets the function that enables the to be updated before each send. /// - /// The second parameter () is set to the . This provides an opportunity where needed to add the likes of OAuth tokens, etc. + /// The second parameter () is set to the . This provides an opportunity where needed to add the likes of OAuth tokens, etc. public Func? OnBeforeHttpRequestMessageSendAsync { get; set; } /// /// Gets or sets the function that enables the to be updated before each send. /// - /// The second parameter () is set to the . This provides an opportunity where needed to add the likes of OAuth tokens, etc. + /// The second parameter () is set to the . This provides an opportunity where needed to add the likes of OAuth tokens, etc. public Func? OnBeforeHttpRequestSendAsync { get; set; } /// diff --git a/src/UnitTestEx/TesterArgs.cs b/src/UnitTestEx/TesterArgs.cs index 211d7e1..3dce827 100644 --- a/src/UnitTestEx/TesterArgs.cs +++ b/src/UnitTestEx/TesterArgs.cs @@ -10,7 +10,7 @@ public class TesterArgs /// /// Indicates whether to bypass the execution of the configured run actions. /// - /// The run actions are: , and . + /// The run actions are: , and . public bool BypassRunActions { get; set; } } } \ No newline at end of file From cca96ae20fff220e79ef631d8c8a619c9d73485f Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 17:58:56 -0700 Subject: [PATCH 10/43] Add UnitTestEx.Aspire: multi-host testing via AspireTester (Phase 2) - New UnitTestEx.Aspire package with AspireTesterBase/AspireTester/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 dotnet/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> --- .github/workflows/ci.yml | 11 +- UnitTestEx.sln | 175 +++++++++++++++ .../Aspire/AspireTesterBase.cs | 199 ++++++++++++++++++ src/UnitTestEx.Aspire/Aspire/AspireTesterT.cs | 14 ++ src/UnitTestEx.Aspire/AspireTester.cs | 22 ++ .../UnitTestEx.Aspire.csproj | 22 ++ src/UnitTestEx.Aspire/strong-name-key.snk | Bin 0 -> 596 bytes src/UnitTestEx/AspNetCore/HttpTesterBase.cs | 4 +- tests/UnitTestEx.Aspire.AppHost/AppHost.cs | 20 ++ .../UnitTestEx.Aspire.AppHost.csproj | 22 ++ .../AspireTesterTest.cs | 40 ++++ .../UnitTestEx.Aspire.MSTest.Test.csproj | 27 +++ .../AspireTesterTest.cs | 40 ++++ .../UnitTestEx.Aspire.NUnit.Test.csproj | 29 +++ .../AspireTesterTest.cs | 43 ++++ .../UnitTestEx.Aspire.Xunit.Test.csproj | 28 +++ 16 files changed, 693 insertions(+), 3 deletions(-) create mode 100644 src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs create mode 100644 src/UnitTestEx.Aspire/Aspire/AspireTesterT.cs create mode 100644 src/UnitTestEx.Aspire/AspireTester.cs create mode 100644 src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj create mode 100644 src/UnitTestEx.Aspire/strong-name-key.snk create mode 100644 tests/UnitTestEx.Aspire.AppHost/AppHost.cs create mode 100644 tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj create mode 100644 tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs create mode 100644 tests/UnitTestEx.Aspire.MSTest.Test/UnitTestEx.Aspire.MSTest.Test.csproj create mode 100644 tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs create mode 100644 tests/UnitTestEx.Aspire.NUnit.Test/UnitTestEx.Aspire.NUnit.Test.csproj create mode 100644 tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs create mode 100644 tests/UnitTestEx.Aspire.Xunit.Test/UnitTestEx.Aspire.Xunit.Test.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b7bf64..dba2709 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,4 +50,13 @@ jobs: dotnet test UnitTestEx.Xunit.Test.dll --no-build --verbosity normal --tests ProductFunctionTest dotnet test UnitTestEx.Xunit.Test.dll --no-build --verbosity normal --tests ServiceBusFunctionTest dotnet test UnitTestEx.Xunit.Test.dll --no-build --verbosity normal --tests PersonControllerTest - dotnet test UnitTestEx.Xunit.Test.dll --no-build --verbosity normal --tests ProductControllerTest \ No newline at end of file + dotnet test UnitTestEx.Xunit.Test.dll --no-build --verbosity normal --tests ProductControllerTest + + - name: Explicit Aspire MSTest test + run: dotnet test tests/UnitTestEx.Aspire.MSTest.Test/UnitTestEx.Aspire.MSTest.Test.csproj --no-build --verbosity normal + + - name: Explicit Aspire NUnit test + run: dotnet test tests/UnitTestEx.Aspire.NUnit.Test/UnitTestEx.Aspire.NUnit.Test.csproj --no-build --verbosity normal + + - name: Explicit Aspire Xunit test + run: dotnet test tests/UnitTestEx.Aspire.Xunit.Test/UnitTestEx.Aspire.Xunit.Test.csproj --no-build --verbosity normal \ No newline at end of file diff --git a/UnitTestEx.sln b/UnitTestEx.sln index 945a484..781f21c 100644 --- a/UnitTestEx.sln +++ b/UnitTestEx.sln @@ -46,60 +46,230 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestEx.Azure.Functions" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestEx.Azure.ServiceBus", "src\UnitTestEx.Azure.ServiceBus\UnitTestEx.Azure.ServiceBus.csproj", "{F4EE44B0-8CCC-412D-A5D2-C60B60509D7D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestEx.Aspire", "src\UnitTestEx.Aspire\UnitTestEx.Aspire.csproj", "{C2B6DFD7-C8FD-49C9-9F83-920FFBDB67DF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestEx.Aspire.AppHost", "tests\UnitTestEx.Aspire.AppHost\UnitTestEx.Aspire.AppHost.csproj", "{6116F652-0A71-444D-B906-A26E1539934D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestEx.Aspire.Xunit.Test", "tests\UnitTestEx.Aspire.Xunit.Test\UnitTestEx.Aspire.Xunit.Test.csproj", "{1A2AF108-BC04-4643-99F1-306865E06C8F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestEx.Aspire.NUnit.Test", "tests\UnitTestEx.Aspire.NUnit.Test\UnitTestEx.Aspire.NUnit.Test.csproj", "{A87D7300-5B77-4552-A4F2-60D566AD154D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestEx.Aspire.MSTest.Test", "tests\UnitTestEx.Aspire.MSTest.Test\UnitTestEx.Aspire.MSTest.Test.csproj", "{6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {741ABD90-509F-41DD-89A0-97368047B9B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {741ABD90-509F-41DD-89A0-97368047B9B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {741ABD90-509F-41DD-89A0-97368047B9B8}.Debug|x64.ActiveCfg = Debug|Any CPU + {741ABD90-509F-41DD-89A0-97368047B9B8}.Debug|x64.Build.0 = Debug|Any CPU + {741ABD90-509F-41DD-89A0-97368047B9B8}.Debug|x86.ActiveCfg = Debug|Any CPU + {741ABD90-509F-41DD-89A0-97368047B9B8}.Debug|x86.Build.0 = Debug|Any CPU {741ABD90-509F-41DD-89A0-97368047B9B8}.Release|Any CPU.ActiveCfg = Release|Any CPU {741ABD90-509F-41DD-89A0-97368047B9B8}.Release|Any CPU.Build.0 = Release|Any CPU + {741ABD90-509F-41DD-89A0-97368047B9B8}.Release|x64.ActiveCfg = Release|Any CPU + {741ABD90-509F-41DD-89A0-97368047B9B8}.Release|x64.Build.0 = Release|Any CPU + {741ABD90-509F-41DD-89A0-97368047B9B8}.Release|x86.ActiveCfg = Release|Any CPU + {741ABD90-509F-41DD-89A0-97368047B9B8}.Release|x86.Build.0 = Release|Any CPU {3A031EFF-2CA2-47B6-8F97-8A529A17638E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3A031EFF-2CA2-47B6-8F97-8A529A17638E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A031EFF-2CA2-47B6-8F97-8A529A17638E}.Debug|x64.ActiveCfg = Debug|Any CPU + {3A031EFF-2CA2-47B6-8F97-8A529A17638E}.Debug|x64.Build.0 = Debug|Any CPU + {3A031EFF-2CA2-47B6-8F97-8A529A17638E}.Debug|x86.ActiveCfg = Debug|Any CPU + {3A031EFF-2CA2-47B6-8F97-8A529A17638E}.Debug|x86.Build.0 = Debug|Any CPU {3A031EFF-2CA2-47B6-8F97-8A529A17638E}.Release|Any CPU.ActiveCfg = Release|Any CPU {3A031EFF-2CA2-47B6-8F97-8A529A17638E}.Release|Any CPU.Build.0 = Release|Any CPU + {3A031EFF-2CA2-47B6-8F97-8A529A17638E}.Release|x64.ActiveCfg = Release|Any CPU + {3A031EFF-2CA2-47B6-8F97-8A529A17638E}.Release|x64.Build.0 = Release|Any CPU + {3A031EFF-2CA2-47B6-8F97-8A529A17638E}.Release|x86.ActiveCfg = Release|Any CPU + {3A031EFF-2CA2-47B6-8F97-8A529A17638E}.Release|x86.Build.0 = Release|Any CPU {12DB0D2A-ADA5-479E-BCFD-DB5438C2D048}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {12DB0D2A-ADA5-479E-BCFD-DB5438C2D048}.Debug|Any CPU.Build.0 = Debug|Any CPU + {12DB0D2A-ADA5-479E-BCFD-DB5438C2D048}.Debug|x64.ActiveCfg = Debug|Any CPU + {12DB0D2A-ADA5-479E-BCFD-DB5438C2D048}.Debug|x64.Build.0 = Debug|Any CPU + {12DB0D2A-ADA5-479E-BCFD-DB5438C2D048}.Debug|x86.ActiveCfg = Debug|Any CPU + {12DB0D2A-ADA5-479E-BCFD-DB5438C2D048}.Debug|x86.Build.0 = Debug|Any CPU {12DB0D2A-ADA5-479E-BCFD-DB5438C2D048}.Release|Any CPU.ActiveCfg = Release|Any CPU {12DB0D2A-ADA5-479E-BCFD-DB5438C2D048}.Release|Any CPU.Build.0 = Release|Any CPU + {12DB0D2A-ADA5-479E-BCFD-DB5438C2D048}.Release|x64.ActiveCfg = Release|Any CPU + {12DB0D2A-ADA5-479E-BCFD-DB5438C2D048}.Release|x64.Build.0 = Release|Any CPU + {12DB0D2A-ADA5-479E-BCFD-DB5438C2D048}.Release|x86.ActiveCfg = Release|Any CPU + {12DB0D2A-ADA5-479E-BCFD-DB5438C2D048}.Release|x86.Build.0 = Release|Any CPU {C49839FB-E54C-464C-B4EE-7310D1B5239C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C49839FB-E54C-464C-B4EE-7310D1B5239C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C49839FB-E54C-464C-B4EE-7310D1B5239C}.Debug|x64.ActiveCfg = Debug|Any CPU + {C49839FB-E54C-464C-B4EE-7310D1B5239C}.Debug|x64.Build.0 = Debug|Any CPU + {C49839FB-E54C-464C-B4EE-7310D1B5239C}.Debug|x86.ActiveCfg = Debug|Any CPU + {C49839FB-E54C-464C-B4EE-7310D1B5239C}.Debug|x86.Build.0 = Debug|Any CPU {C49839FB-E54C-464C-B4EE-7310D1B5239C}.Release|Any CPU.ActiveCfg = Release|Any CPU {C49839FB-E54C-464C-B4EE-7310D1B5239C}.Release|Any CPU.Build.0 = Release|Any CPU + {C49839FB-E54C-464C-B4EE-7310D1B5239C}.Release|x64.ActiveCfg = Release|Any CPU + {C49839FB-E54C-464C-B4EE-7310D1B5239C}.Release|x64.Build.0 = Release|Any CPU + {C49839FB-E54C-464C-B4EE-7310D1B5239C}.Release|x86.ActiveCfg = Release|Any CPU + {C49839FB-E54C-464C-B4EE-7310D1B5239C}.Release|x86.Build.0 = Release|Any CPU {5523990C-1E7B-483E-9823-15057139BBA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5523990C-1E7B-483E-9823-15057139BBA8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5523990C-1E7B-483E-9823-15057139BBA8}.Debug|x64.ActiveCfg = Debug|Any CPU + {5523990C-1E7B-483E-9823-15057139BBA8}.Debug|x64.Build.0 = Debug|Any CPU + {5523990C-1E7B-483E-9823-15057139BBA8}.Debug|x86.ActiveCfg = Debug|Any CPU + {5523990C-1E7B-483E-9823-15057139BBA8}.Debug|x86.Build.0 = Debug|Any CPU {5523990C-1E7B-483E-9823-15057139BBA8}.Release|Any CPU.ActiveCfg = Release|Any CPU {5523990C-1E7B-483E-9823-15057139BBA8}.Release|Any CPU.Build.0 = Release|Any CPU + {5523990C-1E7B-483E-9823-15057139BBA8}.Release|x64.ActiveCfg = Release|Any CPU + {5523990C-1E7B-483E-9823-15057139BBA8}.Release|x64.Build.0 = Release|Any CPU + {5523990C-1E7B-483E-9823-15057139BBA8}.Release|x86.ActiveCfg = Release|Any CPU + {5523990C-1E7B-483E-9823-15057139BBA8}.Release|x86.Build.0 = Release|Any CPU {C0060492-6AE0-43C9-874D-71CF8D27FF96}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C0060492-6AE0-43C9-874D-71CF8D27FF96}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0060492-6AE0-43C9-874D-71CF8D27FF96}.Debug|x64.ActiveCfg = Debug|Any CPU + {C0060492-6AE0-43C9-874D-71CF8D27FF96}.Debug|x64.Build.0 = Debug|Any CPU + {C0060492-6AE0-43C9-874D-71CF8D27FF96}.Debug|x86.ActiveCfg = Debug|Any CPU + {C0060492-6AE0-43C9-874D-71CF8D27FF96}.Debug|x86.Build.0 = Debug|Any CPU {C0060492-6AE0-43C9-874D-71CF8D27FF96}.Release|Any CPU.ActiveCfg = Release|Any CPU {C0060492-6AE0-43C9-874D-71CF8D27FF96}.Release|Any CPU.Build.0 = Release|Any CPU + {C0060492-6AE0-43C9-874D-71CF8D27FF96}.Release|x64.ActiveCfg = Release|Any CPU + {C0060492-6AE0-43C9-874D-71CF8D27FF96}.Release|x64.Build.0 = Release|Any CPU + {C0060492-6AE0-43C9-874D-71CF8D27FF96}.Release|x86.ActiveCfg = Release|Any CPU + {C0060492-6AE0-43C9-874D-71CF8D27FF96}.Release|x86.Build.0 = Release|Any CPU {F0146507-A0C4-4E7A-9CD4-DDFB56591C39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F0146507-A0C4-4E7A-9CD4-DDFB56591C39}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0146507-A0C4-4E7A-9CD4-DDFB56591C39}.Debug|x64.ActiveCfg = Debug|Any CPU + {F0146507-A0C4-4E7A-9CD4-DDFB56591C39}.Debug|x64.Build.0 = Debug|Any CPU + {F0146507-A0C4-4E7A-9CD4-DDFB56591C39}.Debug|x86.ActiveCfg = Debug|Any CPU + {F0146507-A0C4-4E7A-9CD4-DDFB56591C39}.Debug|x86.Build.0 = Debug|Any CPU {F0146507-A0C4-4E7A-9CD4-DDFB56591C39}.Release|Any CPU.ActiveCfg = Release|Any CPU {F0146507-A0C4-4E7A-9CD4-DDFB56591C39}.Release|Any CPU.Build.0 = Release|Any CPU + {F0146507-A0C4-4E7A-9CD4-DDFB56591C39}.Release|x64.ActiveCfg = Release|Any CPU + {F0146507-A0C4-4E7A-9CD4-DDFB56591C39}.Release|x64.Build.0 = Release|Any CPU + {F0146507-A0C4-4E7A-9CD4-DDFB56591C39}.Release|x86.ActiveCfg = Release|Any CPU + {F0146507-A0C4-4E7A-9CD4-DDFB56591C39}.Release|x86.Build.0 = Release|Any CPU {63F25714-C654-4FB7-B6A1-05E7197F416D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {63F25714-C654-4FB7-B6A1-05E7197F416D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {63F25714-C654-4FB7-B6A1-05E7197F416D}.Debug|x64.ActiveCfg = Debug|Any CPU + {63F25714-C654-4FB7-B6A1-05E7197F416D}.Debug|x64.Build.0 = Debug|Any CPU + {63F25714-C654-4FB7-B6A1-05E7197F416D}.Debug|x86.ActiveCfg = Debug|Any CPU + {63F25714-C654-4FB7-B6A1-05E7197F416D}.Debug|x86.Build.0 = Debug|Any CPU {63F25714-C654-4FB7-B6A1-05E7197F416D}.Release|Any CPU.ActiveCfg = Release|Any CPU {63F25714-C654-4FB7-B6A1-05E7197F416D}.Release|Any CPU.Build.0 = Release|Any CPU + {63F25714-C654-4FB7-B6A1-05E7197F416D}.Release|x64.ActiveCfg = Release|Any CPU + {63F25714-C654-4FB7-B6A1-05E7197F416D}.Release|x64.Build.0 = Release|Any CPU + {63F25714-C654-4FB7-B6A1-05E7197F416D}.Release|x86.ActiveCfg = Release|Any CPU + {63F25714-C654-4FB7-B6A1-05E7197F416D}.Release|x86.Build.0 = Release|Any CPU {5A9A5401-0868-4D31-8B9C-E98FF70F0AA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5A9A5401-0868-4D31-8B9C-E98FF70F0AA5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A9A5401-0868-4D31-8B9C-E98FF70F0AA5}.Debug|x64.ActiveCfg = Debug|Any CPU + {5A9A5401-0868-4D31-8B9C-E98FF70F0AA5}.Debug|x64.Build.0 = Debug|Any CPU + {5A9A5401-0868-4D31-8B9C-E98FF70F0AA5}.Debug|x86.ActiveCfg = Debug|Any CPU + {5A9A5401-0868-4D31-8B9C-E98FF70F0AA5}.Debug|x86.Build.0 = Debug|Any CPU {5A9A5401-0868-4D31-8B9C-E98FF70F0AA5}.Release|Any CPU.ActiveCfg = Release|Any CPU {5A9A5401-0868-4D31-8B9C-E98FF70F0AA5}.Release|Any CPU.Build.0 = Release|Any CPU + {5A9A5401-0868-4D31-8B9C-E98FF70F0AA5}.Release|x64.ActiveCfg = Release|Any CPU + {5A9A5401-0868-4D31-8B9C-E98FF70F0AA5}.Release|x64.Build.0 = Release|Any CPU + {5A9A5401-0868-4D31-8B9C-E98FF70F0AA5}.Release|x86.ActiveCfg = Release|Any CPU + {5A9A5401-0868-4D31-8B9C-E98FF70F0AA5}.Release|x86.Build.0 = Release|Any CPU {30C7FB87-936D-45DE-9031-1DB357EDC16D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {30C7FB87-936D-45DE-9031-1DB357EDC16D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {30C7FB87-936D-45DE-9031-1DB357EDC16D}.Debug|x64.ActiveCfg = Debug|Any CPU + {30C7FB87-936D-45DE-9031-1DB357EDC16D}.Debug|x64.Build.0 = Debug|Any CPU + {30C7FB87-936D-45DE-9031-1DB357EDC16D}.Debug|x86.ActiveCfg = Debug|Any CPU + {30C7FB87-936D-45DE-9031-1DB357EDC16D}.Debug|x86.Build.0 = Debug|Any CPU {30C7FB87-936D-45DE-9031-1DB357EDC16D}.Release|Any CPU.ActiveCfg = Release|Any CPU {30C7FB87-936D-45DE-9031-1DB357EDC16D}.Release|Any CPU.Build.0 = Release|Any CPU + {30C7FB87-936D-45DE-9031-1DB357EDC16D}.Release|x64.ActiveCfg = Release|Any CPU + {30C7FB87-936D-45DE-9031-1DB357EDC16D}.Release|x64.Build.0 = Release|Any CPU + {30C7FB87-936D-45DE-9031-1DB357EDC16D}.Release|x86.ActiveCfg = Release|Any CPU + {30C7FB87-936D-45DE-9031-1DB357EDC16D}.Release|x86.Build.0 = Release|Any CPU {6132D56E-60B2-4607-98F2-E5AA7FBB8BFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6132D56E-60B2-4607-98F2-E5AA7FBB8BFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6132D56E-60B2-4607-98F2-E5AA7FBB8BFB}.Debug|x64.ActiveCfg = Debug|Any CPU + {6132D56E-60B2-4607-98F2-E5AA7FBB8BFB}.Debug|x64.Build.0 = Debug|Any CPU + {6132D56E-60B2-4607-98F2-E5AA7FBB8BFB}.Debug|x86.ActiveCfg = Debug|Any CPU + {6132D56E-60B2-4607-98F2-E5AA7FBB8BFB}.Debug|x86.Build.0 = Debug|Any CPU {6132D56E-60B2-4607-98F2-E5AA7FBB8BFB}.Release|Any CPU.ActiveCfg = Release|Any CPU {6132D56E-60B2-4607-98F2-E5AA7FBB8BFB}.Release|Any CPU.Build.0 = Release|Any CPU + {6132D56E-60B2-4607-98F2-E5AA7FBB8BFB}.Release|x64.ActiveCfg = Release|Any CPU + {6132D56E-60B2-4607-98F2-E5AA7FBB8BFB}.Release|x64.Build.0 = Release|Any CPU + {6132D56E-60B2-4607-98F2-E5AA7FBB8BFB}.Release|x86.ActiveCfg = Release|Any CPU + {6132D56E-60B2-4607-98F2-E5AA7FBB8BFB}.Release|x86.Build.0 = Release|Any CPU {F4EE44B0-8CCC-412D-A5D2-C60B60509D7D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F4EE44B0-8CCC-412D-A5D2-C60B60509D7D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F4EE44B0-8CCC-412D-A5D2-C60B60509D7D}.Debug|x64.ActiveCfg = Debug|Any CPU + {F4EE44B0-8CCC-412D-A5D2-C60B60509D7D}.Debug|x64.Build.0 = Debug|Any CPU + {F4EE44B0-8CCC-412D-A5D2-C60B60509D7D}.Debug|x86.ActiveCfg = Debug|Any CPU + {F4EE44B0-8CCC-412D-A5D2-C60B60509D7D}.Debug|x86.Build.0 = Debug|Any CPU {F4EE44B0-8CCC-412D-A5D2-C60B60509D7D}.Release|Any CPU.ActiveCfg = Release|Any CPU {F4EE44B0-8CCC-412D-A5D2-C60B60509D7D}.Release|Any CPU.Build.0 = Release|Any CPU + {F4EE44B0-8CCC-412D-A5D2-C60B60509D7D}.Release|x64.ActiveCfg = Release|Any CPU + {F4EE44B0-8CCC-412D-A5D2-C60B60509D7D}.Release|x64.Build.0 = Release|Any CPU + {F4EE44B0-8CCC-412D-A5D2-C60B60509D7D}.Release|x86.ActiveCfg = Release|Any CPU + {F4EE44B0-8CCC-412D-A5D2-C60B60509D7D}.Release|x86.Build.0 = Release|Any CPU + {C2B6DFD7-C8FD-49C9-9F83-920FFBDB67DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C2B6DFD7-C8FD-49C9-9F83-920FFBDB67DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2B6DFD7-C8FD-49C9-9F83-920FFBDB67DF}.Debug|x64.ActiveCfg = Debug|Any CPU + {C2B6DFD7-C8FD-49C9-9F83-920FFBDB67DF}.Debug|x64.Build.0 = Debug|Any CPU + {C2B6DFD7-C8FD-49C9-9F83-920FFBDB67DF}.Debug|x86.ActiveCfg = Debug|Any CPU + {C2B6DFD7-C8FD-49C9-9F83-920FFBDB67DF}.Debug|x86.Build.0 = Debug|Any CPU + {C2B6DFD7-C8FD-49C9-9F83-920FFBDB67DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C2B6DFD7-C8FD-49C9-9F83-920FFBDB67DF}.Release|Any CPU.Build.0 = Release|Any CPU + {C2B6DFD7-C8FD-49C9-9F83-920FFBDB67DF}.Release|x64.ActiveCfg = Release|Any CPU + {C2B6DFD7-C8FD-49C9-9F83-920FFBDB67DF}.Release|x64.Build.0 = Release|Any CPU + {C2B6DFD7-C8FD-49C9-9F83-920FFBDB67DF}.Release|x86.ActiveCfg = Release|Any CPU + {C2B6DFD7-C8FD-49C9-9F83-920FFBDB67DF}.Release|x86.Build.0 = Release|Any CPU + {6116F652-0A71-444D-B906-A26E1539934D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6116F652-0A71-444D-B906-A26E1539934D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6116F652-0A71-444D-B906-A26E1539934D}.Debug|x64.ActiveCfg = Debug|Any CPU + {6116F652-0A71-444D-B906-A26E1539934D}.Debug|x64.Build.0 = Debug|Any CPU + {6116F652-0A71-444D-B906-A26E1539934D}.Debug|x86.ActiveCfg = Debug|Any CPU + {6116F652-0A71-444D-B906-A26E1539934D}.Debug|x86.Build.0 = Debug|Any CPU + {6116F652-0A71-444D-B906-A26E1539934D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6116F652-0A71-444D-B906-A26E1539934D}.Release|Any CPU.Build.0 = Release|Any CPU + {6116F652-0A71-444D-B906-A26E1539934D}.Release|x64.ActiveCfg = Release|Any CPU + {6116F652-0A71-444D-B906-A26E1539934D}.Release|x64.Build.0 = Release|Any CPU + {6116F652-0A71-444D-B906-A26E1539934D}.Release|x86.ActiveCfg = Release|Any CPU + {6116F652-0A71-444D-B906-A26E1539934D}.Release|x86.Build.0 = Release|Any CPU + {1A2AF108-BC04-4643-99F1-306865E06C8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A2AF108-BC04-4643-99F1-306865E06C8F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A2AF108-BC04-4643-99F1-306865E06C8F}.Debug|x64.ActiveCfg = Debug|Any CPU + {1A2AF108-BC04-4643-99F1-306865E06C8F}.Debug|x64.Build.0 = Debug|Any CPU + {1A2AF108-BC04-4643-99F1-306865E06C8F}.Debug|x86.ActiveCfg = Debug|Any CPU + {1A2AF108-BC04-4643-99F1-306865E06C8F}.Debug|x86.Build.0 = Debug|Any CPU + {1A2AF108-BC04-4643-99F1-306865E06C8F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A2AF108-BC04-4643-99F1-306865E06C8F}.Release|Any CPU.Build.0 = Release|Any CPU + {1A2AF108-BC04-4643-99F1-306865E06C8F}.Release|x64.ActiveCfg = Release|Any CPU + {1A2AF108-BC04-4643-99F1-306865E06C8F}.Release|x64.Build.0 = Release|Any CPU + {1A2AF108-BC04-4643-99F1-306865E06C8F}.Release|x86.ActiveCfg = Release|Any CPU + {1A2AF108-BC04-4643-99F1-306865E06C8F}.Release|x86.Build.0 = Release|Any CPU + {A87D7300-5B77-4552-A4F2-60D566AD154D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A87D7300-5B77-4552-A4F2-60D566AD154D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A87D7300-5B77-4552-A4F2-60D566AD154D}.Debug|x64.ActiveCfg = Debug|Any CPU + {A87D7300-5B77-4552-A4F2-60D566AD154D}.Debug|x64.Build.0 = Debug|Any CPU + {A87D7300-5B77-4552-A4F2-60D566AD154D}.Debug|x86.ActiveCfg = Debug|Any CPU + {A87D7300-5B77-4552-A4F2-60D566AD154D}.Debug|x86.Build.0 = Debug|Any CPU + {A87D7300-5B77-4552-A4F2-60D566AD154D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A87D7300-5B77-4552-A4F2-60D566AD154D}.Release|Any CPU.Build.0 = Release|Any CPU + {A87D7300-5B77-4552-A4F2-60D566AD154D}.Release|x64.ActiveCfg = Release|Any CPU + {A87D7300-5B77-4552-A4F2-60D566AD154D}.Release|x64.Build.0 = Release|Any CPU + {A87D7300-5B77-4552-A4F2-60D566AD154D}.Release|x86.ActiveCfg = Release|Any CPU + {A87D7300-5B77-4552-A4F2-60D566AD154D}.Release|x86.Build.0 = Release|Any CPU + {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}.Debug|x64.ActiveCfg = Debug|Any CPU + {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}.Debug|x64.Build.0 = Debug|Any CPU + {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}.Debug|x86.ActiveCfg = Debug|Any CPU + {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}.Debug|x86.Build.0 = Debug|Any CPU + {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}.Release|Any CPU.Build.0 = Release|Any CPU + {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}.Release|x64.ActiveCfg = Release|Any CPU + {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}.Release|x64.Build.0 = Release|Any CPU + {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}.Release|x86.ActiveCfg = Release|Any CPU + {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -117,6 +287,11 @@ Global {30C7FB87-936D-45DE-9031-1DB357EDC16D} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} {6132D56E-60B2-4607-98F2-E5AA7FBB8BFB} = {B424F961-83B6-44FB-B6A9-BF9825361EA2} {F4EE44B0-8CCC-412D-A5D2-C60B60509D7D} = {B424F961-83B6-44FB-B6A9-BF9825361EA2} + {C2B6DFD7-C8FD-49C9-9F83-920FFBDB67DF} = {B424F961-83B6-44FB-B6A9-BF9825361EA2} + {6116F652-0A71-444D-B906-A26E1539934D} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} + {1A2AF108-BC04-4643-99F1-306865E06C8F} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} + {A87D7300-5B77-4552-A4F2-60D566AD154D} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} + {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5A6E4D99-0084-441B-8349-CB320963E931} diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs new file mode 100644 index 0000000..e07d395 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -0,0 +1,199 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Testing; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using UnitTestEx.Abstractions; +using UnitTestEx.AspNetCore; + +namespace UnitTestEx.Aspire +{ + /// + /// Provides the base .NET Aspire multi-host (distributed application) unit-testing capabilities. + /// + /// The AppHost (a Projects.* type generated for the AppHost's ProjectReference). + /// The to support inheriting fluent-style method-chaining. + /// This extends the host-agnostic directly (not ) as the underlying is a real, + /// multi-process distributed application with no single, in-process dependency injection (DI) container to reach into; see the docs/design/aspire-multi-host-testing.md design + /// note for the rationale. Where a Tier 1 () capability is genuinely shared in concept, it is exposed here via the + /// seam rather than by inheriting 's dependency injection (DI) specific members. + public abstract class AspireTesterBase : TesterBaseCore, IHttpClientSource, IAsyncDisposable + where TAppHost : class + where TSelf : AspireTesterBase + { + private readonly List> _configureBuilder = []; + private Task? _appTask; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The . + protected AspireTesterBase(TestFrameworkImplementor implementor) : base(implementor) { } + + /// + /// Gets the ; builds and starts on first access. + /// + /// The started . + protected Task GetDistributedApplicationAsync() + { + lock (SyncRoot) + { + return _appTask ??= CreateDistributedApplicationAsync(); + } + } + + /// + /// Creates, builds and starts the underlying . + /// + private async Task CreateDistributedApplicationAsync() + { + var builder = await DistributedApplicationTestingBuilder.CreateAsync().ConfigureAwait(false); + + foreach (var configure in _configureBuilder) + { + configure(builder); + } + + OnHostStartUp(); + + var app = await builder.BuildAsync().ConfigureAwait(false); + + try + { + await app.StartAsync().ConfigureAwait(false); + } + catch + { + await app.DisposeAsync().ConfigureAwait(false); + throw; + } + + return app; + } + + /// + protected override void OnResetHost() + { + Task? appTask; + lock (SyncRoot) + { + appTask = _appTask; + _appTask = null; + } + + if (appTask is { IsCompletedSuccessfully: true }) + { + appTask.Result.Dispose(); + Implementor.WriteLine(""); + Implementor.WriteLine("** The underlying UnitTestEx 'AspireTester' distributed application host has been reset. **"); + Implementor.WriteLine(""); + } + } + + /// + /// Overrides an environment variable for the named resource before the underlying is built. + /// + /// The resource name (as configured within the AppHost). + /// The environment variable name. + /// The environment variable value. + /// The to support fluent-style method-chaining. + /// This is the only override surface available for a Tier 2 tester as each resource runs in its own, separate process with no shared, in-process dependency injection + /// (DI) container to reach into; must be called before the underlying has been built (see ). + public TSelf WithResourceEnvironment(string resourceName, string key, string value) + { + if (resourceName is null) throw new ArgumentNullException(nameof(resourceName)); + if (key is null) throw new ArgumentNullException(nameof(key)); + + lock (SyncRoot) + { + if (_appTask is not null) + throw new InvalidOperationException($"{nameof(WithResourceEnvironment)} must be invoked before the underlying {nameof(DistributedApplication)} has been built (i.e. before any {nameof(Http)}/{nameof(WaitForResourceAsync)} call)."); + + _configureBuilder.Add(builder => builder.CreateResourceBuilder(resourceName).WithEnvironment(key, value)); + } + + return (TSelf)this; + } + + /// + /// Waits for the named resource to report a healthy status. + /// + /// The resource name (as configured within the AppHost). + /// The optional timeout (defaults to waiting indefinitely). + public async Task WaitForResourceAsync(string resourceName, TimeSpan? timeout = null) + { + if (resourceName is null) throw new ArgumentNullException(nameof(resourceName)); + + var app = await GetDistributedApplicationAsync().ConfigureAwait(false); + var wait = app.ResourceNotifications.WaitForResourceHealthyAsync(resourceName); + await (timeout.HasValue ? wait.WaitAsync(timeout.Value) : wait).ConfigureAwait(false); + } + + /// + /// Enables a test to be sent to the named resource. + /// + /// The resource name (as configured within the AppHost) to target. + /// The optional endpoint name. Where not specified, the "https" endpoint is preferred when available, falling back to "http" (see ). + /// The . + public HttpTester Http(string resourceName, string? endpointName = null) => new(this, new ResourceHttpClientSource(this, resourceName, endpointName)); + + /// + /// Enables a test to be sent to the named resource with an expected response value . + /// + /// The response value . + /// The resource name (as configured within the AppHost) to target. + /// The optional endpoint name. Where not specified, the "https" endpoint is preferred when available, falling back to "http" (see ). + /// The . + public HttpTester Http(string resourceName, string? endpointName = null) => new(this, new ResourceHttpClientSource(this, resourceName, endpointName)); + + /// + /// The is required as, unlike a single-host Tier 1 tester, there is no single default resource to fall back to; prefer / + /// which bind the resource name for you. + HttpClient IHttpClientSource.CreateHttpClient(string? name) => + GetDistributedApplicationAsync().GetAwaiter().GetResult().CreateHttpClient(name ?? throw new ArgumentNullException(nameof(name))); + + /// + /// An bound to a specific named resource (and optional endpoint). + /// + private sealed class ResourceHttpClientSource(AspireTesterBase owner, string resourceName, string? endpointName) : IHttpClientSource + { + public HttpClient CreateHttpClient(string? name = null) => owner.GetDistributedApplicationAsync().GetAwaiter().GetResult().CreateHttpClient(resourceName, endpointName); + } + + /// + /// Releases all resources. + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + + Task? appTask; + lock (SyncRoot) + { + appTask = _appTask; + _appTask = null; + } + + if (appTask is null) + return; + + try + { + var app = await appTask.ConfigureAwait(false); + await app.DisposeAsync().ConfigureAwait(false); + } + catch + { + // Swallow; there is nothing meaningful to dispose where the underlying host failed to build/start. + } + } + } +} diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterT.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterT.cs new file mode 100644 index 0000000..0161ad7 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterT.cs @@ -0,0 +1,14 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System; +using UnitTestEx.Abstractions; + +namespace UnitTestEx.Aspire +{ + /// + /// Provides the concrete implementation. + /// + /// The AppHost (a Projects.* type generated for the AppHost's ProjectReference). + /// The optional function to create the instance. + public class AspireTester(Func? createFactory = null) : AspireTesterBase>(TestFrameworkImplementor.Create(createFactory)) where TAppHost : class { } +} diff --git a/src/UnitTestEx.Aspire/AspireTester.cs b/src/UnitTestEx.Aspire/AspireTester.cs new file mode 100644 index 0000000..bafc4d1 --- /dev/null +++ b/src/UnitTestEx.Aspire/AspireTester.cs @@ -0,0 +1,22 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System; +using UnitTestEx.Abstractions; +using UnitTestEx.Aspire; + +namespace UnitTestEx +{ + /// + /// Provides the .NET Aspire multi-host (distributed application) testing capability. + /// + public static class AspireTester + { + /// + /// Creates a new instance of the class. + /// + /// The AppHost (a Projects.* type generated for the AppHost's ProjectReference). + /// The optional function to create the instance. + /// The . + public static AspireTester Create(Func? createFactory = null) where TAppHost : class => new(createFactory); + } +} diff --git a/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj b/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj new file mode 100644 index 0000000..db11bb2 --- /dev/null +++ b/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj @@ -0,0 +1,22 @@ + + + + net8.0;net9.0;net10.0 + UnitTestEx + UnitTestEx + UnitTestEx Aspire Multi-Host Test Extensions. + UnitTestEx .NET Aspire distributed-application (multi-host) Test Extensions. + unittestex aspire distributed multi-host unit test unittest + + + + + + + + + + + + + diff --git a/src/UnitTestEx.Aspire/strong-name-key.snk b/src/UnitTestEx.Aspire/strong-name-key.snk new file mode 100644 index 0000000000000000000000000000000000000000..5bced3906b9de4a27f90758305b30ed16b1c5f35 GIT binary patch literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 literal 0 HcmV?d00001 diff --git a/src/UnitTestEx/AspNetCore/HttpTesterBase.cs b/src/UnitTestEx/AspNetCore/HttpTesterBase.cs index bd97c9e..6f093d6 100644 --- a/src/UnitTestEx/AspNetCore/HttpTesterBase.cs +++ b/src/UnitTestEx/AspNetCore/HttpTesterBase.cs @@ -251,10 +251,10 @@ private async Task SendAsync(HttpRequestMessage request, Ca /// /// Create the request. /// - private static HttpRequestMessage CreateRequest(HttpMethod method, string requestUri, HttpContent? content, Action? requestModifier) + private HttpRequestMessage CreateRequest(HttpMethod method, string requestUri, HttpContent? content, Action? requestModifier) { var uri = new Uri(requestUri, UriKind.RelativeOrAbsolute); - var ub = new UriBuilder(uri.IsAbsoluteUri ? uri : new Uri(MockHttpClient.DefaultBaseAddress, requestUri)); + var ub = new UriBuilder(uri.IsAbsoluteUri ? uri : new Uri(_client.BaseAddress ?? MockHttpClient.DefaultBaseAddress, requestUri)); var request = new HttpRequestMessage(method, ub.Uri); if (content != null) diff --git a/tests/UnitTestEx.Aspire.AppHost/AppHost.cs b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs new file mode 100644 index 0000000..a2f0821 --- /dev/null +++ b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs @@ -0,0 +1,20 @@ +// This AppHost exists solely to provide a real, minimal .NET Aspire distributed application for exercising +// UnitTestEx.Aspire's AspireTesterBase capability in the UnitTestEx.Aspire.*.Test projects. +// +// It references the real, multi-targeted UnitTestEx.Api sample (also used by the Tier 1 suites) rather than a +// bespoke single-TFM sample, so the same representative app is exercised end-to-end for both tiers. This +// AppHost is deliberately pinned to a single, fixed TFM (Aspire's AppHost SDK does not support a multi-targeted +// AppHost project) - but that's fine: Aspire's whole premise is that the orchestrator/host doesn't need to +// match the TFM of the resources (or callers) it manages. The UnitTestEx.Aspire.*.Test projects that actually +// exercise UnitTestEx.Aspire's multi-targeted library code (net8.0/net9.0/net10.0) can all reference this one +// fixed AppHost, since a higher-TFM project can reference a lower-TFM one. +// +// Aspire's DCP orchestrator cannot disambiguate a multi-targeted project resource on its own (see +// https://github.com/dotnet/aspire/issues/2962), so the framework is passed explicitly via '--framework'. + +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddProject("api") + .WithArgs("--framework", "net8.0"); + +builder.Build().Run(); diff --git a/tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj b/tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj new file mode 100644 index 0000000..f893fda --- /dev/null +++ b/tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj @@ -0,0 +1,22 @@ + + + + + + Exe + net8.0 + enable + enable + true + false + + + + + + + + + + + diff --git a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs new file mode 100644 index 0000000..25f1874 --- /dev/null +++ b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs @@ -0,0 +1,40 @@ +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UnitTestEx.Api.Models; + +namespace UnitTestEx.Aspire.MSTest.Test +{ + [TestClass] + public class AspireTesterTest + { + [TestMethod] + public async Task Http_Get_ReturnsSuccess() + { + // Note: UnitTestEx.Api's Program.cs deliberately fails fast at host start up unless the 'SpecialKey' configuration is set; this is what WithResourceEnvironment is proving out. + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + + tester.Http("api") + .Run(HttpMethod.Get, "Person?firstName=John&lastName=Doe") + .AssertOK() + .AssertContent("John-Doe-"); + } + + [TestMethod] + public async Task WithResourceEnvironment_AppliedBeforeBuild() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + + tester.Http("api") + .Run(HttpMethod.Get, "Person/1") + .AssertOK() + .AssertValue(new Person { Id = 1, FirstName = "Bob", LastName = "Smith" }); + } + } +} diff --git a/tests/UnitTestEx.Aspire.MSTest.Test/UnitTestEx.Aspire.MSTest.Test.csproj b/tests/UnitTestEx.Aspire.MSTest.Test/UnitTestEx.Aspire.MSTest.Test.csproj new file mode 100644 index 0000000..cc4d301 --- /dev/null +++ b/tests/UnitTestEx.Aspire.MSTest.Test/UnitTestEx.Aspire.MSTest.Test.csproj @@ -0,0 +1,27 @@ + + + + net8.0;net9.0;net10.0 + false + true + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs new file mode 100644 index 0000000..91c3bbd --- /dev/null +++ b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs @@ -0,0 +1,40 @@ +using System.Net.Http; +using System.Threading.Tasks; +using NUnit.Framework; +using UnitTestEx.Api.Models; + +namespace UnitTestEx.Aspire.NUnit.Test +{ + [TestFixture] + public class AspireTesterTest + { + [Test] + public async Task Http_Get_ReturnsSuccess() + { + // Note: UnitTestEx.Api's Program.cs deliberately fails fast at host start up unless the 'SpecialKey' configuration is set; this is what WithResourceEnvironment is proving out. + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + + tester.Http("api") + .Run(HttpMethod.Get, "Person?firstName=John&lastName=Doe") + .AssertOK() + .AssertContent("John-Doe-"); + } + + [Test] + public async Task WithResourceEnvironment_AppliedBeforeBuild() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + + tester.Http("api") + .Run(HttpMethod.Get, "Person/1") + .AssertOK() + .AssertValue(new Person { Id = 1, FirstName = "Bob", LastName = "Smith" }); + } + } +} diff --git a/tests/UnitTestEx.Aspire.NUnit.Test/UnitTestEx.Aspire.NUnit.Test.csproj b/tests/UnitTestEx.Aspire.NUnit.Test/UnitTestEx.Aspire.NUnit.Test.csproj new file mode 100644 index 0000000..21db4da --- /dev/null +++ b/tests/UnitTestEx.Aspire.NUnit.Test/UnitTestEx.Aspire.NUnit.Test.csproj @@ -0,0 +1,29 @@ + + + + net8.0;net9.0;net10.0 + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs new file mode 100644 index 0000000..31f4905 --- /dev/null +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -0,0 +1,43 @@ +using System.Net.Http; +using System.Threading.Tasks; +using UnitTestEx; +using UnitTestEx.Api.Models; +using Xunit; +using Xunit.Abstractions; + +namespace UnitTestEx.Aspire.Xunit.Test +{ + public class AspireTesterTest : UnitTestBase + { + public AspireTesterTest(ITestOutputHelper output) : base(output) { } + + [Fact] + public async Task Http_Get_ReturnsSuccess() + { + // Note: UnitTestEx.Api's Program.cs deliberately fails fast at host start up unless the 'SpecialKey' configuration is set; this is what WithResourceEnvironment is proving out. + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + + tester.Http("api") + .Run(HttpMethod.Get, "Person?firstName=John&lastName=Doe") + .AssertOK() + .AssertContent("John-Doe-"); + } + + [Fact] + public async Task WithResourceEnvironment_AppliedBeforeBuild() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + + tester.Http("api") + .Run(HttpMethod.Get, "Person/1") + .AssertOK() + .AssertValue(new Person { Id = 1, FirstName = "Bob", LastName = "Smith" }); + } + } +} diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/UnitTestEx.Aspire.Xunit.Test.csproj b/tests/UnitTestEx.Aspire.Xunit.Test/UnitTestEx.Aspire.Xunit.Test.csproj new file mode 100644 index 0000000..1f3ce11 --- /dev/null +++ b/tests/UnitTestEx.Aspire.Xunit.Test/UnitTestEx.Aspire.Xunit.Test.csproj @@ -0,0 +1,28 @@ + + + + net8.0;net9.0;net10.0 + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + From 63c0ce2d6427d03bea58ec65b28cba253440b82a Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 18:08:15 -0700 Subject: [PATCH 11/43] Fix Aspire test race: add HTTP health check so WaitForResourceAsync is 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> --- tests/UnitTestEx.Aspire.AppHost/AppHost.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/UnitTestEx.Aspire.AppHost/AppHost.cs b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs index a2f0821..511a048 100644 --- a/tests/UnitTestEx.Aspire.AppHost/AppHost.cs +++ b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs @@ -11,10 +11,16 @@ // // Aspire's DCP orchestrator cannot disambiguate a multi-targeted project resource on its own (see // https://github.com/dotnet/aspire/issues/2962), so the framework is passed explicitly via '--framework'. +// +// A plain AddProject resource has no health check by default, so it is reported "Healthy" as soon as the OS +// process starts - not once Kestrel has actually bound its endpoint(s). WithHttpHealthCheck closes that race so +// that AspireTesterBase.WaitForResourceAsync (which waits on resource health) is a genuine readiness gate before +// tests attempt to call the resource. var builder = DistributedApplication.CreateBuilder(args); builder.AddProject("api") - .WithArgs("--framework", "net8.0"); + .WithArgs("--framework", "net8.0") + .WithHttpHealthCheck("/Person?firstName=health&lastName=check"); builder.Build().Run(); From 1ef9847f20237c7cd75a9b9ce6a24a05365cf952 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 18:15:43 -0700 Subject: [PATCH 12/43] Fix Aspire CI failure: declare endpoints explicitly instead of relying 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> --- tests/UnitTestEx.Aspire.AppHost/AppHost.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/UnitTestEx.Aspire.AppHost/AppHost.cs b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs index 511a048..f11744e 100644 --- a/tests/UnitTestEx.Aspire.AppHost/AppHost.cs +++ b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs @@ -12,6 +12,12 @@ // Aspire's DCP orchestrator cannot disambiguate a multi-targeted project resource on its own (see // https://github.com/dotnet/aspire/issues/2962), so the framework is passed explicitly via '--framework'. // +// Endpoints are declared explicitly (launchProfileName: null + WithHttpsEndpoint) rather than relying on +// AddProject's implicit launchSettings.json-derived endpoint detection: that detection proved unreliable for a +// multi-targeted referenced project (UnitTestEx.Api targets net8.0/9.0/10.0) - it worked locally but failed on +// Linux CI with "no endpoint was found matching one of the specified names: https, http". A single explicit +// https endpoint avoids that ambiguity entirely (UnitTestEx.Api unconditionally redirects http to https anyway). +// // A plain AddProject resource has no health check by default, so it is reported "Healthy" as soon as the OS // process starts - not once Kestrel has actually bound its endpoint(s). WithHttpHealthCheck closes that race so // that AspireTesterBase.WaitForResourceAsync (which waits on resource health) is a genuine readiness gate before @@ -19,8 +25,9 @@ var builder = DistributedApplication.CreateBuilder(args); -builder.AddProject("api") +builder.AddProject("api", launchProfileName: null) + .WithHttpsEndpoint(name: "https") .WithArgs("--framework", "net8.0") - .WithHttpHealthCheck("/Person?firstName=health&lastName=check"); + .WithHttpHealthCheck("/Person?firstName=health&lastName=check", endpointName: "https"); builder.Build().Run(); From b1d1d397c4c218923a51f5f276880c9ccc410002 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 18:34:32 -0700 Subject: [PATCH 13/43] Fix Aspire CI hang: bypass TLS cert validation on the test AppHost + 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> --- tests/UnitTestEx.Aspire.AppHost/AppHost.cs | 12 ++++++++++++ .../AspireTesterTest.cs | 5 +++-- .../UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs | 5 +++-- .../UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs | 5 +++-- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/UnitTestEx.Aspire.AppHost/AppHost.cs b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs index f11744e..e3c8056 100644 --- a/tests/UnitTestEx.Aspire.AppHost/AppHost.cs +++ b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs @@ -23,8 +23,20 @@ // that AspireTesterBase.WaitForResourceAsync (which waits on resource health) is a genuine readiness gate before // tests attempt to call the resource. +using Microsoft.Extensions.DependencyInjection; + var builder = DistributedApplication.CreateBuilder(args); +// dotnet dev-certs https --trust is not fully supported on Linux, so the ASP.NET Core dev cert used by the +// "api" resource's https endpoint is not OS-trusted on Linux CI runners. Both the health check probe above and +// AspireTesterBase's CreateHttpClient() resolve their HttpClient via this same DI container's IHttpClientFactory, +// so disabling certificate validation here (dev/test-only AppHost, never shipped) covers both. +builder.Services.ConfigureHttpClientDefaults(http => + http.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + })); + builder.AddProject("api", launchProfileName: null) .WithHttpsEndpoint(name: "https") .WithArgs("--framework", "net8.0") diff --git a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs index 25f1874..09bdbe9 100644 --- a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs @@ -1,3 +1,4 @@ +using System; using System.Net.Http; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -15,7 +16,7 @@ public async Task Http_Get_ReturnsSuccess() await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); - await tester.WaitForResourceAsync("api"); + await tester.WaitForResourceAsync("api", TimeSpan.FromSeconds(60)); tester.Http("api") .Run(HttpMethod.Get, "Person?firstName=John&lastName=Doe") @@ -29,7 +30,7 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); - await tester.WaitForResourceAsync("api"); + await tester.WaitForResourceAsync("api", TimeSpan.FromSeconds(60)); tester.Http("api") .Run(HttpMethod.Get, "Person/1") diff --git a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs index 91c3bbd..2810fb4 100644 --- a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs @@ -1,3 +1,4 @@ +using System; using System.Net.Http; using System.Threading.Tasks; using NUnit.Framework; @@ -15,7 +16,7 @@ public async Task Http_Get_ReturnsSuccess() await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); - await tester.WaitForResourceAsync("api"); + await tester.WaitForResourceAsync("api", TimeSpan.FromSeconds(60)); tester.Http("api") .Run(HttpMethod.Get, "Person?firstName=John&lastName=Doe") @@ -29,7 +30,7 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); - await tester.WaitForResourceAsync("api"); + await tester.WaitForResourceAsync("api", TimeSpan.FromSeconds(60)); tester.Http("api") .Run(HttpMethod.Get, "Person/1") diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index 31f4905..5438aa4 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -1,3 +1,4 @@ +using System; using System.Net.Http; using System.Threading.Tasks; using UnitTestEx; @@ -18,7 +19,7 @@ public async Task Http_Get_ReturnsSuccess() await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); - await tester.WaitForResourceAsync("api"); + await tester.WaitForResourceAsync("api", TimeSpan.FromSeconds(60)); tester.Http("api") .Run(HttpMethod.Get, "Person?firstName=John&lastName=Doe") @@ -32,7 +33,7 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); - await tester.WaitForResourceAsync("api"); + await tester.WaitForResourceAsync("api", TimeSpan.FromSeconds(60)); tester.Http("api") .Run(HttpMethod.Get, "Person/1") From a2b6e21305b67c6a3fea159b240f63b8b8ef9fa0 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 18:37:17 -0700 Subject: [PATCH 14/43] Default WaitForResourceAsync to a 60s fail-fast timeout instead of waiting 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> --- src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs | 11 +++++++++-- .../UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs | 5 ++--- .../UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs | 5 ++--- .../UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs | 5 ++--- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index e07d395..728627c 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -120,18 +120,25 @@ public TSelf WithResourceEnvironment(string resourceName, string key, string val return (TSelf)this; } + /// + /// Gets the default used by when none is specified. + /// + public static TimeSpan DefaultWaitForResourceTimeout { get; } = TimeSpan.FromSeconds(60); + /// /// Waits for the named resource to report a healthy status. /// /// The resource name (as configured within the AppHost). - /// The optional timeout (defaults to waiting indefinitely). + /// The timeout (defaults to ); pass to wait indefinitely. + /// A resource that never becomes healthy (e.g. a misconfigured health check) should fail the test fast rather than hang it indefinitely, hence the default timeout. public async Task WaitForResourceAsync(string resourceName, TimeSpan? timeout = null) { if (resourceName is null) throw new ArgumentNullException(nameof(resourceName)); + var effectiveTimeout = timeout ?? DefaultWaitForResourceTimeout; var app = await GetDistributedApplicationAsync().ConfigureAwait(false); var wait = app.ResourceNotifications.WaitForResourceHealthyAsync(resourceName); - await (timeout.HasValue ? wait.WaitAsync(timeout.Value) : wait).ConfigureAwait(false); + await (effectiveTimeout == System.Threading.Timeout.InfiniteTimeSpan ? wait : wait.WaitAsync(effectiveTimeout)).ConfigureAwait(false); } /// diff --git a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs index 09bdbe9..25f1874 100644 --- a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs @@ -1,4 +1,3 @@ -using System; using System.Net.Http; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -16,7 +15,7 @@ public async Task Http_Get_ReturnsSuccess() await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); - await tester.WaitForResourceAsync("api", TimeSpan.FromSeconds(60)); + await tester.WaitForResourceAsync("api"); tester.Http("api") .Run(HttpMethod.Get, "Person?firstName=John&lastName=Doe") @@ -30,7 +29,7 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); - await tester.WaitForResourceAsync("api", TimeSpan.FromSeconds(60)); + await tester.WaitForResourceAsync("api"); tester.Http("api") .Run(HttpMethod.Get, "Person/1") diff --git a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs index 2810fb4..91c3bbd 100644 --- a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs @@ -1,4 +1,3 @@ -using System; using System.Net.Http; using System.Threading.Tasks; using NUnit.Framework; @@ -16,7 +15,7 @@ public async Task Http_Get_ReturnsSuccess() await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); - await tester.WaitForResourceAsync("api", TimeSpan.FromSeconds(60)); + await tester.WaitForResourceAsync("api"); tester.Http("api") .Run(HttpMethod.Get, "Person?firstName=John&lastName=Doe") @@ -30,7 +29,7 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); - await tester.WaitForResourceAsync("api", TimeSpan.FromSeconds(60)); + await tester.WaitForResourceAsync("api"); tester.Http("api") .Run(HttpMethod.Get, "Person/1") diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index 5438aa4..31f4905 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -1,4 +1,3 @@ -using System; using System.Net.Http; using System.Threading.Tasks; using UnitTestEx; @@ -19,7 +18,7 @@ public async Task Http_Get_ReturnsSuccess() await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); - await tester.WaitForResourceAsync("api", TimeSpan.FromSeconds(60)); + await tester.WaitForResourceAsync("api"); tester.Http("api") .Run(HttpMethod.Get, "Person?firstName=John&lastName=Doe") @@ -33,7 +32,7 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); - await tester.WaitForResourceAsync("api", TimeSpan.FromSeconds(60)); + await tester.WaitForResourceAsync("api"); tester.Http("api") .Run(HttpMethod.Get, "Person/1") From 1f8107d38b6528fe7b27531d2f8dbd36fa48c03a Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 18:44:45 -0700 Subject: [PATCH 15/43] Fix CS1734 warning: remove invalid paramref from DefaultWaitForResourceTimeout doc comment Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index 728627c..c33fab0 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -121,7 +121,7 @@ public TSelf WithResourceEnvironment(string resourceName, string key, string val } /// - /// Gets the default used by when none is specified. + /// Gets the default timeout used by when none is specified. /// public static TimeSpan DefaultWaitForResourceTimeout { get; } = TimeSpan.FromSeconds(60); From 5ba642c2c066b1b89313975b936ef90201c02dd1 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 18:53:43 -0700 Subject: [PATCH 16/43] Fix VS build failure (MSB4057 GetTargetPath) for AppHost's UnitTestEx.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> --- .../UnitTestEx.Aspire.AppHost.csproj | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj b/tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj index f893fda..04927c1 100644 --- a/tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj +++ b/tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj @@ -16,7 +16,11 @@ - + + From 728e08ff857f6384438128af1efee099ed5577ee Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 18:59:20 -0700 Subject: [PATCH 17/43] Quiet Aspire's own AppHost/DCP bootstrap chatter and resource console-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. '.Resources.') 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> --- .../Aspire/AspireTesterBase.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index c33fab0..f756ce7 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -3,6 +3,8 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Net.Http; @@ -27,6 +29,7 @@ public abstract class AspireTesterBase : TesterBaseCore, IHttpC { private readonly List> _configureBuilder = []; private Task? _appTask; + private LogLevel _minimumLogLevel = LogLevel.Warning; private bool _disposed; /// @@ -47,6 +50,27 @@ protected Task GetDistributedApplicationAsync() } } + /// + /// Gets or sets the minimum for the underlying AppHost's own logging - i.e. Aspire's hosting/DCP bootstrap messages and the console-log relay + /// it forwards for each resource (not the resources' own internal logging, which is governed by their own configuration); defaults to so + /// that this Aspire-internal chatter does not swamp the UnitTestEx request/response tester output. Must be set before the underlying + /// has been built (see ). + /// + public LogLevel MinimumLogLevel + { + get => _minimumLogLevel; + set + { + lock (SyncRoot) + { + if (_appTask is not null) + throw new InvalidOperationException($"{nameof(MinimumLogLevel)} must be set before the underlying {nameof(DistributedApplication)} has been built (i.e. before any {nameof(Http)}/{nameof(WaitForResourceAsync)} call)."); + + _minimumLogLevel = value; + } + } + } + /// /// Creates, builds and starts the underlying . /// @@ -54,6 +78,8 @@ private async Task CreateDistributedApplicationAsync() { var builder = await DistributedApplicationTestingBuilder.CreateAsync().ConfigureAwait(false); + builder.Services.Configure(options => options.MinLevel = _minimumLogLevel); + foreach (var configure in _configureBuilder) { configure(builder); From e2e211a02185c3acfcfe7e4afff14f70dfb60831 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 19:23:27 -0700 Subject: [PATCH 18/43] Surface Aspire resource logging into the existing tester LOGGING > section 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. --- .../Aspire/AspireTesterBase.cs | 86 ++++++++++++++++++- .../Abstractions/IHttpClientSource.cs | 9 ++ src/UnitTestEx/AspNetCore/HttpTesterBase.cs | 2 +- 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index f756ce7..cbd002b 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -5,9 +5,13 @@ using Aspire.Hosting.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using System.Net.Http; +using System.Text.RegularExpressions; using System.Threading.Tasks; using UnitTestEx.Abstractions; using UnitTestEx.AspNetCore; @@ -28,6 +32,7 @@ public abstract class AspireTesterBase : TesterBaseCore, IHttpC where TSelf : AspireTesterBase { private readonly List> _configureBuilder = []; + private readonly ConcurrentDictionary> _resourceLogBuffers = new(); private Task? _appTask; private LogLevel _minimumLogLevel = LogLevel.Warning; private bool _disposed; @@ -78,7 +83,16 @@ private async Task CreateDistributedApplicationAsync() { var builder = await DistributedApplicationTestingBuilder.CreateAsync().ConfigureAwait(false); - builder.Services.Configure(options => options.MinLevel = _minimumLogLevel); + builder.Services.Configure(options => + { + options.MinLevel = _minimumLogLevel; + + // The AppHost forwards each resource's own logging (e.g. its own ILogger writes) under a "{ApplicationName}.Resources.{resourceName}" category (see 'EnableResourceLogging'); + // exempt our own capture provider from the MinimumLogLevel filter above (by provider, not category) so it always sees everything, regardless of the visible console noise level. + options.Rules.Add(new LoggerFilterRule(typeof(ResourceLogCaptureProvider).FullName, null, LogLevel.Trace, null)); + }); + + builder.Services.AddSingleton(new ResourceLogCaptureProvider($"{builder.Environment.ApplicationName}.Resources.", _resourceLogBuffers)); foreach (var configure in _configureBuilder) { @@ -190,12 +204,80 @@ public async Task WaitForResourceAsync(string resourceName, TimeSpan? timeout = HttpClient IHttpClientSource.CreateHttpClient(string? name) => GetDistributedApplicationAsync().GetAwaiter().GetResult().CreateHttpClient(name ?? throw new ArgumentNullException(nameof(name))); + /// + /// An that captures each resource's own forwarded logging (see EnableResourceLogging) - logged by the AppHost under a + /// "{ApplicationName}.Resources.{resourceName}" category - into an in-memory, per-resource buffer. + /// + /// Under , / + /// are not populated (a known limitation of the testing host), so this is the only reliable way to observe a resource's own logging from a test. + private sealed class ResourceLogCaptureProvider(string resourceCategoryPrefix, ConcurrentDictionary> buffers) : ILoggerProvider + { + public ILogger CreateLogger(string categoryName) + { + if (!categoryName.StartsWith(resourceCategoryPrefix, StringComparison.Ordinal)) + return NullLogger.Instance; + + var resourceName = categoryName[resourceCategoryPrefix.Length..]; + return new ResourceLogger(buffers.GetOrAdd(resourceName, _ => new ConcurrentQueue())); + } + + public void Dispose() { } + + private sealed class ResourceLogger(ConcurrentQueue buffer) : ILogger + { + // The AppHost forwards each resource's already console-rendered text (ANSI colour codes and a "{lineNumber}: {timestamp} " prefix per physical line); strip both for a clean, + // readable "LOGGING >" section. + private static readonly Regex _ansiPattern = new(@"\x1b\[[0-9;]*m", RegexOptions.Compiled); + private static readonly Regex _linePrefixPattern = new(@"^\d+:\s+\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s?", RegexOptions.Compiled); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var message = _ansiPattern.Replace(formatter(state, exception), string.Empty); + buffer.Enqueue(_linePrefixPattern.Replace(message, string.Empty)); + } + } + } + /// /// An bound to a specific named resource (and optional endpoint). /// + /// Piggy-backs the resource's own forwarded logging (captured via ) into the same "LOGGING >" section of the tester output that a + /// Tier 1, in-process host correlates via ; as there is no shared, in-process container to correlate by request identifier, the + /// window of lines captured between (invoked immediately before the request is sent) and (invoked + /// immediately after the response is received) is used as a pragmatic proxy - this naturally excludes the resource's own start-up banner noise (already buffered by the time the first + /// request is sent) and correlates correctly for the typical, sequential one-request-at-a-time usage pattern. private sealed class ResourceHttpClientSource(AspireTesterBase owner, string resourceName, string? endpointName) : IHttpClientSource { - public HttpClient CreateHttpClient(string? name = null) => owner.GetDistributedApplicationAsync().GetAwaiter().GetResult().CreateHttpClient(resourceName, endpointName); + private int _lastLineCount; + + public HttpClient CreateHttpClient(string? name = null) + { + var app = owner.GetDistributedApplicationAsync().GetAwaiter().GetResult(); + _lastLineCount = owner._resourceLogBuffers.TryGetValue(resourceName, out var buffer) ? buffer.Count : 0; + return app.CreateHttpClient(resourceName, endpointName); + } + + public IEnumerable? GetRequestLogMessages(string requestId) => GetNewResourceLogMessagesAsync().GetAwaiter().GetResult(); + + /// + /// Gets the resource's own log lines captured since was last invoked. + /// + /// The resource's logging is forwarded asynchronously; a single short grace period is allowed for it to catch up before giving up, rather than an open-ended poll that would + /// otherwise tax every request - including the (typical) majority that log nothing at all. + private async Task> GetNewResourceLogMessagesAsync() + { + if (!owner._resourceLogBuffers.TryGetValue(resourceName, out var buffer)) + return []; + + if (buffer.Count <= _lastLineCount) + await Task.Delay(300).ConfigureAwait(false); + + return [.. buffer.Skip(_lastLineCount)]; + } } /// diff --git a/src/UnitTestEx/Abstractions/IHttpClientSource.cs b/src/UnitTestEx/Abstractions/IHttpClientSource.cs index 3b32262..55ee50c 100644 --- a/src/UnitTestEx/Abstractions/IHttpClientSource.cs +++ b/src/UnitTestEx/Abstractions/IHttpClientSource.cs @@ -1,5 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +using System.Collections.Generic; using System.Net.Http; namespace UnitTestEx.Abstractions @@ -18,5 +19,13 @@ public interface IHttpClientSource /// The optional name used to select the target resource/endpoint; ignored by single-host implementations that only ever have the one target. /// The . HttpClient CreateHttpClient(string? name = null); + + /// + /// Gets the log messages captured for the specified request, where supported by the underlying host. + /// + /// The unique request identifier (see ). + /// The log messages; where null the caller falls back to its default, in-process logger-based correlation (see ), which + /// requires no changes to any existing, single in-process host implementation of this interface. + IEnumerable? GetRequestLogMessages(string requestId) => null; } } diff --git a/src/UnitTestEx/AspNetCore/HttpTesterBase.cs b/src/UnitTestEx/AspNetCore/HttpTesterBase.cs index 6f093d6..fd9254e 100644 --- a/src/UnitTestEx/AspNetCore/HttpTesterBase.cs +++ b/src/UnitTestEx/AspNetCore/HttpTesterBase.cs @@ -243,7 +243,7 @@ private async Task SendAsync(HttpRequestMessage request, Ca sw.Stop(); await Task.Delay(0, cancellationToken).ConfigureAwait(false); - _httpTester.LastLogs = _httpTester.Owner.SharedState.GetLoggerMessages(_httpTester.RequestId); + _httpTester.LastLogs = _httpTester.HttpClientSource.GetRequestLogMessages(_httpTester.RequestId) ?? _httpTester.Owner.SharedState.GetLoggerMessages(_httpTester.RequestId); _httpTester.LogResponse(res, sw, _httpTester.LastLogs); return res; } From 453722c9a1da81d8c7dbad848a07e8b672be3454 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 19:42:26 -0700 Subject: [PATCH 19/43] Reformat Aspire resource log entries to the standard UnitTestEx LoggerBase 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). --- .../Aspire/AspireTesterBase.cs | 128 ++++++++++++++++-- 1 file changed, 119 insertions(+), 9 deletions(-) diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index cbd002b..2fc63e6 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -9,9 +9,12 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Net.Http; +using System.Text; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using UnitTestEx.Abstractions; using UnitTestEx.AspNetCore; @@ -33,6 +36,7 @@ public abstract class AspireTesterBase : TesterBaseCore, IHttpC { private readonly List> _configureBuilder = []; private readonly ConcurrentDictionary> _resourceLogBuffers = new(); + private ResourceLogCaptureProvider? _resourceLogCaptureProvider; private Task? _appTask; private LogLevel _minimumLogLevel = LogLevel.Warning; private bool _disposed; @@ -92,7 +96,7 @@ private async Task CreateDistributedApplicationAsync() options.Rules.Add(new LoggerFilterRule(typeof(ResourceLogCaptureProvider).FullName, null, LogLevel.Trace, null)); }); - builder.Services.AddSingleton(new ResourceLogCaptureProvider($"{builder.Environment.ApplicationName}.Resources.", _resourceLogBuffers)); + builder.Services.AddSingleton(_resourceLogCaptureProvider = new ResourceLogCaptureProvider($"{builder.Environment.ApplicationName}.Resources.", _resourceLogBuffers)); foreach (var configure in _configureBuilder) { @@ -206,29 +210,69 @@ HttpClient IHttpClientSource.CreateHttpClient(string? name) => /// /// An that captures each resource's own forwarded logging (see EnableResourceLogging) - logged by the AppHost under a - /// "{ApplicationName}.Resources.{resourceName}" category - into an in-memory, per-resource buffer. + /// "{ApplicationName}.Resources.{resourceName}" category - into an in-memory, per-resource buffer, reformatted to match the standard UnitTestEx + /// output style (see ). /// /// Under , / /// are not populated (a known limitation of the testing host), so this is the only reliable way to observe a resource's own logging from a test. private sealed class ResourceLogCaptureProvider(string resourceCategoryPrefix, ConcurrentDictionary> buffers) : ILoggerProvider { + private readonly ConcurrentBag _loggers = []; + public ILogger CreateLogger(string categoryName) { if (!categoryName.StartsWith(resourceCategoryPrefix, StringComparison.Ordinal)) return NullLogger.Instance; var resourceName = categoryName[resourceCategoryPrefix.Length..]; - return new ResourceLogger(buffers.GetOrAdd(resourceName, _ => new ConcurrentQueue())); + var logger = new ResourceLogger(buffers.GetOrAdd(resourceName, _ => new ConcurrentQueue())); + _loggers.Add(logger); + return logger; + } + + /// + /// Flushes any pending (not-yet-terminated by a subsequent header line) entry for every resource. + /// + /// An entry only completes once another line (typically the next header) arrives to terminate it, so the most recently logged entry for a resource would otherwise remain + /// invisible until something else happens to log afterwards; callers that need to observe "everything captured so far" (e.g. before reading a resource's log buffer) must force this. + public void FlushPendingEntries() + { + foreach (var logger in _loggers) + { + logger.FlushPending(); + } } - public void Dispose() { } + /// + /// Flushes any pending entry for every resource, so a final log line is not lost when the underlying is disposed mid-entry. + /// + public void Dispose() => FlushPendingEntries(); + /// + /// Parses and reformats each resource's forwarded, already console-rendered text into the standard UnitTestEx output style, i.e. + /// "{timestamp} {level}: {message} [{category}]". + /// + /// The AppHost forwards each resource's own console output one physical line at a time (e.g. the "{level}: {category}[{eventId}]" header line, then one or more + /// indented message lines, as separate calls), each prefixed with a "{lineNumber}: {timestamp}Z " marker that Aspire itself adds; this reassembles those + /// physical lines back into a single logical entry using the same style as an in-process (Tier 1) tester, re-using Aspire's own embedded timestamp (converted to local time) rather than + /// substituting the capture time, so timestamps reflect when the resource itself actually logged the entry. + /// A line that does not match the expected "{lineNumber}: {timestamp}Z " prefix (e.g. a resource that does not use the standard console logger format) is passed through + /// unmodified, ANSI colour codes aside, so nothing is silently dropped. private sealed class ResourceLogger(ConcurrentQueue buffer) : ILogger { - // The AppHost forwards each resource's already console-rendered text (ANSI colour codes and a "{lineNumber}: {timestamp} " prefix per physical line); strip both for a clean, - // readable "LOGGING >" section. private static readonly Regex _ansiPattern = new(@"\x1b\[[0-9;]*m", RegexOptions.Compiled); - private static readonly Regex _linePrefixPattern = new(@"^\d+:\s+\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s?", RegexOptions.Compiled); + private static readonly Regex _linePrefixPattern = new(@"^\d+:\s+(?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+)Z\s?(?.*)$", RegexOptions.Compiled); + private static readonly Regex _headerPattern = new(@"^(?trce|dbug|info|warn|fail|crit): (?.+)\[(?-?\d+)\]$", RegexOptions.Compiled); + +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + private string? _pendingTimestamp; + private string? _pendingLevel; + private string? _pendingCategory; + private readonly List _pendingMessageLines = []; public IDisposable? BeginScope(TState state) where TState : notnull => null; @@ -236,8 +280,67 @@ private sealed class ResourceLogger(ConcurrentQueue buffer) : ILogger public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { - var message = _ansiPattern.Replace(formatter(state, exception), string.Empty); - buffer.Enqueue(_linePrefixPattern.Replace(message, string.Empty)); + var line = _ansiPattern.Replace(formatter(state, exception), string.Empty); + var prefixMatch = _linePrefixPattern.Match(line); + + lock (_lock) + { + if (!prefixMatch.Success) + { + // Not a recognized Aspire-forwarded console line; flush anything pending and pass this through as-is rather than silently dropping it. + FlushPendingNoLock(); + buffer.Enqueue(line); + return; + } + + var rest = prefixMatch.Groups["rest"].Value; + var headerMatch = _headerPattern.Match(rest); + if (headerMatch.Success) + { + // A new header line always terminates any previously pending entry. + FlushPendingNoLock(); + _pendingTimestamp = prefixMatch.Groups["ts"].Value; + _pendingLevel = headerMatch.Groups["level"].Value; + _pendingCategory = headerMatch.Groups["category"].Value; + } + else if (_pendingTimestamp is not null) + _pendingMessageLines.Add(rest.TrimStart()); // A message/continuation line for the currently pending header. + else + buffer.Enqueue(rest); // A line with no preceding header (unexpected); pass through as-is. + } + } + + /// + /// Flushes any pending entry to the buffer, reformatted to match 's output style. + /// + public void FlushPending() + { + lock (_lock) + { + FlushPendingNoLock(); + } + } + + private void FlushPendingNoLock() + { + if (_pendingTimestamp is null) + return; + + var ts = DateTime.Parse(_pendingTimestamp, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal).ToLocalTime(); + var sb = new StringBuilder(); + sb.Append($"{ts.ToString("yyyy-MM-ddTHH:mm:ss.fffff", DateTimeFormatInfo.InvariantInfo)} {_pendingLevel}: {(_pendingMessageLines.Count > 0 ? _pendingMessageLines[0] : string.Empty)} [{_pendingCategory}]"); + for (var i = 1; i < _pendingMessageLines.Count; i++) + { + sb.AppendLine(); + sb.Append($"{new string(' ', 32)}{_pendingMessageLines[i]}"); + } + + buffer.Enqueue(sb.ToString()); + + _pendingTimestamp = null; + _pendingLevel = null; + _pendingCategory = null; + _pendingMessageLines.Clear(); } } } @@ -257,6 +360,9 @@ private sealed class ResourceHttpClientSource(AspireTesterBase public HttpClient CreateHttpClient(string? name = null) { var app = owner.GetDistributedApplicationAsync().GetAwaiter().GetResult(); + + // Flush any pending (not-yet-terminated) entry first so it is counted in the baseline rather than leaking into this request's own captured window. + owner._resourceLogCaptureProvider?.FlushPendingEntries(); _lastLineCount = owner._resourceLogBuffers.TryGetValue(resourceName, out var buffer) ? buffer.Count : 0; return app.CreateHttpClient(resourceName, endpointName); } @@ -276,6 +382,10 @@ public HttpClient CreateHttpClient(string? name = null) if (buffer.Count <= _lastLineCount) await Task.Delay(300).ConfigureAwait(false); + // An entry only becomes visible in the buffer once terminated by a subsequent header line; force-flush whatever is currently pending so the most recently logged entry + // (often the interesting one, e.g. the endpoint's own logging for this very request) is not lost while waiting for a line that may never come. + owner._resourceLogCaptureProvider?.FlushPendingEntries(); + return [.. buffer.Skip(_lastLineCount)]; } } From c06a65668d145ff8b6049e0b2f88c5119e9df810 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 19:48:48 -0700 Subject: [PATCH 20/43] Add Reason() and WaitAndLog() to TesterBaseCore for both Tier 1 and Tier 2 testers Reason(string) writes an arbitrary context note ('REASON >' block) to the test output, for both TesterBase (single in-process host) and AspireTesterBase (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. --- .../Aspire/AspireTesterBase.cs | 45 ++++++++++++++ src/UnitTestEx/Abstractions/TesterBaseCore.cs | 58 +++++++++++++++++++ src/UnitTestEx/Abstractions/TesterBaseT.cs | 20 +++++++ 3 files changed, 123 insertions(+) diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index 2fc63e6..04b12ee 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -36,6 +36,7 @@ public abstract class AspireTesterBase : TesterBaseCore, IHttpC { private readonly List> _configureBuilder = []; private readonly ConcurrentDictionary> _resourceLogBuffers = new(); + private readonly ConcurrentDictionary _elapsedLogLineCounts = new(); private ResourceLogCaptureProvider? _resourceLogCaptureProvider; private Task? _appTask; private LogLevel _minimumLogLevel = LogLevel.Warning; @@ -185,6 +186,50 @@ public async Task WaitForResourceAsync(string resourceName, TimeSpan? timeout = await (effectiveTimeout == System.Threading.Timeout.InfiniteTimeSpan ? wait : wait.WaitAsync(effectiveTimeout)).ConfigureAwait(false); } + /// + /// Writes the specified to the test output to provide additional context (e.g. why a particular action, or wait, is being performed). + /// + /// The reason text. + /// The to support fluent-style method-chaining. + public TSelf Reason(string reason) + { + WriteReason(reason); + return (TSelf)this; + } + + /// + /// Waits for the specified , then writes any resource log messages captured (across all resources) during that time to the test output. + /// + /// The reason for waiting (written to the test output for context). + /// The duration to wait. + /// The to support fluent-style method-chaining. + /// Useful when waiting on background/inter-resource activity (e.g. a message being processed by a downstream resource) that is not tied to a specific HTTP request/response (see + /// for the latter, request-scoped, correlation). + public TSelf WaitAndLog(string reason, TimeSpan duration) => WriteWaitAndLog(reason, duration).ContinueWith(_ => (TSelf)this).Result; + + /// + /// Combines the elapsed log messages captured (via ) across all resources since the last invocation, each line prefixed with its + /// owning resource name for clarity, as background/inter-resource activity is not necessarily confined to a single resource. + protected override IEnumerable? DrainElapsedLogMessages() + { + _resourceLogCaptureProvider?.FlushPendingEntries(); + + var lines = new List(); + foreach (var (resourceName, buffer) in _resourceLogBuffers) + { + var lastCount = _elapsedLogLineCounts.GetOrAdd(resourceName, 0); + var newLines = buffer.Skip(lastCount).ToArray(); + _elapsedLogLineCounts[resourceName] = buffer.Count; + + foreach (var line in newLines) + { + lines.Add($"[{resourceName}] {line}"); + } + } + + return lines; + } + /// /// Enables a test to be sent to the named resource. /// diff --git a/src/UnitTestEx/Abstractions/TesterBaseCore.cs b/src/UnitTestEx/Abstractions/TesterBaseCore.cs index 4b89d66..c2c7022 100644 --- a/src/UnitTestEx/Abstractions/TesterBaseCore.cs +++ b/src/UnitTestEx/Abstractions/TesterBaseCore.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Net.Http; using System.Net.Mime; +using System.Threading.Tasks; using UnitTestEx.Expectations; using UnitTestEx.Json; using UnitTestEx.Logging; @@ -251,6 +252,63 @@ public void ReplaceTestFrameworkImplementor(TestFrameworkImplementor implementor Implementor = implementor ?? throw new ArgumentNullException(nameof(implementor)); } + /// + /// Gets the log messages accumulated since the last time this was invoked (draining/resetting the starting point for the next invocation). + /// + /// The accumulated log messages; null/empty where none. + /// Used by to correlate output that occurs during a wait period rather than a specific HTTP request/response (see + /// for the latter, request-scoped, correlation). The default (Tier 1, single in-process host) implementation surfaces any + /// logging that was not attributed to a specific HTTP request (see ) - e.g. from a background/hosted service. A Tier 2 (e.g. multi-host + /// Aspire) tester should override this to surface whatever is applicable to its own hosting model. + protected virtual IEnumerable? DrainElapsedLogMessages() => SharedState.GetLoggerMessages(); + + /// + /// Writes the specified to the test output to provide additional context (e.g. why a particular action, or wait, is being performed). + /// + /// The reason text. + protected void WriteReason(string reason) + { + if (string.IsNullOrEmpty(reason)) + return; + + Implementor.WriteLine(""); + Implementor.WriteLine("REASON >"); + Implementor.WriteLine(reason); + } + + /// + /// Waits for the specified , then writes any log messages captured during that time (e.g. from a background process) to the test output (see + /// ). + /// + /// The reason for waiting (written to the test output for context). + /// The duration to wait. + protected async Task WriteWaitAndLog(string reason, TimeSpan duration) + { + // Drain any pre-existing/stale backlog first so only messages logged during the wait window itself are reported. + DrainElapsedLogMessages(); + + Implementor.WriteLine(""); + Implementor.WriteLine(new string('=', 80)); + Implementor.WriteLine($"WAIT > {reason}"); + Implementor.WriteLine($"Timeout: {duration}"); + Implementor.WriteLine(new string('=', 80)); + + await Task.Delay(duration).ConfigureAwait(false); + + Implementor.WriteLine(""); + Implementor.WriteLine("LOGGING >"); + var logs = DrainElapsedLogMessages(); + if (logs is not null && logs.Any()) + { + foreach (var msg in logs) + { + Implementor.WriteLine(msg); + } + } + else + Implementor.WriteLine("None."); + } + /// /// Logs the . /// diff --git a/src/UnitTestEx/Abstractions/TesterBaseT.cs b/src/UnitTestEx/Abstractions/TesterBaseT.cs index 4d0798c..7004112 100644 --- a/src/UnitTestEx/Abstractions/TesterBaseT.cs +++ b/src/UnitTestEx/Abstractions/TesterBaseT.cs @@ -400,6 +400,26 @@ public TSelf UseScopedTypeSetUp(Func setupAsync) /// The to support fluent-style method-chaining. public TSelf Delay(int durationInMilliseconds) => Delay(TimeSpan.FromMilliseconds(durationInMilliseconds)); + /// + /// Writes the specified to the test output to provide additional context (e.g. why a particular action, or wait, is being performed). + /// + /// The reason text. + /// The to support fluent-style method-chaining. + public TSelf Reason(string reason) + { + WriteReason(reason); + return (TSelf)this; + } + + /// + /// Waits for the specified , then writes any log messages captured during that time (e.g. from a background/hosted service not attributed to a specific HTTP + /// request) to the test output. + /// + /// The reason for waiting (written to the test output for context). + /// The duration to wait. + /// The to support fluent-style method-chaining. + public TSelf WaitAndLog(string reason, TimeSpan duration) => WriteWaitAndLog(reason, duration).ContinueWith(_ => (TSelf)this).Result; + /// /// Wraps the host execution to perform required start-up style activities; specifically resetting the . /// From 128f74d0aa94d1001922c6f07e4ef995a145678a Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 19:56:17 -0700 Subject: [PATCH 21/43] Remove dead AspireTesterBase.MinimumLogLevel property 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> --- .../Aspire/AspireTesterBase.cs | 31 ++----------------- 1 file changed, 3 insertions(+), 28 deletions(-) diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index 04b12ee..f1865ad 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -39,7 +39,6 @@ public abstract class AspireTesterBase : TesterBaseCore, IHttpC private readonly ConcurrentDictionary _elapsedLogLineCounts = new(); private ResourceLogCaptureProvider? _resourceLogCaptureProvider; private Task? _appTask; - private LogLevel _minimumLogLevel = LogLevel.Warning; private bool _disposed; /// @@ -60,27 +59,6 @@ protected Task GetDistributedApplicationAsync() } } - /// - /// Gets or sets the minimum for the underlying AppHost's own logging - i.e. Aspire's hosting/DCP bootstrap messages and the console-log relay - /// it forwards for each resource (not the resources' own internal logging, which is governed by their own configuration); defaults to so - /// that this Aspire-internal chatter does not swamp the UnitTestEx request/response tester output. Must be set before the underlying - /// has been built (see ). - /// - public LogLevel MinimumLogLevel - { - get => _minimumLogLevel; - set - { - lock (SyncRoot) - { - if (_appTask is not null) - throw new InvalidOperationException($"{nameof(MinimumLogLevel)} must be set before the underlying {nameof(DistributedApplication)} has been built (i.e. before any {nameof(Http)}/{nameof(WaitForResourceAsync)} call)."); - - _minimumLogLevel = value; - } - } - } - /// /// Creates, builds and starts the underlying . /// @@ -89,13 +67,10 @@ private async Task CreateDistributedApplicationAsync() var builder = await DistributedApplicationTestingBuilder.CreateAsync().ConfigureAwait(false); builder.Services.Configure(options => - { - options.MinLevel = _minimumLogLevel; - // The AppHost forwards each resource's own logging (e.g. its own ILogger writes) under a "{ApplicationName}.Resources.{resourceName}" category (see 'EnableResourceLogging'); - // exempt our own capture provider from the MinimumLogLevel filter above (by provider, not category) so it always sees everything, regardless of the visible console noise level. - options.Rules.Add(new LoggerFilterRule(typeof(ResourceLogCaptureProvider).FullName, null, LogLevel.Trace, null)); - }); + // exempt our own capture provider from any ambient filtering (by provider, not category) so it always sees everything, regardless of the AppHost's own console noise level + // (which the AppHost's own default logging configuration governs independently and is not something UnitTestEx overrides). + options.Rules.Add(new LoggerFilterRule(typeof(ResourceLogCaptureProvider).FullName, null, LogLevel.Trace, null))); builder.Services.AddSingleton(_resourceLogCaptureProvider = new ResourceLogCaptureProvider($"{builder.Environment.ApplicationName}.Resources.", _resourceLogBuffers)); From 7fa1868d8b06c1beb2c68e16b0f8f776544b15f4 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 22 Sep 2026 20:14:00 -0700 Subject: [PATCH 22/43] Add Reason()/WaitAndLog() test coverage across MSTest, Xunit and NUnit (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> --- .../AspireTesterTest.cs | 39 ++++++++++ .../SpyTestFrameworkImplementor.cs | 36 +++++++++ .../AspireTesterTest.cs | 39 ++++++++++ .../SpyTestFrameworkImplementor.cs | 36 +++++++++ .../AspireTesterTest.cs | 38 +++++++++ .../SpyTestFrameworkImplementor.cs | 36 +++++++++ .../Other/ReasonAndWaitAndLogTest.cs | 77 ++++++++++++++++++ .../Other/SpyTestFrameworkImplementor.cs | 36 +++++++++ .../Other/ReasonAndWaitAndLogTest.cs | 77 ++++++++++++++++++ .../Other/SpyTestFrameworkImplementor.cs | 36 +++++++++ .../Other/ReasonAndWaitAndLogTest.cs | 78 +++++++++++++++++++ .../Other/SpyTestFrameworkImplementor.cs | 36 +++++++++ 12 files changed, 564 insertions(+) create mode 100644 tests/UnitTestEx.Aspire.MSTest.Test/SpyTestFrameworkImplementor.cs create mode 100644 tests/UnitTestEx.Aspire.NUnit.Test/SpyTestFrameworkImplementor.cs create mode 100644 tests/UnitTestEx.Aspire.Xunit.Test/SpyTestFrameworkImplementor.cs create mode 100644 tests/UnitTestEx.MSTest.Test/Other/ReasonAndWaitAndLogTest.cs create mode 100644 tests/UnitTestEx.MSTest.Test/Other/SpyTestFrameworkImplementor.cs create mode 100644 tests/UnitTestEx.NUnit.Test/Other/ReasonAndWaitAndLogTest.cs create mode 100644 tests/UnitTestEx.NUnit.Test/Other/SpyTestFrameworkImplementor.cs create mode 100644 tests/UnitTestEx.Xunit.Test/Other/ReasonAndWaitAndLogTest.cs create mode 100644 tests/UnitTestEx.Xunit.Test/Other/SpyTestFrameworkImplementor.cs diff --git a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs index 25f1874..d6651fe 100644 --- a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs @@ -1,3 +1,6 @@ +using System; +using System.Diagnostics; +using System.Linq; using System.Net.Http; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -36,5 +39,41 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() .AssertOK() .AssertValue(new Person { Id = 1, FirstName = "Bob", LastName = "Smith" }); } + + [TestMethod] + public async Task Reason_And_WaitAndLog_AggregatesResourceLogs() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + + var spy = new SpyTestFrameworkImplementor(tester.Implementor); + tester.ReplaceTestFrameworkImplementor(spy); + + var reasonResult = tester.Reason("Confirming Reason() writes context for Aspire multi-host testers too."); + + // Fire a request against the 'api' resource part-way through the wait window to simulate background/inter-resource activity. + var backgroundCallTask = Task.Run(async () => + { + await Task.Delay(300); + tester.Http("api").Run(HttpMethod.Get, "Person/1"); + }); + + var sw = Stopwatch.StartNew(); + var waitResult = tester.WaitAndLog("Waiting for a background Person lookup to complete and log.", TimeSpan.FromSeconds(2)); + sw.Stop(); + + await backgroundCallTask; + + Assert.AreSame(tester, reasonResult); + Assert.AreSame(tester, waitResult); + Assert.IsTrue(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(200), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); + Assert.IsTrue(spy.Lines.Contains("REASON >")); + Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("WAIT >") && l.Contains("Waiting for a background Person lookup to complete and log."))); + Assert.IsTrue(spy.Lines.Contains("LOGGING >")); + Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("[api]") && l.Contains("Get using identifier 1."))); + } } } + diff --git a/tests/UnitTestEx.Aspire.MSTest.Test/SpyTestFrameworkImplementor.cs b/tests/UnitTestEx.Aspire.MSTest.Test/SpyTestFrameworkImplementor.cs new file mode 100644 index 0000000..427b248 --- /dev/null +++ b/tests/UnitTestEx.Aspire.MSTest.Test/SpyTestFrameworkImplementor.cs @@ -0,0 +1,36 @@ +#nullable enable + +using System.Collections.Generic; +using UnitTestEx.Abstractions; + +namespace UnitTestEx.Aspire.MSTest.Test +{ + /// + /// A that forwards to an implementor (so the underlying test framework still receives all output), while also capturing every + /// call so tests can assert on the exact output produced (e.g. by the Aspire tester's Reason/WaitAndLog methods). + /// + /// The to forward to. + internal sealed class SpyTestFrameworkImplementor(TestFrameworkImplementor inner) : TestFrameworkImplementor + { + /// + /// Gets the captured lines written via . + /// + public List Lines { get; } = []; + + /// + public override void WriteLine(string? message) + { + Lines.Add(message); + inner.WriteLine(message); + } + + /// + public override void AssertAreEqual(T? expected, T? actual, string? message = null) where T : default => inner.AssertAreEqual(expected, actual, message); + + /// + public override void AssertFail(string? message) => inner.AssertFail(message); + + /// + public override void AssertInconclusive(string? message) => inner.AssertInconclusive(message); + } +} diff --git a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs index 91c3bbd..4d72dcf 100644 --- a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs @@ -1,3 +1,6 @@ +using System; +using System.Diagnostics; +using System.Linq; using System.Net.Http; using System.Threading.Tasks; using NUnit.Framework; @@ -36,5 +39,41 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() .AssertOK() .AssertValue(new Person { Id = 1, FirstName = "Bob", LastName = "Smith" }); } + + [Test] + public async Task Reason_And_WaitAndLog_AggregatesResourceLogs() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + + var spy = new SpyTestFrameworkImplementor(tester.Implementor); + tester.ReplaceTestFrameworkImplementor(spy); + + var reasonResult = tester.Reason("Confirming Reason() writes context for Aspire multi-host testers too."); + + // Fire a request against the 'api' resource part-way through the wait window to simulate background/inter-resource activity. + var backgroundCallTask = Task.Run(async () => + { + await Task.Delay(300); + tester.Http("api").Run(HttpMethod.Get, "Person/1"); + }); + + var sw = Stopwatch.StartNew(); + var waitResult = tester.WaitAndLog("Waiting for a background Person lookup to complete and log.", TimeSpan.FromSeconds(2)); + sw.Stop(); + + await backgroundCallTask; + + Assert.That(reasonResult, Is.SameAs(tester)); + Assert.That(waitResult, Is.SameAs(tester)); + Assert.That(sw.Elapsed, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(200)), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); + Assert.That(spy.Lines, Does.Contain("REASON >")); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("WAIT >") && l.Contains("Waiting for a background Person lookup to complete and log.")), Is.True); + Assert.That(spy.Lines, Does.Contain("LOGGING >")); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("[api]") && l.Contains("Get using identifier 1.")), Is.True); + } } } + diff --git a/tests/UnitTestEx.Aspire.NUnit.Test/SpyTestFrameworkImplementor.cs b/tests/UnitTestEx.Aspire.NUnit.Test/SpyTestFrameworkImplementor.cs new file mode 100644 index 0000000..ad347f6 --- /dev/null +++ b/tests/UnitTestEx.Aspire.NUnit.Test/SpyTestFrameworkImplementor.cs @@ -0,0 +1,36 @@ +#nullable enable + +using System.Collections.Generic; +using UnitTestEx.Abstractions; + +namespace UnitTestEx.Aspire.NUnit.Test +{ + /// + /// A that forwards to an implementor (so the underlying test framework still receives all output), while also capturing every + /// call so tests can assert on the exact output produced (e.g. by the Aspire tester's Reason/WaitAndLog methods). + /// + /// The to forward to. + internal sealed class SpyTestFrameworkImplementor(TestFrameworkImplementor inner) : TestFrameworkImplementor + { + /// + /// Gets the captured lines written via . + /// + public List Lines { get; } = []; + + /// + public override void WriteLine(string? message) + { + Lines.Add(message); + inner.WriteLine(message); + } + + /// + public override void AssertAreEqual(T? expected, T? actual, string? message = null) where T : default => inner.AssertAreEqual(expected, actual, message); + + /// + public override void AssertFail(string? message) => inner.AssertFail(message); + + /// + public override void AssertInconclusive(string? message) => inner.AssertInconclusive(message); + } +} diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index 31f4905..92c2679 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -1,3 +1,5 @@ +using System; +using System.Diagnostics; using System.Net.Http; using System.Threading.Tasks; using UnitTestEx; @@ -39,5 +41,41 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() .AssertOK() .AssertValue(new Person { Id = 1, FirstName = "Bob", LastName = "Smith" }); } + + [Fact] + public async Task Reason_And_WaitAndLog_AggregatesResourceLogs() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + + var spy = new SpyTestFrameworkImplementor(tester.Implementor); + tester.ReplaceTestFrameworkImplementor(spy); + + var reasonResult = tester.Reason("Confirming Reason() writes context for Aspire multi-host testers too."); + + // Fire a request against the 'api' resource part-way through the wait window to simulate background/inter-resource activity. + var backgroundCallTask = Task.Run(async () => + { + await Task.Delay(300); + tester.Http("api").Run(HttpMethod.Get, "Person/1"); + }); + + var sw = Stopwatch.StartNew(); + var waitResult = tester.WaitAndLog("Waiting for a background Person lookup to complete and log.", TimeSpan.FromSeconds(2)); + sw.Stop(); + + await backgroundCallTask; + + Assert.Same(tester, reasonResult); + Assert.Same(tester, waitResult); + Assert.True(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(200), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); + Assert.Contains("REASON >", spy.Lines); + Assert.Contains(spy.Lines, l => l != null && l.Contains("WAIT >") && l.Contains("Waiting for a background Person lookup to complete and log.")); + Assert.Contains("LOGGING >", spy.Lines); + Assert.Contains(spy.Lines, l => l != null && l.Contains("[api]") && l.Contains("Get using identifier 1.")); + } } } + diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/SpyTestFrameworkImplementor.cs b/tests/UnitTestEx.Aspire.Xunit.Test/SpyTestFrameworkImplementor.cs new file mode 100644 index 0000000..6e52885 --- /dev/null +++ b/tests/UnitTestEx.Aspire.Xunit.Test/SpyTestFrameworkImplementor.cs @@ -0,0 +1,36 @@ +#nullable enable + +using System.Collections.Generic; +using UnitTestEx.Abstractions; + +namespace UnitTestEx.Aspire.Xunit.Test +{ + /// + /// A that forwards to an implementor (so the underlying test framework still receives all output), while also capturing every + /// call so tests can assert on the exact output produced (e.g. by the Aspire tester's Reason/WaitAndLog methods). + /// + /// The to forward to. + internal sealed class SpyTestFrameworkImplementor(TestFrameworkImplementor inner) : TestFrameworkImplementor + { + /// + /// Gets the captured lines written via . + /// + public List Lines { get; } = []; + + /// + public override void WriteLine(string? message) + { + Lines.Add(message); + inner.WriteLine(message); + } + + /// + public override void AssertAreEqual(T? expected, T? actual, string? message = null) where T : default => inner.AssertAreEqual(expected, actual, message); + + /// + public override void AssertFail(string? message) => inner.AssertFail(message); + + /// + public override void AssertInconclusive(string? message) => inner.AssertInconclusive(message); + } +} diff --git a/tests/UnitTestEx.MSTest.Test/Other/ReasonAndWaitAndLogTest.cs b/tests/UnitTestEx.MSTest.Test/Other/ReasonAndWaitAndLogTest.cs new file mode 100644 index 0000000..d43e98a --- /dev/null +++ b/tests/UnitTestEx.MSTest.Test/Other/ReasonAndWaitAndLogTest.cs @@ -0,0 +1,77 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UnitTestEx.Api; +using UnitTestEx.Api.Controllers; + +namespace UnitTestEx.MSTest.Test.Other +{ + [TestClass] + public class ReasonAndWaitAndLogTest + { + [TestMethod] + public void Reason_WritesReasonToOutput() + { + using var test = ApiTester.Create(); + var spy = new SpyTestFrameworkImplementor(test.Implementor); + test.ReplaceTestFrameworkImplementor(spy); + + var result = test.Reason("Confirming Reason() writes context to the test output."); + + Assert.AreSame(test, result); + Assert.IsTrue(spy.Lines.Contains("REASON >")); + Assert.IsTrue(spy.Lines.Contains("Confirming Reason() writes context to the test output.")); + } + + [TestMethod] + public async Task WaitAndLog_WaitsAndSurfacesLoggingThatOccursDuringTheWait() + { + using var test = ApiTester.Create(); + var spy = new SpyTestFrameworkImplementor(test.Implementor); + test.ReplaceTestFrameworkImplementor(spy); + + // A logger resolved directly from the host's DI container (i.e. not via a UnitTestEx tester invocation) simulates background/hosted-service logging. + var logger = test.Services.GetRequiredService>(); + + var backgroundLogTask = Task.Run(async () => + { + await Task.Delay(300); + logger.LogInformation("Background message logged during the wait window."); + }); + + var sw = Stopwatch.StartNew(); + var result = test.WaitAndLog("Waiting for a background process to log.", TimeSpan.FromSeconds(2)); + sw.Stop(); + + await backgroundLogTask; + + Assert.AreSame(test, result); + Assert.IsTrue(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(100), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); + Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("WAIT >") && l.Contains("Waiting for a background process to log."))); + Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("Timeout:"))); + Assert.IsTrue(spy.Lines.Contains("LOGGING >")); + Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("Background message logged during the wait window."))); + } + + [TestMethod] + public void WaitAndLog_ExcludesLoggingThatOccurredBeforeTheWaitStarted() + { + using var test = ApiTester.Create(); + var spy = new SpyTestFrameworkImplementor(test.Implementor); + test.ReplaceTestFrameworkImplementor(spy); + + var logger = test.Services.GetRequiredService>(); + logger.LogInformation("Stale message logged before the wait started."); + + test.WaitAndLog("Waiting with nothing new expected.", TimeSpan.FromMilliseconds(200)); + + Assert.IsTrue(spy.Lines.Contains("LOGGING >")); + Assert.IsTrue(spy.Lines.Contains("None.")); + Assert.IsFalse(spy.Lines.Any(l => l != null && l.Contains("Stale message logged before the wait started."))); + } + } +} diff --git a/tests/UnitTestEx.MSTest.Test/Other/SpyTestFrameworkImplementor.cs b/tests/UnitTestEx.MSTest.Test/Other/SpyTestFrameworkImplementor.cs new file mode 100644 index 0000000..a7fa6b5 --- /dev/null +++ b/tests/UnitTestEx.MSTest.Test/Other/SpyTestFrameworkImplementor.cs @@ -0,0 +1,36 @@ +#nullable enable + +using System.Collections.Generic; +using UnitTestEx.Abstractions; + +namespace UnitTestEx.MSTest.Test.Other +{ + /// + /// A that forwards to an implementor (so the underlying test framework still receives all output), while also capturing every + /// call so tests can assert on the exact output produced (e.g. by 's Reason/WaitAndLog methods). + /// + /// The to forward to. + internal sealed class SpyTestFrameworkImplementor(TestFrameworkImplementor inner) : TestFrameworkImplementor + { + /// + /// Gets the captured lines written via . + /// + public List Lines { get; } = []; + + /// + public override void WriteLine(string? message) + { + Lines.Add(message); + inner.WriteLine(message); + } + + /// + public override void AssertAreEqual(T? expected, T? actual, string? message = null) where T : default => inner.AssertAreEqual(expected, actual, message); + + /// + public override void AssertFail(string? message) => inner.AssertFail(message); + + /// + public override void AssertInconclusive(string? message) => inner.AssertInconclusive(message); + } +} diff --git a/tests/UnitTestEx.NUnit.Test/Other/ReasonAndWaitAndLogTest.cs b/tests/UnitTestEx.NUnit.Test/Other/ReasonAndWaitAndLogTest.cs new file mode 100644 index 0000000..0222e67 --- /dev/null +++ b/tests/UnitTestEx.NUnit.Test/Other/ReasonAndWaitAndLogTest.cs @@ -0,0 +1,77 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using UnitTestEx.Api; +using UnitTestEx.Api.Controllers; + +namespace UnitTestEx.NUnit.Test.Other +{ + [TestFixture] + public class ReasonAndWaitAndLogTest + { + [Test] + public void Reason_WritesReasonToOutput() + { + using var test = ApiTester.Create(); + var spy = new SpyTestFrameworkImplementor(test.Implementor); + test.ReplaceTestFrameworkImplementor(spy); + + var result = test.Reason("Confirming Reason() writes context to the test output."); + + Assert.That(result, Is.SameAs(test)); + Assert.That(spy.Lines, Does.Contain("REASON >")); + Assert.That(spy.Lines, Does.Contain("Confirming Reason() writes context to the test output.")); + } + + [Test] + public async Task WaitAndLog_WaitsAndSurfacesLoggingThatOccursDuringTheWait() + { + using var test = ApiTester.Create(); + var spy = new SpyTestFrameworkImplementor(test.Implementor); + test.ReplaceTestFrameworkImplementor(spy); + + // A logger resolved directly from the host's DI container (i.e. not via a UnitTestEx tester invocation) simulates background/hosted-service logging. + var logger = test.Services.GetRequiredService>(); + + var backgroundLogTask = Task.Run(async () => + { + await Task.Delay(300); + logger.LogInformation("Background message logged during the wait window."); + }); + + var sw = Stopwatch.StartNew(); + var result = test.WaitAndLog("Waiting for a background process to log.", TimeSpan.FromSeconds(2)); + sw.Stop(); + + await backgroundLogTask; + + Assert.That(result, Is.SameAs(test)); + Assert.That(sw.Elapsed, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(100)), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("WAIT >") && l.Contains("Waiting for a background process to log.")), Is.True); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("Timeout:")), Is.True); + Assert.That(spy.Lines, Does.Contain("LOGGING >")); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("Background message logged during the wait window.")), Is.True); + } + + [Test] + public void WaitAndLog_ExcludesLoggingThatOccurredBeforeTheWaitStarted() + { + using var test = ApiTester.Create(); + var spy = new SpyTestFrameworkImplementor(test.Implementor); + test.ReplaceTestFrameworkImplementor(spy); + + var logger = test.Services.GetRequiredService>(); + logger.LogInformation("Stale message logged before the wait started."); + + test.WaitAndLog("Waiting with nothing new expected.", TimeSpan.FromMilliseconds(200)); + + Assert.That(spy.Lines, Does.Contain("LOGGING >")); + Assert.That(spy.Lines, Does.Contain("None.")); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("Stale message logged before the wait started.")), Is.False); + } + } +} diff --git a/tests/UnitTestEx.NUnit.Test/Other/SpyTestFrameworkImplementor.cs b/tests/UnitTestEx.NUnit.Test/Other/SpyTestFrameworkImplementor.cs new file mode 100644 index 0000000..b74a2f2 --- /dev/null +++ b/tests/UnitTestEx.NUnit.Test/Other/SpyTestFrameworkImplementor.cs @@ -0,0 +1,36 @@ +#nullable enable + +using System.Collections.Generic; +using UnitTestEx.Abstractions; + +namespace UnitTestEx.NUnit.Test.Other +{ + /// + /// A that forwards to an implementor (so the underlying test framework still receives all output), while also capturing every + /// call so tests can assert on the exact output produced (e.g. by 's Reason/WaitAndLog methods). + /// + /// The to forward to. + internal sealed class SpyTestFrameworkImplementor(TestFrameworkImplementor inner) : TestFrameworkImplementor + { + /// + /// Gets the captured lines written via . + /// + public List Lines { get; } = []; + + /// + public override void WriteLine(string? message) + { + Lines.Add(message); + inner.WriteLine(message); + } + + /// + public override void AssertAreEqual(T? expected, T? actual, string? message = null) where T : default => inner.AssertAreEqual(expected, actual, message); + + /// + public override void AssertFail(string? message) => inner.AssertFail(message); + + /// + public override void AssertInconclusive(string? message) => inner.AssertInconclusive(message); + } +} diff --git a/tests/UnitTestEx.Xunit.Test/Other/ReasonAndWaitAndLogTest.cs b/tests/UnitTestEx.Xunit.Test/Other/ReasonAndWaitAndLogTest.cs new file mode 100644 index 0000000..f167bf9 --- /dev/null +++ b/tests/UnitTestEx.Xunit.Test/Other/ReasonAndWaitAndLogTest.cs @@ -0,0 +1,78 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using UnitTestEx.Api; +using UnitTestEx.Api.Controllers; +using Xunit; +using Xunit.Abstractions; + +namespace UnitTestEx.Xunit.Test.Other +{ + public class ReasonAndWaitAndLogTest : UnitTestBase + { + public ReasonAndWaitAndLogTest(ITestOutputHelper output) : base(output) { } + + [Fact] + public void Reason_WritesReasonToOutput() + { + using var test = ApiTester.Create(); + var spy = new SpyTestFrameworkImplementor(test.Implementor); + test.ReplaceTestFrameworkImplementor(spy); + + var result = test.Reason("Confirming Reason() writes context to the test output."); + + Assert.Same(test, result); + Assert.Contains("REASON >", spy.Lines); + Assert.Contains("Confirming Reason() writes context to the test output.", spy.Lines); + } + + [Fact] + public async Task WaitAndLog_WaitsAndSurfacesLoggingThatOccursDuringTheWait() + { + using var test = ApiTester.Create(); + var spy = new SpyTestFrameworkImplementor(test.Implementor); + test.ReplaceTestFrameworkImplementor(spy); + + // A logger resolved directly from the host's DI container (i.e. not via a UnitTestEx tester invocation) simulates background/hosted-service logging. + var logger = test.Services.GetRequiredService>(); + + var backgroundLogTask = Task.Run(async () => + { + await Task.Delay(300); + logger.LogInformation("Background message logged during the wait window."); + }); + + var sw = Stopwatch.StartNew(); + var result = test.WaitAndLog("Waiting for a background process to log.", TimeSpan.FromSeconds(2)); + sw.Stop(); + + await backgroundLogTask; + + Assert.Same(test, result); + Assert.True(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(100), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); + Assert.Contains(spy.Lines, l => l != null && l.Contains("WAIT >") && l.Contains("Waiting for a background process to log.")); + Assert.Contains(spy.Lines, l => l != null && l.Contains("Timeout:")); + Assert.Contains("LOGGING >", spy.Lines); + Assert.Contains(spy.Lines, l => l != null && l.Contains("Background message logged during the wait window.")); + } + + [Fact] + public void WaitAndLog_ExcludesLoggingThatOccurredBeforeTheWaitStarted() + { + using var test = ApiTester.Create(); + var spy = new SpyTestFrameworkImplementor(test.Implementor); + test.ReplaceTestFrameworkImplementor(spy); + + var logger = test.Services.GetRequiredService>(); + logger.LogInformation("Stale message logged before the wait started."); + + test.WaitAndLog("Waiting with nothing new expected.", TimeSpan.FromMilliseconds(200)); + + Assert.Contains("LOGGING >", spy.Lines); + Assert.Contains("None.", spy.Lines); + Assert.DoesNotContain(spy.Lines, l => l != null && l.Contains("Stale message logged before the wait started.")); + } + } +} diff --git a/tests/UnitTestEx.Xunit.Test/Other/SpyTestFrameworkImplementor.cs b/tests/UnitTestEx.Xunit.Test/Other/SpyTestFrameworkImplementor.cs new file mode 100644 index 0000000..efbf8bd --- /dev/null +++ b/tests/UnitTestEx.Xunit.Test/Other/SpyTestFrameworkImplementor.cs @@ -0,0 +1,36 @@ +#nullable enable + +using System.Collections.Generic; +using UnitTestEx.Abstractions; + +namespace UnitTestEx.Xunit.Test.Other +{ + /// + /// A that forwards to an implementor (so the underlying test framework still receives all output), while also capturing every + /// call so tests can assert on the exact output produced (e.g. by 's Reason/WaitAndLog methods). + /// + /// The to forward to. + internal sealed class SpyTestFrameworkImplementor(TestFrameworkImplementor inner) : TestFrameworkImplementor + { + /// + /// Gets the captured lines written via . + /// + public List Lines { get; } = []; + + /// + public override void WriteLine(string? message) + { + Lines.Add(message); + inner.WriteLine(message); + } + + /// + public override void AssertAreEqual(T? expected, T? actual, string? message = null) where T : default => inner.AssertAreEqual(expected, actual, message); + + /// + public override void AssertFail(string? message) => inner.AssertFail(message); + + /// + public override void AssertInconclusive(string? message) => inner.AssertInconclusive(message); + } +} From 56be73b328a30586adff391db1966758935bfdf7 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 23 Sep 2026 08:37:04 -0700 Subject: [PATCH 23/43] Rename WaitAndLog to Wait, dedupe Aspire resource logs, suppress AppHost 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> --- .../Aspire/AspireTesterBase.cs | 89 ++++++++++++++----- src/UnitTestEx/Abstractions/TesterBaseCore.cs | 26 ++++-- src/UnitTestEx/Abstractions/TesterBaseT.cs | 6 +- .../AspireTesterTest.cs | 17 ++-- .../SpyTestFrameworkImplementor.cs | 2 +- .../AspireTesterTest.cs | 17 ++-- .../SpyTestFrameworkImplementor.cs | 2 +- .../AspireTesterTest.cs | 17 ++-- .../SpyTestFrameworkImplementor.cs | 2 +- ...WaitAndLogTest.cs => ReasonAndWaitTest.cs} | 14 +-- .../Other/SpyTestFrameworkImplementor.cs | 2 +- ...WaitAndLogTest.cs => ReasonAndWaitTest.cs} | 14 +-- .../Other/SpyTestFrameworkImplementor.cs | 2 +- .../PersonControllerTest.cs | 1 + ...WaitAndLogTest.cs => ReasonAndWaitTest.cs} | 16 ++-- .../Other/SpyTestFrameworkImplementor.cs | 2 +- 16 files changed, 147 insertions(+), 82 deletions(-) rename tests/UnitTestEx.MSTest.Test/Other/{ReasonAndWaitAndLogTest.cs => ReasonAndWaitTest.cs} (84%) rename tests/UnitTestEx.NUnit.Test/Other/{ReasonAndWaitAndLogTest.cs => ReasonAndWaitTest.cs} (84%) rename tests/UnitTestEx.Xunit.Test/Other/{ReasonAndWaitAndLogTest.cs => ReasonAndWaitTest.cs} (82%) diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index f1865ad..0dbc9f4 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -4,6 +4,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Testing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using System; @@ -66,9 +67,16 @@ private async Task CreateDistributedApplicationAsync() { var builder = await DistributedApplicationTestingBuilder.CreateAsync().ConfigureAwait(false); + if (!HostDiagnosticsEnabled) + // By default the AppHost's own logging (its start-up banner, DCP process management, and - critically - each resource's own console output mirrored under a + // "{ApplicationName}.Resources.{resourceName}" category; see 'EnableResourceLogging') writes straight to the test's Standard Output via its default provider(s) (e.g. console), + // duplicating what UnitTestEx already captures and reports cleanly (and correlated) via Reason()/Wait()/the Http() request-scoped "LOGGING >" section. Remove the AppHost's + // own provider(s) so only what UnitTestEx explicitly writes reaches the test output; call EnableHostDiagnostics() before this point to opt back into the raw firehose (e.g. when + // troubleshooting an AppHost/resource start-up failure). + builder.Services.RemoveAll(); + builder.Services.Configure(options => - // The AppHost forwards each resource's own logging (e.g. its own ILogger writes) under a "{ApplicationName}.Resources.{resourceName}" category (see 'EnableResourceLogging'); - // exempt our own capture provider from any ambient filtering (by provider, not category) so it always sees everything, regardless of the AppHost's own console noise level + // Exempt our own capture provider from any ambient filtering (by provider, not category) so it always sees everything, regardless of the AppHost's own console noise level // (which the AppHost's own default logging configuration governs independently and is not something UnitTestEx overrides). options.Rules.Add(new LoggerFilterRule(typeof(ResourceLogCaptureProvider).FullName, null, LogLevel.Trace, null))); @@ -140,6 +148,33 @@ public TSelf WithResourceEnvironment(string resourceName, string key, string val return (TSelf)this; } + /// + /// Gets a value indicating whether the AppHost's own raw logging (its start-up banner, DCP process management, and each resource's own console output mirrored via + /// 'EnableResourceLogging') is written to the test output, in addition to what UnitTestEx itself reports (see ). + /// + public bool HostDiagnosticsEnabled { get; private set; } + + /// + /// Opts back into the AppHost's own raw logging (its start-up banner, DCP process management, and each resource's own console output) being written to the test output, in addition + /// to what UnitTestEx itself reports via //the request-scoped "LOGGING >" section. + /// + /// The to support fluent-style method-chaining. + /// By default this raw firehose is suppressed as it otherwise duplicates what UnitTestEx already surfaces cleanly (and correlated); enable it when troubleshooting an + /// AppHost/resource start-up failure that needs the full, unfiltered diagnostic output. Must be called before the underlying has been built + /// (see ). + public TSelf EnableHostDiagnostics() + { + lock (SyncRoot) + { + if (_appTask is not null) + throw new InvalidOperationException($"{nameof(EnableHostDiagnostics)} must be invoked before the underlying {nameof(DistributedApplication)} has been built (i.e. before any {nameof(Http)}/{nameof(WaitForResourceAsync)} call)."); + + HostDiagnosticsEnabled = true; + } + + return (TSelf)this; + } + /// /// Gets the default timeout used by when none is specified. /// @@ -175,16 +210,17 @@ public TSelf Reason(string reason) /// /// Waits for the specified , then writes any resource log messages captured (across all resources) during that time to the test output. /// - /// The reason for waiting (written to the test output for context). - /// The duration to wait. + /// The reason for waiting (written to the test output for context); defaults to "Not specified." where not specified. + /// The duration to wait; defaults to where not specified. /// The to support fluent-style method-chaining. /// Useful when waiting on background/inter-resource activity (e.g. a message being processed by a downstream resource) that is not tied to a specific HTTP request/response (see /// for the latter, request-scoped, correlation). - public TSelf WaitAndLog(string reason, TimeSpan duration) => WriteWaitAndLog(reason, duration).ContinueWith(_ => (TSelf)this).Result; + public TSelf Wait(string? reason = null, TimeSpan? duration = null) => WriteWait(reason, duration).ContinueWith(_ => (TSelf)this).Result; /// - /// Combines the elapsed log messages captured (via ) across all resources since the last invocation, each line prefixed with its - /// owning resource name for clarity, as background/inter-resource activity is not necessarily confined to a single resource. + /// Combines the elapsed log messages captured (via ) across all resources since the last invocation, as background/inter-resource + /// activity is not necessarily confined to a single resource; each line already carries its own owning resource name as a trailing suffix (see ), + /// so no further attribution is added here. protected override IEnumerable? DrainElapsedLogMessages() { _resourceLogCaptureProvider?.FlushPendingEntries(); @@ -196,10 +232,7 @@ public TSelf Reason(string reason) var newLines = buffer.Skip(lastCount).ToArray(); _elapsedLogLineCounts[resourceName] = buffer.Count; - foreach (var line in newLines) - { - lines.Add($"[{resourceName}] {line}"); - } + lines.AddRange(newLines); } return lines; @@ -245,7 +278,7 @@ public ILogger CreateLogger(string categoryName) return NullLogger.Instance; var resourceName = categoryName[resourceCategoryPrefix.Length..]; - var logger = new ResourceLogger(buffers.GetOrAdd(resourceName, _ => new ConcurrentQueue())); + var logger = new ResourceLogger(resourceName, buffers.GetOrAdd(resourceName, _ => new ConcurrentQueue())); _loggers.Add(logger); return logger; } @@ -270,15 +303,17 @@ public void FlushPendingEntries() /// /// Parses and reformats each resource's forwarded, already console-rendered text into the standard UnitTestEx output style, i.e. - /// "{timestamp} {level}: {message} [{category}]". + /// "{timestamp} {level}: {message} [{category} ({resourceName})]". /// /// The AppHost forwards each resource's own console output one physical line at a time (e.g. the "{level}: {category}[{eventId}]" header line, then one or more /// indented message lines, as separate calls), each prefixed with a "{lineNumber}: {timestamp}Z " marker that Aspire itself adds; this reassembles those /// physical lines back into a single logical entry using the same style as an in-process (Tier 1) tester, re-using Aspire's own embedded timestamp (converted to local time) rather than - /// substituting the capture time, so timestamps reflect when the resource itself actually logged the entry. + /// substituting the capture time, so timestamps reflect when the resource itself actually logged the entry. The owning resource name is appended, in parentheses, inside the same + /// trailing "[...]" bracket as the category - rather than as a separate leading prefix - so the timestamp/level/message stay column-aligned whether read via a single-resource + /// () or multi-resource () report. /// A line that does not match the expected "{lineNumber}: {timestamp}Z " prefix (e.g. a resource that does not use the standard console logger format) is passed through /// unmodified, ANSI colour codes aside, so nothing is silently dropped. - private sealed class ResourceLogger(ConcurrentQueue buffer) : ILogger + private sealed class ResourceLogger(string resourceName, ConcurrentQueue buffer) : ILogger { private static readonly Regex _ansiPattern = new(@"\x1b\[[0-9;]*m", RegexOptions.Compiled); private static readonly Regex _linePrefixPattern = new(@"^\d+:\s+(?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+)Z\s?(?.*)$", RegexOptions.Compiled); @@ -348,7 +383,7 @@ private void FlushPendingNoLock() var ts = DateTime.Parse(_pendingTimestamp, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal).ToLocalTime(); var sb = new StringBuilder(); - sb.Append($"{ts.ToString("yyyy-MM-ddTHH:mm:ss.fffff", DateTimeFormatInfo.InvariantInfo)} {_pendingLevel}: {(_pendingMessageLines.Count > 0 ? _pendingMessageLines[0] : string.Empty)} [{_pendingCategory}]"); + sb.Append($"{ts.ToString("yyyy-MM-ddTHH:mm:ss.fffff", DateTimeFormatInfo.InvariantInfo)} {_pendingLevel}: {(_pendingMessageLines.Count > 0 ? _pendingMessageLines[0] : string.Empty)} [{_pendingCategory} ({resourceName})]"); for (var i = 1; i < _pendingMessageLines.Count; i++) { sb.AppendLine(); @@ -372,25 +407,26 @@ private void FlushPendingNoLock() /// Tier 1, in-process host correlates via ; as there is no shared, in-process container to correlate by request identifier, the /// window of lines captured between (invoked immediately before the request is sent) and (invoked /// immediately after the response is received) is used as a pragmatic proxy - this naturally excludes the resource's own start-up banner noise (already buffered by the time the first - /// request is sent) and correlates correctly for the typical, sequential one-request-at-a-time usage pattern. + /// request is sent) and correlates correctly for the typical, sequential one-request-at-a-time usage pattern. The window's baseline/end-point is tracked via the same shared, + /// per-resource watermark () that (used by ) advances, so a resource log line reported + /// here is claimed and will not also be re-reported by a subsequent call (or vice versa). private sealed class ResourceHttpClientSource(AspireTesterBase owner, string resourceName, string? endpointName) : IHttpClientSource { - private int _lastLineCount; - public HttpClient CreateHttpClient(string? name = null) { var app = owner.GetDistributedApplicationAsync().GetAwaiter().GetResult(); - // Flush any pending (not-yet-terminated) entry first so it is counted in the baseline rather than leaking into this request's own captured window. + // Flush any pending (not-yet-terminated) entry first so it is counted in the baseline rather than leaking into this request's own captured window, then establish the shared + // watermark at the current buffer length (i.e. claim everything already buffered as "seen", not part of this request's own window). owner._resourceLogCaptureProvider?.FlushPendingEntries(); - _lastLineCount = owner._resourceLogBuffers.TryGetValue(resourceName, out var buffer) ? buffer.Count : 0; + owner._elapsedLogLineCounts[resourceName] = owner._resourceLogBuffers.TryGetValue(resourceName, out var buffer) ? buffer.Count : 0; return app.CreateHttpClient(resourceName, endpointName); } public IEnumerable? GetRequestLogMessages(string requestId) => GetNewResourceLogMessagesAsync().GetAwaiter().GetResult(); /// - /// Gets the resource's own log lines captured since was last invoked. + /// Gets the resource's own log lines captured since was last invoked, advancing the shared watermark past them so they are claimed. /// /// The resource's logging is forwarded asynchronously; a single short grace period is allowed for it to catch up before giving up, rather than an open-ended poll that would /// otherwise tax every request - including the (typical) majority that log nothing at all. @@ -399,14 +435,19 @@ public HttpClient CreateHttpClient(string? name = null) if (!owner._resourceLogBuffers.TryGetValue(resourceName, out var buffer)) return []; - if (buffer.Count <= _lastLineCount) + var baseline = owner._elapsedLogLineCounts.GetOrAdd(resourceName, buffer.Count); + + if (buffer.Count <= baseline) await Task.Delay(300).ConfigureAwait(false); // An entry only becomes visible in the buffer once terminated by a subsequent header line; force-flush whatever is currently pending so the most recently logged entry // (often the interesting one, e.g. the endpoint's own logging for this very request) is not lost while waiting for a line that may never come. owner._resourceLogCaptureProvider?.FlushPendingEntries(); - return [.. buffer.Skip(_lastLineCount)]; + var newLines = buffer.Skip(baseline).ToArray(); + owner._elapsedLogLineCounts[resourceName] = buffer.Count; + + return newLines; } } diff --git a/src/UnitTestEx/Abstractions/TesterBaseCore.cs b/src/UnitTestEx/Abstractions/TesterBaseCore.cs index c2c7022..8dfd797 100644 --- a/src/UnitTestEx/Abstractions/TesterBaseCore.cs +++ b/src/UnitTestEx/Abstractions/TesterBaseCore.cs @@ -256,12 +256,17 @@ public void ReplaceTestFrameworkImplementor(TestFrameworkImplementor implementor /// Gets the log messages accumulated since the last time this was invoked (draining/resetting the starting point for the next invocation). /// /// The accumulated log messages; null/empty where none. - /// Used by to correlate output that occurs during a wait period rather than a specific HTTP request/response (see + /// Used by to correlate output that occurs during a wait period rather than a specific HTTP request/response (see /// for the latter, request-scoped, correlation). The default (Tier 1, single in-process host) implementation surfaces any /// logging that was not attributed to a specific HTTP request (see ) - e.g. from a background/hosted service. A Tier 2 (e.g. multi-host /// Aspire) tester should override this to surface whatever is applicable to its own hosting model. protected virtual IEnumerable? DrainElapsedLogMessages() => SharedState.GetLoggerMessages(); + /// + /// Gets the default duration used by when none is specified; defaults to 1 second. + /// + public static TimeSpan DefaultWaitDuration { get; } = TimeSpan.FromSeconds(1); + /// /// Writes the specified to the test output to provide additional context (e.g. why a particular action, or wait, is being performed). /// @@ -272,6 +277,7 @@ protected void WriteReason(string reason) return; Implementor.WriteLine(""); + Implementor.WriteLine(new string('-', 80)); Implementor.WriteLine("REASON >"); Implementor.WriteLine(reason); } @@ -280,20 +286,22 @@ protected void WriteReason(string reason) /// Waits for the specified , then writes any log messages captured during that time (e.g. from a background process) to the test output (see /// ). /// - /// The reason for waiting (written to the test output for context). - /// The duration to wait. - protected async Task WriteWaitAndLog(string reason, TimeSpan duration) + /// The reason for waiting (written to the test output for context); defaults to "No reason specified" where not specified. + /// The duration to wait; defaults to where not specified. + protected async Task WriteWait(string? reason, TimeSpan? duration) { + var effectiveReason = string.IsNullOrEmpty(reason) ? "No reason specified" : reason; + var effectiveDuration = duration ?? DefaultWaitDuration; + // Drain any pre-existing/stale backlog first so only messages logged during the wait window itself are reported. DrainElapsedLogMessages(); Implementor.WriteLine(""); - Implementor.WriteLine(new string('=', 80)); - Implementor.WriteLine($"WAIT > {reason}"); - Implementor.WriteLine($"Timeout: {duration}"); - Implementor.WriteLine(new string('=', 80)); + Implementor.WriteLine(new string('-', 80)); + Implementor.WriteLine($"WAIT ({effectiveDuration}) >"); + Implementor.WriteLine(effectiveReason); - await Task.Delay(duration).ConfigureAwait(false); + await Task.Delay(effectiveDuration).ConfigureAwait(false); Implementor.WriteLine(""); Implementor.WriteLine("LOGGING >"); diff --git a/src/UnitTestEx/Abstractions/TesterBaseT.cs b/src/UnitTestEx/Abstractions/TesterBaseT.cs index 7004112..d2ab37d 100644 --- a/src/UnitTestEx/Abstractions/TesterBaseT.cs +++ b/src/UnitTestEx/Abstractions/TesterBaseT.cs @@ -415,10 +415,10 @@ public TSelf Reason(string reason) /// Waits for the specified , then writes any log messages captured during that time (e.g. from a background/hosted service not attributed to a specific HTTP /// request) to the test output. /// - /// The reason for waiting (written to the test output for context). - /// The duration to wait. + /// The reason for waiting (written to the test output for context); defaults to "No reason specified" where not specified. + /// The duration to wait; defaults to where not specified. /// The to support fluent-style method-chaining. - public TSelf WaitAndLog(string reason, TimeSpan duration) => WriteWaitAndLog(reason, duration).ContinueWith(_ => (TSelf)this).Result; + public TSelf Wait(string? reason = null, TimeSpan? duration = null) => WriteWait(reason, duration).ContinueWith(_ => (TSelf)this).Result; /// /// Wraps the host execution to perform required start-up style activities; specifically resetting the . diff --git a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs index d6651fe..fb61797 100644 --- a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; +using UnitTestEx.Abstractions; using UnitTestEx.Api.Models; namespace UnitTestEx.Aspire.MSTest.Test @@ -41,7 +42,7 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() } [TestMethod] - public async Task Reason_And_WaitAndLog_AggregatesResourceLogs() + public async Task Reason_And_Wait_AggregatesResourceLogs() { await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); @@ -53,15 +54,18 @@ public async Task Reason_And_WaitAndLog_AggregatesResourceLogs() var reasonResult = tester.Reason("Confirming Reason() writes context for Aspire multi-host testers too."); - // Fire a request against the 'api' resource part-way through the wait window to simulate background/inter-resource activity. + // Fire a raw (uninstrumented) request against the 'api' resource part-way through the wait window to simulate genuine background/inter-resource activity that is not + // tied to a tester-driven request/response (which would otherwise claim - and so report - the resource's log line itself, rather than Wait). var backgroundCallTask = Task.Run(async () => { await Task.Delay(300); - tester.Http("api").Run(HttpMethod.Get, "Person/1"); + using var client = ((IHttpClientSource)tester).CreateHttpClient("api"); + using var response = await client.GetAsync("Person/1"); + response.EnsureSuccessStatusCode(); }); var sw = Stopwatch.StartNew(); - var waitResult = tester.WaitAndLog("Waiting for a background Person lookup to complete and log.", TimeSpan.FromSeconds(2)); + var waitResult = tester.Wait("Waiting for a background Person lookup to complete and log.", TimeSpan.FromSeconds(2)); sw.Stop(); await backgroundCallTask; @@ -70,9 +74,10 @@ public async Task Reason_And_WaitAndLog_AggregatesResourceLogs() Assert.AreSame(tester, waitResult); Assert.IsTrue(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(200), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); Assert.IsTrue(spy.Lines.Contains("REASON >")); - Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("WAIT >") && l.Contains("Waiting for a background Person lookup to complete and log."))); + Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("WAIT (00:00:02) >"))); + Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("Waiting for a background Person lookup to complete and log."))); Assert.IsTrue(spy.Lines.Contains("LOGGING >")); - Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("[api]") && l.Contains("Get using identifier 1."))); + Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("Get using identifier 1.") && l.EndsWith("(api)]", StringComparison.Ordinal))); } } } diff --git a/tests/UnitTestEx.Aspire.MSTest.Test/SpyTestFrameworkImplementor.cs b/tests/UnitTestEx.Aspire.MSTest.Test/SpyTestFrameworkImplementor.cs index 427b248..30af2b3 100644 --- a/tests/UnitTestEx.Aspire.MSTest.Test/SpyTestFrameworkImplementor.cs +++ b/tests/UnitTestEx.Aspire.MSTest.Test/SpyTestFrameworkImplementor.cs @@ -7,7 +7,7 @@ namespace UnitTestEx.Aspire.MSTest.Test { /// /// A that forwards to an implementor (so the underlying test framework still receives all output), while also capturing every - /// call so tests can assert on the exact output produced (e.g. by the Aspire tester's Reason/WaitAndLog methods). + /// call so tests can assert on the exact output produced (e.g. by the Aspire tester's Reason/Wait methods). /// /// The to forward to. internal sealed class SpyTestFrameworkImplementor(TestFrameworkImplementor inner) : TestFrameworkImplementor diff --git a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs index 4d72dcf..82445d4 100644 --- a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Threading.Tasks; using NUnit.Framework; +using UnitTestEx.Abstractions; using UnitTestEx.Api.Models; namespace UnitTestEx.Aspire.NUnit.Test @@ -41,7 +42,7 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() } [Test] - public async Task Reason_And_WaitAndLog_AggregatesResourceLogs() + public async Task Reason_And_Wait_AggregatesResourceLogs() { await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); @@ -53,15 +54,18 @@ public async Task Reason_And_WaitAndLog_AggregatesResourceLogs() var reasonResult = tester.Reason("Confirming Reason() writes context for Aspire multi-host testers too."); - // Fire a request against the 'api' resource part-way through the wait window to simulate background/inter-resource activity. + // Fire a raw (uninstrumented) request against the 'api' resource part-way through the wait window to simulate genuine background/inter-resource activity that is not + // tied to a tester-driven request/response (which would otherwise claim - and so report - the resource's log line itself, rather than Wait). var backgroundCallTask = Task.Run(async () => { await Task.Delay(300); - tester.Http("api").Run(HttpMethod.Get, "Person/1"); + using var client = ((IHttpClientSource)tester).CreateHttpClient("api"); + using var response = await client.GetAsync("Person/1"); + response.EnsureSuccessStatusCode(); }); var sw = Stopwatch.StartNew(); - var waitResult = tester.WaitAndLog("Waiting for a background Person lookup to complete and log.", TimeSpan.FromSeconds(2)); + var waitResult = tester.Wait("Waiting for a background Person lookup to complete and log.", TimeSpan.FromSeconds(2)); sw.Stop(); await backgroundCallTask; @@ -70,9 +74,10 @@ public async Task Reason_And_WaitAndLog_AggregatesResourceLogs() Assert.That(waitResult, Is.SameAs(tester)); Assert.That(sw.Elapsed, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(200)), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); Assert.That(spy.Lines, Does.Contain("REASON >")); - Assert.That(spy.Lines.Any(l => l != null && l.Contains("WAIT >") && l.Contains("Waiting for a background Person lookup to complete and log.")), Is.True); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("WAIT (00:00:02) >")), Is.True); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("Waiting for a background Person lookup to complete and log.")), Is.True); Assert.That(spy.Lines, Does.Contain("LOGGING >")); - Assert.That(spy.Lines.Any(l => l != null && l.Contains("[api]") && l.Contains("Get using identifier 1.")), Is.True); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("Get using identifier 1.") && l.EndsWith("(api)]", StringComparison.Ordinal)), Is.True); } } } diff --git a/tests/UnitTestEx.Aspire.NUnit.Test/SpyTestFrameworkImplementor.cs b/tests/UnitTestEx.Aspire.NUnit.Test/SpyTestFrameworkImplementor.cs index ad347f6..a42e885 100644 --- a/tests/UnitTestEx.Aspire.NUnit.Test/SpyTestFrameworkImplementor.cs +++ b/tests/UnitTestEx.Aspire.NUnit.Test/SpyTestFrameworkImplementor.cs @@ -7,7 +7,7 @@ namespace UnitTestEx.Aspire.NUnit.Test { /// /// A that forwards to an implementor (so the underlying test framework still receives all output), while also capturing every - /// call so tests can assert on the exact output produced (e.g. by the Aspire tester's Reason/WaitAndLog methods). + /// call so tests can assert on the exact output produced (e.g. by the Aspire tester's Reason/Wait methods). /// /// The to forward to. internal sealed class SpyTestFrameworkImplementor(TestFrameworkImplementor inner) : TestFrameworkImplementor diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index 92c2679..8abe6d5 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -3,6 +3,7 @@ using System.Net.Http; using System.Threading.Tasks; using UnitTestEx; +using UnitTestEx.Abstractions; using UnitTestEx.Api.Models; using Xunit; using Xunit.Abstractions; @@ -43,7 +44,7 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() } [Fact] - public async Task Reason_And_WaitAndLog_AggregatesResourceLogs() + public async Task Reason_And_Wait_AggregatesResourceLogs() { await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); @@ -55,15 +56,18 @@ public async Task Reason_And_WaitAndLog_AggregatesResourceLogs() var reasonResult = tester.Reason("Confirming Reason() writes context for Aspire multi-host testers too."); - // Fire a request against the 'api' resource part-way through the wait window to simulate background/inter-resource activity. + // Fire a raw (uninstrumented) request against the 'api' resource part-way through the wait window to simulate genuine background/inter-resource activity that is not + // tied to a tester-driven request/response (which would otherwise claim - and so report - the resource's log line itself, rather than Wait). var backgroundCallTask = Task.Run(async () => { await Task.Delay(300); - tester.Http("api").Run(HttpMethod.Get, "Person/1"); + using var client = ((IHttpClientSource)tester).CreateHttpClient("api"); + using var response = await client.GetAsync("Person/1"); + response.EnsureSuccessStatusCode(); }); var sw = Stopwatch.StartNew(); - var waitResult = tester.WaitAndLog("Waiting for a background Person lookup to complete and log.", TimeSpan.FromSeconds(2)); + var waitResult = tester.Wait("Waiting for a background Person lookup to complete and log.", TimeSpan.FromSeconds(2)); sw.Stop(); await backgroundCallTask; @@ -72,9 +76,10 @@ public async Task Reason_And_WaitAndLog_AggregatesResourceLogs() Assert.Same(tester, waitResult); Assert.True(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(200), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); Assert.Contains("REASON >", spy.Lines); - Assert.Contains(spy.Lines, l => l != null && l.Contains("WAIT >") && l.Contains("Waiting for a background Person lookup to complete and log.")); + Assert.Contains(spy.Lines, l => l != null && l.Contains("WAIT (00:00:02) >")); + Assert.Contains(spy.Lines, l => l != null && l.Contains("Waiting for a background Person lookup to complete and log.")); Assert.Contains("LOGGING >", spy.Lines); - Assert.Contains(spy.Lines, l => l != null && l.Contains("[api]") && l.Contains("Get using identifier 1.")); + Assert.Contains(spy.Lines, l => l != null && l.Contains("Get using identifier 1.") && l.EndsWith("(api)]", StringComparison.Ordinal)); } } } diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/SpyTestFrameworkImplementor.cs b/tests/UnitTestEx.Aspire.Xunit.Test/SpyTestFrameworkImplementor.cs index 6e52885..5cfdb6f 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/SpyTestFrameworkImplementor.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/SpyTestFrameworkImplementor.cs @@ -7,7 +7,7 @@ namespace UnitTestEx.Aspire.Xunit.Test { /// /// A that forwards to an implementor (so the underlying test framework still receives all output), while also capturing every - /// call so tests can assert on the exact output produced (e.g. by the Aspire tester's Reason/WaitAndLog methods). + /// call so tests can assert on the exact output produced (e.g. by the Aspire tester's Reason/Wait methods). /// /// The to forward to. internal sealed class SpyTestFrameworkImplementor(TestFrameworkImplementor inner) : TestFrameworkImplementor diff --git a/tests/UnitTestEx.MSTest.Test/Other/ReasonAndWaitAndLogTest.cs b/tests/UnitTestEx.MSTest.Test/Other/ReasonAndWaitTest.cs similarity index 84% rename from tests/UnitTestEx.MSTest.Test/Other/ReasonAndWaitAndLogTest.cs rename to tests/UnitTestEx.MSTest.Test/Other/ReasonAndWaitTest.cs index d43e98a..d98e667 100644 --- a/tests/UnitTestEx.MSTest.Test/Other/ReasonAndWaitAndLogTest.cs +++ b/tests/UnitTestEx.MSTest.Test/Other/ReasonAndWaitTest.cs @@ -11,7 +11,7 @@ namespace UnitTestEx.MSTest.Test.Other { [TestClass] - public class ReasonAndWaitAndLogTest + public class ReasonAndWaitTest { [TestMethod] public void Reason_WritesReasonToOutput() @@ -28,7 +28,7 @@ public void Reason_WritesReasonToOutput() } [TestMethod] - public async Task WaitAndLog_WaitsAndSurfacesLoggingThatOccursDuringTheWait() + public async Task Wait_WaitsAndSurfacesLoggingThatOccursDuringTheWait() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); @@ -44,21 +44,21 @@ public async Task WaitAndLog_WaitsAndSurfacesLoggingThatOccursDuringTheWait() }); var sw = Stopwatch.StartNew(); - var result = test.WaitAndLog("Waiting for a background process to log.", TimeSpan.FromSeconds(2)); + var result = test.Wait("Waiting for a background process to log.", TimeSpan.FromSeconds(2)); sw.Stop(); await backgroundLogTask; Assert.AreSame(test, result); Assert.IsTrue(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(100), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); - Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("WAIT >") && l.Contains("Waiting for a background process to log."))); - Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("Timeout:"))); + Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("WAIT (00:00:02) >"))); + Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("Waiting for a background process to log."))); Assert.IsTrue(spy.Lines.Contains("LOGGING >")); Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("Background message logged during the wait window."))); } [TestMethod] - public void WaitAndLog_ExcludesLoggingThatOccurredBeforeTheWaitStarted() + public void Wait_ExcludesLoggingThatOccurredBeforeTheWaitStarted() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); @@ -67,7 +67,7 @@ public void WaitAndLog_ExcludesLoggingThatOccurredBeforeTheWaitStarted() var logger = test.Services.GetRequiredService>(); logger.LogInformation("Stale message logged before the wait started."); - test.WaitAndLog("Waiting with nothing new expected.", TimeSpan.FromMilliseconds(200)); + test.Wait("Waiting with nothing new expected.", TimeSpan.FromMilliseconds(200)); Assert.IsTrue(spy.Lines.Contains("LOGGING >")); Assert.IsTrue(spy.Lines.Contains("None.")); diff --git a/tests/UnitTestEx.MSTest.Test/Other/SpyTestFrameworkImplementor.cs b/tests/UnitTestEx.MSTest.Test/Other/SpyTestFrameworkImplementor.cs index a7fa6b5..a798f10 100644 --- a/tests/UnitTestEx.MSTest.Test/Other/SpyTestFrameworkImplementor.cs +++ b/tests/UnitTestEx.MSTest.Test/Other/SpyTestFrameworkImplementor.cs @@ -7,7 +7,7 @@ namespace UnitTestEx.MSTest.Test.Other { /// /// A that forwards to an implementor (so the underlying test framework still receives all output), while also capturing every - /// call so tests can assert on the exact output produced (e.g. by 's Reason/WaitAndLog methods). + /// call so tests can assert on the exact output produced (e.g. by 's Reason/Wait methods). /// /// The to forward to. internal sealed class SpyTestFrameworkImplementor(TestFrameworkImplementor inner) : TestFrameworkImplementor diff --git a/tests/UnitTestEx.NUnit.Test/Other/ReasonAndWaitAndLogTest.cs b/tests/UnitTestEx.NUnit.Test/Other/ReasonAndWaitTest.cs similarity index 84% rename from tests/UnitTestEx.NUnit.Test/Other/ReasonAndWaitAndLogTest.cs rename to tests/UnitTestEx.NUnit.Test/Other/ReasonAndWaitTest.cs index 0222e67..8cc3ef8 100644 --- a/tests/UnitTestEx.NUnit.Test/Other/ReasonAndWaitAndLogTest.cs +++ b/tests/UnitTestEx.NUnit.Test/Other/ReasonAndWaitTest.cs @@ -11,7 +11,7 @@ namespace UnitTestEx.NUnit.Test.Other { [TestFixture] - public class ReasonAndWaitAndLogTest + public class ReasonAndWaitTest { [Test] public void Reason_WritesReasonToOutput() @@ -28,7 +28,7 @@ public void Reason_WritesReasonToOutput() } [Test] - public async Task WaitAndLog_WaitsAndSurfacesLoggingThatOccursDuringTheWait() + public async Task Wait_WaitsAndSurfacesLoggingThatOccursDuringTheWait() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); @@ -44,21 +44,21 @@ public async Task WaitAndLog_WaitsAndSurfacesLoggingThatOccursDuringTheWait() }); var sw = Stopwatch.StartNew(); - var result = test.WaitAndLog("Waiting for a background process to log.", TimeSpan.FromSeconds(2)); + var result = test.Wait("Waiting for a background process to log.", TimeSpan.FromSeconds(2)); sw.Stop(); await backgroundLogTask; Assert.That(result, Is.SameAs(test)); Assert.That(sw.Elapsed, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(100)), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); - Assert.That(spy.Lines.Any(l => l != null && l.Contains("WAIT >") && l.Contains("Waiting for a background process to log.")), Is.True); - Assert.That(spy.Lines.Any(l => l != null && l.Contains("Timeout:")), Is.True); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("WAIT (00:00:02) >")), Is.True); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("Waiting for a background process to log.")), Is.True); Assert.That(spy.Lines, Does.Contain("LOGGING >")); Assert.That(spy.Lines.Any(l => l != null && l.Contains("Background message logged during the wait window.")), Is.True); } [Test] - public void WaitAndLog_ExcludesLoggingThatOccurredBeforeTheWaitStarted() + public void Wait_ExcludesLoggingThatOccurredBeforeTheWaitStarted() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); @@ -67,7 +67,7 @@ public void WaitAndLog_ExcludesLoggingThatOccurredBeforeTheWaitStarted() var logger = test.Services.GetRequiredService>(); logger.LogInformation("Stale message logged before the wait started."); - test.WaitAndLog("Waiting with nothing new expected.", TimeSpan.FromMilliseconds(200)); + test.Wait("Waiting with nothing new expected.", TimeSpan.FromMilliseconds(200)); Assert.That(spy.Lines, Does.Contain("LOGGING >")); Assert.That(spy.Lines, Does.Contain("None.")); diff --git a/tests/UnitTestEx.NUnit.Test/Other/SpyTestFrameworkImplementor.cs b/tests/UnitTestEx.NUnit.Test/Other/SpyTestFrameworkImplementor.cs index b74a2f2..cb8142c 100644 --- a/tests/UnitTestEx.NUnit.Test/Other/SpyTestFrameworkImplementor.cs +++ b/tests/UnitTestEx.NUnit.Test/Other/SpyTestFrameworkImplementor.cs @@ -7,7 +7,7 @@ namespace UnitTestEx.NUnit.Test.Other { /// /// A that forwards to an implementor (so the underlying test framework still receives all output), while also capturing every - /// call so tests can assert on the exact output produced (e.g. by 's Reason/WaitAndLog methods). + /// call so tests can assert on the exact output produced (e.g. by 's Reason/Wait methods). /// /// The to forward to. internal sealed class SpyTestFrameworkImplementor(TestFrameworkImplementor inner) : TestFrameworkImplementor diff --git a/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs b/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs index 7540548..0f8a601 100644 --- a/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs +++ b/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs @@ -59,6 +59,7 @@ public void Get_Test4() public void Get_Test4_WithResetHost() { using var test = ApiTester.Create().ResetHost(); + test.Reason("Need to reset the host."); test.Controller() .Run(c => c.Get(4)) .AssertNotFound(); diff --git a/tests/UnitTestEx.Xunit.Test/Other/ReasonAndWaitAndLogTest.cs b/tests/UnitTestEx.Xunit.Test/Other/ReasonAndWaitTest.cs similarity index 82% rename from tests/UnitTestEx.Xunit.Test/Other/ReasonAndWaitAndLogTest.cs rename to tests/UnitTestEx.Xunit.Test/Other/ReasonAndWaitTest.cs index f167bf9..a0bfdbc 100644 --- a/tests/UnitTestEx.Xunit.Test/Other/ReasonAndWaitAndLogTest.cs +++ b/tests/UnitTestEx.Xunit.Test/Other/ReasonAndWaitTest.cs @@ -10,9 +10,9 @@ namespace UnitTestEx.Xunit.Test.Other { - public class ReasonAndWaitAndLogTest : UnitTestBase + public class ReasonAndWaitTest : UnitTestBase { - public ReasonAndWaitAndLogTest(ITestOutputHelper output) : base(output) { } + public ReasonAndWaitTest(ITestOutputHelper output) : base(output) { } [Fact] public void Reason_WritesReasonToOutput() @@ -29,7 +29,7 @@ public void Reason_WritesReasonToOutput() } [Fact] - public async Task WaitAndLog_WaitsAndSurfacesLoggingThatOccursDuringTheWait() + public async Task Wait_WaitsAndSurfacesLoggingThatOccursDuringTheWait() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); @@ -45,21 +45,21 @@ public async Task WaitAndLog_WaitsAndSurfacesLoggingThatOccursDuringTheWait() }); var sw = Stopwatch.StartNew(); - var result = test.WaitAndLog("Waiting for a background process to log.", TimeSpan.FromSeconds(2)); + var result = test.Wait("Waiting for a background process to log.", TimeSpan.FromSeconds(2)); sw.Stop(); await backgroundLogTask; Assert.Same(test, result); Assert.True(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(100), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); - Assert.Contains(spy.Lines, l => l != null && l.Contains("WAIT >") && l.Contains("Waiting for a background process to log.")); - Assert.Contains(spy.Lines, l => l != null && l.Contains("Timeout:")); + Assert.Contains(spy.Lines, l => l != null && l.Contains("WAIT (00:00:02) >")); + Assert.Contains(spy.Lines, l => l != null && l.Contains("Waiting for a background process to log.")); Assert.Contains("LOGGING >", spy.Lines); Assert.Contains(spy.Lines, l => l != null && l.Contains("Background message logged during the wait window.")); } [Fact] - public void WaitAndLog_ExcludesLoggingThatOccurredBeforeTheWaitStarted() + public void Wait_ExcludesLoggingThatOccurredBeforeTheWaitStarted() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); @@ -68,7 +68,7 @@ public void WaitAndLog_ExcludesLoggingThatOccurredBeforeTheWaitStarted() var logger = test.Services.GetRequiredService>(); logger.LogInformation("Stale message logged before the wait started."); - test.WaitAndLog("Waiting with nothing new expected.", TimeSpan.FromMilliseconds(200)); + test.Wait("Waiting with nothing new expected.", TimeSpan.FromMilliseconds(200)); Assert.Contains("LOGGING >", spy.Lines); Assert.Contains("None.", spy.Lines); diff --git a/tests/UnitTestEx.Xunit.Test/Other/SpyTestFrameworkImplementor.cs b/tests/UnitTestEx.Xunit.Test/Other/SpyTestFrameworkImplementor.cs index efbf8bd..da30f69 100644 --- a/tests/UnitTestEx.Xunit.Test/Other/SpyTestFrameworkImplementor.cs +++ b/tests/UnitTestEx.Xunit.Test/Other/SpyTestFrameworkImplementor.cs @@ -7,7 +7,7 @@ namespace UnitTestEx.Xunit.Test.Other { /// /// A that forwards to an implementor (so the underlying test framework still receives all output), while also capturing every - /// call so tests can assert on the exact output produced (e.g. by 's Reason/WaitAndLog methods). + /// call so tests can assert on the exact output produced (e.g. by 's Reason/Wait methods). /// /// The to forward to. internal sealed class SpyTestFrameworkImplementor(TestFrameworkImplementor inner) : TestFrameworkImplementor From 112b27984f65011d44607d4d9bc460903aac2895 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 23 Sep 2026 14:51:10 -0700 Subject: [PATCH 24/43] Add HttpMock (WireMock.Net) fluent API for Aspire multi-host testing - 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> --- docs/design/aspire-multi-host-testing.md | 24 ++ .../Aspire/AspireTesterBase.cs | 18 ++ .../Aspire/HttpMock/AspireHttpMockClient.cs | 103 +++++++++ .../Aspire/HttpMock/AspireHttpMockRequest.cs | 163 ++++++++++++++ .../HttpMock/AspireHttpMockRequestBody.cs | 26 +++ .../Aspire/HttpMock/AspireHttpMockResponse.cs | 213 ++++++++++++++++++ .../AspireHttpMockResponseSequence.cs | 34 +++ .../AspireHttpMockResponseSequenceItem.cs | 115 ++++++++++ .../HttpMock/AspireHttpMockedRequest.cs | 123 ++++++++++ .../Aspire/HttpMock/ResponseModelHelper.cs | 57 +++++ .../UnitTestEx.Aspire.csproj | 2 + tests/UnitTestEx.Api/Startup.cs | 7 +- tests/UnitTestEx.Aspire.AppHost/AppHost.cs | 11 +- .../UnitTestEx.Aspire.AppHost.csproj | 1 + .../AspireTesterTest.cs | 99 ++++++++ 15 files changed, 994 insertions(+), 2 deletions(-) create mode 100644 src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs create mode 100644 src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs create mode 100644 src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequestBody.cs create mode 100644 src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponse.cs create mode 100644 src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequence.cs create mode 100644 src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequenceItem.cs create mode 100644 src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockedRequest.cs create mode 100644 src/UnitTestEx.Aspire/Aspire/HttpMock/ResponseModelHelper.cs diff --git a/docs/design/aspire-multi-host-testing.md b/docs/design/aspire-multi-host-testing.md index 8c962b2..95ffb36 100644 --- a/docs/design/aspire-multi-host-testing.md +++ b/docs/design/aspire-multi-host-testing.md @@ -138,6 +138,30 @@ consistent, `UnitTestEx.Aspire` should ship a thin fluent wrapper over WireMock. the existing `MockHttpClient`/`MockHttpClientRequest`/`MockHttpClientResponse` builder syntax as closely as possible, so switching between tiers doesn't mean learning a new mocking DSL. +### 5.1 `HttpMock` implementation notes (finalized during build-out) + +Two semantics divergences from WireMock.Net's raw defaults were deliberately closed to keep the `HttpMock` +authoring experience consistent with Tier 1's `MockHttpClientFactory`: + +- **Sequenced responses (`WithSequenceAsync`) exactly mirror Tier 1**: the sequence length is the *exact* + expected invocation count (not a minimum, not silently repeating the last response forever) — the same + as `MockHttpClientRequest`'s `Responses` mode. `.Times(...)` is disallowed in combination with + `WithSequenceAsync` (throws `InvalidOperationException`) because it's meaningless once the sequence + itself is the exact-count contract. An invocation beyond the configured sequence returns a distinct + `500 InternalServerError` from a synthetic guard mapping (WireMock.Net can't throw a .NET exception + across the process boundary the way Tier 1 can from inside the mocked call), and `VerifyAsync()` checks + both that guard was never hit and that every configured response was invoked exactly once. +- **`WithJsonBody(..., pathsToIgnore: ...)` uses WireMock.Net's native `JsonPartialMatcher`**, not + UnitTestEx's own `JsonElementComparer` — matching happens inside the (potentially separate-process) + WireMock.Net server, which has no way to call back into the test process's comparer. Named paths are + stripped from the pattern before it's posted, then the matcher switches from strict `JsonMatcher` to + `JsonPartialMatcher`. This is deliberately simpler than Tier 1's `pathsToIgnore` (dot-separated property + names only, no JSONPath/array indices) and *looser*: `JsonPartialMatcher` also silently tolerates any + other unanticipated extra property in the actual body, not just the ones explicitly ignored. Good enough + for the common case (an unpredictable ETag/timestamp/GUID) but callers should know it isn't a drop-in + equivalent of Tier 1's stricter bidirectional-deep-equal-minus-ignored-paths behavior. Left unchanged + (strict `JsonMatcher`) when `pathsToIgnore` isn't supplied. + ## 6. Mocking individual components case-by-case across processes Since there's no cross-process DI, "mock this one component in that other service" has to be solved diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index 0dbc9f4..7169beb 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -255,6 +255,24 @@ public TSelf Reason(string reason) /// The . public HttpTester Http(string resourceName, string? endpointName = null) => new(this, new ResourceHttpClientSource(this, resourceName, endpointName)); + /// + /// Enables a fluent to stub HTTP responses from a WireMock.Net server resource (added via the official WireMock.Net.Aspire package's + /// builder.AddWireMock(name) in the AppHost), for mocking external/third-party HTTP dependencies within a Tier 2 test. + /// + /// The WireMock.Net server resource name (as configured within the AppHost) to target. + /// The optional endpoint name; where not specified, the resource's single default endpoint is used (see DistributedApplicationExtensions.CreateWireMockAdminClient). + /// The . + public HttpMock.AspireHttpMockClient HttpMock(string resourceName, string? endpointName = null) + { + if (resourceName is null) throw new ArgumentNullException(nameof(resourceName)); + + return new HttpMock.AspireHttpMockClient(async () => + { + var app = await GetDistributedApplicationAsync().ConfigureAwait(false); + return app.CreateWireMockAdminClient(resourceName, endpointName); + }); + } + /// /// The is required as, unlike a single-host Tier 1 tester, there is no single default resource to fall back to; prefer / /// which bind the resource name for you. diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs new file mode 100644 index 0000000..251c01f --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs @@ -0,0 +1,103 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using WireMock.Admin.Mappings; +using WireMock.Client; + +namespace UnitTestEx.Aspire.HttpMock +{ + /// + /// Provides a thin fluent wrapper over a real, out-of-process WireMock.Net server resource (added to the AppHost via the official WireMock.Net.Aspire package's + /// builder.AddWireMock(name)), for stubbing HTTP responses from a Tier 2 () multi-host test. + /// + /// Unlike Tier 1's in-memory, purely synchronous (a Moq-based substitution), this issues genuine HTTP requests + /// to the WireMock.Net server's admin API (a separate OS process, potentially in a container); every stub-defining and verification method is therefore asynchronous - there is no honest way + /// to hide that real network I/O behind a synchronous-looking API. See the docs/design/aspire-multi-host-testing.md design note for the rationale. + public sealed class AspireHttpMockClient + { + private readonly Func> _adminApiFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The factory function used to (asynchronously) resolve the underlying . + internal AspireHttpMockClient(Func> adminApiFactory) => _adminApiFactory = adminApiFactory ?? throw new ArgumentNullException(nameof(adminApiFactory)); + + /// + /// Gets the underlying as an escape hatch for scenarios not covered by the fluent wrapper (e.g. scenarios, proxying, gRPC/protobuf mappings). + /// + /// The . + /// The . + public Task GetAdminApiAsync(CancellationToken cancellationToken = default) => _adminApiFactory(); + + /// + /// Begins configuration of a stubbed request/response mapping. + /// + /// The to match; where not specified any method will match. + /// The relative request URI (path) to match (exact match); where not specified any path will match. + /// The to continue the fluent-style configuration. + public AspireHttpMockRequest Request(HttpMethod? method = null, string? requestUri = null) => new(this, method, requestUri); + + /// + /// Removes all previously configured mappings and clears the recorded request log on the underlying WireMock.Net server resource. + /// + /// The . + /// As the underlying is expensive to rebuild (see ), the mapped + /// server resource's state otherwise persists across all tests sharing the same host instance; call this (e.g. from a per-test setup) to isolate each test's stubs. + public async Task ResetAsync(CancellationToken cancellationToken = default) + { + var adminApi = await GetAdminApiAsync(cancellationToken).ConfigureAwait(false); + _ = await adminApi.ResetMappingsAsync(null, cancellationToken).ConfigureAwait(false); + _ = await adminApi.ResetRequestsAsync(cancellationToken).ConfigureAwait(false); + _ = await adminApi.ResetScenariosAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Posts the specified to the underlying WireMock.Net server resource's admin API. + /// + /// The to post. + /// The number of times the request is expected to be invoked; used as the default for . + /// The . + /// The resulting which can be used to verify invocation after the fact. + internal async Task ApplyAsync(MappingModel mapping, Times? expectedTimes, CancellationToken cancellationToken) + { + mapping.Guid ??= Guid.NewGuid(); + + var adminApi = await GetAdminApiAsync(cancellationToken).ConfigureAwait(false); + _ = await adminApi.PostMappingAsync(mapping, cancellationToken).ConfigureAwait(false); + + return new AspireHttpMockedRequest(adminApi, [mapping.Guid.Value], expectedTimes); + } + + /// + /// Posts the specified sequence of (chained together via a shared WireMock.Net Scenario/state) to the underlying WireMock.Net server resource's admin API. + /// + /// The ordered s to post; all but the last are one per response in the sequence, with the last being the "exhausted" guard mapping + /// (see ). + /// The . + /// The resulting which can be used to verify invocation (exactly once per configured response) after the fact. + internal async Task ApplySequenceAsync(IReadOnlyList mappings, CancellationToken cancellationToken) + { + var adminApi = await GetAdminApiAsync(cancellationToken).ConfigureAwait(false); + var mappingIds = new List(mappings.Count); + + foreach (var mapping in mappings) + { + mapping.Guid ??= Guid.NewGuid(); + mappingIds.Add(mapping.Guid.Value); + _ = await adminApi.PostMappingAsync(mapping, cancellationToken).ConfigureAwait(false); + } + + // The last posted mapping is the "exhausted" guard; the rest are the actual per-response mappings. + var exhaustedMappingId = mappingIds[^1]; + var responseMappingIds = mappingIds.GetRange(0, mappingIds.Count - 1); + + return AspireHttpMockedRequest.ForSequence(adminApi, responseMappingIds, exhaustedMappingId); + } + } +} diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs new file mode 100644 index 0000000..ad75a31 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs @@ -0,0 +1,163 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Moq; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using WireMock.Admin.Mappings; + +namespace UnitTestEx.Aspire.HttpMock +{ + /// + /// Represents the request-matching configuration for a stubbed mapping. + /// + public sealed class AspireHttpMockRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The owning . + /// The to match; where not specified any method will match. + /// The relative request URI (path) to match (exact match); where not specified any path will match. + internal AspireHttpMockRequest(AspireHttpMockClient client, HttpMethod? method, string? requestUri) + { + Client = client ?? throw new ArgumentNullException(nameof(client)); + Rule = new RequestModel + { + Methods = method is null ? null : [method.Method], + Path = requestUri + }; + } + + /// + /// Gets the owning . + /// + internal AspireHttpMockClient Client { get; } + + /// + /// Gets the underlying being built up. + /// + internal RequestModel Rule { get; } + + /// + /// Gets the number of the request is expected to be invoked; used as the default for where not overridden there. + /// + internal Times? ExpectedTimes { get; private set; } + + /// + /// Sets the number of that the request is expected to be invoked. + /// + /// The expected . + /// The to support fluent-style method-chaining. + /// Where not set, will default to verifying at least one invocation (see ). + /// Each time this is invoked it will override the previously set value. + public AspireHttpMockRequest Times(Times times) + { + ExpectedTimes = times; + return this; + } + + /// + /// Indicates that the request will match regardless of the body content. + /// + /// The to continue the fluent-style configuration. + public AspireHttpMockRequestBody WithAnyBody() => new(this); + + /// + /// Indicates that the request body must match the specified exactly. + /// + /// The exact body text to match. + /// The to continue the fluent-style configuration. + public AspireHttpMockRequestBody WithBody(string text) + { + Rule.Body = new BodyModel { Matcher = new MatcherModel { Name = "ExactMatcher", Pattern = text ?? throw new ArgumentNullException(nameof(text)) } }; + return new(this); + } + + /// + /// Indicates that the request body must match the specified (using WireMock.Net's JSON comparison semantics). + /// + /// The JSON body to match. + /// The simple (dot-separated, non-JSONPath) property paths to exclude from the match pattern - e.g. a + /// generated eTag whose runtime value is unknown/unimportant. + /// The to continue the fluent-style configuration. + /// See the remarks regarding WireMock.Net's JSON comparison semantics versus (as used by, for + /// example, ): they are not equivalent, and there is no semantic value coercion + /// (e.g. dates, GUIDs) here. + /// Where is specified, the named properties are removed from the match pattern and the underlying matcher switches from an exact, + /// bidirectional JsonMatcher to WireMock.Net's own JsonPartialMatcher (a subset match): properties present in the pattern must still match exactly, but the + /// ignored (removed) properties - and any other property not present in the pattern - are not checked at all, regardless of their value or even presence in the actual + /// request body. This is looser than Tier 1's pathsToIgnore (which still requires an exact 1:1 property correspondence apart from the ignored paths) but is the + /// pragmatic native tool WireMock.Net provides for this, since request matching is evaluated by the out-of-process WireMock.Net server itself - it has no access to + /// to delegate the decision back to. + public AspireHttpMockRequestBody WithJsonBody([StringSyntax(StringSyntaxAttribute.Json)] string json, params string[] pathsToIgnore) + { + ArgumentNullException.ThrowIfNull(json); + + if (pathsToIgnore is { Length: > 0 }) + { + var node = JsonNode.Parse(json) ?? throw new ArgumentException("The json could not be parsed as a JSON object.", nameof(json)); + foreach (var path in pathsToIgnore) + RemovePath(node, path); + + Rule.Body = new BodyModel { Matcher = new MatcherModel { Name = "JsonPartialMatcher", Pattern = node.ToJsonString() } }; + } + else + Rule.Body = new BodyModel { Matcher = new MatcherModel { Name = "JsonMatcher", Pattern = json } }; + + return new(this); + } + + /// + /// Removes the (simple, dot-separated) from the ; silently does nothing where the path cannot be resolved (nothing to ignore). + /// + private static void RemovePath(JsonNode node, string path) + { + var segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + return; + + var current = node; + for (var i = 0; i < segments.Length - 1; i++) + { + if (current is not JsonObject obj || !obj.TryGetPropertyValue(segments[i], out var next) || next is null) + return; + + current = next; + } + + if (current is JsonObject parent) + parent.Remove(segments[^1]); + } + + /// + /// Indicates that the request body must match the JSON serialized representation of the specified (using WireMock.Net's JSON comparison semantics). + /// + /// The value . + /// The value to serialize and match. + /// The simple property paths to exclude from the match pattern; see remarks. + /// The to continue the fluent-style configuration. + public AspireHttpMockRequestBody WithJsonBody(T value, params string[] pathsToIgnore) => WithJsonBody(JsonSerializer.Serialize(value), pathsToIgnore); + + /// + /// Indicates that the request body must match the JSON formatted embedded resource content (using WireMock.Net's JSON comparison semantics). + /// + /// The used to infer that contains the embedded resource. + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// The simple property paths to exclude from the match pattern; see remarks. + /// The to continue the fluent-style configuration. + public AspireHttpMockRequestBody WithJsonResourceBody(string resourceName, params string[] pathsToIgnore) => WithJsonResourceBody(resourceName, typeof(TAssembly).Assembly, pathsToIgnore); + + /// + /// Indicates that the request body must match the JSON formatted embedded resource content (using WireMock.Net's JSON comparison semantics). + /// + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// The that contains the embedded resource; defaults to . + /// The simple property paths to exclude from the match pattern; see remarks. + /// The to continue the fluent-style configuration. + public AspireHttpMockRequestBody WithJsonResourceBody(string resourceName, Assembly? assembly = null, params string[] pathsToIgnore) => WithJsonBody(Resource.GetJson(resourceName, assembly ?? Assembly.GetCallingAssembly()), pathsToIgnore); + } +} diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequestBody.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequestBody.cs new file mode 100644 index 0000000..6bf7117 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequestBody.cs @@ -0,0 +1,26 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +namespace UnitTestEx.Aspire.HttpMock +{ + /// + /// Represents the result of adding a body matcher to the and to accordingly. + /// + public sealed class AspireHttpMockRequestBody + { + /// + /// Initializes a new instance of the class. + /// + /// The . + internal AspireHttpMockRequestBody(AspireHttpMockRequest request) => Request = request; + + /// + /// Gets the owning . + /// + internal AspireHttpMockRequest Request { get; } + + /// + /// Gets the to configure the stubbed response. + /// + public AspireHttpMockResponse Respond => new(Request); + } +} diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponse.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponse.cs new file mode 100644 index 0000000..7623129 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponse.cs @@ -0,0 +1,213 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Moq; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net; +using System.Net.Mime; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using WireMock.Admin.Mappings; + +namespace UnitTestEx.Aspire.HttpMock +{ + /// + /// Provides the stubbed response configuration for an . + /// + /// Unlike Tier 1's , the terminal With* methods here are asynchronous (see remarks) as they perform + /// a real HTTP POST to the underlying WireMock.Net server resource's admin API to register the mapping; they must be awaited. + public sealed class AspireHttpMockResponse + { + private readonly AspireHttpMockRequest _request; + private readonly ResponseModel _response = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The . + internal AspireHttpMockResponse(AspireHttpMockRequest request) => _request = request; + + /// + /// Adds the specified response header. + /// + /// The header name. + /// The header value. + /// The to support fluent-style method-chaining. + public AspireHttpMockResponse Header(string name, string value) + { + ResponseModelHelper.Header(_response, name, value); + return this; + } + + /// + /// Adds the specified response headers. + /// + /// The headers. + /// The to support fluent-style method-chaining. + public AspireHttpMockResponse Headers(IEnumerable> headers) + { + ResponseModelHelper.Headers(_response, headers); + return this; + } + + /// + /// Delays the response by the specified . + /// + /// The delay in milliseconds. + /// The to support fluent-style method-chaining. + public AspireHttpMockResponse Delay(int milliseconds) + { + _response.Delay = milliseconds; + return this; + } + + /// + /// Delays the response by the specified . + /// + /// The delay. + /// The to support fluent-style method-chaining. + public AspireHttpMockResponse Delay(TimeSpan timeSpan) => Delay((int)timeSpan.TotalMilliseconds); + + /// + /// Stubs the response with the specified and no body. + /// + /// The ; defaults to . + /// The . + /// The which can be used to verify invocation after the fact. + public Task WithAsync(HttpStatusCode statusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default) + { + ResponseModelHelper.With(_response, statusCode); + return ApplyAsync(cancellationToken); + } + + /// + /// Stubs the response with the specified , and . + /// + /// The response body content. + /// The ; defaults to . + /// The response content media type; defaults to . + /// The . + /// The which can be used to verify invocation after the fact. + public Task WithAsync(string content, HttpStatusCode statusCode = HttpStatusCode.OK, string mediaType = MediaTypeNames.Text.Plain, CancellationToken cancellationToken = default) + { + ResponseModelHelper.With(_response, content, statusCode, mediaType); + return ApplyAsync(cancellationToken); + } + + /// + /// Stubs the response with the specified body. + /// + /// The JSON response body. + /// The ; defaults to . + /// The . + /// The which can be used to verify invocation after the fact. + public Task WithJsonAsync([StringSyntax(StringSyntaxAttribute.Json)] string json, HttpStatusCode statusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default) + => WithAsync(json, statusCode, MediaTypeNames.Application.Json, cancellationToken); + + /// + /// Stubs the response with the JSON serialized representation of the specified . + /// + /// The value . + /// The value to serialize as the response body. + /// The ; defaults to . + /// The . + /// The which can be used to verify invocation after the fact. + public Task WithJsonAsync(T value, HttpStatusCode statusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default) + => WithJsonAsync(JsonSerializer.Serialize(value), statusCode, cancellationToken); + + /// + /// Stubs the response with the JSON formatted embedded resource content. + /// + /// The used to infer that contains the embedded resource. + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// The ; defaults to . + /// The . + /// The which can be used to verify invocation after the fact. + public Task WithJsonResourceAsync(string resourceName, HttpStatusCode statusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default) + => WithJsonResourceAsync(resourceName, typeof(TAssembly).Assembly, statusCode, cancellationToken); + + /// + /// Stubs the response with the JSON formatted embedded resource content. + /// + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// The that contains the embedded resource; defaults to . + /// The ; defaults to . + /// The . + /// The which can be used to verify invocation after the fact. + public Task WithJsonResourceAsync(string resourceName, Assembly? assembly = null, HttpStatusCode statusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default) + => WithJsonAsync(Resource.GetJson(resourceName, assembly ?? Assembly.GetCallingAssembly()), statusCode, cancellationToken); + + /// + /// Provides the means to mock a sequence of one or more responses for the owning ; each subsequent matching invocation of the request will + /// return the next response in the sequence. Exactly one invocation per configured response is expected: any additional invocation beyond the configured responses will + /// itself be met with a distinct "exhausted" response (mirroring Tier 1's , + /// which throws synchronously on an unexpected additional invocation). + /// + /// The action to enable the addition of one or more responses (see ). + /// The . + /// The which can be used to verify invocation (across the whole sequence) after the fact. + /// Thrown where has already been configured; mirroring Tier 1, a sequence's own + /// completeness (exactly one invocation per configured response) is the expectation, so an explicit cannot also be specified. + /// Internally this is implemented using WireMock.Net's Scenario/state mechanism (see the Scenario, WhenStateIs and SetStateTo properties of + /// ): one mapping per response is posted, plus a final "exhausted" guard mapping, all chained together via a scenario unique to this sequence, so they + /// cannot clash with other stubs or tests sharing the same underlying WireMock.Net server resource. + public async Task WithSequenceAsync(Action sequence, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(sequence); + + if (_request.ExpectedTimes != null) + throw new InvalidOperationException($"{nameof(AspireHttpMockRequest)}.{nameof(AspireHttpMockRequest.Times)} cannot be combined with {nameof(WithSequenceAsync)}; the sequence's own completeness (exactly one invocation per configured response, no more) is the implicit expectation, mirroring Tier 1's {nameof(Mocking.MockHttpClientResponse)}.{nameof(Mocking.MockHttpClientResponse.WithSequence)}."); + + var seq = new AspireHttpMockResponseSequence(); + sequence(seq); + + if (seq.Responses.Count == 0) + throw new InvalidOperationException($"At least one response must be added via {nameof(AspireHttpMockResponseSequence)}.{nameof(AspireHttpMockResponseSequence.Respond)} within the sequence action."); + + var count = seq.Responses.Count; + var scenario = Guid.NewGuid().ToString("N"); + var mappings = new List(count + 1); + + for (var i = 0; i < count; i++) + { + mappings.Add(new MappingModel + { + Request = _request.Rule, + Response = seq.Responses[i], + Scenario = scenario, + WhenStateIs = i == 0 ? null : i.ToString(CultureInfo.InvariantCulture), + // Note: always advances - even the final configured response transitions onward into the "exhausted" guard state below, rather than looping back on itself; this is + // what turns the sequence length into an exact (not "at least") expectation, mirroring Tier 1's exhaustion behaviour. + SetStateTo = (i + 1).ToString(CultureInfo.InvariantCulture) + }); + } + + // The final "exhausted" guard mapping: matches once every configured response has been consumed exactly once, and self-loops so any further invocation keeps hitting it. + var exhaustedResponse = new ResponseModel(); + ResponseModelHelper.WithJson(exhaustedResponse, JsonSerializer.Serialize(new { error = $"There were {count} response(s) configured for the sequence and these have been exhausted; i.e. an unexpected additional invocation has occurred." }), HttpStatusCode.InternalServerError); + mappings.Add(new MappingModel + { + Request = _request.Rule, + Response = exhaustedResponse, + Scenario = scenario, + WhenStateIs = count.ToString(CultureInfo.InvariantCulture), + SetStateTo = count.ToString(CultureInfo.InvariantCulture) + }); + + return await _request.Client.ApplySequenceAsync(mappings, cancellationToken).ConfigureAwait(false); + } + + /// + /// Posts the built-up (with the ) to the underlying WireMock.Net server resource. + /// + private Task ApplyAsync(CancellationToken cancellationToken) + { + var mapping = new MappingModel { Request = _request.Rule, Response = _response }; + return _request.Client.ApplyAsync(mapping, _request.ExpectedTimes, cancellationToken); + } + } +} diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequence.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequence.cs new file mode 100644 index 0000000..e01affa --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequence.cs @@ -0,0 +1,34 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System.Collections.Generic; +using WireMock.Admin.Mappings; + +namespace UnitTestEx.Aspire.HttpMock +{ + /// + /// Provides the means to build up a sequence of responses via . + /// + /// Mirrors Tier 1's ; unlike Tier 1, the actual posting of the underlying mappings occurs only after the sequence + /// configuration action has completed (see ), as each entry requires a real HTTP POST to the WireMock.Net server resource's + /// admin API - configuring the sequence itself remains synchronous. + public sealed class AspireHttpMockResponseSequence + { + private readonly List _responses = []; + + /// + /// Gets the s added via , in sequence order. + /// + internal IReadOnlyList Responses => _responses; + + /// + /// Adds the next in sequence. + /// + /// The next in sequence, to configure the corresponding response. + public AspireHttpMockResponseSequenceItem Respond() + { + var response = new ResponseModel(); + _responses.Add(response); + return new AspireHttpMockResponseSequenceItem(response); + } + } +} diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequenceItem.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequenceItem.cs new file mode 100644 index 0000000..5610fbb --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequenceItem.cs @@ -0,0 +1,115 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Mime; +using System.Reflection; +using System.Text.Json; +using WireMock.Admin.Mappings; + +namespace UnitTestEx.Aspire.HttpMock +{ + /// + /// Represents a single response within a . + /// + /// Unlike 's terminal With*Async methods, these configuration methods are synchronous; the underlying mapping is only posted + /// once the whole sequence has been configured (see ). + public sealed class AspireHttpMockResponseSequenceItem + { + private readonly ResponseModel _response; + + /// + /// Initializes a new instance of the class. + /// + /// The being built up. + internal AspireHttpMockResponseSequenceItem(ResponseModel response) => _response = response; + + /// + /// Adds the specified response header. + /// + /// The header name. + /// The header value. + /// The to support fluent-style method-chaining. + public AspireHttpMockResponseSequenceItem Header(string name, string value) + { + ResponseModelHelper.Header(_response, name, value); + return this; + } + + /// + /// Adds the specified response headers. + /// + /// The headers. + /// The to support fluent-style method-chaining. + public AspireHttpMockResponseSequenceItem Headers(IEnumerable> headers) + { + ResponseModelHelper.Headers(_response, headers); + return this; + } + + /// + /// Delays the response by the specified . + /// + /// The delay in milliseconds. + /// The to support fluent-style method-chaining. + public AspireHttpMockResponseSequenceItem Delay(int milliseconds) + { + _response.Delay = milliseconds; + return this; + } + + /// + /// Delays the response by the specified . + /// + /// The delay. + /// The to support fluent-style method-chaining. + public AspireHttpMockResponseSequenceItem Delay(TimeSpan timeSpan) => Delay((int)timeSpan.TotalMilliseconds); + + /// + /// Sets the response with the specified and no body. + /// + /// The ; defaults to . + public void With(HttpStatusCode statusCode = HttpStatusCode.OK) => ResponseModelHelper.With(_response, statusCode); + + /// + /// Sets the response with the specified , and . + /// + /// The response body content. + /// The ; defaults to . + /// The response content media type; defaults to . + public void With(string content, HttpStatusCode statusCode = HttpStatusCode.OK, string mediaType = MediaTypeNames.Text.Plain) => ResponseModelHelper.With(_response, content, statusCode, mediaType); + + /// + /// Sets the response with the specified body. + /// + /// The JSON response body. + /// The ; defaults to . + public void WithJson([StringSyntax(StringSyntaxAttribute.Json)] string json, HttpStatusCode statusCode = HttpStatusCode.OK) => ResponseModelHelper.WithJson(_response, json, statusCode); + + /// + /// Sets the response with the JSON serialized representation of the specified . + /// + /// The value . + /// The value to serialize as the response body. + /// The ; defaults to . + public void WithJson(T value, HttpStatusCode statusCode = HttpStatusCode.OK) => WithJson(JsonSerializer.Serialize(value), statusCode); + + /// + /// Sets the response with the JSON formatted embedded resource content. + /// + /// The used to infer that contains the embedded resource. + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// The ; defaults to . + public void WithJsonResource(string resourceName, HttpStatusCode statusCode = HttpStatusCode.OK) => WithJsonResource(resourceName, typeof(TAssembly).Assembly, statusCode); + + /// + /// Sets the response with the JSON formatted embedded resource content. + /// + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// The that contains the embedded resource; defaults to . + /// The ; defaults to . + public void WithJsonResource(string resourceName, Assembly? assembly = null, HttpStatusCode statusCode = HttpStatusCode.OK) => WithJson(Resource.GetJson(resourceName, assembly ?? Assembly.GetCallingAssembly()), statusCode); + } +} diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockedRequest.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockedRequest.cs new file mode 100644 index 0000000..33b85d8 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockedRequest.cs @@ -0,0 +1,123 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Moq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using WireMock.Client; + +namespace UnitTestEx.Aspire.HttpMock +{ + /// + /// Represents a stubbed mapping (or, for a sequence, the set of mappings) that has been applied (posted) to a + /// WireMock.Net server resource, allowing its invocation to be verified after the fact. + /// + public sealed class AspireHttpMockedRequest + { + private readonly IWireMockAdminApi _adminApi; + private readonly IReadOnlyList _mappingIds; + private readonly Times? _expectedTimes; + private readonly Guid? _exhaustedMappingId; + + /// + /// Initializes a new instance of the class for a single (non-sequence) mapping. + /// + /// The . + /// The unique identifier(s) assigned to the posted mapping(s). + /// The number of times the request is expected to be invoked (see ); defaults to + /// where not specified. + internal AspireHttpMockedRequest(IWireMockAdminApi adminApi, IReadOnlyList mappingIds, Times? expectedTimes) + { + _adminApi = adminApi; + _mappingIds = mappingIds; + _expectedTimes = expectedTimes; + MappingId = mappingIds[0]; + } + + /// + /// Initializes a new instance of the class for a sequence of mappings. + /// + private AspireHttpMockedRequest(IWireMockAdminApi adminApi, IReadOnlyList responseMappingIds, Guid exhaustedMappingId) + { + _adminApi = adminApi; + _mappingIds = responseMappingIds; + _exhaustedMappingId = exhaustedMappingId; + MappingId = responseMappingIds[0]; + } + + /// + /// Creates a new for a sequence of mappings. + /// + /// The . + /// The unique identifiers assigned to each of the sequence's posted per-response mappings (excluding the "exhausted" guard mapping). + /// The unique identifier assigned to the sequence's "exhausted" guard mapping. + /// The . + internal static AspireHttpMockedRequest ForSequence(IWireMockAdminApi adminApi, IReadOnlyList responseMappingIds, Guid exhaustedMappingId) => new(adminApi, responseMappingIds, exhaustedMappingId); + + /// + /// Gets the unique identifier assigned to the (first) posted mapping; used to correlate matched requests via . + /// + /// Where applied via , this is the identifier of the sequence's first mapping only; see + /// for the complete set. + public Guid MappingId { get; } + + /// + /// Gets the complete set of unique identifiers assigned to the posted per-response mapping(s); a single entry unless applied via + /// (in which case the sequence's internal "exhausted" guard mapping is not included). + /// + public IReadOnlyList MappingIds => _mappingIds; + + /// + /// Verifies the stubbed request was invoked the expected number of times. + /// + /// The . + /// Thrown when the actual invocation count does not satisfy the expectation. + /// Where applied via , verifies the total invocation count (in total, across all ) satisfies the + /// configured (or at least once where not configured). Where applied via , verifies that each configured + /// response was invoked exactly once - no more, no less - mirroring Tier 1's ; any invocation beyond the configured responses + /// (which will have already received a distinct "exhausted" response) is reported here as a failure too. + public async Task VerifyAsync(CancellationToken cancellationToken = default) + { + if (_exhaustedMappingId.HasValue) + { + await VerifySequenceAsync(cancellationToken).ConfigureAwait(false); + return; + } + + var count = 0; + foreach (var mappingId in _mappingIds) + { + var requests = await _adminApi.FindRequestsByMappingGuidAsync(mappingId, cancellationToken).ConfigureAwait(false); + count += requests?.Count ?? 0; + } + + var expected = _expectedTimes ?? Times.AtLeastOnce(); + expected.Deconstruct(out var from, out var to); + if (count < from || count > to) + throw new MockHttpClientException($"The request was invoked {count} times; expected {expected}. Mapping(s): {string.Join(", ", _mappingIds.Select(m => m.ToString()))}."); + } + + /// + /// Verifies a sequence's invocations: exactly one per configured response, and none against the "exhausted" guard. + /// + private async Task VerifySequenceAsync(CancellationToken cancellationToken) + { + var exhaustedRequests = await _adminApi.FindRequestsByMappingGuidAsync(_exhaustedMappingId!.Value, cancellationToken).ConfigureAwait(false); + var exhaustedCount = exhaustedRequests?.Count ?? 0; + if (exhaustedCount > 0) + throw new MockHttpClientException($"There were {_mappingIds.Count} response(s) configured for the sequence and these have been exhausted; i.e. {exhaustedCount} unexpected additional invocation(s) occurred. Mapping: {_exhaustedMappingId}."); + + var count = 0; + foreach (var mappingId in _mappingIds) + { + var requests = await _adminApi.FindRequestsByMappingGuidAsync(mappingId, cancellationToken).ConfigureAwait(false); + count += requests?.Count ?? 0; + } + + if (count != _mappingIds.Count) + throw new MockHttpClientException($"There were {_mappingIds.Count} response(s) configured for the sequence and only {count} response(s) invoked. Mapping(s): {string.Join(", ", _mappingIds.Select(m => m.ToString()))}."); + } + } +} diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/ResponseModelHelper.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/ResponseModelHelper.cs new file mode 100644 index 0000000..2248165 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/ResponseModelHelper.cs @@ -0,0 +1,57 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Mime; +using WireMock.Admin.Mappings; + +namespace UnitTestEx.Aspire.HttpMock +{ + /// + /// Provides shared building logic reused by both (single response) and + /// (one entry within a ). + /// + internal static class ResponseModelHelper + { + /// + /// Adds the specified response header. + /// + public static void Header(ResponseModel response, string name, string value) + { + response.Headers ??= new Dictionary(); + response.Headers[name ?? throw new ArgumentNullException(nameof(name))] = value; + } + + /// + /// Adds the specified response headers. + /// + public static void Headers(ResponseModel response, IEnumerable> headers) + { + foreach (var header in headers ?? throw new ArgumentNullException(nameof(headers))) + { + Header(response, header.Key, header.Value); + } + } + + /// + /// Sets the status code with no body. + /// + public static void With(ResponseModel response, HttpStatusCode statusCode) => response.StatusCode = (int)statusCode; + + /// + /// Sets the status code, body content and media type. + /// + public static void With(ResponseModel response, string content, HttpStatusCode statusCode, string mediaType) + { + response.StatusCode = (int)statusCode; + response.Body = content ?? throw new ArgumentNullException(nameof(content)); + Header(response, "Content-Type", mediaType); + } + + /// + /// Sets the status code and JSON body content. + /// + public static void WithJson(ResponseModel response, string json, HttpStatusCode statusCode) => With(response, json, statusCode, MediaTypeNames.Application.Json); + } +} diff --git a/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj b/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj index db11bb2..8fc6704 100644 --- a/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj +++ b/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj @@ -11,6 +11,8 @@ + + diff --git a/tests/UnitTestEx.Api/Startup.cs b/tests/UnitTestEx.Api/Startup.cs index 2bab7a5..32df4b8 100644 --- a/tests/UnitTestEx.Api/Startup.cs +++ b/tests/UnitTestEx.Api/Startup.cs @@ -23,7 +23,12 @@ public Startup(IConfiguration configuration) public void ConfigureServices(IServiceCollection services) { services.AddControllers().AddNewtonsoftJson(); - services.AddHttpClient("XXX", hc => hc.BaseAddress = new System.Uri("https://somesys")) + + // The base address defaults to a bogus, unreachable address as Tier 1 (WebApplicationFactory) tests always replace the whole IHttpClientFactory via MockHttpClientFactory, so the + // configured value is never actually dialled. It is overridable via configuration (e.g. an Aspire AppHost-supplied "XXX__BaseUrl" environment variable) so that Tier 2 (Aspire + // multi-host) tests can point this client at a real resource - e.g. a WireMock.Net.Aspire resource standing in for an external dependency - since there is no DI container to + // reach into and replace across process boundaries. + services.AddHttpClient("XXX", hc => hc.BaseAddress = new System.Uri(Configuration["XXX:BaseUrl"] ?? "https://somesys")) .AddHttpMessageHandler(_ => new MessageProcessingHandler()) .ConfigureHttpClient(hc => hc.DefaultRequestVersion = new Version(1, 2)); } diff --git a/tests/UnitTestEx.Aspire.AppHost/AppHost.cs b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs index e3c8056..133754d 100644 --- a/tests/UnitTestEx.Aspire.AppHost/AppHost.cs +++ b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs @@ -22,6 +22,12 @@ // process starts - not once Kestrel has actually bound its endpoint(s). WithHttpHealthCheck closes that race so // that AspireTesterBase.WaitForResourceAsync (which waits on resource health) is a genuine readiness gate before // tests attempt to call the resource. +// +// The "gateway" resource is a real WireMock.Net.Aspire container (WireMockServerResource), used by the Tier 2 +// HttpMock tests to stub the "api" resource's "XXX" external HTTP dependency (see UnitTestEx.Api's Startup.cs +// and ProductController). Unlike a hand-rolled/embedded resource, it is a genuine DCP-managed resource with its +// own health check, dashboard entry and logs - requires Docker/Podman at runtime (see the WireMock.Net.Aspire +// package), which CI provides but a local Docker-free sandbox may not. using Microsoft.Extensions.DependencyInjection; @@ -37,9 +43,12 @@ ServerCertificateCustomValidationCallback = (_, _, _, _) => true })); +var gateway = builder.AddWireMock("gateway"); + builder.AddProject("api", launchProfileName: null) .WithHttpsEndpoint(name: "https") .WithArgs("--framework", "net8.0") - .WithHttpHealthCheck("/Person?firstName=health&lastName=check", endpointName: "https"); + .WithHttpHealthCheck("/Person?firstName=health&lastName=check", endpointName: "https") + .WithEnvironment("XXX__BaseUrl", gateway.GetEndpoint("http")); builder.Build().Run(); diff --git a/tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj b/tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj index 04927c1..268446e 100644 --- a/tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj +++ b/tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj @@ -13,6 +13,7 @@ + diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index 8abe6d5..8075676 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Net.Http; using System.Threading.Tasks; +using Moq; using UnitTestEx; using UnitTestEx.Abstractions; using UnitTestEx.Api.Models; @@ -81,6 +82,104 @@ public async Task Reason_And_Wait_AggregatesResourceLogs() Assert.Contains("LOGGING >", spy.Lines); Assert.Contains(spy.Lines, l => l != null && l.Contains("Get using identifier 1.") && l.EndsWith("(api)]", StringComparison.Ordinal)); } + + [Fact] + public async Task HttpMock_StubsExternalGatewayDependency_Product() + { + // Note: this exercises a real WireMock.Net container resource (added via 'gateway' in the AppHost using the official WireMock.Net.Aspire package); it requires Docker/Podman to + // be available to the CI/dev machine running the test (see docs/design/aspire-multi-host-testing.md). + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + await tester.WaitForResourceAsync("gateway"); + + var stub = await tester.HttpMock("gateway") + .Request(HttpMethod.Get, "/products/abc") + .Times(Times.Once()) + .WithAnyBody() + .Respond.WithJsonAsync(new { id = "Abc", description = "A blue carrot" }); + + tester.Http("api") + .Run(HttpMethod.Get, "Product/abc") + .AssertOK() + .AssertValue(new { id = "Abc", description = "A blue carrot" }); + + await stub.VerifyAsync(); + } + + [Fact] + public async Task HttpMock_WithSequenceAsync_ReturnsResponsesInOrderThenExhausts() + { + // Note: this exercises WireMock.Net's Scenario/state mechanism (see docs/design/aspire-multi-host-testing.md); each subsequent invocation returns the next configured + // response. Unlike a single stub, a sequence's own completeness (exactly one invocation per configured response) IS the expectation - mirroring Tier 1's WithSequence - + // so Times cannot be combined with it, and any invocation beyond the configured responses receives a distinct 500 "exhausted" response instead of silently repeating. + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + await tester.WaitForResourceAsync("gateway"); + + var stub = await tester.HttpMock("gateway") + .Request(HttpMethod.Get, "/products/seq") + .WithAnyBody() + .Respond.WithSequenceAsync(seq => + { + seq.Respond().WithJson(new { id = "Seq", description = "First" }); + seq.Respond().WithJson(new { id = "Seq", description = "Second" }); + seq.Respond().WithJson(new { id = "Seq", description = "Third" }); + }); + + tester.Http("api").Run(HttpMethod.Get, "Product/seq").AssertOK().AssertValue(new { id = "Seq", description = "First" }); + tester.Http("api").Run(HttpMethod.Get, "Product/seq").AssertOK().AssertValue(new { id = "Seq", description = "Second" }); + tester.Http("api").Run(HttpMethod.Get, "Product/seq").AssertOK().AssertValue(new { id = "Seq", description = "Third" }); + + await stub.VerifyAsync(); + } + + [Fact] + public async Task HttpMock_WithSequenceAsync_ExceedingConfiguredResponses_Throws() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + await tester.WaitForResourceAsync("gateway"); + + var stub = await tester.HttpMock("gateway") + .Request(HttpMethod.Get, "/products/seq") + .WithAnyBody() + .Respond.WithSequenceAsync(seq => seq.Respond().WithJson(new { id = "Seq", description = "Only" })); + + tester.Http("api").Run(HttpMethod.Get, "Product/seq").AssertOK().AssertValue(new { id = "Seq", description = "Only" }); + tester.Http("api").Run(HttpMethod.Get, "Product/seq").AssertInternalServerError(); + + await Assert.ThrowsAsync(() => stub.VerifyAsync()); + } + + [Fact] + public async Task HttpMock_WithJsonBody_PathsToIgnore_IgnoresSpecifiedProperty() + { + // Note: exercises WireMock.Net's own JsonPartialMatcher (see docs/design/aspire-multi-host-testing.md) - a variable 'eTag' is ignored from the match pattern, while a + // genuinely differing (non-ignored) 'id' still fails to match any stub (WireMock.Net's default "no mapping found" 404 response). + await using var tester = AspireTester.Create(); + + await tester.WaitForResourceAsync("gateway"); + + var stub = await tester.HttpMock("gateway") + .Request(HttpMethod.Post, "/echo") + .Times(Times.Once()) + .WithJsonBody(new { id = "Abc", eTag = "pattern-etag-value" }, "eTag") + .Respond.WithJsonAsync(new { id = "Abc", description = "A blue carrot" }); + + tester.Http("gateway").Run(HttpMethod.Post, "/echo", new { id = "Abc", eTag = Guid.NewGuid().ToString() }) + .AssertOK() + .AssertValue(new { id = "Abc", description = "A blue carrot" }); + + tester.Http("gateway").Run(HttpMethod.Post, "/echo", new { id = "Xyz", eTag = "whatever" }).AssertNotFound(); + + await stub.VerifyAsync(); + } } } From 187dfae9a3be3afb89eedd5772600c7f114b3f98 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 23 Sep 2026 17:13:01 -0700 Subject: [PATCH 25/43] Add shared IHttpMock* interfaces for cross-tier HttpMock stubbing 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> --- docs/design/aspire-multi-host-testing.md | 34 ++++ .../Aspire/HttpMock/AspireHttpMockClient.cs | 10 +- .../Aspire/HttpMock/AspireHttpMockRequest.cs | 41 ++++- .../HttpMock/AspireHttpMockRequestBody.cs | 7 +- .../Aspire/HttpMock/AspireHttpMockResponse.cs | 27 ++- .../AspireHttpMockResponseSequence.cs | 10 +- .../AspireHttpMockResponseSequenceItem.cs | 25 ++- .../HttpMock/AspireHttpMockedRequest.cs | 5 +- src/UnitTestEx/Mocking/IHttpMockClient.cs | 24 +++ src/UnitTestEx/Mocking/IHttpMockRequest.cs | 94 ++++++++++ .../Mocking/IHttpMockRequestBody.cs | 16 ++ src/UnitTestEx/Mocking/IHttpMockResponse.cs | 133 +++++++++++++++ .../Mocking/IHttpMockResponseSequence.cs | 17 ++ .../Mocking/IHttpMockResponseSequenceItem.cs | 103 +++++++++++ src/UnitTestEx/Mocking/IHttpMockedRequest.cs | 22 +++ src/UnitTestEx/Mocking/MockHttpClient.cs | 5 +- .../Mocking/MockHttpClientRequest.cs | 32 +++- .../Mocking/MockHttpClientRequestBody.cs | 5 +- .../Mocking/MockHttpClientResponse.cs | 60 ++++++- .../Mocking/MockHttpClientResponseSequence.cs | 5 +- .../AspireTesterTest.cs | 78 +++++++++ .../HttpMockSharedConfig.cs | 30 ++++ .../MockHttpClientTest.cs | 7 +- .../MockHttpClientTest.cs | 20 ++- .../HttpMockInterfaceTest.cs | 160 ++++++++++++++++++ .../HttpMockSharedConfig.cs | 30 ++++ .../MockHttpClientTest.cs | 7 +- .../ProductControllerTest.cs | 19 +++ 28 files changed, 1002 insertions(+), 24 deletions(-) create mode 100644 src/UnitTestEx/Mocking/IHttpMockClient.cs create mode 100644 src/UnitTestEx/Mocking/IHttpMockRequest.cs create mode 100644 src/UnitTestEx/Mocking/IHttpMockRequestBody.cs create mode 100644 src/UnitTestEx/Mocking/IHttpMockResponse.cs create mode 100644 src/UnitTestEx/Mocking/IHttpMockResponseSequence.cs create mode 100644 src/UnitTestEx/Mocking/IHttpMockResponseSequenceItem.cs create mode 100644 src/UnitTestEx/Mocking/IHttpMockedRequest.cs create mode 100644 tests/UnitTestEx.Aspire.Xunit.Test/HttpMockSharedConfig.cs create mode 100644 tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs create mode 100644 tests/UnitTestEx.Xunit.Test/HttpMockSharedConfig.cs diff --git a/docs/design/aspire-multi-host-testing.md b/docs/design/aspire-multi-host-testing.md index 95ffb36..e0308db 100644 --- a/docs/design/aspire-multi-host-testing.md +++ b/docs/design/aspire-multi-host-testing.md @@ -162,6 +162,40 @@ authoring experience consistent with Tier 1's `MockHttpClientFactory`: equivalent of Tier 1's stricter bidirectional-deep-equal-minus-ignored-paths behavior. Left unchanged (strict `JsonMatcher`) when `pathsToIgnore` isn't supplied. +### 5.2 Shared `IHttpMock*` interfaces — write the stubbing code once, use it on either tier + +Tier 1's `MockHttpClient*` (Moq-based, in-process) and Tier 2/3's `AspireHttpMock*` (WireMock.Net-based, +out-of-process) both implement a common set of interfaces in `src/UnitTestEx/Mocking/`: +`IHttpMockClient`, `IHttpMockRequest`, `IHttpMockRequestBody`, `IHttpMockResponse`, +`IHttpMockResponseSequence`, `IHttpMockResponseSequenceItem`, and `IHttpMockedRequest`. A helper method +written once against `IHttpMockClient` (e.g. `HttpMockSharedConfig.ConfigureProductStubAsync` in the test +suites) configures request/response stubbing identically regardless of which concrete tier is passed in — +only the `Times`/JSON-comparison/sequence-exhaustion *semantics* remain tier-specific (see 5.1 above); the +authoring surface itself is unified. + +Each interface is deliberately kept to a minimal, irreducible core of abstract members; every convenience +overload (a plain-text `WithBody(text)`, a raw-JSON-string `WithJsonAsync(json)`, the embedded-resource +`WithJsonResource*` variants, `Headers(...)`, an integer-millisecond `Delay(int)`, etc.) is a C# default +interface method (DIM) composed purely from those core members, so neither tier has to reimplement them. +A DIM is only reached when called through a variable of the *interface* type — calling a same-named method +directly on the concrete class invokes that class's own native method instead, an entirely separate code +path. `tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs` exercises every DIM extra via +interface-typed variables (shared bytecode, so Tier-1-only coverage proves both tiers); a corresponding +Aspire test (`HttpMock_InterfaceCoreMembers_TierSpecificAdaptations` in `AspireTesterTest.cs`) exercises +the *core* members' hand-written, tier-specific explicit interface implementations instead (the 2-arg +`WithBody`, `WithJsonBody`, both branches of the `WithAsync` content/no-content adaptation, and the +`Action`-wrapping adapter inside `WithSequenceAsync`). + +Retrofitting Tier 1 onto this shared surface surfaced one genuine cross-tier semantic gap: +**`WithAnyBody()`**. Aspire's native implementation adds zero WireMock.Net body constraints — a true +wildcard, matching a request with or without a body. Tier 1's native implementation, however, required +`request.Content != null` before matching, so it could never match a body-less request (e.g. a `GET`) — +inconsistent with both Aspire's behaviour and the interface's own documented contract ("matches regardless +of the body content"). Tier 1 was changed to also be a true wildcard, aligning it with Aspire; this was a +deliberate, user-approved behavior change to an existing native Tier 1 API (not just new interface-only +code), so any test previously relying on "a body-less request after `WithAnyBody()` still fails to match" +needed updating (see `UriAndAnyBody`/`DefaultHttpClient` in the Tier 1 test projects). + ## 6. Mocking individual components case-by-case across processes Since there's no cross-process DI, "mock this one component in that other service" has to be solved diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs index 251c01f..7ac31b7 100644 --- a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Moq; +using UnitTestEx.Mocking; using WireMock.Admin.Mappings; using WireMock.Client; @@ -17,8 +18,10 @@ namespace UnitTestEx.Aspire.HttpMock /// /// Unlike Tier 1's in-memory, purely synchronous (a Moq-based substitution), this issues genuine HTTP requests /// to the WireMock.Net server's admin API (a separate OS process, potentially in a container); every stub-defining and verification method is therefore asynchronous - there is no honest way - /// to hide that real network I/O behind a synchronous-looking API. See the docs/design/aspire-multi-host-testing.md design note for the rationale. - public sealed class AspireHttpMockClient + /// to hide that real network I/O behind a synchronous-looking API. See the docs/design/aspire-multi-host-testing.md design note for the rationale. + /// Implements the shared abstraction (see that type's remarks) so that test-authoring code can be written once against the interface and reused + /// identically regardless of which tier applied it. + public sealed class AspireHttpMockClient : IHttpMockClient { private readonly Func> _adminApiFactory; @@ -43,6 +46,9 @@ public sealed class AspireHttpMockClient /// The to continue the fluent-style configuration. public AspireHttpMockRequest Request(HttpMethod? method = null, string? requestUri = null) => new(this, method, requestUri); + /// + IHttpMockRequest IHttpMockClient.Request(HttpMethod? method, string? requestUri) => Request(method, requestUri); + /// /// Removes all previously configured mappings and clears the recorded request log on the underlying WireMock.Net server resource. /// diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs index ad75a31..77c0e14 100644 --- a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs @@ -7,6 +7,7 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; +using UnitTestEx.Mocking; using WireMock.Admin.Mappings; namespace UnitTestEx.Aspire.HttpMock @@ -14,7 +15,9 @@ namespace UnitTestEx.Aspire.HttpMock /// /// Represents the request-matching configuration for a stubbed mapping. /// - public sealed class AspireHttpMockRequest + /// Implements the shared abstraction (see that type's remarks) so that test-authoring code can be written once against the interface and + /// reused identically regardless of which tier applied it. + public sealed class AspireHttpMockRequest : IHttpMockRequest { /// /// Initializes a new instance of the class. @@ -77,6 +80,27 @@ public AspireHttpMockRequestBody WithBody(string text) return new(this); } + /// + /// Indicates that the request body must match the specified exactly, with the specified (via a Content-Type header match). + /// + /// The exact body text to match. + /// The media type of the request. + /// The to continue the fluent-style configuration. + /// Unlike , which is media-type agnostic, this additionally adds a Content-Type header matcher (a wildcard match on ); + /// this is the member used to satisfy the shared abstraction, mirroring Tier 1's stricter (media-type checked) behaviour there. + public AspireHttpMockRequestBody WithBody(string text, string mediaType) + { + Rule.Body = new BodyModel { Matcher = new MatcherModel { Name = "ExactMatcher", Pattern = text ?? throw new ArgumentNullException(nameof(text)) } }; + Rule.Headers ??= []; + Rule.Headers.Add(new HeaderModel + { + Name = "Content-Type", + Matchers = [new MatcherModel { Name = "WildcardMatcher", Pattern = $"{mediaType ?? throw new ArgumentNullException(nameof(mediaType))}*" }] + }); + + return new(this); + } + /// /// Indicates that the request body must match the specified (using WireMock.Net's JSON comparison semantics). /// @@ -159,5 +183,20 @@ private static void RemovePath(JsonNode node, string path) /// The simple property paths to exclude from the match pattern; see remarks. /// The to continue the fluent-style configuration. public AspireHttpMockRequestBody WithJsonResourceBody(string resourceName, Assembly? assembly = null, params string[] pathsToIgnore) => WithJsonBody(Resource.GetJson(resourceName, assembly ?? Assembly.GetCallingAssembly()), pathsToIgnore); + + /// + IHttpMockRequest IHttpMockRequest.Times(Times times) => Times(times); + + /// + IHttpMockRequestBody IHttpMockRequest.WithAnyBody() => WithAnyBody(); + + /// + IHttpMockRequestBody IHttpMockRequest.WithBody(string body, string mediaType) => WithBody(body, mediaType); + + /// + IHttpMockRequestBody IHttpMockRequest.WithJsonBody(string json, params string[] pathsToIgnore) => WithJsonBody(json, pathsToIgnore); + + /// + IHttpMockRequestBody IHttpMockRequest.WithJsonBody(T value, params string[] pathsToIgnore) => WithJsonBody(value, pathsToIgnore); } } diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequestBody.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequestBody.cs index 6bf7117..2b59f01 100644 --- a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequestBody.cs +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequestBody.cs @@ -1,11 +1,13 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +using UnitTestEx.Mocking; + namespace UnitTestEx.Aspire.HttpMock { /// /// Represents the result of adding a body matcher to the and to accordingly. /// - public sealed class AspireHttpMockRequestBody + public sealed class AspireHttpMockRequestBody : IHttpMockRequestBody { /// /// Initializes a new instance of the class. @@ -22,5 +24,8 @@ public sealed class AspireHttpMockRequestBody /// Gets the to configure the stubbed response. /// public AspireHttpMockResponse Respond => new(Request); + + /// + IHttpMockResponse IHttpMockRequestBody.Respond => Respond; } } diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponse.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponse.cs index 7623129..1ef8745 100644 --- a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponse.cs +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponse.cs @@ -11,6 +11,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using UnitTestEx.Mocking; using WireMock.Admin.Mappings; namespace UnitTestEx.Aspire.HttpMock @@ -19,8 +20,10 @@ namespace UnitTestEx.Aspire.HttpMock /// Provides the stubbed response configuration for an . /// /// Unlike Tier 1's , the terminal With* methods here are asynchronous (see remarks) as they perform - /// a real HTTP POST to the underlying WireMock.Net server resource's admin API to register the mapping; they must be awaited. - public sealed class AspireHttpMockResponse + /// a real HTTP POST to the underlying WireMock.Net server resource's admin API to register the mapping; they must be awaited. + /// Implements the shared abstraction (see that type's remarks) so that test-authoring code can be written once against the interface and + /// reused identically regardless of which tier applied it. + public sealed class AspireHttpMockResponse : IHttpMockResponse { private readonly AspireHttpMockRequest _request; private readonly ResponseModel _response = new(); @@ -209,5 +212,25 @@ private Task ApplyAsync(CancellationToken cancellationT var mapping = new MappingModel { Request = _request.Rule, Response = _response }; return _request.Client.ApplyAsync(mapping, _request.ExpectedTimes, cancellationToken); } + + /// + IHttpMockResponse IHttpMockResponse.Header(string name, string value) => Header(name, value); + + /// + IHttpMockResponse IHttpMockResponse.Delay(TimeSpan timeSpan) => Delay(timeSpan); + + /// + async Task IHttpMockResponse.WithAsync(string? content, HttpStatusCode statusCode, string mediaType, CancellationToken cancellationToken) + => content is null ? await WithAsync(statusCode, cancellationToken).ConfigureAwait(false) : await WithAsync(content, statusCode, mediaType, cancellationToken).ConfigureAwait(false); + + /// + async Task IHttpMockResponse.WithJsonAsync(T value, HttpStatusCode statusCode, CancellationToken cancellationToken) => await WithJsonAsync(value, statusCode, cancellationToken).ConfigureAwait(false); + + /// + async Task IHttpMockResponse.WithSequenceAsync(Action sequence, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(sequence); + return await WithSequenceAsync(s => sequence(s), cancellationToken).ConfigureAwait(false); + } } } diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequence.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequence.cs index e01affa..240016f 100644 --- a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequence.cs +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequence.cs @@ -1,6 +1,7 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using System.Collections.Generic; +using UnitTestEx.Mocking; using WireMock.Admin.Mappings; namespace UnitTestEx.Aspire.HttpMock @@ -10,8 +11,10 @@ namespace UnitTestEx.Aspire.HttpMock /// /// Mirrors Tier 1's ; unlike Tier 1, the actual posting of the underlying mappings occurs only after the sequence /// configuration action has completed (see ), as each entry requires a real HTTP POST to the WireMock.Net server resource's - /// admin API - configuring the sequence itself remains synchronous. - public sealed class AspireHttpMockResponseSequence + /// admin API - configuring the sequence itself remains synchronous. + /// Implements the shared abstraction (see that type's remarks) so that test-authoring code can be written once against the interface + /// and reused identically regardless of which tier applied it. + public sealed class AspireHttpMockResponseSequence : IHttpMockResponseSequence { private readonly List _responses = []; @@ -30,5 +33,8 @@ public AspireHttpMockResponseSequenceItem Respond() _responses.Add(response); return new AspireHttpMockResponseSequenceItem(response); } + + /// + IHttpMockResponseSequenceItem IHttpMockResponseSequence.Respond() => Respond(); } } diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequenceItem.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequenceItem.cs index 5610fbb..1980173 100644 --- a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequenceItem.cs +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequenceItem.cs @@ -7,6 +7,7 @@ using System.Net.Mime; using System.Reflection; using System.Text.Json; +using UnitTestEx.Mocking; using WireMock.Admin.Mappings; namespace UnitTestEx.Aspire.HttpMock @@ -15,8 +16,10 @@ namespace UnitTestEx.Aspire.HttpMock /// Represents a single response within a . /// /// Unlike 's terminal With*Async methods, these configuration methods are synchronous; the underlying mapping is only posted - /// once the whole sequence has been configured (see ). - public sealed class AspireHttpMockResponseSequenceItem + /// once the whole sequence has been configured (see ). + /// Implements the shared abstraction (see that type's remarks) so that test-authoring code can be written once against the + /// interface and reused identically regardless of which tier applied it. + public sealed class AspireHttpMockResponseSequenceItem : IHttpMockResponseSequenceItem { private readonly ResponseModel _response; @@ -111,5 +114,23 @@ public AspireHttpMockResponseSequenceItem Delay(int milliseconds) /// The that contains the embedded resource; defaults to . /// The ; defaults to . public void WithJsonResource(string resourceName, Assembly? assembly = null, HttpStatusCode statusCode = HttpStatusCode.OK) => WithJson(Resource.GetJson(resourceName, assembly ?? Assembly.GetCallingAssembly()), statusCode); + + /// + IHttpMockResponseSequenceItem IHttpMockResponseSequenceItem.Header(string name, string value) => Header(name, value); + + /// + IHttpMockResponseSequenceItem IHttpMockResponseSequenceItem.Delay(TimeSpan timeSpan) => Delay(timeSpan); + + /// + void IHttpMockResponseSequenceItem.With(string? content, HttpStatusCode statusCode, string mediaType) + { + if (content is null) + With(statusCode); + else + With(content, statusCode, mediaType); + } + + /// + void IHttpMockResponseSequenceItem.WithJson(T value, HttpStatusCode statusCode) => WithJson(value, statusCode); } } diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockedRequest.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockedRequest.cs index 33b85d8..b749061 100644 --- a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockedRequest.cs +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockedRequest.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using UnitTestEx.Mocking; using WireMock.Client; namespace UnitTestEx.Aspire.HttpMock @@ -14,7 +15,9 @@ namespace UnitTestEx.Aspire.HttpMock /// Represents a stubbed mapping (or, for a sequence, the set of mappings) that has been applied (posted) to a /// WireMock.Net server resource, allowing its invocation to be verified after the fact. /// - public sealed class AspireHttpMockedRequest + /// Implements the shared abstraction (see that type's remarks) so that test-authoring code can be written once against the interface + /// and reused identically regardless of which tier applied it. + public sealed class AspireHttpMockedRequest : IHttpMockedRequest { private readonly IWireMockAdminApi _adminApi; private readonly IReadOnlyList _mappingIds; diff --git a/src/UnitTestEx/Mocking/IHttpMockClient.cs b/src/UnitTestEx/Mocking/IHttpMockClient.cs new file mode 100644 index 0000000..49a949b --- /dev/null +++ b/src/UnitTestEx/Mocking/IHttpMockClient.cs @@ -0,0 +1,24 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System.Net.Http; + +namespace UnitTestEx.Mocking +{ + /// + /// Provides mocking, common to both Tier 1's in-process (a Moq-based substitution) and + /// Tier 2/3's out-of-process AspireHttpMockClient (a thin wrapper over a real WireMock.Net server resource). + /// + /// This abstraction allows a single piece of test-authoring code (e.g. a shared static helper method) to configure request/response stubbing identically regardless + /// of which tier is under test - only the concrete instance passed in differs. See the docs/design/aspire-multi-host-testing.md design note for + /// the tier-specific semantics that remain (JSON comparison engine, sequence-exhaustion behaviour) despite the shared surface. + public interface IHttpMockClient + { + /// + /// Begins configuration of a stubbed request/response mapping. + /// + /// The to match; where not specified any method will match. + /// The relative request URI (path) to match; where not specified any path will match. + /// The to continue the fluent-style configuration. + IHttpMockRequest Request(HttpMethod? method = null, string? requestUri = null); + } +} diff --git a/src/UnitTestEx/Mocking/IHttpMockRequest.cs b/src/UnitTestEx/Mocking/IHttpMockRequest.cs new file mode 100644 index 0000000..5c42a06 --- /dev/null +++ b/src/UnitTestEx/Mocking/IHttpMockRequest.cs @@ -0,0 +1,94 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Moq; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Net.Mime; +using System.Reflection; + +namespace UnitTestEx.Mocking +{ + /// + /// Represents the request-matching configuration for a stubbed mapping. + /// + /// Common to both Tier 1's and Tier 2/3's AspireHttpMockRequest, enabling shared test-authoring code (e.g. a helper method + /// accepting an ) to configure request matching identically regardless of which tier applied it. + /// This is deliberately a minimal, irreducible core of abstract members - the remaining convenience overloads (e.g. a plain-text WithBody(text), or the JSON-embedded-resource + /// variants) are provided as C# default interface methods, composed purely from the core members below, so concrete implementations (i.e. each tier) don't need to reimplement them. + public interface IHttpMockRequest + { + /// + /// Sets the number of that the request is expected to be invoked. + /// + /// The expected . + /// The to support fluent-style method-chaining. + /// Where not set, verification will default to at least one invocation (see ). Each time this is invoked it will override the + /// previously set value. + IHttpMockRequest Times(Times times); + + /// + /// Indicates that the request will match regardless of the body content. + /// + /// The to continue the fluent-style configuration. + IHttpMockRequestBody WithAnyBody(); + + /// + /// Indicates that the request body must match the specified exactly, with the specified . + /// + /// The exact body text to match. + /// The media type of the request. + /// The to continue the fluent-style configuration. + IHttpMockRequestBody WithBody(string body, string mediaType); + + /// + /// Indicates that the request body must match the specified content. + /// + /// The JSON body to match. + /// The paths to ignore from the comparison. + /// The to continue the fluent-style configuration. + /// The exact JSON comparison semantics (including how is interpreted) are tier-specific - see the native WithJsonBody documentation + /// on the concrete implementation being used; they are not guaranteed to be equivalent between tiers. +#if NET7_0_OR_GREATER + IHttpMockRequestBody WithJsonBody([StringSyntax(StringSyntaxAttribute.Json)] string json, params string[] pathsToIgnore); +#else + IHttpMockRequestBody WithJsonBody(string json, params string[] pathsToIgnore); +#endif + + /// + /// Indicates that the request body must match the JSON serialized representation of the specified . + /// + /// The value . + /// The value to serialize and match. + /// The paths to ignore from the comparison. + /// The to continue the fluent-style configuration. + /// Kept as a core (non-default) member, rather than composed from , because Tier 1 serializes using + /// its own configurable IJsonSerializer (which may not be System.Text.Json); a shared default implementation could not honour that. See the + /// remarks regarding tier-specific JSON comparison semantics. + IHttpMockRequestBody WithJsonBody(T value, params string[] pathsToIgnore); + + /// + /// Indicates that the request body must match the specified exactly, with a media type. + /// + /// The exact body text to match. + /// The to continue the fluent-style configuration. + IHttpMockRequestBody WithBody(string text) => WithBody(text, MediaTypeNames.Text.Plain); + + /// + /// Indicates that the request body must match the JSON content of the named embedded resource within the calling . + /// + /// The used to infer the that contains the embedded resource. + /// The embedded resource name. + /// The paths to ignore from the comparison. + /// The to continue the fluent-style configuration. + IHttpMockRequestBody WithJsonResourceBody(string resourceName, params string[] pathsToIgnore) => WithJsonResourceBody(resourceName, typeof(TAssembly).Assembly, pathsToIgnore); + + /// + /// Indicates that the request body must match the JSON content of the named embedded resource. + /// + /// The embedded resource name. + /// The that contains the embedded resource; defaults to the calling where not specified. + /// The paths to ignore from the comparison. + /// The to continue the fluent-style configuration. + IHttpMockRequestBody WithJsonResourceBody(string resourceName, Assembly? assembly = null, params string[] pathsToIgnore) => WithJsonBody(Resource.GetJson(resourceName, assembly ?? Assembly.GetCallingAssembly()), pathsToIgnore); + } +} diff --git a/src/UnitTestEx/Mocking/IHttpMockRequestBody.cs b/src/UnitTestEx/Mocking/IHttpMockRequestBody.cs new file mode 100644 index 0000000..e6829f0 --- /dev/null +++ b/src/UnitTestEx/Mocking/IHttpMockRequestBody.cs @@ -0,0 +1,16 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +namespace UnitTestEx.Mocking +{ + /// + /// Represents the result of adding a body matcher to an and to accordingly. + /// + /// Common to both Tier 1's and Tier 2/3's AspireHttpMockRequestBody. + public interface IHttpMockRequestBody + { + /// + /// Gets the to configure the stubbed response. + /// + IHttpMockResponse Respond { get; } + } +} diff --git a/src/UnitTestEx/Mocking/IHttpMockResponse.cs b/src/UnitTestEx/Mocking/IHttpMockResponse.cs new file mode 100644 index 0000000..4063d2d --- /dev/null +++ b/src/UnitTestEx/Mocking/IHttpMockResponse.cs @@ -0,0 +1,133 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Mime; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace UnitTestEx.Mocking +{ + /// + /// Provides the stubbed response configuration for an . + /// + /// Common to both Tier 1's and Tier 2/3's AspireHttpMockResponse. The terminal With*Async members are asynchronous on + /// both tiers here (unlike their tier-native equivalents, which are synchronous on Tier 1): Tier 2/3 genuinely performs real HTTP I/O (a POST to the WireMock.Net server + /// resource's admin API) to apply the mapping, so there is no honest way to offer a synchronous member on this shared surface. Tier 1's own native members remain synchronous for + /// direct (non-interface) use; the *Async members here perform the identical synchronous configuration and wrap the result, so they are safe (and cheap) to await on Tier 1 too. + /// This is deliberately a minimal, irreducible core of abstract members - the remaining convenience overloads (e.g. Headers(...), an integer-Delay, a no-body + /// WithAsync, a raw-JSON-string WithJsonAsync, or the JSON-embedded-resource variants) are provided as C# default interface methods, composed purely from the core + /// members below, so concrete implementations (i.e. each tier) don't need to reimplement them. + public interface IHttpMockResponse + { + /// + /// Adds the specified response header. + /// + /// The header name. + /// The header value. + /// The to support fluent-style method-chaining. + IHttpMockResponse Header(string name, string value); + + /// + /// Delays the response by the specified . + /// + /// The delay. + /// The to support fluent-style method-chaining. + IHttpMockResponse Delay(TimeSpan timeSpan); + + /// + /// Stubs the response with the specified (optional) , and . + /// + /// The response body content; where null the response will have no body. + /// The ; defaults to . + /// The response content media type (ignored where is null); defaults to . + /// The . + /// The which can be used to verify invocation after the fact. + Task WithAsync(string? content, HttpStatusCode statusCode = HttpStatusCode.OK, string mediaType = MediaTypeNames.Text.Plain, CancellationToken cancellationToken = default); + + /// + /// Stubs the response with the JSON serialized representation of the specified . + /// + /// The value . + /// The value to serialize as the response body. + /// The ; defaults to . + /// The . + /// The which can be used to verify invocation after the fact. + /// Kept as a core (non-extension) member, rather than composed from , because Tier 1 serializes + /// using its own configurable IJsonSerializer (which may not be System.Text.Json); a shared extension method could not honour that. + Task WithJsonAsync(T value, HttpStatusCode statusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default); + + /// + /// Provides the means to mock a sequence of one or more responses for the owning ; each subsequent matching invocation of the request will + /// return the next response in the sequence. + /// + /// The action to enable the addition of one or more responses (see ). + /// The . + /// The which can be used to verify invocation (across the whole sequence) after the fact. + /// The exact exhaustion semantics (what happens on an invocation beyond the configured responses) are tier-specific - see the native WithSequence/WithSequenceAsync + /// documentation on the concrete implementation being used. + Task WithSequenceAsync(Action sequence, CancellationToken cancellationToken = default); + + /// + /// Adds the specified response . + /// + /// The header name/value pairs. + /// The to support fluent-style method-chaining. + IHttpMockResponse Headers(IEnumerable> headers) + { + foreach (var header in headers ?? throw new ArgumentNullException(nameof(headers))) + Header(header.Key, header.Value); + + return this; + } + + /// + /// Delays the response by the specified number of . + /// + /// The delay in milliseconds. + /// The to support fluent-style method-chaining. + IHttpMockResponse Delay(int milliseconds) => Delay(TimeSpan.FromMilliseconds(milliseconds)); + + /// + /// Stubs the response with no body and the specified . + /// + /// The ; defaults to . + /// The . + /// The which can be used to verify invocation after the fact. + Task WithAsync(HttpStatusCode statusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default) => WithAsync(null, statusCode, MediaTypeNames.Text.Plain, cancellationToken); + + /// + /// Stubs the response with the specified raw content. + /// + /// The raw JSON response body. + /// The ; defaults to . + /// The . + /// The which can be used to verify invocation after the fact. + Task WithJsonAsync(string json, HttpStatusCode statusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default) + => WithAsync(json ?? throw new ArgumentNullException(nameof(json)), statusCode, MediaTypeNames.Application.Json, cancellationToken); + + /// + /// Stubs the response with the JSON content of the named embedded resource within the calling . + /// + /// The used to infer the that contains the embedded resource. + /// The embedded resource name. + /// The ; defaults to . + /// The . + /// The which can be used to verify invocation after the fact. + Task WithJsonResourceAsync(string resourceName, HttpStatusCode statusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default) + => WithJsonResourceAsync(resourceName, typeof(TAssembly).Assembly, statusCode, cancellationToken); + + /// + /// Stubs the response with the JSON content of the named embedded resource. + /// + /// The embedded resource name. + /// The that contains the embedded resource; defaults to the calling where not specified. + /// The ; defaults to . + /// The . + /// The which can be used to verify invocation after the fact. + Task WithJsonResourceAsync(string resourceName, Assembly? assembly = null, HttpStatusCode statusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default) + => WithJsonAsync(Resource.GetJson(resourceName, assembly ?? Assembly.GetCallingAssembly()), statusCode, cancellationToken); + } +} diff --git a/src/UnitTestEx/Mocking/IHttpMockResponseSequence.cs b/src/UnitTestEx/Mocking/IHttpMockResponseSequence.cs new file mode 100644 index 0000000..54c4ba4 --- /dev/null +++ b/src/UnitTestEx/Mocking/IHttpMockResponseSequence.cs @@ -0,0 +1,17 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +namespace UnitTestEx.Mocking +{ + /// + /// Provides the means to build up a sequence of responses via . + /// + /// Common to both Tier 1's and Tier 2/3's AspireHttpMockResponseSequence. + public interface IHttpMockResponseSequence + { + /// + /// Adds the next in sequence. + /// + /// The next in sequence, to configure the corresponding response. + IHttpMockResponseSequenceItem Respond(); + } +} diff --git a/src/UnitTestEx/Mocking/IHttpMockResponseSequenceItem.cs b/src/UnitTestEx/Mocking/IHttpMockResponseSequenceItem.cs new file mode 100644 index 0000000..52bd43e --- /dev/null +++ b/src/UnitTestEx/Mocking/IHttpMockResponseSequenceItem.cs @@ -0,0 +1,103 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Mime; +using System.Reflection; + +namespace UnitTestEx.Mocking +{ + /// + /// Represents a single response within an . + /// + /// Common to both Tier 1's (reused for this role) and Tier 2/3's AspireHttpMockResponseSequenceItem. Unlike 's + /// terminal members, these are synchronous on both tiers - the underlying mapping/mock is only applied once the whole sequence has been configured. + /// This is deliberately a minimal, irreducible core of abstract members - the remaining convenience overloads (e.g. Headers(...), an integer-Delay, a no-body + /// With, a raw-JSON-string WithJson, or the JSON-embedded-resource variants) are provided as C# default interface methods, composed purely from the core members below, + /// so concrete implementations (i.e. each tier) don't need to reimplement them. + public interface IHttpMockResponseSequenceItem + { + /// + /// Adds the specified response header. + /// + /// The header name. + /// The header value. + /// The to support fluent-style method-chaining. + IHttpMockResponseSequenceItem Header(string name, string value); + + /// + /// Delays the response by the specified . + /// + /// The delay. + /// The to support fluent-style method-chaining. + IHttpMockResponseSequenceItem Delay(TimeSpan timeSpan); + + /// + /// Sets the response with the specified (optional) , and . + /// + /// The response body content; where null the response will have no body. + /// The ; defaults to . + /// The response content media type (ignored where is null); defaults to . + void With(string? content, HttpStatusCode statusCode = HttpStatusCode.OK, string mediaType = MediaTypeNames.Text.Plain); + + /// + /// Sets the response with the JSON serialized representation of the specified . + /// + /// The value . + /// The value to serialize as the response body. + /// The ; defaults to . + /// Kept as a core (non-default) member, rather than composed from , because Tier 1 serializes + /// using its own configurable IJsonSerializer (which may not be System.Text.Json); a shared default implementation could not honour that. + void WithJson(T value, HttpStatusCode statusCode = HttpStatusCode.OK); + + /// + /// Adds the specified response . + /// + /// The header name/value pairs. + /// The to support fluent-style method-chaining. + IHttpMockResponseSequenceItem Headers(IEnumerable> headers) + { + foreach (var header in headers ?? throw new ArgumentNullException(nameof(headers))) + Header(header.Key, header.Value); + + return this; + } + + /// + /// Delays the response by the specified number of . + /// + /// The delay in milliseconds. + /// The to support fluent-style method-chaining. + IHttpMockResponseSequenceItem Delay(int milliseconds) => Delay(TimeSpan.FromMilliseconds(milliseconds)); + + /// + /// Sets the response with no body and the specified . + /// + /// The ; defaults to . + void With(HttpStatusCode statusCode = HttpStatusCode.OK) => With(null, statusCode, MediaTypeNames.Text.Plain); + + /// + /// Sets the response with the specified raw content. + /// + /// The raw JSON response body. + /// The ; defaults to . + void WithJson(string json, HttpStatusCode statusCode = HttpStatusCode.OK) => With(json ?? throw new ArgumentNullException(nameof(json)), statusCode, MediaTypeNames.Application.Json); + + /// + /// Sets the response with the JSON content of the named embedded resource within the calling . + /// + /// The used to infer the that contains the embedded resource. + /// The embedded resource name. + /// The ; defaults to . + void WithJsonResource(string resourceName, HttpStatusCode statusCode = HttpStatusCode.OK) => WithJsonResource(resourceName, typeof(TAssembly).Assembly, statusCode); + + /// + /// Sets the response with the JSON content of the named embedded resource. + /// + /// The embedded resource name. + /// The that contains the embedded resource; defaults to the calling where not specified. + /// The ; defaults to . + void WithJsonResource(string resourceName, Assembly? assembly = null, HttpStatusCode statusCode = HttpStatusCode.OK) => WithJson(Resource.GetJson(resourceName, assembly ?? Assembly.GetCallingAssembly()), statusCode); + } +} diff --git a/src/UnitTestEx/Mocking/IHttpMockedRequest.cs b/src/UnitTestEx/Mocking/IHttpMockedRequest.cs new file mode 100644 index 0000000..eef1877 --- /dev/null +++ b/src/UnitTestEx/Mocking/IHttpMockedRequest.cs @@ -0,0 +1,22 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System.Threading; +using System.Threading.Tasks; + +namespace UnitTestEx.Mocking +{ + /// + /// Represents a stubbed request/response mapping that has been applied and can be verified after the fact. + /// + /// Common to both the in-process, Moq-based (Tier 1) and the out-of-process, WireMock.Net-based AspireHttpMockedRequest (Tier 2/3), + /// allowing shared test-authoring code to verify a stubbed mapping regardless of which tier applied it. + public interface IHttpMockedRequest + { + /// + /// Verifies the stubbed request was invoked the expected number of times. + /// + /// The . + /// Thrown when the actual invocation count does not satisfy the expectation. + Task VerifyAsync(CancellationToken cancellationToken = default); + } +} diff --git a/src/UnitTestEx/Mocking/MockHttpClient.cs b/src/UnitTestEx/Mocking/MockHttpClient.cs index 8b4d9ff..0c48e29 100644 --- a/src/UnitTestEx/Mocking/MockHttpClient.cs +++ b/src/UnitTestEx/Mocking/MockHttpClient.cs @@ -20,7 +20,7 @@ namespace UnitTestEx.Mocking /// /// Provides the (more specifically ) mocking. /// - public sealed class MockHttpClient : IDisposable + public sealed class MockHttpClient : IDisposable, IHttpMockClient { /// /// Gets the default being 'https://unittest'. @@ -256,6 +256,9 @@ public MockHttpClientRequest Request(HttpMethod? method = null, string? requestU return r; } + /// + IHttpMockRequest IHttpMockClient.Request(HttpMethod? method, string? requestUri) => Request(method, requestUri); + /// /// Indicates whether the request content comparison differences should be trace logged to aid in debugging/troubleshooting. /// diff --git a/src/UnitTestEx/Mocking/MockHttpClientRequest.cs b/src/UnitTestEx/Mocking/MockHttpClientRequest.cs index 7540c9d..0383397 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientRequest.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientRequest.cs @@ -19,7 +19,7 @@ namespace UnitTestEx.Mocking /// /// Provides the configuration for mocking. /// - public sealed class MockHttpClientRequest + public sealed class MockHttpClientRequest : IHttpMockRequest, IHttpMockedRequest { private readonly MockHttpClient _client; private readonly HttpMethod _method; @@ -179,15 +179,15 @@ private bool RequestPredicate(HttpRequestMessage request) else if (!WebUtility.UrlDecode(request.RequestUri!.PathAndQuery).EndsWith(WebUtility.UrlDecode(uri.OriginalString))) return false; + if (_anyContent) + return true; + if (_mediaType == null) return request.Content == null; if (request?.Content == null) return false; - if (_anyContent) - return true; - if (string.Compare(_mediaType, request.Content?.Headers?.ContentType?.MediaType, StringComparison.InvariantCultureIgnoreCase) != 0) return false; @@ -256,7 +256,7 @@ private void Reset() } /// - /// Enables any request with a body (functionally equivalent to ). + /// Enables the request to match regardless of body content, including where no body is present at all (functionally equivalent to ). /// /// The resulting to accordingly. public MockHttpClientRequestBody WithAnyBody() @@ -419,5 +419,27 @@ public void Verify() /// Gets the . /// public MockHttpClientResponse Respond => Rule.Response!; + + /// + IHttpMockRequest IHttpMockRequest.Times(Times times) => Times(times); + + /// + IHttpMockRequestBody IHttpMockRequest.WithAnyBody() => WithAnyBody(); + + /// + IHttpMockRequestBody IHttpMockRequest.WithBody(string body, string mediaType) => WithBody(body, mediaType); + + /// + IHttpMockRequestBody IHttpMockRequest.WithJsonBody(T value, params string[] pathsToIgnore) => WithJsonBody(value, pathsToIgnore); + + /// + IHttpMockRequestBody IHttpMockRequest.WithJsonBody(string json, params string[] pathsToIgnore) => WithJsonBody(json, pathsToIgnore); + + /// + Task IHttpMockedRequest.VerifyAsync(CancellationToken cancellationToken) + { + Verify(); + return Task.CompletedTask; + } } } \ No newline at end of file diff --git a/src/UnitTestEx/Mocking/MockHttpClientRequestBody.cs b/src/UnitTestEx/Mocking/MockHttpClientRequestBody.cs index f261b71..df9985b 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientRequestBody.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientRequestBody.cs @@ -5,7 +5,7 @@ namespace UnitTestEx.Mocking /// /// Represents the result of adding a body to the and to accordingly. /// - public sealed class MockHttpClientRequestBody + public sealed class MockHttpClientRequestBody : IHttpMockRequestBody { private readonly MockHttpClientRequestRule _rule; @@ -19,5 +19,8 @@ public sealed class MockHttpClientRequestBody /// Gets the . /// public MockHttpClientResponse Respond => _rule.Response!; + + /// + IHttpMockResponse IHttpMockRequestBody.Respond => Respond; } } \ No newline at end of file diff --git a/src/UnitTestEx/Mocking/MockHttpClientResponse.cs b/src/UnitTestEx/Mocking/MockHttpClientResponse.cs index 66052c2..9425d51 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientResponse.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientResponse.cs @@ -18,7 +18,7 @@ namespace UnitTestEx.Mocking /// /// Provides the configuration for mocking. /// - public sealed class MockHttpClientResponse + public sealed class MockHttpClientResponse : IHttpMockResponse, IHttpMockResponseSequenceItem { private readonly MockHttpClientRequest _clientRequest; private readonly MockHttpClientRequestRule? _rule; @@ -262,5 +262,63 @@ public void WithSequence(Action sequence) if (_rule.Responses.Count > 0) _clientRequest.MockResponse(); } + + #region IHttpMockResponse + + /// + IHttpMockResponse IHttpMockResponse.Header(string name, string value) => Header(name, value); + + /// + IHttpMockResponse IHttpMockResponse.Delay(TimeSpan timeSpan) => Delay(timeSpan); + + /// + Task IHttpMockResponse.WithAsync(string? content, HttpStatusCode statusCode, string mediaType, CancellationToken cancellationToken) + { + if (content is null) + With((HttpContent?)null, statusCode); + else + With(content, statusCode, mediaType); + + return Task.FromResult(_clientRequest); + } + + /// + Task IHttpMockResponse.WithJsonAsync(T value, HttpStatusCode statusCode, CancellationToken cancellationToken) + { + WithJson(value, statusCode); + return Task.FromResult(_clientRequest); + } + + /// + Task IHttpMockResponse.WithSequenceAsync(Action sequence, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(sequence); + WithSequence(s => sequence(s)); + return Task.FromResult(_clientRequest); + } + + #endregion + + #region IHttpMockResponseSequenceItem + + /// + IHttpMockResponseSequenceItem IHttpMockResponseSequenceItem.Header(string name, string value) => Header(name, value); + + /// + IHttpMockResponseSequenceItem IHttpMockResponseSequenceItem.Delay(TimeSpan timeSpan) => Delay(timeSpan); + + /// + void IHttpMockResponseSequenceItem.With(string? content, HttpStatusCode statusCode, string mediaType) + { + if (content is null) + With((HttpContent?)null, statusCode); + else + With(content, statusCode, mediaType); + } + + /// + void IHttpMockResponseSequenceItem.WithJson(T value, HttpStatusCode statusCode) => WithJson(value, statusCode); + + #endregion } } \ No newline at end of file diff --git a/src/UnitTestEx/Mocking/MockHttpClientResponseSequence.cs b/src/UnitTestEx/Mocking/MockHttpClientResponseSequence.cs index 5529c77..6338ede 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientResponseSequence.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientResponseSequence.cs @@ -5,7 +5,7 @@ namespace UnitTestEx.Mocking /// /// Mocks the within a sequence. /// - public sealed class MockHttpClientResponseSequence + public sealed class MockHttpClientResponseSequence : IHttpMockResponseSequence { private readonly MockHttpClientRequest _clientRequest; private readonly MockHttpClientRequestRule _rule; @@ -31,5 +31,8 @@ public MockHttpClientResponse Respond() _rule.Responses!.Add(resp); return resp; } + + /// + IHttpMockResponseSequenceItem IHttpMockResponseSequence.Respond() => Respond(); } } \ No newline at end of file diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index 8075676..f7847d4 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -1,11 +1,14 @@ using System; using System.Diagnostics; +using System.Net; using System.Net.Http; +using System.Net.Mime; using System.Threading.Tasks; using Moq; using UnitTestEx; using UnitTestEx.Abstractions; using UnitTestEx.Api.Models; +using UnitTestEx.Mocking; using Xunit; using Xunit.Abstractions; @@ -180,6 +183,81 @@ public async Task HttpMock_WithJsonBody_PathsToIgnore_IgnoresSpecifiedProperty() await stub.VerifyAsync(); } + [Fact] + public async Task HttpMock_SharedInterface_ConfiguresIdenticallyAcrossTiers() + { + // Proves that the exact same test-authoring code (HttpMockSharedConfig.ConfigureProductStubAsync, written once against IHttpMockClient) can configure Tier 2/3's + // AspireHttpMockClient identically to Tier 1's MockHttpClient (see the equivalent test in UnitTestEx.Xunit.Test). + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + await tester.WaitForResourceAsync("gateway"); + + var stub = await HttpMockSharedConfig.ConfigureProductStubAsync(tester.HttpMock("gateway"), "/products/shared"); + + tester.Http("api") + .Run(HttpMethod.Get, "Product/shared") + .AssertOK() + .AssertValue(new { id = "Shared", description = "Configured via the shared IHttpMockClient interface." }); + + await stub.VerifyAsync(); + } + + [Fact] + public async Task HttpMock_InterfaceCoreMembers_TierSpecificAdaptations() + { + // Unlike HttpMockInterfaceTest.cs in UnitTestEx.Xunit.Test (which exercises the shared IHttpMock* DIM "extras" - identical bytecode on both tiers), this exercises + // AspireHttpMockRequest/AspireHttpMockResponse's own hand-written, tier-specific explicit interface implementations of the *core* (non-DIM) members via interface-typed + // variables: the 2-arg WithBody, WithJsonBody, both branches (with/without content) of the WithAsync adaptation, and the Action-wrapping + // adapter within WithSequenceAsync. + await using var tester = AspireTester.Create(); + + await tester.WaitForResourceAsync("gateway"); + + IHttpMockClient client = tester.HttpMock("gateway"); + + var stub1 = await client.Request(HttpMethod.Post, "/interface/with-body") + .Times(Times.Once()) + .WithBody("plain-text-body", MediaTypeNames.Text.Plain) + .Respond.WithAsync("received", HttpStatusCode.OK, MediaTypeNames.Text.Plain); + + var stub2 = await client.Request(HttpMethod.Get, "/interface/no-content") + .Times(Times.Once()) + .WithAnyBody() + .Respond.WithAsync(statusCode: HttpStatusCode.NoContent); + + var stub3 = await client.Request(HttpMethod.Post, "/interface/json-body") + .Times(Times.Once()) + .WithJsonBody(new { id = "Abc" }) + .Respond.WithJsonAsync(new { id = "Abc", description = "Configured via IHttpMockRequest.WithJsonBody." }); + + var stub4 = await client.Request(HttpMethod.Get, "/interface/sequence") + .WithAnyBody() + .Respond.WithSequenceAsync(seq => + { + seq.Respond().WithJson(new { id = "First" }); + seq.Respond().WithJson(new { id = "Second" }); + }); + + tester.Http("gateway").Run(HttpMethod.Post, "/interface/with-body", "plain-text-body", MediaTypeNames.Text.Plain) + .AssertOK() + .AssertContent("received"); + + tester.Http("gateway").Run(HttpMethod.Get, "/interface/no-content").AssertNoContent(); + + tester.Http("gateway").Run(HttpMethod.Post, "/interface/json-body", new { id = "Abc" }) + .AssertOK() + .AssertValue(new { id = "Abc", description = "Configured via IHttpMockRequest.WithJsonBody." }); + + tester.Http("gateway").Run(HttpMethod.Get, "/interface/sequence").AssertOK().AssertValue(new { id = "First" }); + tester.Http("gateway").Run(HttpMethod.Get, "/interface/sequence").AssertOK().AssertValue(new { id = "Second" }); + + await stub1.VerifyAsync(); + await stub2.VerifyAsync(); + await stub3.VerifyAsync(); + await stub4.VerifyAsync(); + } } } diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/HttpMockSharedConfig.cs b/tests/UnitTestEx.Aspire.Xunit.Test/HttpMockSharedConfig.cs new file mode 100644 index 0000000..7bdf9d6 --- /dev/null +++ b/tests/UnitTestEx.Aspire.Xunit.Test/HttpMockSharedConfig.cs @@ -0,0 +1,30 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System.Net.Http; +using System.Threading.Tasks; +using Moq; +using UnitTestEx.Mocking; + +namespace UnitTestEx.Aspire.Xunit.Test +{ + /// + /// A single, tier-agnostic HTTP mock configuration method - written once against the shared abstraction - used to prove that Tier 1's in-process + /// and Tier 2/3's out-of-process are interchangeable for basic request/response configuration. + /// + /// Deliberately duplicated verbatim in both the UnitTestEx.Xunit.Test and UnitTestEx.Aspire.Xunit.Test projects (rather than factored into a shared project + /// reference) to keep this proof-of-concept minimal and avoid introducing a new cross-project dependency purely for a demonstration/test method. + internal static class HttpMockSharedConfig + { + /// + /// Configures a stub for a GET request to that returns a fixed product payload, using only the shared abstraction. + /// + /// The - either tier's concrete client, accessed purely through the shared interface. + /// The request URI to match (tier-specific path convention, e.g. with or without a leading slash). + /// The which can be used to verify invocation after the fact. + internal static async Task ConfigureProductStubAsync(IHttpMockClient client, string requestUri) => + await client.Request(HttpMethod.Get, requestUri) + .Times(Times.Once()) + .WithAnyBody() + .Respond.WithJsonAsync(new { id = "Shared", description = "Configured via the shared IHttpMockClient interface." }); + } +} diff --git a/tests/UnitTestEx.MSTest.Test/MockHttpClientTest.cs b/tests/UnitTestEx.MSTest.Test/MockHttpClientTest.cs index b1cb7db..aae0c02 100644 --- a/tests/UnitTestEx.MSTest.Test/MockHttpClientTest.cs +++ b/tests/UnitTestEx.MSTest.Test/MockHttpClientTest.cs @@ -294,7 +294,12 @@ public async Task UriAndAnyBody() Assert.AreEqual(HttpStatusCode.Accepted, res.StatusCode); Assert.AreEqual("{\"first\":\"Bob\",\"last\":\"Jane\"}", await res.Content.ReadAsStringAsync().ConfigureAwait(false)); - await Assert.ThrowsExceptionAsync(async () => await hc.SendAsync(new HttpRequestMessage(HttpMethod.Post, "products/xyz"))); + // WithAnyBody is a true wildcard - it also matches a request with no body at all. + var res2 = await hc.SendAsync(new HttpRequestMessage(HttpMethod.Post, "products/xyz")); + Assert.AreEqual(HttpStatusCode.Accepted, res2.StatusCode); + Assert.AreEqual("{\"first\":\"Bob\",\"last\":\"Jane\"}", await res2.Content.ReadAsStringAsync()); + + await Assert.ThrowsExceptionAsync(async () => await hc.SendAsync(new HttpRequestMessage(HttpMethod.Get, "products/xyz"))); } [TestMethod] diff --git a/tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs b/tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs index 2eebc52..f0c7206 100644 --- a/tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs +++ b/tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs @@ -316,7 +316,15 @@ public async Task UriAndAnyBody() Assert.That(res.RequestMessage, Is.Not.Null); }); - Assert.ThrowsAsync(async () => await hc.SendAsync(new HttpRequestMessage(HttpMethod.Post, "products/xyz"))); + // WithAnyBody is a true wildcard - it also matches a request with no body at all. + var res2 = await hc.SendAsync(new HttpRequestMessage(HttpMethod.Post, "products/xyz")).ConfigureAwait(false); + Assert.Multiple(async () => + { + Assert.That(res2.StatusCode, Is.EqualTo(HttpStatusCode.Accepted)); + Assert.That(await res2.Content.ReadAsStringAsync().ConfigureAwait(false), Is.EqualTo("{\"first\":\"Bob\",\"last\":\"Jane\"}")); + }); + + Assert.ThrowsAsync(async () => await hc.SendAsync(new HttpRequestMessage(HttpMethod.Get, "products/xyz"))); } [Test] @@ -539,7 +547,15 @@ public async Task DefaultHttpClient() Assert.That(res.Headers.Age, Is.EqualTo(TimeSpan.FromSeconds(55))); }); - Assert.ThrowsAsync(async () => await hc.SendAsync(new HttpRequestMessage(HttpMethod.Post, "products/xyz"))); + // WithAnyBody is a true wildcard - it also matches a request with no body at all. + var res2 = await hc.SendAsync(new HttpRequestMessage(HttpMethod.Post, "products/xyz")).ConfigureAwait(false); + Assert.Multiple(async () => + { + Assert.That(res2.StatusCode, Is.EqualTo(HttpStatusCode.Accepted)); + Assert.That(await res2.Content.ReadAsStringAsync().ConfigureAwait(false), Is.EqualTo("{\"first\":\"Bob\",\"last\":\"Jane\"}")); + }); + + Assert.ThrowsAsync(async () => await hc.SendAsync(new HttpRequestMessage(HttpMethod.Get, "products/xyz"))); } [Test] diff --git a/tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs b/tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs new file mode 100644 index 0000000..705ffa9 --- /dev/null +++ b/tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs @@ -0,0 +1,160 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Moq; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using UnitTestEx.Mocking; +using Xunit; +using Xunit.Abstractions; + +namespace UnitTestEx.Xunit.Test +{ + /// + /// Exercises the shared IHttpMock* interfaces' default interface method ("DIM") convenience extras (e.g. Headers, an integer Delay, no-body/raw-JSON-string/ + /// embedded-resource overloads) - not just the core abstract members - via interface-typed variables against Tier 1's . These are not otherwise exercised + /// by (which calls the concrete native API directly) nor by + /// (which only proves the core members). + /// + public class HttpMockInterfaceTest : UnitTestBase + { + public HttpMockInterfaceTest(ITestOutputHelper output) : base(output) { } + + [Fact] + public async Task Interface_Request_WithBody_TextDim_MatchesPlainTextBody() + { + var mcf = MockHttpClientFactory.Create(); + IHttpMockClient client = mcf.CreateClient("XXX", new System.Uri("https://d365test")); + + var stub = await client.Request(HttpMethod.Post, "echo") + .Times(Times.Once()) + .WithBody("hello world") // DIM: single-arg overload, defaults to text/plain. + .Respond.WithAsync("received", HttpStatusCode.OK, MediaTypeNames.Text.Plain); + + var hc = mcf.GetHttpClient("XXX"); + var res = await hc.PostAsync("echo", new StringContent("hello world", Encoding.UTF8, MediaTypeNames.Text.Plain)); + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + Assert.Equal("received", await res.Content.ReadAsStringAsync()); + + await stub.VerifyAsync(); + } + + [Fact] + public async Task Interface_Request_WithJsonResourceBody_Dim_MatchesResourceContent() + { + var mcf = MockHttpClientFactory.Create(); + IHttpMockClient client = mcf.CreateClient("XXX", new System.Uri("https://d365test")); + + var stub = await client.Request(HttpMethod.Post, "products/xyz") + .Times(Times.Once()) + .WithJsonResourceBody("MockHttpClientTest-UriAndBody_WithJsonResponse3.json") // DIM: matches request body against embedded resource content. + .Respond.WithJsonAsync(new { message = "matched" }); + + var hc = mcf.GetHttpClient("XXX"); + var res = await hc.PostAsJsonAsync("products/xyz", new { first = "Bob", last = "Jane" }); + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + Assert.Equal("{\"message\":\"matched\"}", await res.Content.ReadAsStringAsync()); + + await stub.VerifyAsync(); + } + + [Fact] + public async Task Interface_Response_Extras_HeadersDelayAndNoContentWithAsync_Dim() + { + var mcf = MockHttpClientFactory.Create(); + IHttpMockClient client = mcf.CreateClient("XXX", new System.Uri("https://d365test")); + + var stub = await client.Request(HttpMethod.Get, "ping") + .Times(Times.Once()) + .WithAnyBody() + .Respond + .Headers([new("x-custom", "abc"), new("x-other", "def")]) // DIM: iterates and calls the core Header member. + .Delay(50) // DIM: int milliseconds overload. + .WithAsync(HttpStatusCode.NoContent); // DIM: no-content overload. + + var hc = mcf.GetHttpClient("XXX"); + var sw = Stopwatch.StartNew(); + var res = await hc.GetAsync("ping"); + sw.Stop(); + + Assert.Equal(HttpStatusCode.NoContent, res.StatusCode); + Assert.Equal("abc", res.Headers.GetValues("x-custom").Single()); + Assert.Equal("def", res.Headers.GetValues("x-other").Single()); + Assert.True(sw.ElapsedMilliseconds >= 45, $"Actual elapsed milliseconds {sw.ElapsedMilliseconds}."); + + await stub.VerifyAsync(); + } + + [Fact] + public async Task Interface_Response_WithJsonAsync_StringAndResource_Dim() + { + var mcf = MockHttpClientFactory.Create(); + IHttpMockClient client = mcf.CreateClient("XXX", new System.Uri("https://d365test")); + + var stub1 = await client.Request(HttpMethod.Get, "raw-json") + .Times(Times.Once()) + .WithAnyBody() + .Respond.WithJsonAsync("{\"literal\":true}"); // DIM: raw JSON string overload. + + var stub2 = await client.Request(HttpMethod.Get, "resource-json") + .Times(Times.Once()) + .WithAnyBody() + .Respond.WithJsonResourceAsync("MockHttpClientTest-UriAndBody_WithJsonResponse3.json", HttpStatusCode.Accepted); // DIM: embedded resource overload. + + var hc = mcf.GetHttpClient("XXX"); + + var res1 = await hc.GetAsync("raw-json"); + Assert.Equal(HttpStatusCode.OK, res1.StatusCode); + Assert.Equal("{\"literal\":true}", await res1.Content.ReadAsStringAsync()); + + var res2 = await hc.GetAsync("resource-json"); + Assert.Equal(HttpStatusCode.Accepted, res2.StatusCode); + Assert.Equal("{\"first\":\"Bob\",\"last\":\"Jane\"}", await res2.Content.ReadAsStringAsync()); + + await stub1.VerifyAsync(); + await stub2.VerifyAsync(); + } + + [Fact] + public async Task Interface_ResponseSequenceItem_Extras_Dim() + { + var mcf = MockHttpClientFactory.Create(); + IHttpMockClient client = mcf.CreateClient("XXX", new System.Uri("https://d365test")); + + var stub = await client.Request(HttpMethod.Get, "sequence") + .WithAnyBody() + .Respond.WithSequenceAsync(seq => + { + seq.Respond() + .Headers([new("x-seq", "1")]) // DIM: iterates and calls the core Header member. + .Delay(10) // DIM: int milliseconds overload. + .With(HttpStatusCode.NoContent); // DIM: no-content overload. + + seq.Respond().WithJson("{\"raw\":true}", HttpStatusCode.OK); // DIM: raw JSON string overload. + + seq.Respond().WithJsonResource("MockHttpClientTest-UriAndBody_WithJsonResponse3.json", HttpStatusCode.Accepted); // DIM: embedded resource overload. + }); + + var hc = mcf.GetHttpClient("XXX"); + + var res1 = await hc.GetAsync("sequence"); + Assert.Equal(HttpStatusCode.NoContent, res1.StatusCode); + Assert.Equal("1", res1.Headers.GetValues("x-seq").Single()); + + var res2 = await hc.GetAsync("sequence"); + Assert.Equal(HttpStatusCode.OK, res2.StatusCode); + Assert.Equal("{\"raw\":true}", await res2.Content.ReadAsStringAsync()); + + var res3 = await hc.GetAsync("sequence"); + Assert.Equal(HttpStatusCode.Accepted, res3.StatusCode); + Assert.Equal("{\"first\":\"Bob\",\"last\":\"Jane\"}", await res3.Content.ReadAsStringAsync()); + + await stub.VerifyAsync(); + } + } +} diff --git a/tests/UnitTestEx.Xunit.Test/HttpMockSharedConfig.cs b/tests/UnitTestEx.Xunit.Test/HttpMockSharedConfig.cs new file mode 100644 index 0000000..ed7e288 --- /dev/null +++ b/tests/UnitTestEx.Xunit.Test/HttpMockSharedConfig.cs @@ -0,0 +1,30 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System.Net.Http; +using System.Threading.Tasks; +using Moq; +using UnitTestEx.Mocking; + +namespace UnitTestEx.Xunit.Test +{ + /// + /// A single, tier-agnostic HTTP mock configuration method - written once against the shared abstraction - used to prove that Tier 1's in-process + /// and Tier 2/3's out-of-process AspireHttpMockClient are interchangeable for basic request/response configuration. + /// + /// Deliberately duplicated verbatim in both the UnitTestEx.Xunit.Test and UnitTestEx.Aspire.Xunit.Test projects (rather than factored into a shared project + /// reference) to keep this proof-of-concept minimal and avoid introducing a new cross-project dependency purely for a demonstration/test method. + internal static class HttpMockSharedConfig + { + /// + /// Configures a stub for a GET request to that returns a fixed product payload, using only the shared abstraction. + /// + /// The - either tier's concrete client, accessed purely through the shared interface. + /// The request URI to match (tier-specific path convention, e.g. with or without a leading slash). + /// The which can be used to verify invocation after the fact. + internal static async Task ConfigureProductStubAsync(IHttpMockClient client, string requestUri) => + await client.Request(HttpMethod.Get, requestUri) + .Times(Times.Once()) + .WithAnyBody() + .Respond.WithJsonAsync(new { id = "Shared", description = "Configured via the shared IHttpMockClient interface." }); + } +} diff --git a/tests/UnitTestEx.Xunit.Test/MockHttpClientTest.cs b/tests/UnitTestEx.Xunit.Test/MockHttpClientTest.cs index 2af5413..710055d 100644 --- a/tests/UnitTestEx.Xunit.Test/MockHttpClientTest.cs +++ b/tests/UnitTestEx.Xunit.Test/MockHttpClientTest.cs @@ -252,7 +252,12 @@ public async Task UriAndAnyBody() Assert.Equal(HttpStatusCode.Accepted, res.StatusCode); Assert.Equal("{\"first\":\"Bob\",\"last\":\"Jane\"}", await res.Content.ReadAsStringAsync()); - await Assert.ThrowsAsync(async () => await hc.SendAsync(new HttpRequestMessage(HttpMethod.Post, "products/xyz"))); + // WithAnyBody is a true wildcard - it also matches a request with no body at all. + var res2 = await hc.SendAsync(new HttpRequestMessage(HttpMethod.Post, "products/xyz")); + Assert.Equal(HttpStatusCode.Accepted, res2.StatusCode); + Assert.Equal("{\"first\":\"Bob\",\"last\":\"Jane\"}", await res2.Content.ReadAsStringAsync()); + + await Assert.ThrowsAsync(async () => await hc.SendAsync(new HttpRequestMessage(HttpMethod.Get, "products/xyz"))); } [Fact] diff --git a/tests/UnitTestEx.Xunit.Test/ProductControllerTest.cs b/tests/UnitTestEx.Xunit.Test/ProductControllerTest.cs index 8ad6aa1..f612505 100644 --- a/tests/UnitTestEx.Xunit.Test/ProductControllerTest.cs +++ b/tests/UnitTestEx.Xunit.Test/ProductControllerTest.cs @@ -55,6 +55,25 @@ public async Task ServiceProvider() Assert.Equal("test output", await r.Content.ReadAsStringAsync()); } + [Fact] + public async Task HttpMock_SharedInterface_ConfiguresIdenticallyAcrossTiers() + { + // Proves that the exact same test-authoring code (HttpMockSharedConfig.ConfigureProductStubAsync, written once against IHttpMockClient) can configure Tier 1's + // MockHttpClient identically to Tier 2/3's AspireHttpMockClient (see the equivalent test in UnitTestEx.Aspire.Xunit.Test). + var mcf = MockHttpClientFactory.Create(); + var client = mcf.CreateClient("XXX", new Uri("https://somesys/")); + + var stub = await HttpMockSharedConfig.ConfigureProductStubAsync(client, "products/shared"); + + Test.ReplaceHttpClientFactory(mcf) + .Controller() + .Run(c => c.Get("shared")) + .AssertOK() + .AssertValue(new { id = "Shared", description = "Configured via the shared IHttpMockClient interface." }); + + await stub.VerifyAsync(); + } + [Fact] public void To_HttpResponseMessage_Created() { From 09e62e88c6f231ccfb10f550366a2777f2cd154b Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 23 Sep 2026 17:22:25 -0700 Subject: [PATCH 26/43] Document Aspire multi-host testing in README 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> --- README.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/README.md b/README.md index 04cb91a..f7c925e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ The scenarios that _UnitTestEx_ looks to address is the end-to-end unit-style te - [Service Bus-trigger Azure Function](#Service-Bus-trigger-Azure-Function) - [Generic Azure Function Type](#Generic-Azure-Function-Type) - [HTTP Client mocking](#HTTP-Client-mocking) +- [Aspire multi-host testing](#Aspire-multi-host-testing)
@@ -301,6 +302,63 @@ _Note:_ Not all scenarios are currently available using YAML/JSON configuration.
+## Aspire multi-host testing + +Everything above (`ApiTester`, `FunctionTester`, `GenericTester`, etc.) hosts a **single** system/service in-process via `WebApplicationFactory` - ideal for intra-domain testing where you want deep, per-component control (DI replacement, mocked `HttpClient`s) of one service in isolation. + +Sometimes, though, you genuinely need to prove that two or more **real**, separately-hosted services interact correctly over the network (inter-domain testing) - for example a [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/) distributed application where a "shopping" API calls a "products" API. For this, the [`UnitTestEx.Aspire`](./src/UnitTestEx.Aspire) package provides `AspireTester`, which spins up the **entire AppHost** - every project, container and executable resource it declares - as separate, real OS processes wired together with Aspire's actual service discovery, exactly as they'd run in production. This is deliberately a second, opt-in tier rather than an extension of the first: pick the tier per test based on what you're actually trying to prove, don't force a hybrid. See the [design note](./docs/design/aspire-multi-host-testing.md) for the full rationale. + +``` csharp +await using var tester = AspireTester.Create(); + +await tester.WaitForResourceAsync("shopping"); + +tester.Http("shopping") + .Run(HttpMethod.Get, "orders/123") + .AssertOK() + .AssertValue(new { id = "123", product = "Widget" }); +``` + +External-to-the-solution dependencies (an email/notification provider, an identity/auth service, an ERP system, a payment gateway, etc.) still need to be mocked - a real inter-domain test proves *your* services talk to each other correctly, not that a third-party's sandbox environment is up. For this, add a [`WireMock.Net.Aspire`](https://github.com/WireMock-Net/WireMock.Net-Aspire) container resource per external dependency to the AppHost, and wire its endpoint in as configuration: + +``` csharp +// AppHost.cs +var email = builder.AddWireMock("email"); +var auth = builder.AddWireMock("auth"); +var erp = builder.AddWireMock("erp"); + +builder.AddProject("api") + .WithEnvironment("Email__BaseUrl", email.GetEndpoint("http")) + .WithEnvironment("Auth__BaseUrl", auth.GetEndpoint("http")) + .WithEnvironment("Erp__BaseUrl", erp.GetEndpoint("http")); +``` + +Each is a genuine, isolated WireMock.Net container/process - one per external system, so stubs configured for `"email"` can never leak into `"auth"` or `"erp"`. Within a test, `AspireTesterBase.HttpMock(resourceName)` returns a fluent `AspireHttpMockClient` for the named resource: + +``` csharp +await using var tester = AspireTester.Create(); + +await tester.WaitForResourceAsync("api"); +await tester.WaitForResourceAsync("erp"); + +var stub = await tester.HttpMock("erp") + .Request(HttpMethod.Get, "products/abc") + .Respond.WithJsonAsync(new { id = "Abc", description = "A blue carrot" }); + +tester.Http("api") + .Run(HttpMethod.Get, "Product/abc") + .AssertOK() + .AssertValue(new { id = "Abc", description = "A blue carrot" }); + +await stub.VerifyAsync(); +``` + +The fluent configuration API is intentionally near-identical to Tier 1's `MockHttpClientFactory` above (both implement the shared [`IHttpMockClient`](./src/UnitTestEx/Mocking/IHttpMockClient.cs)/`IHttpMockRequest`/`IHttpMockResponse` interfaces) - a helper method written once against these interfaces can configure request/response stubbing identically regardless of which tier it's handed. The main differences are that the terminal `With*` methods here are asynchronous (`AspireHttpMockClient` performs a real HTTP call to the WireMock.Net server's admin API to register each mapping) and must be awaited, and the underlying JSON comparison/sequence-exhaustion semantics are WireMock.Net's own (not identical to Tier 1's) - see the [design note](./docs/design/aspire-multi-host-testing.md) for the specifics. + +_Note:_ Aspire-hosted resources are real OS processes/containers (Docker/Podman required for `AddWireMock` and similar container resources), so `AspireTester` tests are inherently slower than the in-process Tier 1 testers - use them where the inter-process interaction itself is what needs proving. + +
+ ## Expectations By default _UnitTestEx_ provides out-of-the-box `Assert*` capabilities that are applied after execution to verify the test results. However, by adding the `UnitTestEx.Expectations` namespace in a test additional `Expect*` capabilities will be enabled (where applicable). These allow expectations to be defined prior to the execution which are automatically asserted on execution. @@ -339,6 +397,7 @@ As _UnitTestEx_ is intended for testing, look at the tests for further details o - [UnitTestEx.MSTest.Test](./tests/UnitTestEx.MSTest.Test) - [UnitTestEx.NUnit.Test](./tests/UnitTestEx.NUnit.Test) - [UnitTestEx.Xunit.Test](./tests/UnitTestEx.Xunit.Test) +- [UnitTestEx.Aspire.Xunit.Test](./tests/UnitTestEx.Aspire.Xunit.Test) - Aspire multi-host testing (see [above](#Aspire-multi-host-testing)) _Note:_ There may be some slight variations in how the tests are constructed per test capability, this is to account for any differences between the frameworks themselves. For the most part the code should be near identical. From 133b9a1649e48beb099e010263277b809eafbf1d Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 23 Sep 2026 17:26:51 -0700 Subject: [PATCH 27/43] Remove design doc; add Aspire guidance quote to README 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> --- README.md | 9 +- docs/design/aspire-multi-host-testing.md | 439 ------------------ .../Aspire/AspireTesterBase.cs | 3 +- .../Aspire/HttpMock/AspireHttpMockClient.cs | 2 +- src/UnitTestEx/Mocking/IHttpMockClient.cs | 4 +- .../AspireTesterTest.cs | 6 +- 6 files changed, 14 insertions(+), 449 deletions(-) delete mode 100644 docs/design/aspire-multi-host-testing.md diff --git a/README.md b/README.md index f7c925e..f5817ad 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,12 @@ _Note:_ Not all scenarios are currently available using YAML/JSON configuration. Everything above (`ApiTester`, `FunctionTester`, `GenericTester`, etc.) hosts a **single** system/service in-process via `WebApplicationFactory` - ideal for intra-domain testing where you want deep, per-component control (DI replacement, mocked `HttpClient`s) of one service in isolation. -Sometimes, though, you genuinely need to prove that two or more **real**, separately-hosted services interact correctly over the network (inter-domain testing) - for example a [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/) distributed application where a "shopping" API calls a "products" API. For this, the [`UnitTestEx.Aspire`](./src/UnitTestEx.Aspire) package provides `AspireTester`, which spins up the **entire AppHost** - every project, container and executable resource it declares - as separate, real OS processes wired together with Aspire's actual service discovery, exactly as they'd run in production. This is deliberately a second, opt-in tier rather than an extension of the first: pick the tier per test based on what you're actually trying to prove, don't force a hybrid. See the [design note](./docs/design/aspire-multi-host-testing.md) for the full rationale. +Sometimes, though, you genuinely need to prove that two or more **real**, separately-hosted services interact correctly over the network (inter-domain testing) - for example a [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/) distributed application where a "shopping" API calls a "products" API. For this, the [`UnitTestEx.Aspire`](./src/UnitTestEx.Aspire) package provides `AspireTester`, which spins up the **entire AppHost** - every project, container and executable resource it declares - as separate, real OS processes wired together with Aspire's actual service discovery, exactly as they'd run in production. This is deliberately a second, opt-in tier rather than an extension of the first: pick the tier per test based on what you're actually trying to prove, don't force a hybrid. + +This isn't a UnitTestEx-specific compromise - it matches Aspire's own guidance verbatim: + +> "If your goal is to test a single project in isolation, run components in-memory, or mock external dependencies, consider using `WebApplicationFactory` instead." +> — [aspire.dev/testing/overview](https://aspire.dev/testing/overview/) ``` csharp await using var tester = AspireTester.Create(); @@ -353,7 +358,7 @@ tester.Http("api") await stub.VerifyAsync(); ``` -The fluent configuration API is intentionally near-identical to Tier 1's `MockHttpClientFactory` above (both implement the shared [`IHttpMockClient`](./src/UnitTestEx/Mocking/IHttpMockClient.cs)/`IHttpMockRequest`/`IHttpMockResponse` interfaces) - a helper method written once against these interfaces can configure request/response stubbing identically regardless of which tier it's handed. The main differences are that the terminal `With*` methods here are asynchronous (`AspireHttpMockClient` performs a real HTTP call to the WireMock.Net server's admin API to register each mapping) and must be awaited, and the underlying JSON comparison/sequence-exhaustion semantics are WireMock.Net's own (not identical to Tier 1's) - see the [design note](./docs/design/aspire-multi-host-testing.md) for the specifics. +The fluent configuration API is intentionally near-identical to Tier 1's `MockHttpClientFactory` above (both implement the shared [`IHttpMockClient`](./src/UnitTestEx/Mocking/IHttpMockClient.cs)/`IHttpMockRequest`/`IHttpMockResponse` interfaces) - a helper method written once against these interfaces can configure request/response stubbing identically regardless of which tier it's handed. The main differences are that the terminal `With*` methods here are asynchronous (`AspireHttpMockClient` performs a real HTTP call to the WireMock.Net server's admin API to register each mapping) and must be awaited, and the underlying JSON comparison/sequence-exhaustion semantics are WireMock.Net's own (not identical to Tier 1's). _Note:_ Aspire-hosted resources are real OS processes/containers (Docker/Podman required for `AddWireMock` and similar container resources), so `AspireTester` tests are inherently slower than the in-process Tier 1 testers - use them where the inter-process interaction itself is what needs proving. diff --git a/docs/design/aspire-multi-host-testing.md b/docs/design/aspire-multi-host-testing.md deleted file mode 100644 index e0308db..0000000 --- a/docs/design/aspire-multi-host-testing.md +++ /dev/null @@ -1,439 +0,0 @@ -# Design note: supporting .NET Aspire multi-host testing alongside `WebApplicationFactory` - -## TL;DR - -Yes, it's possible — but it's **two separate tiers**, not one API extended to cover both: - -| | Tier 1 — intra-domain (today) | Tier 2 — inter-domain (proposed) | -|---|---|---| -| Package | `UnitTestEx` (existing) | new `UnitTestEx.Aspire` | -| Underlying host | `WebApplicationFactory` + in-memory `TestServer` | `Aspire.Hosting.Testing.DistributedApplicationTestingBuilder` | -| Process model | Single in-memory process | Real, separate OS processes per resource | -| Transport | In-memory short-circuit (no sockets) | Real network / service discovery | -| DI reach-in | Full — `ConfigureServices`, `ReplaceScoped`, `ReplaceSingleton` | None — DI containers are in other processes | -| Use it when... | You want deep, per-component control of **one** service in isolation | You want to exercise the **real** interaction between two or more services | - -This isn't a UnitTestEx-specific compromise — it matches Aspire's own guidance verbatim: - -> "If your goal is to test a single project in isolation, run components in-memory, or mock external -> dependencies, consider using `WebApplicationFactory` instead." -> — [aspire.dev/testing/overview](https://aspire.dev/testing/overview/) - -So the plan is to keep Tier 1 exactly as-is, and add Tier 2 as a new, separately-versioned, opt-in -package — picking the tier per test based on what you're actually trying to prove, not blending them. - -One important wrinkle: `TesterBase` is the shared base a lot of fluent extension methods hang off -today, and its DI-flavoured methods (`ReplaceSingleton`, `MockScoped`, `ScopedType`, etc.) would be -awkward — even dangerous — on a Tier 2 tester if it simply inherited that surface: they'd compile, look -valid, and either silently no-op or need a runtime guard to catch them. Section 9 works through this and -lands on a **compile-time** fix instead of a runtime one: extract a new `TesterBaseCore` holding only the -genuinely host-agnostic members, have today's `TesterBase` inherit it unchanged (Tier 1's shape doesn't -move), and have the new Tier 2 base inherit `TesterBaseCore` directly — so the DI-flavoured methods -simply don't exist on a Tier 2 tester, rather than existing and throwing. - -## 1. The current model (Tier 1), recapped - -`ApiTesterBase` (`src/UnitTestEx/AspNetCore/ApiTesterBase.cs`) wraps a -`WebApplicationFactory`: - -- One process, one in-memory `TestServer` — `Server.CreateClient()`/`TestServer` has no real socket. -- `ConfigureServices` runs inside the *same* DI container the app under test uses, so tests can - `ReplaceScoped`/`ReplaceSingleton` any component (repository, clock, feature flag, etc.) with a test - double, per test, with zero ceremony. -- Outbound `HttpClient` calls are intercepted via `MockHttpClientFactory` - (`src/UnitTestEx/Mocking/MockHttpClientFactory.cs`), which replaces the singleton - `IHttpClientFactory` in that same DI container with a Moq-backed fake. Requests are matched - (method/URL/headers/body) and canned responses returned — entirely in-process, no real HTTP. -- `appsettings.unittest.json` + `AdditionalConfiguration` layer test-only configuration in. - -This is fast (no process spin-up, no network), fully deterministic, and gives complete component-level -control — but it can only ever exercise **one** service's pipeline at a time. Anything the service calls -outside itself (another domain's API, a payment gateway) is necessarily a mock, never the real thing. - -## 2. Why Aspire changes the rules - -`Aspire.Hosting.Testing`'s `DistributedApplicationTestingBuilder` starts the **entire AppHost** — -every project, container, and executable resource it declares — as **separate real processes**, -wired together with Aspire's real service discovery, exactly as they'd run in production: - -```csharp -var appHost = await DistributedApplicationTestingBuilder.CreateAsync(); -await using var app = await appHost.BuildAsync(); -await app.StartAsync(); - -await app.ResourceNotifications.WaitForResourceHealthyAsync("shopping", cts.Token); -using var httpClient = app.CreateHttpClient("shopping"); -``` - -Consequences that matter for test design: - -- **No cross-process DI.** You cannot `ConfigureServices`/`ReplaceScoped` into another process's - container. Aspire's own docs are explicit about this: *"Because Aspire tests run services in separate - processes, you can't inject services directly through dependency injection. However, you can - influence application behavior through environment variables or configuration."* Overrides go through - `appHost.CreateResourceBuilder("name").WithEnvironment(...)`, or `WithExplicitStart` - and config-conditional resource registration in the AppHost itself — not through an in-memory DI hook. -- **Real network.** Requests actually leave one process's socket and arrive at another's Kestrel - listener — real serialization, real middleware pipeline, real service-discovery resolution. -- **Higher cost.** Process start-up, health-check waits, and (for container resources) Docker pulls make - these tests orders of magnitude slower than an in-memory `TestServer` round-trip. - -None of this is a limitation to "work around" — it's precisely what makes Tier 2 valuable: it's the only -way to prove that two real services actually talk to each other correctly. - -## 3. The two-tier model — pick one per test, don't force a hybrid - -- **Tier 1 stays exactly as it is.** If the point of a test is "does the Shopping service correctly - handle a `ProductPriceChanged` event, with the repository/clock/feature-flag mocked out precisely" — - that's a single-service, deep-DI-control test. Use `ApiTesterBase` directly. No amount of Aspire - wrapping replaces this, because the DI reach-in it relies on simply isn't available across process - boundaries. -- **Tier 2 (new `UnitTestEx.Aspire`) is for the case where the point of the test *is* the real - interaction between two or more domains.** If you actually want to prove Shopping's real HTTP call to - Products resolves correctly, hits the real routing/model binding/middleware in Products, and gets a - real response back — that can only be proven by running both as real processes. -- A hybrid (Tier-1-hosted service running alongside Tier-2 resources, with Aspire's resolved endpoints - fed into a `WebApplicationFactory`'s configuration) is *technically* possible, but it's a narrow, - advanced escape hatch — not something to design the mainline API around. Keep it out of scope for the - initial `UnitTestEx.Aspire` package; document it later only if a real need shows up. - -## 4. Worked scenario: Shopping + Products calling an external mail/payment gateway - -This is the shape of test the user described, mapped onto the model above: - -1. **Shopping and Products are the "key internals" under test** → run both as real Aspire project - resources under Tier 2. A request into Shopping that triggers a real call into Products exercises - the actual contract between them — the thing Tier 1 can never prove. -2. **The mail/payment gateway is a true external 3rd-party boundary**, not something you own or want to - hit for real in a test → swap it for a **`WireMock.Net.Aspire`** resource in the AppHost, and point - Shopping's/Products' configuration at the WireMock endpoint instead of the real one: - - ```csharp - var gateway = builder.AddWireMock("payment-gateway"); // WireMock.Net.Aspire resource - - builder.AddProject("shopping") - .WithReference(gateway) - .WithEnvironment("PaymentGateway__BaseUrl", gateway.GetEndpoint("http")); - ``` - - In the test, stub the gateway's responses through WireMock's server API (real HTTP, matched by - route/method/body) before asserting on Shopping's/Products' behavior. - -## 5. Is WireMock the equivalent of UnitTestEx's `MockHttpClientFactory`? - -**Conceptually, yes** — both let you declare "when a request matching X comes in, respond with Y" and -verify the expected calls happened. **Mechanically, they're different tools solving the same intent at -different layers**: - -| | `MockHttpClientFactory` (Tier 1) | WireMock.Net (Tier 2) | -|---|---|---| -| What it replaces | `IHttpClientFactory` inside one process's DI container | Nothing — it's a real HTTP server | -| Reachable from | Only the process it's registered in | Any process on the network — that's the whole point | -| Transport | In-memory (`DelegatingHandler`), no socket | Real HTTP, in-proc host or container | -| Works across Aspire processes? | No | Yes | - -So for Tier 2, WireMock.Net (via the official `WireMock.Net.Aspire` package) *is* the cross-process -counterpart of what `MockHttpClientFactory` does in-process for Tier 1. To keep the authoring experience -consistent, `UnitTestEx.Aspire` should ship a thin fluent wrapper over WireMock.Net's stub API, mirroring -the existing `MockHttpClient`/`MockHttpClientRequest`/`MockHttpClientResponse` builder syntax as closely -as possible, so switching between tiers doesn't mean learning a new mocking DSL. - -### 5.1 `HttpMock` implementation notes (finalized during build-out) - -Two semantics divergences from WireMock.Net's raw defaults were deliberately closed to keep the `HttpMock` -authoring experience consistent with Tier 1's `MockHttpClientFactory`: - -- **Sequenced responses (`WithSequenceAsync`) exactly mirror Tier 1**: the sequence length is the *exact* - expected invocation count (not a minimum, not silently repeating the last response forever) — the same - as `MockHttpClientRequest`'s `Responses` mode. `.Times(...)` is disallowed in combination with - `WithSequenceAsync` (throws `InvalidOperationException`) because it's meaningless once the sequence - itself is the exact-count contract. An invocation beyond the configured sequence returns a distinct - `500 InternalServerError` from a synthetic guard mapping (WireMock.Net can't throw a .NET exception - across the process boundary the way Tier 1 can from inside the mocked call), and `VerifyAsync()` checks - both that guard was never hit and that every configured response was invoked exactly once. -- **`WithJsonBody(..., pathsToIgnore: ...)` uses WireMock.Net's native `JsonPartialMatcher`**, not - UnitTestEx's own `JsonElementComparer` — matching happens inside the (potentially separate-process) - WireMock.Net server, which has no way to call back into the test process's comparer. Named paths are - stripped from the pattern before it's posted, then the matcher switches from strict `JsonMatcher` to - `JsonPartialMatcher`. This is deliberately simpler than Tier 1's `pathsToIgnore` (dot-separated property - names only, no JSONPath/array indices) and *looser*: `JsonPartialMatcher` also silently tolerates any - other unanticipated extra property in the actual body, not just the ones explicitly ignored. Good enough - for the common case (an unpredictable ETag/timestamp/GUID) but callers should know it isn't a drop-in - equivalent of Tier 1's stricter bidirectional-deep-equal-minus-ignored-paths behavior. Left unchanged - (strict `JsonMatcher`) when `pathsToIgnore` isn't supplied. - -### 5.2 Shared `IHttpMock*` interfaces — write the stubbing code once, use it on either tier - -Tier 1's `MockHttpClient*` (Moq-based, in-process) and Tier 2/3's `AspireHttpMock*` (WireMock.Net-based, -out-of-process) both implement a common set of interfaces in `src/UnitTestEx/Mocking/`: -`IHttpMockClient`, `IHttpMockRequest`, `IHttpMockRequestBody`, `IHttpMockResponse`, -`IHttpMockResponseSequence`, `IHttpMockResponseSequenceItem`, and `IHttpMockedRequest`. A helper method -written once against `IHttpMockClient` (e.g. `HttpMockSharedConfig.ConfigureProductStubAsync` in the test -suites) configures request/response stubbing identically regardless of which concrete tier is passed in — -only the `Times`/JSON-comparison/sequence-exhaustion *semantics* remain tier-specific (see 5.1 above); the -authoring surface itself is unified. - -Each interface is deliberately kept to a minimal, irreducible core of abstract members; every convenience -overload (a plain-text `WithBody(text)`, a raw-JSON-string `WithJsonAsync(json)`, the embedded-resource -`WithJsonResource*` variants, `Headers(...)`, an integer-millisecond `Delay(int)`, etc.) is a C# default -interface method (DIM) composed purely from those core members, so neither tier has to reimplement them. -A DIM is only reached when called through a variable of the *interface* type — calling a same-named method -directly on the concrete class invokes that class's own native method instead, an entirely separate code -path. `tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs` exercises every DIM extra via -interface-typed variables (shared bytecode, so Tier-1-only coverage proves both tiers); a corresponding -Aspire test (`HttpMock_InterfaceCoreMembers_TierSpecificAdaptations` in `AspireTesterTest.cs`) exercises -the *core* members' hand-written, tier-specific explicit interface implementations instead (the 2-arg -`WithBody`, `WithJsonBody`, both branches of the `WithAsync` content/no-content adaptation, and the -`Action`-wrapping adapter inside `WithSequenceAsync`). - -Retrofitting Tier 1 onto this shared surface surfaced one genuine cross-tier semantic gap: -**`WithAnyBody()`**. Aspire's native implementation adds zero WireMock.Net body constraints — a true -wildcard, matching a request with or without a body. Tier 1's native implementation, however, required -`request.Content != null` before matching, so it could never match a body-less request (e.g. a `GET`) — -inconsistent with both Aspire's behaviour and the interface's own documented contract ("matches regardless -of the body content"). Tier 1 was changed to also be a true wildcard, aligning it with Aspire; this was a -deliberate, user-approved behavior change to an existing native Tier 1 API (not just new interface-only -code), so any test previously relying on "a body-less request after `WithAnyBody()` still fails to match" -needed updating (see `UriAndAnyBody`/`DefaultHttpClient` in the Tier 1 test projects). - -## 6. Mocking individual components case-by-case across processes - -Since there's no cross-process DI, "mock this one component in that other service" has to be solved -without reaching into its container. Options, in order of preference: - -1. **Environment-variable/config-driven test hooks baked into each project's own `Program.cs`** — e.g. a - convention like the existing `appsettings.unittest.json`, where a project opts in to swapping a - component when a specific config value/env var is set. This is a per-project responsibility (a - documented convention UnitTestEx can recommend), not something injected externally. -2. **Swap the whole resource for a stub** — if you don't need the real implementation at all, replace it - in the AppHost with a WireMock resource or a minimal stand-in project, exactly as in the gateway - example above. -3. **Use Tier 1 directly** — if what you actually need is deep DI control of one specific service, that's - a sign the test's real goal is single-service isolation, not multi-host integration. Write it as a - Tier 1 test instead of contorting Tier 2 to fake DI reach-in it was never designed to have. - -## 7. The Playwright/UI angle - -If the AppHost grows to include a web frontend (Blazor, React, etc.), Tier 2 resources are real, -reachable URLs — so [Playwright can drive them directly](https://learn.microsoft.com/dotnet/aspire/fundamentals/testing/web-testing/playwright/), -an officially documented Microsoft pattern, not something UnitTestEx needs to build: - -```csharp -await app.ResourceNotifications.WaitForResourceHealthyAsync("frontend", cts.Token); -var frontendUrl = app.CreateHttpClient("frontend").BaseAddress; - -await using var playwright = await Playwright.CreateAsync(); -await using var browser = await playwright.Chromium.LaunchAsync(); -var page = await browser.NewPageAsync(); -await page.GotoAsync(frontendUrl!.ToString()); -``` - -This is a natural extension of Tier 2, not a third tier: the AppHost already orchestrates the frontend as -a real process, so a real browser can just point at it. Contrast with Tier 1, where `TestServer` has no -real socket — Playwright has nothing to connect to. `UnitTestEx.Aspire` doesn't need its own Playwright -wrapper; a documentation section showing this pattern (resource → real URL → Playwright) is enough. - -## 8. Sketch of the new `UnitTestEx.Aspire` package surface - -Illustrative only — not implemented by this doc. **Revised per section 9's analysis:** `AspireTesterBase` -extends the new `TesterBaseCore` (not `TesterBase`, and not `TesterBase`) — see section 9 for why. - -```csharp -public abstract class AspireTesterBase : TesterBaseCore, IAsyncDisposable - where TAppHost : class - where TSelf : AspireTesterBase -{ - // Builds + starts the DistributedApplicationTestingBuilder for TAppHost on first access, - // mirroring ApiTesterBase's lazy GetWebApplicationFactory() pattern. - protected Task GetDistributedApplicationAsync(); - - // Config/env overrides applied before BuildAsync (the only override surface available - // across process boundaries). - public TSelf WithResourceEnvironment(string resourceName, string key, string value); - - // Waits for a resource to be healthy/running before issuing requests against it. - public Task WaitForResourceAsync(string resourceName, TimeSpan? timeout = null); - - // Resource-scoped Http()/Http() wired to the resource's real HttpClient, reusing the - // existing HttpTester/HttpTester fluent assertion API for consistency with Tier 1 — made - // possible because HttpTesterBase depends only on TesterBaseCore plus the new IHttpClientSource - // seam (see section 9), not on anything DI-related from TesterBase/TesterBase. - public HttpTester Http(string resourceName); - public HttpTester Http(string resourceName); - - // Implements the new seam so HttpTesterBase can source an HttpClient for a named resource - // instead of a TestServer (see section 9). - HttpClient IHttpClientSource.CreateHttpClient(string? name) => - GetDistributedApplicationAsync().GetAwaiter().GetResult().CreateHttpClient(name ?? throw new ArgumentNullException(nameof(name))); -} -``` - -Note what's conspicuously *absent* here: no `Services`, no `Configuration`, no `NotSupportedException` -overrides at all. `AspireTesterBase` never inherits those members in the first place (they live on -`TesterBase`, which it doesn't extend) — so there's nothing to throw away or guard. A consumer's -IntelliSense on an `AspireTesterBase`-derived tester simply doesn't show `Services`/`Configuration`/ -`ReplaceSingleton`/etc. — the tier boundary is enforced by the compiler, not by a runtime exception. - -A WireMock resource plugs into the same model as just another named resource: stub it via the thin -wrapper described in section 5, then assert against the real domain resource's behavior via -`Http(resourceName)` as usual. - -## 9. `TesterBaseCore`: extracting the genuinely shared base - -**Revised conclusion, superseding two earlier drafts of this section.** The doc has now gone through two -iterations on this point, each closer to the truth: -1. First draft: `AspireTesterBase : TesterBase` — reuse the generic DI-heavy base - and guard away the parts that don't apply. Wrong — see point 2. -2. Second draft: `AspireTesterBase : TesterBase` (non-generic) — better, but still meant overriding the - two abstract `Services`/`Configuration` members to throw, and still relied on a runtime - `SupportsServiceConfiguration` guard (shipped in this repo, now reverted — see below) as a safety net - for anything else on `TesterBase` that might reach into DI. -3. **This draft:** extract a new abstract `TesterBaseCore`, containing only the members that are - genuinely host-agnostic. `TesterBase` (unchanged in shape from before this doc's changes) inherits it. - `AspireTesterBase` inherits it directly, **skipping `TesterBase` entirely**. This isn't just "one fewer - level of inheritance" — it means `AspireTesterBase` never has `Services`, `Configuration`, - `ConfigureServices`, or any DI-flavoured member to override, guard, or explain away. There's nothing to - throw because there's nothing to call. The tier boundary is enforced by what the compiler will let you - write, full stop — not by a runtime flag someone has to remember to check. - -**Why this is better than the runtime guard, concretely:** the guard's entire premise was "a Tier 2 -tester will end up with ~25 DI-flavoured methods it can't honor, so let's make them throw a clear -exception instead of silently no-opping." That premise assumed Tier 2 *would* inherit those methods. Once -`AspireTesterBase` simply doesn't — because it never extends the class that declares them — the premise -disappears, and with it the reason for the guard to exist. Keeping a public `SupportsServiceConfiguration` -property and a runtime check in `ConfigureServices` around after that would be exactly the kind of -speculative infrastructure the rest of this doc argues against: a flag that, in every real subclass that -will ever exist, can only ever be `true`. **The guard has been reverted from `TesterBase`** (it shipped -in this branch's PR before this revision; removing it is a clean, zero-impact change since the PR was -never released). - -**What's actually shared between the tiers, concretely, not hypothetically:** the evidence is -`HttpTesterBase` (`src/UnitTestEx/AspNetCore/HttpTesterBase.cs`) — the class that already implements -Tier 1's entire HTTP request/response/assertion engine (request+response logging, JSON (de)serialization, -the `Expectations`/`Assertors` pipeline, per-request log capture correlated via `SharedState`/`RequestId`, -the `OnBeforeHttpRequestMessageSendAsync` user-impersonation hook). It takes an `Owner` typed as -`TesterBase` today, but never touches `Services`, `Configuration`, `ConfigureServices`, or any -`Replace*`/`ScopedType`/`Type` member — everything it actually uses is a genuinely host-agnostic member. -That's real, working evidence — not a guess — of exactly where the tier boundary sits. - -**`TesterBaseCore` — the proposed member split, cross-checked against every member currently on -`TesterBase`:** - -| Stays agnostic → moves to `TesterBaseCore` | Stays DI-specific → stays on `TesterBase` | -|---|---| -| `Implementor`, `LoggerProvider`, `SharedState`, `SetUp` | `Configuration` (`abstract`) | -| `UserName`/`AdditionalConfiguration` (user-impersonation, config overrides) | `Services` (`abstract`) | -| `JsonSerializer`, `JsonComparerOptions`, `CreateJsonComparer()` | `ConfigureServices(Action, bool)` | -| `IsHostInstantiated`, `SyncRoot`, `ResetHost()` (abstract hook) | `AddConfiguredServices(IServiceCollection)` | -| `OnHostStart`/`OnHostStartUp` | the backing `_configureServices` list | -| `PreRunActions`/`PostRunBeforeExpectationsActions`/`PostRunAfterExpectationsActions`/`PostRunActions` + `Execute*Actions` | | -| `ReplaceTestFrameworkImplementor`, `LogHttpResponseMessage` | | -| all `CreateHttpRequest*`/`CreateJsonHttpRequest*` overloads (pure request-DTO builders, no DI touched at all) | | - -One mechanical wrinkle: today's public `ResetHost(bool resetConfiguredServices = false)` both resets -`IsHostInstantiated` (agnostic) *and* clears `_configureServices` (DI-specific) in one method. Splitting -the class means splitting this too: `TesterBaseCore` keeps a parameterless `ResetHost()` that resets -`IsHostInstantiated` and calls the abstract reset hook; `TesterBase` keeps the existing -`ResetHost(bool resetConfiguredServices)` overload, which additionally clears `_configureServices` before -calling `base.ResetHost()`. Existing Tier 1 callers see no change — same overload, same behavior. - -**The `IHttpClientSource` interface — the concrete answer to "what's common but doesn't fit the base -class":** `HttpTesterBase.CreateHttpClient()` is hard-coded to `TestServer.CreateHandler()` today. Rather -than adding a Tier-2-aware branch inside `HttpTesterBase` itself, or a virtual method that only makes -sense for one tier, the cleaner seam is a small interface: - -```csharp -public interface IHttpClientSource -{ - // name is the resource/client name for Tier 2 (e.g. Aspire's app.CreateHttpClient(name)) and - // ignored (or unused) for Tier 1, which always has exactly one TestServer to source from. - HttpClient CreateHttpClient(string? name = null); -} -``` - -`ApiTesterBase` (Tier 1) implements it by wrapping its existing `TestServer`; `AspireTesterBase` (Tier 2) -implements it by wrapping `app.CreateHttpClient(name)` (see the section 8 sketch). `HttpTesterBase` takes -an `IHttpClientSource` instead of reaching for a `TestServer` field directly, and calls -`Source.CreateHttpClient(resourceName)` where it currently calls `TestServer.CreateHandler()`. This is -exactly the pattern the discussion raised: a capability that's genuinely shared in *concept* between the -tiers, but whose *implementation* is unavoidably tier-specific, doesn't belong crammed onto the base -class (as a field/virtual method that only half the hierarchy can sensibly implement) — it belongs on a -small, focused interface that each tier's concrete tester implements on its own terms, and that -composed/consumed code (`HttpTesterBase`, future extension methods) depends on instead of a concrete -base type. Everything else in `HttpTesterBase` — logging, JSON, expectations, the user-impersonation -hook — needs no change at all; only the `HttpClient`-sourcing line moves behind this interface. - -**So, concretely, for Phase 3 (prototyping `AspireTesterBase`):** -1. Extract `TesterBaseCore` per the table above; `TesterBase : TesterBaseCore` (Tier 1's public shape is - unchanged — this is a pure "extract base class" refactor, source- and binary-compatible for existing - consumers). -2. Introduce `IHttpClientSource`; have `ApiTesterBase` implement it (wrapping its existing `TestServer`) - and have `HttpTesterBase` depend on the interface instead of a concrete `TestServer` field. -3. `AspireTesterBase : TesterBaseCore, IHttpClientSource, IAsyncDisposable` — see the - revised section 8 sketch. Zero `Services`/`Configuration`/`Replace*`/`ScopedType`/`Type` members: not - guarded away, simply never inherited. -4. `Http(resourceName)`/`Http(resourceName)` reuse `HttpTesterBase`/`HttpTester`/`HttpTester` - unchanged, fed via `AspireTesterBase`'s `IHttpClientSource` implementation. - -**Existing extension methods, re-audited against the new split.** `UnitTestEx.Azure.ServiceBus`'s and -`UnitTestEx.Azure.Functions`' extension methods on `TesterBase` only read `JsonSerializer`/`Implementor` -— both now on `TesterBaseCore` — so they're unaffected either way. `UnitTestEx/ExtensionMethods.cs`'s -`ReplaceSingleton`/`ReplaceScoped`/etc. are extensions on `IServiceCollection`, only ever invoked from -inside a `ConfigureServices(sc => ...)` callback — a callback that, for `AspireTesterBase`, is never -queued in the first place because `ConfigureServices` doesn't exist on the type it's calling through. -No runtime check needed anywhere in this chain. - -**`TestSetUp.ConfigureServices` — the one direct configuration entry point, now trivially out of reach -rather than merely unguarded.** This is a global `Action?` delegate (set once on -`TestSetUp`/`TestSetUp.Default`, not per-tester) that each *concrete* Tier 1 tester's own host-building -code invokes directly — e.g. `ApiTesterBase`/`GenericTesterCore` both call -`SetUp.ConfigureServices?.Invoke(sc)` immediately before `AddConfiguredServices(sc)` while constructing -their `IServiceCollection`. Under the previous (guard-based) draft this was called out as "safe by -construction, not by the guard, because there's no in-process `IServiceCollection` to build." Under this -draft that's even more clearly true: `AspireTesterBase`'s host-building code is written against -`TesterBaseCore`, which doesn't have `AddConfiguredServices`/`SetUp.ConfigureServices` plumbing to call in -the first place — there is no call site to reach, guarded or otherwise. - -## 10. Versioning and CI impact - -- Aspire requires **.NET 8+**. `UnitTestEx.Aspire` would target `net8.0;net9.0;net10.0` only — it cannot - support `net6.0`/`net7.0` the way the core `UnitTestEx` package currently does. - `Aspire.Hosting.Testing` is the driving dependency. -- The test project needs a `` to the AppHost project — `DistributedApplicationTestingBuilder.CreateAsync()` - requires a `Projects.*`-namespace type that's only source-generated for referenced AppHost projects. - This is a structural requirement on consumers, not something UnitTestEx can abstract away. -- CI runners need **Docker** available for any container-backed resource (WireMock container variant, - service bus/DB emulators). Tier 1 has no such requirement today. -- **Startup latency** is much higher — process spin-up and health-check waits per resource, vs. an - in-memory `TestServer`. Expect Tier 2 suites to run substantially slower and probably to be a smaller, - more targeted subset of the overall test suite than Tier 1 suites. -- **Log/trace capture** across multiple real processes is harder than today's single in-process - `ILoggerProvider` capture (`LoggerProvider.CreateLogger(...)` in `ApiTesterBase`) — Aspire's dashboard/ - OTLP pipeline is the natural place to look, rather than trying to replicate today's single-process log - capture across processes. - -## 11. Recommendation - -Ship this as a new, separately-versioned, **opt-in** `UnitTestEx.Aspire` package, following the same -companion-package pattern already established by `UnitTestEx.Azure.Functions` and -`UnitTestEx.Azure.ServiceBus` — the core `UnitTestEx` package and `ApiTesterBase`/`MockHttpClientFactory` -are untouched in observable behavior either way. The small, backward-compatible changes needed in core -`UnitTestEx` are the `TesterBaseCore` extraction and the new `IHttpClientSource` interface (section 9) — -everything else is additive. Suggested phasing for follow-up work: - -1. This design doc (done). -2. Extract `TesterBaseCore` from `TesterBase` per the member split in section 9 — a small, - backward-compatible "extract base class" refactor (Tier 1's public shape is unchanged); introduce - `IHttpClientSource` and have `ApiTesterBase`/`HttpTesterBase` implement/depend on it instead of a - hard-coded `TestServer` field. Land and release this independently, ahead of the rest. - - *(Note: an earlier draft of this doc shipped a runtime `SupportsServiceConfiguration` guard on - `TesterBase` as a first step instead. That guard has been reverted — the `TesterBaseCore` split - makes it structurally unnecessary: `AspireTesterBase` never inherits the DI-flavoured members it - would have guarded, so there was nothing left for it to protect.)* -3. Prototype `AspireTesterBase : TesterBaseCore` (see revised section 8/9) with - resource-scoped `Http()`/`Http()` (reusing the existing HTTP engine via `IHttpClientSource` from - step 2), `WaitForResourceAsync`, and environment-override helpers. -4. Thin WireMock.Net wrapper mirroring `MockHttpClient`'s authoring syntax. -5. Documentation for the Playwright/UI pattern (section 7) — no new code required, just guidance. - diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index 7169beb..75262d7 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -28,8 +28,7 @@ namespace UnitTestEx.Aspire /// The AppHost (a Projects.* type generated for the AppHost's ProjectReference). /// The to support inheriting fluent-style method-chaining. /// This extends the host-agnostic directly (not ) as the underlying is a real, - /// multi-process distributed application with no single, in-process dependency injection (DI) container to reach into; see the docs/design/aspire-multi-host-testing.md design - /// note for the rationale. Where a Tier 1 () capability is genuinely shared in concept, it is exposed here via the + /// multi-process distributed application with no single, in-process dependency injection (DI) container to reach into. Where a Tier 1 () capability is genuinely shared in concept, it is exposed here via the /// seam rather than by inheriting 's dependency injection (DI) specific members. public abstract class AspireTesterBase : TesterBaseCore, IHttpClientSource, IAsyncDisposable where TAppHost : class diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs index 7ac31b7..983e040 100644 --- a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs @@ -18,7 +18,7 @@ namespace UnitTestEx.Aspire.HttpMock ///
/// Unlike Tier 1's in-memory, purely synchronous (a Moq-based substitution), this issues genuine HTTP requests /// to the WireMock.Net server's admin API (a separate OS process, potentially in a container); every stub-defining and verification method is therefore asynchronous - there is no honest way - /// to hide that real network I/O behind a synchronous-looking API. See the docs/design/aspire-multi-host-testing.md design note for the rationale. + /// to hide that real network I/O behind a synchronous-looking API. /// Implements the shared abstraction (see that type's remarks) so that test-authoring code can be written once against the interface and reused /// identically regardless of which tier applied it. public sealed class AspireHttpMockClient : IHttpMockClient diff --git a/src/UnitTestEx/Mocking/IHttpMockClient.cs b/src/UnitTestEx/Mocking/IHttpMockClient.cs index 49a949b..ef04be5 100644 --- a/src/UnitTestEx/Mocking/IHttpMockClient.cs +++ b/src/UnitTestEx/Mocking/IHttpMockClient.cs @@ -9,8 +9,8 @@ namespace UnitTestEx.Mocking /// Tier 2/3's out-of-process AspireHttpMockClient (a thin wrapper over a real WireMock.Net server resource). ///
/// This abstraction allows a single piece of test-authoring code (e.g. a shared static helper method) to configure request/response stubbing identically regardless - /// of which tier is under test - only the concrete instance passed in differs. See the docs/design/aspire-multi-host-testing.md design note for - /// the tier-specific semantics that remain (JSON comparison engine, sequence-exhaustion behaviour) despite the shared surface. + /// of which tier is under test - only the concrete instance passed in differs. The tier-specific semantics that remain (JSON comparison engine, + /// sequence-exhaustion behaviour) despite the shared surface are documented on each concrete implementation. public interface IHttpMockClient { /// diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index f7847d4..245fd13 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -90,7 +90,7 @@ public async Task Reason_And_Wait_AggregatesResourceLogs() public async Task HttpMock_StubsExternalGatewayDependency_Product() { // Note: this exercises a real WireMock.Net container resource (added via 'gateway' in the AppHost using the official WireMock.Net.Aspire package); it requires Docker/Podman to - // be available to the CI/dev machine running the test (see docs/design/aspire-multi-host-testing.md). + // be available to the CI/dev machine running the test. await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); @@ -114,7 +114,7 @@ public async Task HttpMock_StubsExternalGatewayDependency_Product() [Fact] public async Task HttpMock_WithSequenceAsync_ReturnsResponsesInOrderThenExhausts() { - // Note: this exercises WireMock.Net's Scenario/state mechanism (see docs/design/aspire-multi-host-testing.md); each subsequent invocation returns the next configured + // Note: this exercises WireMock.Net's Scenario/state mechanism; each subsequent invocation returns the next configured // response. Unlike a single stub, a sequence's own completeness (exactly one invocation per configured response) IS the expectation - mirroring Tier 1's WithSequence - // so Times cannot be combined with it, and any invocation beyond the configured responses receives a distinct 500 "exhausted" response instead of silently repeating. await using var tester = AspireTester.Create() @@ -163,7 +163,7 @@ public async Task HttpMock_WithSequenceAsync_ExceedingConfiguredResponses_Throws [Fact] public async Task HttpMock_WithJsonBody_PathsToIgnore_IgnoresSpecifiedProperty() { - // Note: exercises WireMock.Net's own JsonPartialMatcher (see docs/design/aspire-multi-host-testing.md) - a variable 'eTag' is ignored from the match pattern, while a + // Note: exercises WireMock.Net's own JsonPartialMatcher - a variable 'eTag' is ignored from the match pattern, while a // genuinely differing (non-ignored) 'id' still fails to match any stub (WireMock.Net's default "no mapping found" 404 response). await using var tester = AspireTester.Create(); From 84db75f1b66d0b7470184c378d2df6c5883e1140 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 23 Sep 2026 17:27:05 -0700 Subject: [PATCH 28/43] Add UnitTestEx.Aspire to README Status table Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f5817ad..7fd3545 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,9 @@ The scenarios that _UnitTestEx_ looks to address is the end-to-end unit-style te The build and packaging status is as follows. -CI | `UnitTestEx` | `UnitTestEx.MSTest` | `UnitTestEx.NUnit` | `UnitTestEx.Xunit` --|-|-|-|- -[![CI](https://github.com/Avanade/UnitTestEx/workflows/CI/badge.svg)](https://github.com/Avanade/UnitTestEx/actions?query=workflow%3ACI) | [![NuGet version](https://badge.fury.io/nu/UnitTestEx.svg)](https://badge.fury.io/nu/UnitTestEx) | [![NuGet version](https://badge.fury.io/nu/UnitTestEx.MSTest.svg)](https://badge.fury.io/nu/UnitTestEx.MSTest) | [![NuGet version](https://badge.fury.io/nu/UnitTestEx.NUnit.svg)](https://badge.fury.io/nu/UnitTestEx.NUnit) | [![NuGet version](https://badge.fury.io/nu/UnitTestEx.Xunit.svg)](https://badge.fury.io/nu/UnitTestEx.Xunit) +CI | `UnitTestEx` | `UnitTestEx.Aspire` | `UnitTestEx.MSTest` | `UnitTestEx.NUnit` | `UnitTestEx.Xunit` +-|-|-|-|-|- +[![CI](https://github.com/Avanade/UnitTestEx/workflows/CI/badge.svg)](https://github.com/Avanade/UnitTestEx/actions?query=workflow%3ACI) | [![NuGet version](https://badge.fury.io/nu/UnitTestEx.svg)](https://badge.fury.io/nu/UnitTestEx) | [![NuGet version](https://badge.fury.io/nu/UnitTestEx.Aspire.svg)](https://badge.fury.io/nu/UnitTestEx.Aspire) | [![NuGet version](https://badge.fury.io/nu/UnitTestEx.MSTest.svg)](https://badge.fury.io/nu/UnitTestEx.MSTest) | [![NuGet version](https://badge.fury.io/nu/UnitTestEx.NUnit.svg)](https://badge.fury.io/nu/UnitTestEx.NUnit) | [![NuGet version](https://badge.fury.io/nu/UnitTestEx.Xunit.svg)](https://badge.fury.io/nu/UnitTestEx.Xunit) The included [change log](CHANGELOG.md) details all key changes per published version. From cef7aa5d908cbeea564bce4dcdf1ef2195ccd3df Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 23 Sep 2026 18:50:13 -0700 Subject: [PATCH 29/43] Add shared IHttpMockClient.WithRequestsFromResourceAsync to close YAML/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 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> --- README.md | 11 +- .../Mocking/HttpMockResourceConfig.cs | 187 ++++++++++++++++++ src/UnitTestEx/Mocking/IHttpMockClient.cs | 23 +++ .../AspireTesterTest.cs | 21 ++ .../AspireTesterTest-mock.unittestex.yaml | 16 ++ .../UnitTestEx.Aspire.Xunit.Test.csproj | 8 + .../HttpMockInterfaceTest.cs | 24 +++ ...HttpMockInterfaceTest-mock.unittestex.yaml | 30 +++ .../UnitTestEx.Xunit.Test.csproj | 2 + 9 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 src/UnitTestEx/Mocking/HttpMockResourceConfig.cs create mode 100644 tests/UnitTestEx.Aspire.Xunit.Test/Resources/AspireTesterTest-mock.unittestex.yaml create mode 100644 tests/UnitTestEx.Xunit.Test/Resources/HttpMockInterfaceTest-mock.unittestex.yaml diff --git a/README.md b/README.md index 7fd3545..0f4ae1e 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,15 @@ The following represents a YAML example for a request/response with sequences: _Note:_ Not all scenarios are currently available using YAML/JSON configuration. +The same YAML/JSON schema/file can also be loaded via the shared [`IHttpMockClient.WithRequestsFromResourceAsync`](./src/UnitTestEx/Mocking/IHttpMockClient.cs) - so a single embedded resource can configure both Tier 1's `MockHttpClient` above *and* Tier 2/3's `AspireHttpMockClient` (see [Aspire multi-host testing](#Aspire-multi-host-testing) below) identically: + +``` csharp +IHttpMockClient client = mcf.CreateClient("XXX", new Uri("https://unit-test")); // or tester.HttpMock("erp") for Aspire. +await client.WithRequestsFromResourceAsync("my.mock.unittestex.yaml"); +``` + +One intentional difference versus the native `WithRequestsFromResource` above: where a request entry omits `body` entirely, this shared loader matches *any* body (equivalent to `body: ^`), rather than Tier 1's native "no body at all" semantics - the shared interface has no means to express the latter. +
## Aspire multi-host testing @@ -358,7 +367,7 @@ tester.Http("api") await stub.VerifyAsync(); ``` -The fluent configuration API is intentionally near-identical to Tier 1's `MockHttpClientFactory` above (both implement the shared [`IHttpMockClient`](./src/UnitTestEx/Mocking/IHttpMockClient.cs)/`IHttpMockRequest`/`IHttpMockResponse` interfaces) - a helper method written once against these interfaces can configure request/response stubbing identically regardless of which tier it's handed. The main differences are that the terminal `With*` methods here are asynchronous (`AspireHttpMockClient` performs a real HTTP call to the WireMock.Net server's admin API to register each mapping) and must be awaited, and the underlying JSON comparison/sequence-exhaustion semantics are WireMock.Net's own (not identical to Tier 1's). +The fluent configuration API is intentionally near-identical to Tier 1's `MockHttpClientFactory` above (both implement the shared [`IHttpMockClient`](./src/UnitTestEx/Mocking/IHttpMockClient.cs)/`IHttpMockRequest`/`IHttpMockResponse` interfaces) - a helper method written once against these interfaces can configure request/response stubbing identically regardless of which tier it's handed. The main differences are that the terminal `With*` methods here are asynchronous (`AspireHttpMockClient` performs a real HTTP call to the WireMock.Net server's admin API to register each mapping) and must be awaited, and the underlying JSON comparison/sequence-exhaustion semantics are WireMock.Net's own (not identical to Tier 1's). The same shared interface also brings across Tier 1's [YAML/JSON configuration](#YAML/JSON-configuration) - `await tester.HttpMock("erp").WithRequestsFromResourceAsync("my.mock.unittestex.yaml")` loads the exact same embedded resource schema against a real WireMock.Net resource. _Note:_ Aspire-hosted resources are real OS processes/containers (Docker/Podman required for `AddWireMock` and similar container resources), so `AspireTester` tests are inherently slower than the in-process Tier 1 testers - use them where the inter-process interaction itself is what needs proving. diff --git a/src/UnitTestEx/Mocking/HttpMockResourceConfig.cs b/src/UnitTestEx/Mocking/HttpMockResourceConfig.cs new file mode 100644 index 0000000..223efff --- /dev/null +++ b/src/UnitTestEx/Mocking/HttpMockResourceConfig.cs @@ -0,0 +1,187 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Mime; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace UnitTestEx.Mocking +{ + /// + /// Provides the shared (tier-agnostic) YAML/JSON embedded-resource request/response configuration loading used by + /// . + /// + /// Uses the same YAML/JSON schema as Tier 1's native, synchronous (see src/UnitTestEx/Schema/mock.unittestex.json), + /// but is implemented purely against the shared /// abstractions - + /// so the same embedded resource works identically against Tier 1's and Tier 2/3's AspireHttpMockClient, without either tier reimplementing the parsing. + /// One intentional behavioral difference versus Tier 1's native method: where a request entry omits body entirely, Tier 1's native method matches only a request with no body + /// (content must be absent); this shared implementation instead matches any body (equivalent to body: ^), via - the shared interface has no + /// means to express "body must be absent", and this is both consistent with 's own true-wildcard semantics and almost always what's actually wanted + /// (e.g. a bodyless GET matches either way). + /// Response header values are applied via repeated / calls, one per + /// configured value; Tier 1 accumulates multiple values per header name, whereas Aspire's WireMock.Net-backed response currently retains only the last value written for a given header name. + internal static class HttpMockResourceConfig + { + /// + /// Adds mocked request(s) from the named embedded resource (formatted as either YAML or JSON) to the specified . + /// + /// The to configure. + /// The embedded resource name (matches to the end of the fully qualified resource name); must end in .yaml, .yml, .json or .jsn. + /// The that contains the embedded resource. + /// The . + public static async Task AddRequestsFromResourceAsync(IHttpMockClient client, string resourceName, Assembly assembly, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resourceName, nameof(resourceName)); + + bool isYaml; + if (resourceName.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) || resourceName.EndsWith(".yml", StringComparison.OrdinalIgnoreCase)) + isYaml = true; + else if (resourceName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) || resourceName.EndsWith(".jsn", StringComparison.OrdinalIgnoreCase)) + isYaml = false; + else + throw new ArgumentException("Only YAML or JSON embedded resources are supported; the extension must be one of the following: .yaml, .yml, .json, .jsn", nameof(resourceName)); + + using var sr = Resource.GetStream(resourceName, assembly); + var reqs = isYaml ? Resource.DeserializeYaml>(sr) : Resource.DeserializeJson>(sr); + + if (reqs is not null) + { + foreach (var req in reqs) + await req.AddAsync(client, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Infers the default media type for the specified where not explicitly configured: where it parses as JSON, + /// otherwise . + /// + private static string InferMediaType(string body) + { + try + { + _ = JsonDocument.Parse(body); + return MediaTypeNames.Application.Json; + } + catch (JsonException) + { + return MediaTypeNames.Text.Plain; + } + } + + /// + /// The mocked config contract for a request. + /// + private sealed class ConfigRequest + { + public string? Method { get; set; } + public string? Uri { get; set; } + public string? Body { get; set; } + public string? Media { get; set; } + public string[]? Ignore { get; set; } + public ConfigResponse? Response { get; set; } + public List? Sequence { get; set; } + + /// + /// Adds the request and response(s) to the . + /// + public async Task AddAsync(IHttpMockClient client, CancellationToken cancellationToken) + { + var request = client.Request(string.IsNullOrEmpty(Method) ? HttpMethod.Get : new HttpMethod(Method.ToUpperInvariant()), Uri); + + IHttpMockRequestBody body; + if (Body is not null && Body != "^") + { + var media = string.IsNullOrEmpty(Media) ? InferMediaType(Body) : Media; + body = string.Equals(media, MediaTypeNames.Application.Json, StringComparison.OrdinalIgnoreCase) + ? request.WithJsonBody(Body, Ignore ?? []) + : request.WithBody(Body, media); + } + else + body = request.WithAnyBody(); // Also covers the omitted-body case; see remarks on this class for the resulting (intentional) behavioral difference from Tier 1's native method. + + if (Response is not null && Sequence is not null) + throw new InvalidOperationException($"A mocked request can not contain both a {nameof(Response)} and a {nameof(Sequence)} as they are mutually exclusive."); + + // One-to-one response. + if (Sequence is null) + { + await (Response ?? new()).AddAsync(body.Respond, cancellationToken).ConfigureAwait(false); + return; + } + + // A sequence of responses. + await body.Respond.WithSequenceAsync(seq => + { + foreach (var res in Sequence) + (res ?? new()).Add(seq.Respond()); + }, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// The mocked config contract for a response. + /// + private sealed class ConfigResponse + { + public HttpStatusCode? Status { get; set; } + public string? Body { get; set; } + public string? Media { get; set; } + public Dictionary? Headers { get; set; } + + /// + /// Applies this response to a terminal (single) . + /// + public async Task AddAsync(IHttpMockResponse response, CancellationToken cancellationToken) + { + ApplyHeaders((name, value) => response.Header(name, value)); + + if (string.IsNullOrEmpty(Body)) + { + await response.WithAsync(Status ?? HttpStatusCode.NoContent, cancellationToken).ConfigureAwait(false); + return; + } + + var media = string.IsNullOrEmpty(Media) ? InferMediaType(Body) : Media; + await response.WithAsync(Body, Status ?? HttpStatusCode.OK, media, cancellationToken).ConfigureAwait(false); + } + + /// + /// Applies this response to a single within an . + /// + public void Add(IHttpMockResponseSequenceItem item) + { + ApplyHeaders((name, value) => item.Header(name, value)); + + if (string.IsNullOrEmpty(Body)) + { + item.With(Status ?? HttpStatusCode.NoContent); + return; + } + + var media = string.IsNullOrEmpty(Media) ? InferMediaType(Body) : Media; + item.With(Body, Status ?? HttpStatusCode.OK, media); + } + + /// + /// Applies each configured header name/value pair (one invocation per value) - see remarks on regarding the + /// cross-tier multi-value header discrepancy. + /// + private void ApplyHeaders(Action add) + { + if (Headers is null) + return; + + foreach (var header in Headers) + { + foreach (var value in header.Value ?? []) + add(header.Key, value ?? string.Empty); + } + } + } + } +} diff --git a/src/UnitTestEx/Mocking/IHttpMockClient.cs b/src/UnitTestEx/Mocking/IHttpMockClient.cs index ef04be5..0779921 100644 --- a/src/UnitTestEx/Mocking/IHttpMockClient.cs +++ b/src/UnitTestEx/Mocking/IHttpMockClient.cs @@ -1,6 +1,9 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using System.Net.Http; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; namespace UnitTestEx.Mocking { @@ -20,5 +23,25 @@ public interface IHttpMockClient /// The relative request URI (path) to match; where not specified any path will match. /// The to continue the fluent-style configuration. IHttpMockRequest Request(HttpMethod? method = null, string? requestUri = null); + + /// + /// Adds mocked request(s) from the named embedded resource (formatted as either YAML or JSON) within the calling , using the same schema as Tier 1's native + /// . + /// + /// The used to infer the that contains the embedded resource. + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// The . + Task WithRequestsFromResourceAsync(string resourceName, CancellationToken cancellationToken = default) => WithRequestsFromResourceAsync(resourceName, typeof(TAssembly).Assembly, cancellationToken); + + /// + /// Adds mocked request(s) from the named embedded resource (formatted as either YAML or JSON), using the same schema as Tier 1's native . + /// + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// The that contains the embedded resource; defaults to the calling where not specified. + /// The . + /// Works identically regardless of which tier's is supplied, composed purely from this interface's other members - see + /// for the shared implementation, including the one intentional behavioral difference from Tier 1's native method (how a request entry that omits body is matched). + Task WithRequestsFromResourceAsync(string resourceName, Assembly? assembly = null, CancellationToken cancellationToken = default) + => HttpMockResourceConfig.AddRequestsFromResourceAsync(this, resourceName, assembly ?? Assembly.GetCallingAssembly(), cancellationToken); } } diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index 245fd13..82974b1 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -204,6 +204,27 @@ public async Task HttpMock_SharedInterface_ConfiguresIdenticallyAcrossTiers() await stub.VerifyAsync(); } + [Fact] + public async Task HttpMock_WithRequestsFromResourceAsync_LoadsStubsFromYaml_IdenticallyToTier1() + { + // Proves the shared IHttpMockClient.WithRequestsFromResourceAsync DIM (see HttpMockResourceConfig in the core UnitTestEx project) loads the exact same YAML/JSON schema + // as Tier 1's native MockHttpClient.WithRequestsFromResource against a real WireMock.Net gateway resource - closing the feature gap between the two tiers. + await using var tester = AspireTester.Create(); + + await tester.WaitForResourceAsync("gateway"); + + IHttpMockClient client = tester.HttpMock("gateway"); + await client.WithRequestsFromResourceAsync("AspireTesterTest-mock.unittestex.yaml"); + + tester.Http("gateway").Run(HttpMethod.Get, "/resource-config/simple") + .AssertOK() + .AssertValue(new { id = "FromResource", description = "Loaded from shared YAML resource." }); + + tester.Http("gateway").Run(HttpMethod.Post, "/resource-config/echo", new { id = "Abc", stamp = Guid.NewGuid().ToString() }) + .AssertAccepted() + .AssertValue(new { id = "Abc", description = "matched" }); + } + [Fact] public async Task HttpMock_InterfaceCoreMembers_TierSpecificAdaptations() { diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/Resources/AspireTesterTest-mock.unittestex.yaml b/tests/UnitTestEx.Aspire.Xunit.Test/Resources/AspireTesterTest-mock.unittestex.yaml new file mode 100644 index 0000000..6c2fe2f --- /dev/null +++ b/tests/UnitTestEx.Aspire.Xunit.Test/Resources/AspireTesterTest-mock.unittestex.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Avanade/UnitTestEx/refs/heads/main/src/UnitTestEx/Schema/mock.unittestex.json +- method: get + uri: /resource-config/simple + response: + status: 200 + body: | + {"id":"FromResource","description":"Loaded from shared YAML resource."} + +- method: post + uri: /resource-config/echo + body: '{"id":"Abc","stamp":"ignore-me"}' + ignore: [ stamp ] + response: + status: 202 + body: | + {"id":"Abc","description":"matched"} diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/UnitTestEx.Aspire.Xunit.Test.csproj b/tests/UnitTestEx.Aspire.Xunit.Test/UnitTestEx.Aspire.Xunit.Test.csproj index 1f3ce11..ea0f91b 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/UnitTestEx.Aspire.Xunit.Test.csproj +++ b/tests/UnitTestEx.Aspire.Xunit.Test/UnitTestEx.Aspire.Xunit.Test.csproj @@ -18,6 +18,14 @@ + + + + + + + + diff --git a/tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs b/tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs index 705ffa9..16a7628 100644 --- a/tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs +++ b/tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs @@ -120,6 +120,30 @@ public async Task Interface_Response_WithJsonAsync_StringAndResource_Dim() await stub2.VerifyAsync(); } + [Fact] + public async Task Interface_Client_WithRequestsFromResourceAsync_Dim_LoadsStubsFromYaml() + { + var mcf = MockHttpClientFactory.Create(); + IHttpMockClient client = mcf.CreateClient("XXX", new System.Uri("https://d365test")); + + await client.WithRequestsFromResourceAsync("HttpMockInterfaceTest-mock.unittestex.yaml"); // DIM: shared YAML/JSON resource loader. + + var hc = mcf.GetHttpClient("XXX"); + + var res1 = await hc.PostAsJsonAsync("products/xyz", new { product = "xyz", quantity = 1, stamp = "does-not-matter" }); + Assert.Equal(HttpStatusCode.Accepted, res1.StatusCode); + Assert.Equal("{\"product\":\"xyz\",\"quantity\":1}", (await res1.Content.ReadAsStringAsync()).Trim()); + + var res2 = await hc.GetAsync("people/123"); + Assert.Equal(HttpStatusCode.OK, res2.StatusCode); + Assert.Equal("abc", res2.Headers.GetValues("x-blah").Single()); + + var res3 = await hc.GetAsync("sequence"); + Assert.Equal("{\"seq\":1}", (await res3.Content.ReadAsStringAsync()).Trim()); + var res4 = await hc.GetAsync("sequence"); + Assert.Equal("{\"seq\":2}", (await res4.Content.ReadAsStringAsync()).Trim()); + } + [Fact] public async Task Interface_ResponseSequenceItem_Extras_Dim() { diff --git a/tests/UnitTestEx.Xunit.Test/Resources/HttpMockInterfaceTest-mock.unittestex.yaml b/tests/UnitTestEx.Xunit.Test/Resources/HttpMockInterfaceTest-mock.unittestex.yaml new file mode 100644 index 0000000..c32d28b --- /dev/null +++ b/tests/UnitTestEx.Xunit.Test/Resources/HttpMockInterfaceTest-mock.unittestex.yaml @@ -0,0 +1,30 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Avanade/UnitTestEx/refs/heads/main/src/UnitTestEx/Schema/mock.unittestex.json +- method: post + uri: products/xyz + body: '{"product":"xyz","quantity":1,"stamp":"ignore-me"}' + ignore: [ stamp ] + response: + status: 202 + body: | + {"product":"xyz","quantity":1} + +- method: get + uri: people/123 + response: + headers: + x-blah: [ abc ] + body: | + { + "first":"Bob", + "last":"Jane" + } + +- method: get + uri: sequence + sequence: + - status: 200 + body: | + {"seq":1} + - status: 200 + body: | + {"seq":2} diff --git a/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj b/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj index 02ab924..4c0696c 100644 --- a/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj +++ b/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj @@ -8,6 +8,7 @@ + @@ -19,6 +20,7 @@ + From 5ab50790e4a6cbd399a8ad3224865c754cea99f3 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Thu, 24 Sep 2026 03:35:11 -0700 Subject: [PATCH 30/43] Add UnitTestEx.Aspire for .NET Aspire testing in v5.12.0 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. --- CHANGELOG.md | 3 +++ Common.targets | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd55491..e67f2d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ Represents the **NuGet** versions. +## v5.12.0 +- *Enhancement:* Added `UnitTestEx.Aspire` (NET8+) to enable testing of [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/) multi-host (inter-domain) testing alongside existing in-process `WebApplicationFactory`. The API surface between the two is consistent where appropriate. Additionally, `HttpClient` mocking is supported via `AspireHttpClientMock` which internally leverages [](). + ## v5.11.1 - *Fixed:* Wrapped `Assembly.LoadFrom` in `TestSetUp` with try-catch to log exceptions and prevent setup failures. diff --git a/Common.targets b/Common.targets index 0ea044b..4b8740c 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 5.11.1 + 5.12.0 preview Avanade Avanade From 9fd9185669b742c760da0161018622a5c38c00f2 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Thu, 24 Sep 2026 09:22:46 -0700 Subject: [PATCH 31/43] Add self-hosted WireMock.Net MockHost pattern, JsonElementComparerMatcher, 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> --- CHANGELOG.md | 5 +- README.md | 42 +++- UnitTestEx.sln | 15 ++ .../Aspire/AspireTesterBase.cs | 121 +++++++++-- .../Aspire/HttpMock/AspireHttpMockClient.cs | 18 +- .../Aspire/HttpMock/AspireHttpMockRequest.cs | 50 ++++- .../Aspire/JsonElementComparerMatcher.cs | 137 ++++++++++++ .../Aspire/WireMockConsole.cs | 71 +++++++ .../UnitTestEx.Aspire.csproj | 9 +- .../UnitTestExAspireExtensions.cs | 56 +++++ src/UnitTestEx/Abstractions/TesterBaseCore.cs | 28 +-- src/UnitTestEx/Abstractions/TesterBaseT.cs | 27 +-- tests/UnitTestEx.Aspire.AppHost/AppHost.cs | 27 ++- .../UnitTestEx.Aspire.AppHost.csproj | 11 +- .../AspireTesterTest.cs | 14 +- .../UnitTestEx.Aspire.MSTest.Test.csproj | 1 + tests/UnitTestEx.Aspire.MockHost/Program.cs | 16 ++ .../UnitTestEx.Aspire.MockHost.csproj | 37 ++++ .../AspireTesterTest.cs | 14 +- .../UnitTestEx.Aspire.NUnit.Test.csproj | 1 + .../AspireTesterTest.cs | 198 +++++++++++++++--- .../UnitTestEx.Aspire.Xunit.Test.csproj | 1 + ...onAndWaitTest.cs => ReasonAndDelayTest.cs} | 22 +- ...onAndWaitTest.cs => ReasonAndDelayTest.cs} | 22 +- ...onAndWaitTest.cs => ReasonAndDelayTest.cs} | 24 +-- 25 files changed, 815 insertions(+), 152 deletions(-) create mode 100644 src/UnitTestEx.Aspire/Aspire/JsonElementComparerMatcher.cs create mode 100644 src/UnitTestEx.Aspire/Aspire/WireMockConsole.cs create mode 100644 src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs create mode 100644 tests/UnitTestEx.Aspire.MockHost/Program.cs create mode 100644 tests/UnitTestEx.Aspire.MockHost/UnitTestEx.Aspire.MockHost.csproj rename tests/UnitTestEx.MSTest.Test/Other/{ReasonAndWaitTest.cs => ReasonAndDelayTest.cs} (79%) rename tests/UnitTestEx.NUnit.Test/Other/{ReasonAndWaitTest.cs => ReasonAndDelayTest.cs} (80%) rename tests/UnitTestEx.Xunit.Test/Other/{ReasonAndWaitTest.cs => ReasonAndDelayTest.cs} (76%) diff --git a/CHANGELOG.md b/CHANGELOG.md index e67f2d7..e42a66e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,9 @@ Represents the **NuGet** versions. ## v5.12.0 -- *Enhancement:* Added `UnitTestEx.Aspire` (NET8+) to enable testing of [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/) multi-host (inter-domain) testing alongside existing in-process `WebApplicationFactory`. The API surface between the two is consistent where appropriate. Additionally, `HttpClient` mocking is supported via `AspireHttpClientMock` which internally leverages [](). - +- *Enhancement:* Added `UnitTestEx.Aspire` (NET8+) to enable testing of [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/) multi-host (inter-domain) testing alongside existing in-process `WebApplicationFactory`. The API surface between the two is consistent where appropriate. + - Additionally, `HttpClient` mocking of external dependencies is supported via `AspireHttpMockClient`, which stubs a real, out-of-process [WireMock.Net](https://wiremock.org/dotnet/) server resource - see the README's [Aspire multi-host testing](./README.md#Aspire-multi-host-testing) section for the recommended, self-hosted (Docker-free) pattern. + ## v5.11.1 - *Fixed:* Wrapped `Assembly.LoadFrom` in `TestSetUp` with try-catch to log exceptions and prevent setup failures. diff --git a/README.md b/README.md index 0f4ae1e..dee29d6 100644 --- a/README.md +++ b/README.md @@ -333,21 +333,32 @@ tester.Http("shopping") .AssertValue(new { id = "123", product = "Widget" }); ``` -External-to-the-solution dependencies (an email/notification provider, an identity/auth service, an ERP system, a payment gateway, etc.) still need to be mocked - a real inter-domain test proves *your* services talk to each other correctly, not that a third-party's sandbox environment is up. For this, add a [`WireMock.Net.Aspire`](https://github.com/WireMock-Net/WireMock.Net-Aspire) container resource per external dependency to the AppHost, and wire its endpoint in as configuration: +External-to-the-solution dependencies (an email/notification provider, an identity/auth service, an ERP system, a payment gateway, etc.) still need to be mocked - a real inter-domain test proves *your* services talk to each other correctly, not that a third-party's sandbox environment is up. UnitTestEx's recommended pattern is to **self-host** [WireMock.Net](https://github.com/WireMock-Net/WireMock.Net) as an ordinary Aspire *project* resource (`AddProject`) - one small console app per external system, added to your own solution - rather than the official [`WireMock.Net.Aspire`](https://github.com/WireMock-Net/WireMock.Net-Aspire) package's *container* resource (`AddWireMock`). UnitTestEx does not ship this host as a package (there's nothing to install or version); [`UnitTestEx.Aspire.MockHost`](./tests/UnitTestEx.Aspire.MockHost) is a template you copy into your own solution (referencing `UnitTestEx.Aspire`, which ships both the `JsonElementComparerMatcher` type and the `WireMockConsole.RunAsync` helper used below, so there's no matcher code - or port/custom-matcher/graceful-shutdown boilerplate - to write yourself): + +``` csharp +// MockApis/Program.cs +await WireMockConsole.RunAsync(settings => WireMockServer.Start(settings)); +``` + +`WireMockConsole.RunAsync` reads the `PORT` environment variable Aspire assigns, builds the `WireMockServerSettings` with `JsonElementComparerMatcher` already registered, invokes your factory to start the actual server (any `IWireMockServer` - typically `WireMockServer.Start`), then blocks gracefully until Aspire stops the process (Ctrl+C locally, SIGTERM in orchestration), disposing the server on the way out. ``` csharp // AppHost.cs -var email = builder.AddWireMock("email"); -var auth = builder.AddWireMock("auth"); -var erp = builder.AddWireMock("erp"); +var email = builder.AddMockHostProject("email"); +var auth = builder.AddMockHostProject("auth"); +var erp = builder.AddMockHostProject("erp"); builder.AddProject("api") - .WithEnvironment("Email__BaseUrl", email.GetEndpoint("http")) - .WithEnvironment("Auth__BaseUrl", auth.GetEndpoint("http")) - .WithEnvironment("Erp__BaseUrl", erp.GetEndpoint("http")); + .WithMockHostEnvironment("Email__BaseUrl", email, "http") + .WithMockHostEnvironment("Auth__BaseUrl", auth, "http") + .WithMockHostEnvironment("Erp__BaseUrl", erp, "http"); ``` -Each is a genuine, isolated WireMock.Net container/process - one per external system, so stubs configured for `"email"` can never leak into `"auth"` or `"erp"`. Within a test, `AspireTesterBase.HttpMock(resourceName)` returns a fluent `AspireHttpMockClient` for the named resource: +`AddMockHostProject`/`WithMockHostEnvironment` (both extension methods on `UnitTestEx.Aspire`, in the `Aspire.Hosting` namespace alongside Aspire's own `AddProject`/`WithEnvironment`) exist because each mock resource is test-only and must never appear in a *published manifest* - the JSON resource graph `aspire publish` (or `dotnet run --publisher manifest`) emits for deployment tooling (e.g. Azure Developer CLI) to turn into real infrastructure; a mock host has no production equivalent, so including it there would make deployment tooling try to provision it as if it were real. `AddMockHostProject` only actually adds the resource when `IDistributedApplicationBuilder.ExecutionContext.IsRunMode` is `true` (returning `null` in publish mode instead), and `WithMockHostEnvironment` accepts that potentially-`null` result directly, no-op'ing rather than requiring an `if (email is not null)` guard at every call site. + +Being just our own process (not a published, off-the-shelf binary), it can register `JsonElementComparerMatcher` - a custom `IMatcher`, shipped as part of `UnitTestEx.Aspire`, that delegates JSON body matching to UnitTestEx's own [`JsonElementComparer`](./src/UnitTestEx/Json/JsonElementComparer.cs) - via `WireMockServerSettings.CustomMatcherMappings`. This gives Tier 2/3 JSON body matching genuine parity with Tier 1 (semantic value coercion for dates, GUIDs and numbers), rather than being limited to WireMock.Net's own textual `JsonMatcher`/`JsonPartialMatcher`. It also drops the Docker/Podman requirement entirely - it's an ordinary .NET console app, so there's nothing to pull or run as a container. The official `WireMock.Net.Aspire` container resource (`AddWireMock`) remains fully supported for teams already standardized on that package - `AspireTesterBase.HttpMock` works against either resource type identically, since request matching is driven by the resource's admin API rather than its hosting mechanism. + +Each mock resource is a genuine, isolated WireMock.Net process - one per external system, so stubs configured for `"email"` can never leak into `"auth"` or `"erp"`. Within a test, `AspireTesterBase.HttpMock(resourceName)` returns a fluent `AspireHttpMockClient` for the named resource: ``` csharp await using var tester = AspireTester.Create(); @@ -367,9 +378,20 @@ tester.Http("api") await stub.VerifyAsync(); ``` -The fluent configuration API is intentionally near-identical to Tier 1's `MockHttpClientFactory` above (both implement the shared [`IHttpMockClient`](./src/UnitTestEx/Mocking/IHttpMockClient.cs)/`IHttpMockRequest`/`IHttpMockResponse` interfaces) - a helper method written once against these interfaces can configure request/response stubbing identically regardless of which tier it's handed. The main differences are that the terminal `With*` methods here are asynchronous (`AspireHttpMockClient` performs a real HTTP call to the WireMock.Net server's admin API to register each mapping) and must be awaited, and the underlying JSON comparison/sequence-exhaustion semantics are WireMock.Net's own (not identical to Tier 1's). The same shared interface also brings across Tier 1's [YAML/JSON configuration](#YAML/JSON-configuration) - `await tester.HttpMock("erp").WithRequestsFromResourceAsync("my.mock.unittestex.yaml")` loads the exact same embedded resource schema against a real WireMock.Net resource. +The fluent configuration API is intentionally near-identical to Tier 1's `MockHttpClientFactory` above (both implement the shared [`IHttpMockClient`](./src/UnitTestEx/Mocking/IHttpMockClient.cs)/`IHttpMockRequest`/`IHttpMockResponse` interfaces) - a helper method written once against these interfaces can configure request/response stubbing identically regardless of which tier it's handed. The main differences are that the terminal `With*` methods here are asynchronous (`AspireHttpMockClient` performs a real HTTP call to the WireMock.Net server's admin API to register each mapping) and must be awaited, and the underlying JSON comparison/sequence-exhaustion semantics are WireMock.Net's own (not identical to Tier 1's) - unless you use `WithJsonBodyUsingUnitTestExComparer` instead of `WithJsonBody`: + +``` csharp +var stub = await tester.HttpMock("erp") + .Request(HttpMethod.Post, "orders") + .WithJsonBodyUsingUnitTestExComparer(new { id = "Abc", occurredAt = "2024-01-01T00:00:00Z" }) // matches "2024-01-01T00:00:00.000+00:00" too - same instant, same as Tier 1 + .Respond.WithJsonAsync(new { id = "Abc", status = "Accepted" }); +``` + +_Note:_ `WithJsonBodyUsingUnitTestExComparer` requires the target resource to have registered `JsonElementComparerMatcher` (i.e. a self-hosted resource per the template above) - the official `WireMock.Net.Aspire` container resource has no way to load a custom .NET matcher type. Where WireMock's own strict, non-semantic textual matching is instead wanted against a self-hosted resource, configure `JsonElementComparerOptions.ValueComparison` to `JsonElementComparison.Exact` rather than falling back to `WithJsonBody`. + +The same shared interface also brings across Tier 1's [YAML/JSON configuration](#YAML/JSON-configuration) - `await tester.HttpMock("erp").WithRequestsFromResourceAsync("my.mock.unittestex.yaml")` loads the exact same embedded resource schema against a real WireMock.Net resource. -_Note:_ Aspire-hosted resources are real OS processes/containers (Docker/Podman required for `AddWireMock` and similar container resources), so `AspireTester` tests are inherently slower than the in-process Tier 1 testers - use them where the inter-process interaction itself is what needs proving. +_Note:_ Aspire-hosted resources are real OS processes (containers, where you opt into one), so `AspireTester` tests are inherently slower than the in-process Tier 1 testers - use them where the inter-process interaction itself is what needs proving.
diff --git a/UnitTestEx.sln b/UnitTestEx.sln index 781f21c..b699669 100644 --- a/UnitTestEx.sln +++ b/UnitTestEx.sln @@ -56,6 +56,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestEx.Aspire.NUnit.Tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestEx.Aspire.MSTest.Test", "tests\UnitTestEx.Aspire.MSTest.Test\UnitTestEx.Aspire.MSTest.Test.csproj", "{6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestEx.Aspire.MockHost", "tests\UnitTestEx.Aspire.MockHost\UnitTestEx.Aspire.MockHost.csproj", "{E66415A4-85F2-4A18-97F5-EB6A99C9F5C3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -270,6 +272,18 @@ Global {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}.Release|x64.Build.0 = Release|Any CPU {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}.Release|x86.ActiveCfg = Release|Any CPU {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD}.Release|x86.Build.0 = Release|Any CPU + {E66415A4-85F2-4A18-97F5-EB6A99C9F5C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E66415A4-85F2-4A18-97F5-EB6A99C9F5C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E66415A4-85F2-4A18-97F5-EB6A99C9F5C3}.Debug|x64.ActiveCfg = Debug|Any CPU + {E66415A4-85F2-4A18-97F5-EB6A99C9F5C3}.Debug|x64.Build.0 = Debug|Any CPU + {E66415A4-85F2-4A18-97F5-EB6A99C9F5C3}.Debug|x86.ActiveCfg = Debug|Any CPU + {E66415A4-85F2-4A18-97F5-EB6A99C9F5C3}.Debug|x86.Build.0 = Debug|Any CPU + {E66415A4-85F2-4A18-97F5-EB6A99C9F5C3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E66415A4-85F2-4A18-97F5-EB6A99C9F5C3}.Release|Any CPU.Build.0 = Release|Any CPU + {E66415A4-85F2-4A18-97F5-EB6A99C9F5C3}.Release|x64.ActiveCfg = Release|Any CPU + {E66415A4-85F2-4A18-97F5-EB6A99C9F5C3}.Release|x64.Build.0 = Release|Any CPU + {E66415A4-85F2-4A18-97F5-EB6A99C9F5C3}.Release|x86.ActiveCfg = Release|Any CPU + {E66415A4-85F2-4A18-97F5-EB6A99C9F5C3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -292,6 +306,7 @@ Global {1A2AF108-BC04-4643-99F1-306865E06C8F} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} {A87D7300-5B77-4552-A4F2-60D566AD154D} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} {6E3FE4D0-AA5E-4DA9-9697-ABC6459DCCDD} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} + {E66415A4-85F2-4A18-97F5-EB6A99C9F5C3} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5A6E4D99-0084-441B-8349-CB320963E931} diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index 75262d7..3a8c75d 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -47,6 +47,77 @@ public abstract class AspireTesterBase : TesterBaseCore, IHttpC /// The . protected AspireTesterBase(TestFrameworkImplementor implementor) : base(implementor) { } + /// + /// Replaces the by cloning the . + /// + /// The + /// The to support fluent-style method-chaining. + /// Updates the and from the . + /// Unlike 's (and other Tier 1 testers') equivalent, this does not reset the underlying + /// : - the reason Tier 1 must rebuild its single, in-process dependency injection (DI) container host - has + /// no equivalent here, as the AppHost is a genuine, separately-built multi-process distributed application that does not configure. Where its properties are + /// consumed by a Tier 2 test (e.g. when sending a request via , or + /// / when asserting a response), they are read live at request/assert time, so no reset is + /// required for a subsequent call to see the change. + public TSelf UseSetUp(TestSetUp setUp) + { + SetUp = setUp?.Clone() ?? throw new ArgumentNullException(nameof(setUp)); + JsonSerializer = SetUp.JsonSerializer; + JsonComparerOptions = SetUp.JsonComparerOptions; + return (TSelf)this; + } + + /// + /// Updates (replaces) the default test . + /// + /// The test user name (a null value will reset to ). + /// The to support fluent-style method-chaining. + public TSelf UseUser(string? userName) + { + UserName = userName ?? SetUp.DefaultUserName; + return (TSelf)this; + } + + /// + /// Updates (replaces) the default test . + /// + /// The test user identifier (a null value will reset to ). + /// The to support fluent-style method-chaining. + /// The is required for the conversion to take place. + public TSelf UseUser(object? userIdentifier) + { + if (userIdentifier == null) + return UseUser(null); + + if (SetUp.UserNameConverter == null) + throw new InvalidOperationException($"The {nameof(TestSetUp)}.{nameof(TestSetUp.UserNameConverter)} must be defined to support user identifier conversion."); + + return UseUser(SetUp.UserNameConverter(userIdentifier)); + } + + /// + /// Updates the used by the itself, not any underlying resource which should be configured separately. + /// + /// The . + /// The to support fluent-style method-chaining. + public TSelf UseJsonSerializer(Json.IJsonSerializer jsonSerializer) + { + JsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); + return (TSelf)this; + } + + /// + /// Updates the used by the itself, not any underlying resource which should be configured separately. + /// + /// The . + /// The to support fluent-style method-chaining. + /// Where the is null then the will be used. + public TSelf UseJsonComparerOptions(Json.JsonElementComparerOptions options) + { + JsonComparerOptions = options ?? throw new ArgumentNullException(nameof(options)); + return (TSelf)this; + } + /// /// Gets the ; builds and starts on first access. /// @@ -69,7 +140,7 @@ private async Task CreateDistributedApplicationAsync() if (!HostDiagnosticsEnabled) // By default the AppHost's own logging (its start-up banner, DCP process management, and - critically - each resource's own console output mirrored under a // "{ApplicationName}.Resources.{resourceName}" category; see 'EnableResourceLogging') writes straight to the test's Standard Output via its default provider(s) (e.g. console), - // duplicating what UnitTestEx already captures and reports cleanly (and correlated) via Reason()/Wait()/the Http() request-scoped "LOGGING >" section. Remove the AppHost's + // duplicating what UnitTestEx already captures and reports cleanly (and correlated) via Reason()/Delay()/the Http() request-scoped "LOGGING >" section. Remove the AppHost's // own provider(s) so only what UnitTestEx explicitly writes reaches the test output; call EnableHostDiagnostics() before this point to opt back into the raw firehose (e.g. when // troubleshooting an AppHost/resource start-up failure). builder.Services.RemoveAll(); @@ -155,7 +226,7 @@ public TSelf WithResourceEnvironment(string resourceName, string key, string val /// /// Opts back into the AppHost's own raw logging (its start-up banner, DCP process management, and each resource's own console output) being written to the test output, in addition - /// to what UnitTestEx itself reports via //the request-scoped "LOGGING >" section. + /// to what UnitTestEx itself reports via //the request-scoped "LOGGING >" section. /// /// The to support fluent-style method-chaining. /// By default this raw firehose is suppressed as it otherwise duplicates what UnitTestEx already surfaces cleanly (and correlated); enable it when troubleshooting an @@ -207,14 +278,24 @@ public TSelf Reason(string reason) } /// - /// Waits for the specified , then writes any resource log messages captured (across all resources) during that time to the test output. + /// Delays for the specified , then writes any resource log messages captured (across all resources) during that time to the test output. + /// + /// The duration to delay; defaults to where not specified. + /// The reason for delaying (written to the test output for context); defaults to "No reason specified" where not specified. + /// The to support fluent-style method-chaining. + /// Useful when waiting on background/inter-resource activity (e.g. a message being processed by a downstream resource) that is not tied to a specific HTTP request/response (see + /// for the latter, request-scoped, correlation). + public TSelf Delay(TimeSpan? duration = null, string? reason = null) => WriteDelay(reason, duration).ContinueWith(_ => (TSelf)this).Result; + + /// + /// Delays for the specified , then writes any resource log messages captured (across all resources) during that time to the test output. /// - /// The reason for waiting (written to the test output for context); defaults to "Not specified." where not specified. - /// The duration to wait; defaults to where not specified. + /// The amount of time, in milliseconds, to delay. Must be a non-negative . + /// The reason for delaying (written to the test output for context); defaults to "No reason specified" where not specified. /// The to support fluent-style method-chaining. /// Useful when waiting on background/inter-resource activity (e.g. a message being processed by a downstream resource) that is not tied to a specific HTTP request/response (see /// for the latter, request-scoped, correlation). - public TSelf Wait(string? reason = null, TimeSpan? duration = null) => WriteWait(reason, duration).ContinueWith(_ => (TSelf)this).Result; + public TSelf Delay(int durationInMilliseconds, string? reason = null) => Delay(TimeSpan.FromMilliseconds(durationInMilliseconds), reason); /// /// Combines the elapsed log messages captured (via ) across all resources since the last invocation, as background/inter-resource @@ -255,12 +336,23 @@ public TSelf Reason(string reason) public HttpTester Http(string resourceName, string? endpointName = null) => new(this, new ResourceHttpClientSource(this, resourceName, endpointName)); /// - /// Enables a fluent to stub HTTP responses from a WireMock.Net server resource (added via the official WireMock.Net.Aspire package's - /// builder.AddWireMock(name) in the AppHost), for mocking external/third-party HTTP dependencies within a Tier 2 test. + /// Enables a fluent to stub HTTP responses from a real, out-of-process WireMock.Net server resource, for mocking external/third-party + /// HTTP dependencies within a Tier 2 test. /// /// The WireMock.Net server resource name (as configured within the AppHost) to target. - /// The optional endpoint name; where not specified, the resource's single default endpoint is used (see DistributedApplicationExtensions.CreateWireMockAdminClient). + /// The optional endpoint name; where not specified, the resource's default endpoint is used. /// The . + /// Works against any resource type exposing a WireMock.Net admin API endpoint - most commonly a self-hosted, ordinary Aspire project resource (a small, + /// self-contained sample the consumer copies into their own solution; see UnitTestEx's README "Aspire multi-host testing" section for the ~20-line template) - UnitTestEx's + /// recommended default, as it needs no Docker/Podman and unlocks for genuine + /// JSON comparison-semantics parity with Tier 1. It equally works against the official WireMock.Net.Aspire package's container resource (builder.AddWireMock(name)), + /// for teams already standardized on that package - though its JsonMatcher/JsonPartialMatcher only offer WireMock.Net's own JSON comparison semantics (see + /// remarks). + /// The admin API client is built directly from a resolved (the same resolution path + /// uses), rather than via the official package's own resource-type-specific helper - which keeps this resource-type-agnostic. + /// This tester's current (see ) is captured live at the point + /// is subsequently called - not at this method's call time - so a prior + /// call is always honoured. public HttpMock.AspireHttpMockClient HttpMock(string resourceName, string? endpointName = null) { if (resourceName is null) throw new ArgumentNullException(nameof(resourceName)); @@ -268,8 +360,9 @@ public HttpMock.AspireHttpMockClient HttpMock(string resourceName, string? endpo return new HttpMock.AspireHttpMockClient(async () => { var app = await GetDistributedApplicationAsync().ConfigureAwait(false); - return app.CreateWireMockAdminClient(resourceName, endpointName); - }); + var httpClient = app.CreateHttpClient(resourceName, endpointName); + return RestEase.RestClient.For(httpClient); + }, () => JsonComparerOptions); } /// @@ -327,7 +420,7 @@ public void FlushPendingEntries() /// physical lines back into a single logical entry using the same style as an in-process (Tier 1) tester, re-using Aspire's own embedded timestamp (converted to local time) rather than /// substituting the capture time, so timestamps reflect when the resource itself actually logged the entry. The owning resource name is appended, in parentheses, inside the same /// trailing "[...]" bracket as the category - rather than as a separate leading prefix - so the timestamp/level/message stay column-aligned whether read via a single-resource - /// () or multi-resource () report. + /// () or multi-resource () report. /// A line that does not match the expected "{lineNumber}: {timestamp}Z " prefix (e.g. a resource that does not use the standard console logger format) is passed through /// unmodified, ANSI colour codes aside, so nothing is silently dropped. private sealed class ResourceLogger(string resourceName, ConcurrentQueue buffer) : ILogger @@ -425,8 +518,8 @@ private void FlushPendingNoLock() /// window of lines captured between (invoked immediately before the request is sent) and (invoked /// immediately after the response is received) is used as a pragmatic proxy - this naturally excludes the resource's own start-up banner noise (already buffered by the time the first /// request is sent) and correlates correctly for the typical, sequential one-request-at-a-time usage pattern. The window's baseline/end-point is tracked via the same shared, - /// per-resource watermark () that (used by ) advances, so a resource log line reported - /// here is claimed and will not also be re-reported by a subsequent call (or vice versa). + /// per-resource watermark () that (used by ) advances, so a resource log line reported + /// here is claimed and will not also be re-reported by a subsequent call (or vice versa). private sealed class ResourceHttpClientSource(AspireTesterBase owner, string resourceName, string? endpointName) : IHttpClientSource { public HttpClient CreateHttpClient(string? name = null) diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs index 983e040..a070780 100644 --- a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs @@ -13,8 +13,8 @@ namespace UnitTestEx.Aspire.HttpMock { /// - /// Provides a thin fluent wrapper over a real, out-of-process WireMock.Net server resource (added to the AppHost via the official WireMock.Net.Aspire package's - /// builder.AddWireMock(name)), for stubbing HTTP responses from a Tier 2 () multi-host test. + /// Provides a thin fluent wrapper over a real, out-of-process WireMock.Net server resource - typically a self-hosted, ordinary Aspire project resource (see + /// remarks) - for stubbing HTTP responses from a Tier 2 () multi-host test. /// /// Unlike Tier 1's in-memory, purely synchronous (a Moq-based substitution), this issues genuine HTTP requests /// to the WireMock.Net server's admin API (a separate OS process, potentially in a container); every stub-defining and verification method is therefore asynchronous - there is no honest way @@ -24,12 +24,24 @@ namespace UnitTestEx.Aspire.HttpMock public sealed class AspireHttpMockClient : IHttpMockClient { private readonly Func> _adminApiFactory; + private readonly Func _jsonComparerOptionsFactory; /// /// Initializes a new instance of the class. /// /// The factory function used to (asynchronously) resolve the underlying . - internal AspireHttpMockClient(Func> adminApiFactory) => _adminApiFactory = adminApiFactory ?? throw new ArgumentNullException(nameof(adminApiFactory)); + /// The function used to read the owning tester's current (see ). + internal AspireHttpMockClient(Func> adminApiFactory, Func jsonComparerOptionsFactory) + { + _adminApiFactory = adminApiFactory ?? throw new ArgumentNullException(nameof(adminApiFactory)); + _jsonComparerOptionsFactory = jsonComparerOptionsFactory ?? throw new ArgumentNullException(nameof(jsonComparerOptionsFactory)); + } + + /// + /// Gets the owning tester's current , read live (not cached) so a later + /// call is honoured by a subsequent . + /// + internal Json.JsonElementComparerOptions JsonComparerOptions => _jsonComparerOptionsFactory(); /// /// Gets the underlying as an escape hatch for scenarios not covered by the fluent wrapper (e.g. scenarios, proxying, gRPC/protobuf mappings). diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs index 77c0e14..da16305 100644 --- a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs @@ -110,7 +110,8 @@ public AspireHttpMockRequestBody WithBody(string text, string mediaType) /// The to continue the fluent-style configuration. /// See the remarks regarding WireMock.Net's JSON comparison semantics versus (as used by, for /// example, ): they are not equivalent, and there is no semantic value coercion - /// (e.g. dates, GUIDs) here. + /// (e.g. dates, GUIDs) here. Where that parity matters, target a self-hosted WireMock.Net project resource (see remarks) + /// and use instead. /// Where is specified, the named properties are removed from the match pattern and the underlying matcher switches from an exact, /// bidirectional JsonMatcher to WireMock.Net's own JsonPartialMatcher (a subset match): properties present in the pattern must still match exactly, but the /// ignored (removed) properties - and any other property not present in the pattern - are not checked at all, regardless of their value or even presence in the actual @@ -166,6 +167,53 @@ private static void RemovePath(JsonNode node, string path) /// The to continue the fluent-style configuration. public AspireHttpMockRequestBody WithJsonBody(T value, params string[] pathsToIgnore) => WithJsonBody(JsonSerializer.Serialize(value), pathsToIgnore); + /// + /// Indicates that the request body must match the specified using UnitTestEx's own - i.e. the same + /// comparison semantics as Tier 1 (including semantic value coercion for dates, GUIDs and numbers) - rather than WireMock.Net's own JSON comparison (see ). + /// + /// The JSON body to match. + /// The simple (dot-separated) property paths to exclude from the match - see ; + /// unlike , this still requires an exact 1:1 property correspondence apart from the ignored paths (true parity with Tier 1), rather + /// than switching to a looser subset/partial match. + /// The to continue the fluent-style configuration. + /// Requires the target to have been resolved against a self-hosted WireMock.Net project resource (via + /// , targeting a small sample project the consumer copies into their own solution - see UnitTestEx's README + /// "Aspire multi-host testing" section) that has registered - the official WireMock.Net.Aspire package's container resource has + /// no way to load this custom matcher type and will reject the mapping with a "Matcher 'JsonElementComparerMatcher' is not supported" error. + /// The tester's current - specifically , + /// and - is captured (as data, into the matcher's + /// payload) at the point this method is called, since the matcher itself runs in a separate OS process (the self-hosted WireMock.Net server) with no access to this test + /// process's live state; mutating or this tester's + /// after calling this method has no effect on an already-configured mapping. Where strict, WireMock-style textual matching is instead wanted against a self-hosted + /// resource, call with set to + /// before calling this method, rather than falling back to . + public AspireHttpMockRequestBody WithJsonBodyUsingUnitTestExComparer([StringSyntax(StringSyntaxAttribute.Json)] string json, params string[] pathsToIgnore) + { + ArgumentNullException.ThrowIfNull(json); + + var options = Client.JsonComparerOptions; + var envelope = JsonSerializer.Serialize(new + { + Json = json, + PathsToIgnore = pathsToIgnore, + options.ValueComparison, + options.NullComparison, + options.MaxDifferences + }); + Rule.Body = new BodyModel { Matcher = new MatcherModel { Name = JsonElementComparerMatcher.MatcherName, Pattern = envelope } }; + return new(this); + } + + /// + /// Indicates that the request body must match the JSON serialized representation of the specified using UnitTestEx's own ; + /// see remarks. + /// + /// The value . + /// The value to serialize and match. + /// The simple property paths to exclude from the match pattern; see remarks. + /// The to continue the fluent-style configuration. + public AspireHttpMockRequestBody WithJsonBodyUsingUnitTestExComparer(T value, params string[] pathsToIgnore) => WithJsonBodyUsingUnitTestExComparer(JsonSerializer.Serialize(value), pathsToIgnore); + /// /// Indicates that the request body must match the JSON formatted embedded resource content (using WireMock.Net's JSON comparison semantics). /// diff --git a/src/UnitTestEx.Aspire/Aspire/JsonElementComparerMatcher.cs b/src/UnitTestEx.Aspire/Aspire/JsonElementComparerMatcher.cs new file mode 100644 index 0000000..3c49ee0 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/JsonElementComparerMatcher.cs @@ -0,0 +1,137 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System; +using AnyOfTypes; +using WireMock.Admin.Mappings; +using WireMock.Matchers; +using WireMock.Models; +using JsonSerializer = System.Text.Json.JsonSerializer; +using UnitTestExJsonElementComparer = UnitTestEx.Json.JsonElementComparer; + +namespace UnitTestEx.Aspire +{ + /// + /// A custom WireMock.Net that delegates JSON body matching to UnitTestEx's own , giving a + /// self-hosted WireMock.Net project resource (see remarks and the README's "Aspire multi-host testing" section) genuine JSON + /// comparison-semantics parity with Tier 1's in-process MockHttpClient - notably semantic value coercion for dates, GUIDs and numbers (e.g. "2024-01-01T00:00:00Z" + /// and "2024-01-01T00:00:00+00:00" are considered equal) - which WireMock.Net's own JsonMatcher/JsonPartialMatcher (a textual/structural comparison) does + /// not provide. + /// + /// Register this against the well-known matcher name via in the + /// self-hosted WireMock.Net process (see UnitTestEx.Aspire.MockHost's Program.cs template); the corresponding client-side mapping is produced by + /// , which encodes the JSON pattern, any pathsToIgnore and the calling + /// test's (the comparison-affecting subset of it - see ) as a small carried in + /// (WireMock.Net's admin API has no other extensibility point for passing matcher-specific configuration through to a + /// CustomMatcherMappings factory). This matcher instance runs in the self-hosted WireMock.Net process - a genuinely separate OS process from the test - so it has no + /// access to the test process's /; the carried subset of the latter + /// is therefore used to build a dedicated per mapping, rather than falling back to . + /// On a non-match caused by an unparseable request body (invalid JSON), a is attached to the returned + /// - consistent with WireMock.Net's own built-in matchers' convention for a genuine evaluation failure. This is + /// deliberately NOT done for an ordinary semantic mismatch (a completed comparison that simply scored zero): WireMock.Net's internal MappingMatcher.FindBestMatch + /// treats ANY non-null as "this mapping failed to evaluate" and excludes it entirely from partial/closest-match + /// tracking, so attaching a per-mismatch diagnostic there would silently break WireMock.Net's own "closest match" debugging (visible via its admin API/dashboard) rather + /// than improve it. See for details. + public sealed class JsonElementComparerMatcher : IStringMatcher + { + /// + /// Gets the well-known matcher name used for both the registration key (host-side) and the + /// value (client-side). + /// + public const string MatcherName = nameof(JsonElementComparerMatcher); + + private readonly Envelope _envelope; + private readonly UnitTestExJsonElementComparer _comparer; + + /// + /// Initializes a new instance of the class from the supplied by WireMock.Net's + /// CustomMatcherMappings factory callback. + /// + /// The received via the admin API mapping request. + public JsonElementComparerMatcher(MatcherModel matcherModel) + { + ArgumentNullException.ThrowIfNull(matcherModel); + var pattern = matcherModel.Pattern?.ToString() ?? throw new ArgumentException($"The {MatcherName} matcher requires a Pattern containing the encoded {nameof(Envelope)}.", nameof(matcherModel)); + _envelope = JsonSerializer.Deserialize(pattern) ?? throw new ArgumentException($"The {MatcherName} matcher's Pattern could not be deserialized as a {nameof(Envelope)}.", nameof(matcherModel)); + MatchBehaviour = matcherModel.RejectOnMatch == true ? MatchBehaviour.RejectOnMatch : MatchBehaviour.AcceptOnMatch; + + // Rebuild (rather than use UnitTestExJsonElementComparer.Default) as this matcher runs in a separate OS process from the test that configured it - see remarks above. + _comparer = new UnitTestExJsonElementComparer(new UnitTestEx.Json.JsonElementComparerOptions + { + ValueComparison = _envelope.ValueComparison, + NullComparison = _envelope.NullComparison, + MaxDifferences = _envelope.MaxDifferences + }); + } + + /// + public string Name => MatcherName; + + /// + public MatchBehaviour MatchBehaviour { get; } + + /// + public MatchOperator MatchOperator => MatchOperator.Or; + + /// + public MatchResult IsMatch(string? input) + { + bool isMatch; + try + { + var result = _comparer.Compare(_envelope.Json, input ?? "null", _envelope.PathsToIgnore ?? []); + isMatch = result.AreEqual; + } + catch (ArgumentException ex) + { + // The input (or, less likely, the configured pattern) is not valid JSON. Unlike an ordinary non-match (a completed comparison that simply scored zero), this is a + // genuine evaluation failure - consistent with WireMock.Net's own built-in matchers (e.g. JsonMatcher), attach it as the MatchResult's Exception. Note this is + // deliberately NOT done for an ordinary semantic mismatch: WireMock.Net's MappingMatcher.FindBestMatch treats ANY non-null MatchResult.Exception as "this mapping + // failed to evaluate" - it excludes the mapping entirely from partial/closest-match tracking (so it never appears via the admin API's request log) and instead + // only logs the exception via WireMock.Net's own internal error logger. Attaching a diagnostic exception on every ordinary mismatch would therefore silently + // break WireMock.Net's built-in "closest match" debugging experience rather than improve it. + return MatchResult.From(Name, new JsonElementComparerMatcherException("The request body is not valid JSON.", ex)); + } + + return MatchResult.From(Name, MatchBehaviour, isMatch); + } + + /// + public AnyOf[] GetPatterns() => [new(_envelope.Json)]; + + /// + public string GetCSharpCodeArguments() => $"new {typeof(JsonElementComparerMatcher).FullName}(/* not supported for this matcher */)"; + + /// + /// The small payload carried in (as a serialized JSON string) that lets the client pass the JSON pattern to match, the pathsToIgnore, + /// and the comparison-affecting subset of the calling test's through WireMock.Net's admin API, which otherwise has no dedicated + /// field for custom matcher configuration. + /// + /// The JSON pattern to match against. + /// The simple (dot-separated) property paths to exclude from the comparison; see . + /// The at the point the mapping was configured. + /// The at the point the mapping was configured. + /// The at the point the mapping was configured. + public sealed record Envelope(string Json, string[]? PathsToIgnore, Json.JsonElementComparison ValueComparison, Json.JsonElementComparison NullComparison, int MaxDifferences); + } + + /// + /// Represents a evaluation failure - specifically, that the request body could not be parsed as JSON. This is attached to + /// only for that genuine failure case (never for an ordinary semantic mismatch - see + /// remarks), consistent with how WireMock.Net's own built-in matchers use . + /// + public class JsonElementComparerMatcherException : Exception + { + /// + /// Initializes a new instance of the class with a specified message. + /// + /// The message text. + public JsonElementComparerMatcherException(string? message) : base(message) { } + + /// + /// Initializes a new instance of the class with a specified message and inner exception. + /// + /// The message text. + /// The inner . + public JsonElementComparerMatcherException(string? message, Exception innerException) : base(message, innerException) { } + } +} diff --git a/src/UnitTestEx.Aspire/Aspire/WireMockConsole.cs b/src/UnitTestEx.Aspire/Aspire/WireMockConsole.cs new file mode 100644 index 0000000..71d20c5 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/WireMockConsole.cs @@ -0,0 +1,71 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using WireMock.Admin.Mappings; +using WireMock.Matchers; +using WireMock.Server; +using WireMock.Settings; + +namespace UnitTestEx.Aspire +{ + /// + /// Encapsulates the standard boilerplate for UnitTestEx's recommended self-hosted WireMock.Net Aspire project resource (see the README's "Aspire multi-host testing" section + /// and UnitTestEx.Aspire.MockHost's Program.cs) - reading the PORT environment variable Aspire assigns via WithHttpEndpoint(env: "PORT"), registering + /// as a custom matcher, invoking the caller-supplied factory to start the actual WireMock.Net server, then blocking gracefully until + /// Aspire stops the process (Ctrl+C locally, SIGTERM in orchestration) before disposing it. This turns the console app's Program.cs into essentially a one-liner: + /// await WireMockConsole.RunAsync(settings => WireMockServer.Start(settings)); + /// + public static class WireMockConsole + { + /// + /// Reads the PORT environment variable, builds a with registered against + /// , invokes to start the actual WireMock.Net server, then blocks until the process is asked + /// to shut down (Ctrl+C or SIGTERM), disposing the returned before returning. + /// + /// A factory that starts and returns the - typically settings => WireMockServer.Start(settings). The caller's + /// own project must reference the full WireMock.Net (or WireMock.Net.StandAlone) package for WireMock.Server.WireMockServer itself; this method + /// only depends on WireMock.Net.Abstractions' /, which UnitTestEx.Aspire already brings in. + /// An optional additional that also triggers shutdown (e.g. to host this in-process for a test); shutdown + /// otherwise occurs on Ctrl+C or process exit. + public static async Task RunAsync(Func server, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + + var portText = Environment.GetEnvironmentVariable("PORT") ?? throw new InvalidOperationException("The 'PORT' environment variable must be set (expected to be supplied by Aspire's WithHttpEndpoint(env: \"PORT\"))."); + if (!int.TryParse(portText, out var port)) + throw new InvalidOperationException($"The 'PORT' environment variable value '{portText}' is not a valid port number."); + + var settings = new WireMockServerSettings + { + Port = port, + StartAdminInterface = true, + CustomMatcherMappings = new Dictionary> + { + [JsonElementComparerMatcher.MatcherName] = model => new JsonElementComparerMatcher(model) + } + }; + + using var wireMockServer = server(settings); + + Console.WriteLine($"{nameof(WireMockConsole)}: WireMock.Net server listening on port {port} (custom matcher '{JsonElementComparerMatcher.MatcherName}' registered)."); + + // Block until Aspire stops the process (Ctrl+C locally, SIGTERM in orchestration) rather than a bare Thread.Sleep(Timeout.Infinite), so shutdown is graceful (the + // 'using' above still disposes/stops the WireMock.Net server) rather than relying purely on the OS killing the process. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + AppDomain.CurrentDomain.ProcessExit += (_, _) => cts.Cancel(); + + try + { + await Task.Delay(Timeout.Infinite, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + } +} diff --git a/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj b/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj index 8fc6704..da3d40c 100644 --- a/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj +++ b/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj @@ -1,4 +1,4 @@ - + net8.0;net9.0;net10.0 @@ -11,8 +11,13 @@ - + + + diff --git a/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs b/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs new file mode 100644 index 0000000..939f3e7 --- /dev/null +++ b/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs @@ -0,0 +1,56 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Aspire.Hosting.ApplicationModel; + +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace Aspire.Hosting +#pragma warning restore IDE0130 // Namespace does not match folder structure +{ + /// + /// Provides / extension methods for AppHost projects that use UnitTestEx's recommended + /// self-hosted WireMock.Net "mockhost" pattern (see the README's "Aspire multi-host testing" section and UnitTestEx.Aspire.MockHost). Deliberately placed in the + /// namespace (matching Aspire's own extension method convention) so these methods are discoverable alongside AddProject/WithEnvironment + /// without an extra using. + /// + public static class UnitTestExAspireExtensions + { + /// + /// Adds a self-hosted WireMock.Net project resource (e.g. a copy of UnitTestEx.Aspire.MockHost) - but only when + /// is in run mode (). The resource is never added in publish mode, since it is a test-only stand-in for an + /// external dependency with no real production equivalent and must never appear in a published manifest (see + /// for wiring the resulting endpoint into another resource's environment without a null check at every call site). + /// + /// The generated Projects metadata type (see Aspire's AppHost SDK) for the mock host project, e.g. Projects.UnitTestEx_Aspire_MockHost. + /// The . + /// The resource name (e.g. "mockhost"). + /// The optional launch profile name to use; pass null (the default) to declare the endpoint explicitly via WithHttpEndpoint + /// rather than relying on launchSettings.json. + /// The for the mock host, with its http endpoint bound to the PORT environment variable (see + /// , which reads it); or null when not in run mode. + public static IResourceBuilder? AddMockHostProject(this IDistributedApplicationBuilder builder, [ResourceName] string name, string? launchProfileName = null) where TProject : IProjectMetadata, new() + => builder.ExecutionContext.IsRunMode ? builder.AddProject(name, launchProfileName).WithHttpEndpoint(env: "PORT") : null; + + /// + /// Sets an environment variable on to 's named endpoint - but only when is not null. + /// + /// This exists because (typically the result of ) is legitimately null in publish mode, + /// yet Aspire's own WithEnvironment(IResourceBuilder{T}, string, EndpointReference) overload requires a non-null - and there is no + /// null/default to fall back on. A second overload differing from Aspire's only by a nullable annotation + /// isn't possible either (both erase to the same CLR signature and would collide). Taking the nullable resource builder itself instead lets this + /// no-op cleanly when it's null, so call sites stay a single fluent line instead of repeating an if (source is not null) guard (and risking someone forgetting + /// it) at every optional-resource call site. + /// The resource type receiving the environment variable. + /// The resource type being referenced for its endpoint. + /// The to set the environment variable on. + /// The environment variable name. + /// The to resolve the endpoint from, or null to no-op (e.g. when the resource does not exist in the + /// current , such as publish mode). + /// The endpoint name to resolve from (e.g. "http"). + /// , for chaining. + public static IResourceBuilder WithMockHostEnvironment( + this IResourceBuilder builder, string name, IResourceBuilder? source, string endpointName) + where TDestination : IResourceWithEnvironment + where TSource : IResourceWithEndpoints + => source is null ? builder : builder.WithEnvironment(name, source.GetEndpoint(endpointName)); + } +} diff --git a/src/UnitTestEx/Abstractions/TesterBaseCore.cs b/src/UnitTestEx/Abstractions/TesterBaseCore.cs index 8dfd797..68b6e78 100644 --- a/src/UnitTestEx/Abstractions/TesterBaseCore.cs +++ b/src/UnitTestEx/Abstractions/TesterBaseCore.cs @@ -87,7 +87,7 @@ protected TesterBaseCore(TestFrameworkImplementor implementor) /// Gets the configured . /// /// Defaults to . - public TestSetUp SetUp { get; internal set; } + public TestSetUp SetUp { get; protected set; } /// /// Indicates whether the underlying host has been instantiated. @@ -116,13 +116,13 @@ public string UserName /// Gets the not from the underlying host. /// /// Defaults to . This does not use the instance from the underlying host as a different serializer may be required or may not have been configured. - public IJsonSerializer JsonSerializer { get; internal set; } + public IJsonSerializer JsonSerializer { get; protected set; } /// /// Gets the not from the underlying host. /// /// Defaults to . This does not use the instance from the underlying host as a different serializer may be required or may not have been configured. - public JsonElementComparerOptions JsonComparerOptions { get; internal set; } + public JsonElementComparerOptions JsonComparerOptions { get; protected set; } /// /// Creates a using the configured and . @@ -256,19 +256,19 @@ public void ReplaceTestFrameworkImplementor(TestFrameworkImplementor implementor /// Gets the log messages accumulated since the last time this was invoked (draining/resetting the starting point for the next invocation). /// /// The accumulated log messages; null/empty where none. - /// Used by to correlate output that occurs during a wait period rather than a specific HTTP request/response (see + /// Used by to correlate output that occurs during a delay period rather than a specific HTTP request/response (see /// for the latter, request-scoped, correlation). The default (Tier 1, single in-process host) implementation surfaces any /// logging that was not attributed to a specific HTTP request (see ) - e.g. from a background/hosted service. A Tier 2 (e.g. multi-host /// Aspire) tester should override this to surface whatever is applicable to its own hosting model. protected virtual IEnumerable? DrainElapsedLogMessages() => SharedState.GetLoggerMessages(); /// - /// Gets the default duration used by when none is specified; defaults to 1 second. + /// Gets the default duration used by when none is specified; defaults to 1 second. /// - public static TimeSpan DefaultWaitDuration { get; } = TimeSpan.FromSeconds(1); + public static TimeSpan DefaultDelayDuration { get; } = TimeSpan.FromSeconds(1); /// - /// Writes the specified to the test output to provide additional context (e.g. why a particular action, or wait, is being performed). + /// Writes the specified to the test output to provide additional context (e.g. why a particular action, or delay, is being performed). /// /// The reason text. protected void WriteReason(string reason) @@ -283,22 +283,22 @@ protected void WriteReason(string reason) } /// - /// Waits for the specified , then writes any log messages captured during that time (e.g. from a background process) to the test output (see + /// Delays for the specified , then writes any log messages captured during that time (e.g. from a background process) to the test output (see /// ). /// - /// The reason for waiting (written to the test output for context); defaults to "No reason specified" where not specified. - /// The duration to wait; defaults to where not specified. - protected async Task WriteWait(string? reason, TimeSpan? duration) + /// The reason for delaying (written to the test output for context); defaults to "No reason specified" where not specified. + /// The duration to delay; defaults to where not specified. + protected async Task WriteDelay(string? reason, TimeSpan? duration) { var effectiveReason = string.IsNullOrEmpty(reason) ? "No reason specified" : reason; - var effectiveDuration = duration ?? DefaultWaitDuration; + var effectiveDuration = duration ?? DefaultDelayDuration; - // Drain any pre-existing/stale backlog first so only messages logged during the wait window itself are reported. + // Drain any pre-existing/stale backlog first so only messages logged during the delay window itself are reported. DrainElapsedLogMessages(); Implementor.WriteLine(""); Implementor.WriteLine(new string('-', 80)); - Implementor.WriteLine($"WAIT ({effectiveDuration}) >"); + Implementor.WriteLine($"DELAY ({effectiveDuration}) >"); Implementor.WriteLine(effectiveReason); await Task.Delay(effectiveDuration).ConfigureAwait(false); diff --git a/src/UnitTestEx/Abstractions/TesterBaseT.cs b/src/UnitTestEx/Abstractions/TesterBaseT.cs index d2ab37d..7fab3f2 100644 --- a/src/UnitTestEx/Abstractions/TesterBaseT.cs +++ b/src/UnitTestEx/Abstractions/TesterBaseT.cs @@ -387,21 +387,25 @@ public TSelf UseScopedTypeSetUp(Func setupAsync) public TSelf ReplaceKeyedTransient(object? serviceKey, bool autoResetHost = true) where TService : class where TImplementation : class, TService => ConfigureServices(sc => sc.ReplaceKeyedTransient(serviceKey), autoResetHost); /// - /// Delays the execution of the test for the specified . + /// Delays the execution of the test for the specified , then writes any log messages captured during that time (e.g. from a background/hosted service not + /// attributed to a specific HTTP request) to the test output. /// - /// The amount of time to delay the operation. Must be a non-negative . + /// The duration to delay; defaults to where not specified. + /// The reason for delaying (written to the test output for context); defaults to "No reason specified" where not specified. /// The to support fluent-style method-chaining. - public TSelf Delay(TimeSpan duration) => Task.Delay(duration).ContinueWith(_ => (TSelf)this).Result; + public TSelf Delay(TimeSpan? duration = null, string? reason = null) => WriteDelay(reason, duration).ContinueWith(_ => (TSelf)this).Result; /// - /// Delays the execution of the test for the specified . + /// Delays the execution of the test for the specified , then writes any log messages captured during that time (e.g. from a background/hosted + /// service not attributed to a specific HTTP request) to the test output. /// - /// The amount of time to delay the operation. Must be a non-negative . + /// The amount of time, in milliseconds, to delay the operation. Must be a non-negative . + /// The reason for delaying (written to the test output for context); defaults to "No reason specified" where not specified. /// The to support fluent-style method-chaining. - public TSelf Delay(int durationInMilliseconds) => Delay(TimeSpan.FromMilliseconds(durationInMilliseconds)); + public TSelf Delay(int durationInMilliseconds, string? reason = null) => Delay(TimeSpan.FromMilliseconds(durationInMilliseconds), reason); /// - /// Writes the specified to the test output to provide additional context (e.g. why a particular action, or wait, is being performed). + /// Writes the specified to the test output to provide additional context (e.g. why a particular action, or delay, is being performed). /// /// The reason text. /// The to support fluent-style method-chaining. @@ -411,15 +415,6 @@ public TSelf Reason(string reason) return (TSelf)this; } - /// - /// Waits for the specified , then writes any log messages captured during that time (e.g. from a background/hosted service not attributed to a specific HTTP - /// request) to the test output. - /// - /// The reason for waiting (written to the test output for context); defaults to "No reason specified" where not specified. - /// The duration to wait; defaults to where not specified. - /// The to support fluent-style method-chaining. - public TSelf Wait(string? reason = null, TimeSpan? duration = null) => WriteWait(reason, duration).ContinueWith(_ => (TSelf)this).Result; - /// /// Wraps the host execution to perform required start-up style activities; specifically resetting the . /// diff --git a/tests/UnitTestEx.Aspire.AppHost/AppHost.cs b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs index 133754d..e423713 100644 --- a/tests/UnitTestEx.Aspire.AppHost/AppHost.cs +++ b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs @@ -23,12 +23,21 @@ // that AspireTesterBase.WaitForResourceAsync (which waits on resource health) is a genuine readiness gate before // tests attempt to call the resource. // -// The "gateway" resource is a real WireMock.Net.Aspire container (WireMockServerResource), used by the Tier 2 -// HttpMock tests to stub the "api" resource's "XXX" external HTTP dependency (see UnitTestEx.Api's Startup.cs -// and ProductController). Unlike a hand-rolled/embedded resource, it is a genuine DCP-managed resource with its -// own health check, dashboard entry and logs - requires Docker/Podman at runtime (see the WireMock.Net.Aspire -// package), which CI provides but a local Docker-free sandbox may not. +// The "mockhost" resource is UnitTestEx's recommended, self-hosted WireMock.Net pattern: an ordinary project resource +// (UnitTestEx.Aspire.MockHost - a *sample* a consumer would copy into their own solution, not a package UnitTestEx +// ships) that self-hosts the WireMock.Net engine directly (WireMock.Net.StandAlone), rather than the official +// WireMock.Net.Aspire package's container resource. It stubs the "api" resource's "XXX" external HTTP dependency +// (see UnitTestEx.Api's Startup.cs and ProductController) for the Tier 2 HttpMock tests. Because it's just our own +// process, it registers a custom IMatcher (UnitTestEx.Aspire's JsonElementComparerMatcher) that delegates JSON body matching to UnitTestEx's +// own JsonElementComparer - see AspireTesterBase.HttpMock and AspireHttpMockRequest.WithJsonBodyUsingUnitTestExComparer +// - giving genuine JSON comparison-semantics parity with Tier 1 (including semantic date/GUID/number coercion; use +// JsonElementComparerOptions.Exact instead for WireMock-style strict textual matching) rather than being limited to +// WireMock.Net's own JSON comparison (see AspireHttpMockRequest.WithJsonBody's remarks). It also needs no +// Docker/Podman at all, unlike the official container resource - which remains a fully supported alternative for +// teams already standardized on it, simply by adding it directly to their own AppHost (builder.AddWireMock(name) +// from the WireMock.Net.Aspire package); UnitTestEx's HttpMock works against either resource type identically. +using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.DependencyInjection; var builder = DistributedApplication.CreateBuilder(args); @@ -43,12 +52,16 @@ ServerCertificateCustomValidationCallback = (_, _, _, _) => true })); -var gateway = builder.AddWireMock("gateway"); +// IsRunMode: never surface this test-only resource in a published manifest (per the self-hosted WireMock.Net housekeeping note - see AppHost.cs's header comment). +var mockhost = builder.AddMockHostProject("mockhost"); builder.AddProject("api", launchProfileName: null) .WithHttpsEndpoint(name: "https") .WithArgs("--framework", "net8.0") .WithHttpHealthCheck("/Person?firstName=health&lastName=check", endpointName: "https") - .WithEnvironment("XXX__BaseUrl", gateway.GetEndpoint("http")); + // "mockhost" only exists in run mode (see the note above); WithMockHostEnvironment no-ops when it's null + // rather than requiring an inline "if" guard at this (or any future) optional-resource call site. + .WithMockHostEnvironment("XXX__BaseUrl", mockhost, "http"); builder.Build().Run(); + diff --git a/tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj b/tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj index 268446e..e6d134d 100644 --- a/tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj +++ b/tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj @@ -13,15 +13,24 @@ - + + + + + +
diff --git a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs index fb61797..4a3bce0 100644 --- a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs @@ -42,7 +42,7 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() } [TestMethod] - public async Task Reason_And_Wait_AggregatesResourceLogs() + public async Task Reason_And_Delay_AggregatesResourceLogs() { await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); @@ -54,8 +54,8 @@ public async Task Reason_And_Wait_AggregatesResourceLogs() var reasonResult = tester.Reason("Confirming Reason() writes context for Aspire multi-host testers too."); - // Fire a raw (uninstrumented) request against the 'api' resource part-way through the wait window to simulate genuine background/inter-resource activity that is not - // tied to a tester-driven request/response (which would otherwise claim - and so report - the resource's log line itself, rather than Wait). + // Fire a raw (uninstrumented) request against the 'api' resource part-way through the delay window to simulate genuine background/inter-resource activity that is not + // tied to a tester-driven request/response (which would otherwise claim - and so report - the resource's log line itself, rather than Delay). var backgroundCallTask = Task.Run(async () => { await Task.Delay(300); @@ -65,16 +65,16 @@ public async Task Reason_And_Wait_AggregatesResourceLogs() }); var sw = Stopwatch.StartNew(); - var waitResult = tester.Wait("Waiting for a background Person lookup to complete and log.", TimeSpan.FromSeconds(2)); + var delayResult = tester.Delay(TimeSpan.FromSeconds(2), "Waiting for a background Person lookup to complete and log."); sw.Stop(); await backgroundCallTask; Assert.AreSame(tester, reasonResult); - Assert.AreSame(tester, waitResult); - Assert.IsTrue(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(200), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); + Assert.AreSame(tester, delayResult); + Assert.IsTrue(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(200), $"Expected to delay ~2s, actually waited {sw.Elapsed}."); Assert.IsTrue(spy.Lines.Contains("REASON >")); - Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("WAIT (00:00:02) >"))); + Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("DELAY (00:00:02) >"))); Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("Waiting for a background Person lookup to complete and log."))); Assert.IsTrue(spy.Lines.Contains("LOGGING >")); Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("Get using identifier 1.") && l.EndsWith("(api)]", StringComparison.Ordinal))); diff --git a/tests/UnitTestEx.Aspire.MSTest.Test/UnitTestEx.Aspire.MSTest.Test.csproj b/tests/UnitTestEx.Aspire.MSTest.Test/UnitTestEx.Aspire.MSTest.Test.csproj index cc4d301..ec91905 100644 --- a/tests/UnitTestEx.Aspire.MSTest.Test/UnitTestEx.Aspire.MSTest.Test.csproj +++ b/tests/UnitTestEx.Aspire.MSTest.Test/UnitTestEx.Aspire.MSTest.Test.csproj @@ -5,6 +5,7 @@ false true true + enable diff --git a/tests/UnitTestEx.Aspire.MockHost/Program.cs b/tests/UnitTestEx.Aspire.MockHost/Program.cs new file mode 100644 index 0000000..2c26f2f --- /dev/null +++ b/tests/UnitTestEx.Aspire.MockHost/Program.cs @@ -0,0 +1,16 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// +// This is a template for UnitTestEx's recommended, self-hosted WireMock.Net pattern (see the README's "Aspire multi-host testing" section) - a plain console app that starts a +// WireMock.Net standalone server (WireMock.Net.StandAlone's WireMockServer.Start) bound to the port Aspire assigns via the 'PORT' environment variable (the same convention the +// official WireMock.Net.Aspire container resource uses), registering UnitTestEx.Aspire's JsonElementComparerMatcher as a custom matcher so this project resource's JSON body matching has +// genuine parity with UnitTestEx's own JsonElementComparer. See UnitTestEx.Aspire.MockHost.csproj's header comment for the full rationale. Consumers are expected to copy this +// Program.cs into their own solution (referencing the UnitTestEx.Aspire package, which ships JsonElementComparerMatcher and WireMockConsole) rather than reference this project as +// a package - UnitTestEx does not ship it as an executable. +// +// All of the port/settings/custom-matcher/graceful-shutdown boilerplate lives in UnitTestEx.Aspire's WireMockConsole.RunAsync - this Program.cs only needs to say how to start the +// actual WireMock.Net server itself (WireMockServer.Start, from the WireMock.Net package this project references). + +using UnitTestEx.Aspire; +using WireMock.Server; + +await WireMockConsole.RunAsync(settings => WireMockServer.Start(settings)); diff --git a/tests/UnitTestEx.Aspire.MockHost/UnitTestEx.Aspire.MockHost.csproj b/tests/UnitTestEx.Aspire.MockHost/UnitTestEx.Aspire.MockHost.csproj new file mode 100644 index 0000000..c442bd5 --- /dev/null +++ b/tests/UnitTestEx.Aspire.MockHost/UnitTestEx.Aspire.MockHost.csproj @@ -0,0 +1,37 @@ + + + + + + Exe + net8.0 + enable + enable + false + + + + + + + + + + + + + + diff --git a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs index 82445d4..41997f0 100644 --- a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs @@ -42,7 +42,7 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() } [Test] - public async Task Reason_And_Wait_AggregatesResourceLogs() + public async Task Reason_And_Delay_AggregatesResourceLogs() { await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); @@ -54,8 +54,8 @@ public async Task Reason_And_Wait_AggregatesResourceLogs() var reasonResult = tester.Reason("Confirming Reason() writes context for Aspire multi-host testers too."); - // Fire a raw (uninstrumented) request against the 'api' resource part-way through the wait window to simulate genuine background/inter-resource activity that is not - // tied to a tester-driven request/response (which would otherwise claim - and so report - the resource's log line itself, rather than Wait). + // Fire a raw (uninstrumented) request against the 'api' resource part-way through the delay window to simulate genuine background/inter-resource activity that is not + // tied to a tester-driven request/response (which would otherwise claim - and so report - the resource's log line itself, rather than Delay). var backgroundCallTask = Task.Run(async () => { await Task.Delay(300); @@ -65,16 +65,16 @@ public async Task Reason_And_Wait_AggregatesResourceLogs() }); var sw = Stopwatch.StartNew(); - var waitResult = tester.Wait("Waiting for a background Person lookup to complete and log.", TimeSpan.FromSeconds(2)); + var delayResult = tester.Delay(TimeSpan.FromSeconds(2), "Waiting for a background Person lookup to complete and log."); sw.Stop(); await backgroundCallTask; Assert.That(reasonResult, Is.SameAs(tester)); - Assert.That(waitResult, Is.SameAs(tester)); - Assert.That(sw.Elapsed, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(200)), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); + Assert.That(delayResult, Is.SameAs(tester)); + Assert.That(sw.Elapsed, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(200)), $"Expected to delay ~2s, actually waited {sw.Elapsed}."); Assert.That(spy.Lines, Does.Contain("REASON >")); - Assert.That(spy.Lines.Any(l => l != null && l.Contains("WAIT (00:00:02) >")), Is.True); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("DELAY (00:00:02) >")), Is.True); Assert.That(spy.Lines.Any(l => l != null && l.Contains("Waiting for a background Person lookup to complete and log.")), Is.True); Assert.That(spy.Lines, Does.Contain("LOGGING >")); Assert.That(spy.Lines.Any(l => l != null && l.Contains("Get using identifier 1.") && l.EndsWith("(api)]", StringComparison.Ordinal)), Is.True); diff --git a/tests/UnitTestEx.Aspire.NUnit.Test/UnitTestEx.Aspire.NUnit.Test.csproj b/tests/UnitTestEx.Aspire.NUnit.Test/UnitTestEx.Aspire.NUnit.Test.csproj index 21db4da..258efb2 100644 --- a/tests/UnitTestEx.Aspire.NUnit.Test/UnitTestEx.Aspire.NUnit.Test.csproj +++ b/tests/UnitTestEx.Aspire.NUnit.Test/UnitTestEx.Aspire.NUnit.Test.csproj @@ -3,6 +3,7 @@ net8.0;net9.0;net10.0 false + enable diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index 82974b1..76c69cd 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -8,6 +8,7 @@ using UnitTestEx; using UnitTestEx.Abstractions; using UnitTestEx.Api.Models; +using UnitTestEx.Json; using UnitTestEx.Mocking; using Xunit; using Xunit.Abstractions; @@ -48,7 +49,7 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() } [Fact] - public async Task Reason_And_Wait_AggregatesResourceLogs() + public async Task Reason_And_Delay_AggregatesResourceLogs() { await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); @@ -60,8 +61,8 @@ public async Task Reason_And_Wait_AggregatesResourceLogs() var reasonResult = tester.Reason("Confirming Reason() writes context for Aspire multi-host testers too."); - // Fire a raw (uninstrumented) request against the 'api' resource part-way through the wait window to simulate genuine background/inter-resource activity that is not - // tied to a tester-driven request/response (which would otherwise claim - and so report - the resource's log line itself, rather than Wait). + // Fire a raw (uninstrumented) request against the 'api' resource part-way through the delay window to simulate genuine background/inter-resource activity that is not + // tied to a tester-driven request/response (which would otherwise claim - and so report - the resource's log line itself, rather than Delay). var backgroundCallTask = Task.Run(async () => { await Task.Delay(300); @@ -71,33 +72,32 @@ public async Task Reason_And_Wait_AggregatesResourceLogs() }); var sw = Stopwatch.StartNew(); - var waitResult = tester.Wait("Waiting for a background Person lookup to complete and log.", TimeSpan.FromSeconds(2)); + var delayResult = tester.Delay(TimeSpan.FromSeconds(2), "Waiting for a background Person lookup to complete and log."); sw.Stop(); await backgroundCallTask; Assert.Same(tester, reasonResult); - Assert.Same(tester, waitResult); - Assert.True(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(200), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); + Assert.Same(tester, delayResult); + Assert.True(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(200), $"Expected to delay ~2s, actually waited {sw.Elapsed}."); Assert.Contains("REASON >", spy.Lines); - Assert.Contains(spy.Lines, l => l != null && l.Contains("WAIT (00:00:02) >")); + Assert.Contains(spy.Lines, l => l != null && l.Contains("DELAY (00:00:02) >")); Assert.Contains(spy.Lines, l => l != null && l.Contains("Waiting for a background Person lookup to complete and log.")); Assert.Contains("LOGGING >", spy.Lines); Assert.Contains(spy.Lines, l => l != null && l.Contains("Get using identifier 1.") && l.EndsWith("(api)]", StringComparison.Ordinal)); } [Fact] - public async Task HttpMock_StubsExternalGatewayDependency_Product() + public async Task HttpMock_StubsExternalMockHostDependency_Product() { - // Note: this exercises a real WireMock.Net container resource (added via 'gateway' in the AppHost using the official WireMock.Net.Aspire package); it requires Docker/Podman to - // be available to the CI/dev machine running the test. + // Note: this exercises the self-hosted WireMock.Net project resource (added via 'mockhost' in the AppHost - see AppHost.cs's header comment); no Docker/Podman is required. await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); await tester.WaitForResourceAsync("api"); - await tester.WaitForResourceAsync("gateway"); + await tester.WaitForResourceAsync("mockhost"); - var stub = await tester.HttpMock("gateway") + var stub = await tester.HttpMock("mockhost") .Request(HttpMethod.Get, "/products/abc") .Times(Times.Once()) .WithAnyBody() @@ -121,9 +121,9 @@ public async Task HttpMock_WithSequenceAsync_ReturnsResponsesInOrderThenExhausts .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); await tester.WaitForResourceAsync("api"); - await tester.WaitForResourceAsync("gateway"); + await tester.WaitForResourceAsync("mockhost"); - var stub = await tester.HttpMock("gateway") + var stub = await tester.HttpMock("mockhost") .Request(HttpMethod.Get, "/products/seq") .WithAnyBody() .Respond.WithSequenceAsync(seq => @@ -147,9 +147,9 @@ public async Task HttpMock_WithSequenceAsync_ExceedingConfiguredResponses_Throws .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); await tester.WaitForResourceAsync("api"); - await tester.WaitForResourceAsync("gateway"); + await tester.WaitForResourceAsync("mockhost"); - var stub = await tester.HttpMock("gateway") + var stub = await tester.HttpMock("mockhost") .Request(HttpMethod.Get, "/products/seq") .WithAnyBody() .Respond.WithSequenceAsync(seq => seq.Respond().WithJson(new { id = "Seq", description = "Only" })); @@ -167,19 +167,19 @@ public async Task HttpMock_WithJsonBody_PathsToIgnore_IgnoresSpecifiedProperty() // genuinely differing (non-ignored) 'id' still fails to match any stub (WireMock.Net's default "no mapping found" 404 response). await using var tester = AspireTester.Create(); - await tester.WaitForResourceAsync("gateway"); + await tester.WaitForResourceAsync("mockhost"); - var stub = await tester.HttpMock("gateway") + var stub = await tester.HttpMock("mockhost") .Request(HttpMethod.Post, "/echo") .Times(Times.Once()) .WithJsonBody(new { id = "Abc", eTag = "pattern-etag-value" }, "eTag") .Respond.WithJsonAsync(new { id = "Abc", description = "A blue carrot" }); - tester.Http("gateway").Run(HttpMethod.Post, "/echo", new { id = "Abc", eTag = Guid.NewGuid().ToString() }) + tester.Http("mockhost").Run(HttpMethod.Post, "/echo", new { id = "Abc", eTag = Guid.NewGuid().ToString() }) .AssertOK() .AssertValue(new { id = "Abc", description = "A blue carrot" }); - tester.Http("gateway").Run(HttpMethod.Post, "/echo", new { id = "Xyz", eTag = "whatever" }).AssertNotFound(); + tester.Http("mockhost").Run(HttpMethod.Post, "/echo", new { id = "Xyz", eTag = "whatever" }).AssertNotFound(); await stub.VerifyAsync(); } @@ -192,9 +192,9 @@ public async Task HttpMock_SharedInterface_ConfiguresIdenticallyAcrossTiers() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); await tester.WaitForResourceAsync("api"); - await tester.WaitForResourceAsync("gateway"); + await tester.WaitForResourceAsync("mockhost"); - var stub = await HttpMockSharedConfig.ConfigureProductStubAsync(tester.HttpMock("gateway"), "/products/shared"); + var stub = await HttpMockSharedConfig.ConfigureProductStubAsync(tester.HttpMock("mockhost"), "/products/shared"); tester.Http("api") .Run(HttpMethod.Get, "Product/shared") @@ -208,19 +208,19 @@ public async Task HttpMock_SharedInterface_ConfiguresIdenticallyAcrossTiers() public async Task HttpMock_WithRequestsFromResourceAsync_LoadsStubsFromYaml_IdenticallyToTier1() { // Proves the shared IHttpMockClient.WithRequestsFromResourceAsync DIM (see HttpMockResourceConfig in the core UnitTestEx project) loads the exact same YAML/JSON schema - // as Tier 1's native MockHttpClient.WithRequestsFromResource against a real WireMock.Net gateway resource - closing the feature gap between the two tiers. + // as Tier 1's native MockHttpClient.WithRequestsFromResource against a real WireMock.Net mockhost resource - closing the feature gap between the two tiers. await using var tester = AspireTester.Create(); - await tester.WaitForResourceAsync("gateway"); + await tester.WaitForResourceAsync("mockhost"); - IHttpMockClient client = tester.HttpMock("gateway"); + IHttpMockClient client = tester.HttpMock("mockhost"); await client.WithRequestsFromResourceAsync("AspireTesterTest-mock.unittestex.yaml"); - tester.Http("gateway").Run(HttpMethod.Get, "/resource-config/simple") + tester.Http("mockhost").Run(HttpMethod.Get, "/resource-config/simple") .AssertOK() .AssertValue(new { id = "FromResource", description = "Loaded from shared YAML resource." }); - tester.Http("gateway").Run(HttpMethod.Post, "/resource-config/echo", new { id = "Abc", stamp = Guid.NewGuid().ToString() }) + tester.Http("mockhost").Run(HttpMethod.Post, "/resource-config/echo", new { id = "Abc", stamp = Guid.NewGuid().ToString() }) .AssertAccepted() .AssertValue(new { id = "Abc", description = "matched" }); } @@ -234,9 +234,9 @@ public async Task HttpMock_InterfaceCoreMembers_TierSpecificAdaptations() // adapter within WithSequenceAsync. await using var tester = AspireTester.Create(); - await tester.WaitForResourceAsync("gateway"); + await tester.WaitForResourceAsync("mockhost"); - IHttpMockClient client = tester.HttpMock("gateway"); + IHttpMockClient client = tester.HttpMock("mockhost"); var stub1 = await client.Request(HttpMethod.Post, "/interface/with-body") .Times(Times.Once()) @@ -261,24 +261,154 @@ public async Task HttpMock_InterfaceCoreMembers_TierSpecificAdaptations() seq.Respond().WithJson(new { id = "Second" }); }); - tester.Http("gateway").Run(HttpMethod.Post, "/interface/with-body", "plain-text-body", MediaTypeNames.Text.Plain) + tester.Http("mockhost").Run(HttpMethod.Post, "/interface/with-body", "plain-text-body", MediaTypeNames.Text.Plain) .AssertOK() .AssertContent("received"); - tester.Http("gateway").Run(HttpMethod.Get, "/interface/no-content").AssertNoContent(); + tester.Http("mockhost").Run(HttpMethod.Get, "/interface/no-content").AssertNoContent(); - tester.Http("gateway").Run(HttpMethod.Post, "/interface/json-body", new { id = "Abc" }) + tester.Http("mockhost").Run(HttpMethod.Post, "/interface/json-body", new { id = "Abc" }) .AssertOK() .AssertValue(new { id = "Abc", description = "Configured via IHttpMockRequest.WithJsonBody." }); - tester.Http("gateway").Run(HttpMethod.Get, "/interface/sequence").AssertOK().AssertValue(new { id = "First" }); - tester.Http("gateway").Run(HttpMethod.Get, "/interface/sequence").AssertOK().AssertValue(new { id = "Second" }); + tester.Http("mockhost").Run(HttpMethod.Get, "/interface/sequence").AssertOK().AssertValue(new { id = "First" }); + tester.Http("mockhost").Run(HttpMethod.Get, "/interface/sequence").AssertOK().AssertValue(new { id = "Second" }); await stub1.VerifyAsync(); await stub2.VerifyAsync(); await stub3.VerifyAsync(); await stub4.VerifyAsync(); } + + [Fact] + public async Task HttpMock_WithJsonBodyUsingUnitTestExComparer_SelfHostedMockHost_MatchesUsingJsonElementComparerSemantics() + { + // 'mockhost' is UnitTestEx.Aspire.MockHost - the recommended, self-hosted WireMock.Net project resource (a sample a consumer would copy into their own solution; no + // Docker/Podman needed) that registers UnitTestEx.Aspire's JsonElementComparerMatcher, giving genuine JsonElementComparer semantics rather than WireMock.Net's own JSON comparison. + // + // The differentiator this proves: JsonElementComparer performs semantic value coercion for dates (and GUIDs/numbers) - "2024-01-01T00:00:00Z" and + // "2024-01-01T00:00:00.000+00:00" are considered equal even though they differ textually. WireMock.Net's own JsonMatcher (see WithJsonBody, which 'mockhost' also + // supports, being a genuine WireMock.Net server) is a textual/structural comparison and would NOT consider these equal - only WithJsonBodyUsingUnitTestExComparer's + // custom matcher can bridge that gap. (Where strict, WireMock-style textual matching is instead wanted, configure JsonElementComparerOptions.Exact rather than + // falling back to WithJsonBody.) + await using var tester = AspireTester.Create(); + + await tester.WaitForResourceAsync("mockhost"); + + var stub = await tester.HttpMock("mockhost") + .Request(HttpMethod.Post, "/echo") + .Times(Times.Once()) + .WithJsonBodyUsingUnitTestExComparer(new { id = "Abc", occurredAt = "2024-01-01T00:00:00Z" }) + .Respond.WithJsonAsync(new { id = "Abc", description = "Matched via JsonElementComparer semantics." }); + + // Same instant, different (but equally valid) textual representation - JsonElementComparer's semantic date coercion still considers this a match. + tester.Http("mockhost").Run(HttpMethod.Post, "/echo", new { id = "Abc", occurredAt = "2024-01-01T00:00:00.000+00:00" }) + .AssertOK() + .AssertValue(new { id = "Abc", description = "Matched via JsonElementComparer semantics." }); + + await stub.VerifyAsync(); + + // A genuinely different (non-coercible) value still fails to match any stub (WireMock.Net's default "no mapping found" 404 response). + tester.Http("mockhost").Run(HttpMethod.Post, "/echo", new { id = "Xyz", occurredAt = "2024-01-01T00:00:00Z" }).AssertNotFound(); + } + + [Fact] + public async Task HttpMock_WithJsonBodyUsingUnitTestExComparer_HonoursLiveJsonComparerOptions() + { + // Proves that WithJsonBodyUsingUnitTestExComparer captures *this tester's* current JsonComparerOptions (set via UseJsonComparerOptions) into the matcher's payload - + // not JsonElementComparer.Default - since the matcher runs in a separate OS process (the self-hosted 'mockhost') with no access to this test process's static state. + await using var tester = AspireTester.Create(); + + await tester.WaitForResourceAsync("mockhost"); + + // Switch this tester to strict (WireMock-style) textual value comparison before configuring the mapping. + tester.UseJsonComparerOptions(new JsonElementComparerOptions { ValueComparison = JsonElementComparison.Exact }); + + var stub = await tester.HttpMock("mockhost") + .Request(HttpMethod.Post, "/echo") + .Times(Times.Once()) + .WithJsonBodyUsingUnitTestExComparer(new { id = "Abc", occurredAt = "2024-01-01T00:00:00Z" }) + .Respond.WithJsonAsync(new { id = "Abc", description = "Matched via JsonElementComparer semantics." }); + + // Same instant, different textual representation - under Exact this is no longer considered a match (unlike the Semantic-mode test above). + tester.Http("mockhost").Run(HttpMethod.Post, "/echo", new { id = "Abc", occurredAt = "2024-01-01T00:00:00.000+00:00" }).AssertNotFound(); + + // The exact same textual representation still matches. + tester.Http("mockhost").Run(HttpMethod.Post, "/echo", new { id = "Abc", occurredAt = "2024-01-01T00:00:00Z" }) + .AssertOK() + .AssertValue(new { id = "Abc", description = "Matched via JsonElementComparer semantics." }); + + await stub.VerifyAsync(); + } + + [Fact] + public async Task HttpMock_WithJsonBodyUsingUnitTestExComparer_InvalidJsonRequestBodyStillTreatedAsCleanNonMatch() + { + // Proves that a request body that is not valid JSON is treated as an ordinary non-match (404, "no matching mapping found") rather than faulting the mock host's + // request pipeline. Internally this attaches a JsonElementComparerMatcherException to the WireMock.Net MatchResult - the one case where the matcher does so, since + // it's a genuine evaluation failure (consistent with WireMock.Net's own built-in matchers); this is deliberately NOT done for an ordinary semantic mismatch, as + // WireMock.Net's MappingMatcher.FindBestMatch treats ANY non-null MatchResult.Exception as "this mapping failed to evaluate" and excludes it from partial/closest-match + // tracking entirely (see JsonElementComparerMatcher.IsMatch remarks) - so attaching one for every mismatch would silently break WireMock.Net's own "closest match" + // debugging rather than improve it. + await using var tester = AspireTester.Create(); + + await tester.WaitForResourceAsync("mockhost"); + + _ = await tester.HttpMock("mockhost") + .Request(HttpMethod.Post, "/echo") + .WithJsonBodyUsingUnitTestExComparer(new { id = "Abc" }) + .Respond.WithJsonAsync(new { id = "Abc" }); + + tester.Http("mockhost").Run(HttpMethod.Post, "/echo", "{ not valid json", MediaTypeNames.Application.Json).AssertNotFound(); + } + + [Fact] + public async Task UseSetUp_And_UseUser_FlowThroughToHttpRequestSend() + { + string? capturedUserName = null; + HttpRequestMessage? capturedRequest = null; + + var setUp = new TestSetUp + { + OnBeforeHttpRequestMessageSendAsync = (request, userName, _) => + { + capturedUserName = userName; + capturedRequest = request; + request.Headers.Add("X-Test-User", userName); + return Task.CompletedTask; + } + }; + + // UseSetUp/UseUser mirror TesterBase's equivalents; unlike Tier 1 there is no host to reset - SetUp is consumed live by HttpTester at send time. + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .UseSetUp(setUp) + .UseUser("Jane"); + + await tester.WaitForResourceAsync("api"); + + tester.Http("api") + .Run(HttpMethod.Get, "Person?firstName=John&lastName=Doe") + .AssertOK(); + + Assert.Equal("Jane", capturedUserName); + Assert.NotNull(capturedRequest); + Assert.True(capturedRequest!.Headers.Contains("X-Test-User")); + } + + [Fact] + public async Task UseJsonSerializer_And_UseJsonComparerOptions_UpdatesTesterProperties() + { + await using var tester = AspireTester.Create(); + + var serializer = new JsonSerializer(); + var options = new JsonElementComparerOptions { NullComparison = JsonElementComparison.Exact }; + + tester.UseJsonSerializer(serializer).UseJsonComparerOptions(options); + + Assert.Same(serializer, tester.JsonSerializer); + Assert.Same(options, tester.JsonComparerOptions); + } } } diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/UnitTestEx.Aspire.Xunit.Test.csproj b/tests/UnitTestEx.Aspire.Xunit.Test/UnitTestEx.Aspire.Xunit.Test.csproj index ea0f91b..fbaf81a 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/UnitTestEx.Aspire.Xunit.Test.csproj +++ b/tests/UnitTestEx.Aspire.Xunit.Test/UnitTestEx.Aspire.Xunit.Test.csproj @@ -3,6 +3,7 @@ net8.0;net9.0;net10.0 false + enable diff --git a/tests/UnitTestEx.MSTest.Test/Other/ReasonAndWaitTest.cs b/tests/UnitTestEx.MSTest.Test/Other/ReasonAndDelayTest.cs similarity index 79% rename from tests/UnitTestEx.MSTest.Test/Other/ReasonAndWaitTest.cs rename to tests/UnitTestEx.MSTest.Test/Other/ReasonAndDelayTest.cs index d98e667..17a99be 100644 --- a/tests/UnitTestEx.MSTest.Test/Other/ReasonAndWaitTest.cs +++ b/tests/UnitTestEx.MSTest.Test/Other/ReasonAndDelayTest.cs @@ -11,7 +11,7 @@ namespace UnitTestEx.MSTest.Test.Other { [TestClass] - public class ReasonAndWaitTest + public class ReasonAndDelayTest { [TestMethod] public void Reason_WritesReasonToOutput() @@ -28,7 +28,7 @@ public void Reason_WritesReasonToOutput() } [TestMethod] - public async Task Wait_WaitsAndSurfacesLoggingThatOccursDuringTheWait() + public async Task Delay_DelaysAndSurfacesLoggingThatOccursDuringTheDelay() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); @@ -40,38 +40,38 @@ public async Task Wait_WaitsAndSurfacesLoggingThatOccursDuringTheWait() var backgroundLogTask = Task.Run(async () => { await Task.Delay(300); - logger.LogInformation("Background message logged during the wait window."); + logger.LogInformation("Background message logged during the delay window."); }); var sw = Stopwatch.StartNew(); - var result = test.Wait("Waiting for a background process to log.", TimeSpan.FromSeconds(2)); + var result = test.Delay(TimeSpan.FromSeconds(2), "Waiting for a background process to log."); sw.Stop(); await backgroundLogTask; Assert.AreSame(test, result); - Assert.IsTrue(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(100), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); - Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("WAIT (00:00:02) >"))); + Assert.IsTrue(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(100), $"Expected to delay ~2s, actually waited {sw.Elapsed}."); + Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("DELAY (00:00:02) >"))); Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("Waiting for a background process to log."))); Assert.IsTrue(spy.Lines.Contains("LOGGING >")); - Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("Background message logged during the wait window."))); + Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("Background message logged during the delay window."))); } [TestMethod] - public void Wait_ExcludesLoggingThatOccurredBeforeTheWaitStarted() + public void Delay_ExcludesLoggingThatOccurredBeforeTheDelayStarted() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); test.ReplaceTestFrameworkImplementor(spy); var logger = test.Services.GetRequiredService>(); - logger.LogInformation("Stale message logged before the wait started."); + logger.LogInformation("Stale message logged before the delay started."); - test.Wait("Waiting with nothing new expected.", TimeSpan.FromMilliseconds(200)); + test.Delay(TimeSpan.FromMilliseconds(200), "Waiting with nothing new expected."); Assert.IsTrue(spy.Lines.Contains("LOGGING >")); Assert.IsTrue(spy.Lines.Contains("None.")); - Assert.IsFalse(spy.Lines.Any(l => l != null && l.Contains("Stale message logged before the wait started."))); + Assert.IsFalse(spy.Lines.Any(l => l != null && l.Contains("Stale message logged before the delay started."))); } } } diff --git a/tests/UnitTestEx.NUnit.Test/Other/ReasonAndWaitTest.cs b/tests/UnitTestEx.NUnit.Test/Other/ReasonAndDelayTest.cs similarity index 80% rename from tests/UnitTestEx.NUnit.Test/Other/ReasonAndWaitTest.cs rename to tests/UnitTestEx.NUnit.Test/Other/ReasonAndDelayTest.cs index 8cc3ef8..4f2141c 100644 --- a/tests/UnitTestEx.NUnit.Test/Other/ReasonAndWaitTest.cs +++ b/tests/UnitTestEx.NUnit.Test/Other/ReasonAndDelayTest.cs @@ -11,7 +11,7 @@ namespace UnitTestEx.NUnit.Test.Other { [TestFixture] - public class ReasonAndWaitTest + public class ReasonAndDelayTest { [Test] public void Reason_WritesReasonToOutput() @@ -28,7 +28,7 @@ public void Reason_WritesReasonToOutput() } [Test] - public async Task Wait_WaitsAndSurfacesLoggingThatOccursDuringTheWait() + public async Task Delay_DelaysAndSurfacesLoggingThatOccursDuringTheDelay() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); @@ -40,38 +40,38 @@ public async Task Wait_WaitsAndSurfacesLoggingThatOccursDuringTheWait() var backgroundLogTask = Task.Run(async () => { await Task.Delay(300); - logger.LogInformation("Background message logged during the wait window."); + logger.LogInformation("Background message logged during the delay window."); }); var sw = Stopwatch.StartNew(); - var result = test.Wait("Waiting for a background process to log.", TimeSpan.FromSeconds(2)); + var result = test.Delay(TimeSpan.FromSeconds(2), "Waiting for a background process to log."); sw.Stop(); await backgroundLogTask; Assert.That(result, Is.SameAs(test)); - Assert.That(sw.Elapsed, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(100)), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); - Assert.That(spy.Lines.Any(l => l != null && l.Contains("WAIT (00:00:02) >")), Is.True); + Assert.That(sw.Elapsed, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(100)), $"Expected to delay ~2s, actually waited {sw.Elapsed}."); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("DELAY (00:00:02) >")), Is.True); Assert.That(spy.Lines.Any(l => l != null && l.Contains("Waiting for a background process to log.")), Is.True); Assert.That(spy.Lines, Does.Contain("LOGGING >")); - Assert.That(spy.Lines.Any(l => l != null && l.Contains("Background message logged during the wait window.")), Is.True); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("Background message logged during the delay window.")), Is.True); } [Test] - public void Wait_ExcludesLoggingThatOccurredBeforeTheWaitStarted() + public void Delay_ExcludesLoggingThatOccurredBeforeTheDelayStarted() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); test.ReplaceTestFrameworkImplementor(spy); var logger = test.Services.GetRequiredService>(); - logger.LogInformation("Stale message logged before the wait started."); + logger.LogInformation("Stale message logged before the delay started."); - test.Wait("Waiting with nothing new expected.", TimeSpan.FromMilliseconds(200)); + test.Delay(TimeSpan.FromMilliseconds(200), "Waiting with nothing new expected."); Assert.That(spy.Lines, Does.Contain("LOGGING >")); Assert.That(spy.Lines, Does.Contain("None.")); - Assert.That(spy.Lines.Any(l => l != null && l.Contains("Stale message logged before the wait started.")), Is.False); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("Stale message logged before the delay started.")), Is.False); } } } diff --git a/tests/UnitTestEx.Xunit.Test/Other/ReasonAndWaitTest.cs b/tests/UnitTestEx.Xunit.Test/Other/ReasonAndDelayTest.cs similarity index 76% rename from tests/UnitTestEx.Xunit.Test/Other/ReasonAndWaitTest.cs rename to tests/UnitTestEx.Xunit.Test/Other/ReasonAndDelayTest.cs index a0bfdbc..2e60d1b 100644 --- a/tests/UnitTestEx.Xunit.Test/Other/ReasonAndWaitTest.cs +++ b/tests/UnitTestEx.Xunit.Test/Other/ReasonAndDelayTest.cs @@ -10,9 +10,9 @@ namespace UnitTestEx.Xunit.Test.Other { - public class ReasonAndWaitTest : UnitTestBase + public class ReasonAndDelayTest : UnitTestBase { - public ReasonAndWaitTest(ITestOutputHelper output) : base(output) { } + public ReasonAndDelayTest(ITestOutputHelper output) : base(output) { } [Fact] public void Reason_WritesReasonToOutput() @@ -29,7 +29,7 @@ public void Reason_WritesReasonToOutput() } [Fact] - public async Task Wait_WaitsAndSurfacesLoggingThatOccursDuringTheWait() + public async Task Delay_DelaysAndSurfacesLoggingThatOccursDuringTheDelay() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); @@ -41,38 +41,38 @@ public async Task Wait_WaitsAndSurfacesLoggingThatOccursDuringTheWait() var backgroundLogTask = Task.Run(async () => { await Task.Delay(300); - logger.LogInformation("Background message logged during the wait window."); + logger.LogInformation("Background message logged during the delay window."); }); var sw = Stopwatch.StartNew(); - var result = test.Wait("Waiting for a background process to log.", TimeSpan.FromSeconds(2)); + var result = test.Delay(TimeSpan.FromSeconds(2), "Waiting for a background process to log."); sw.Stop(); await backgroundLogTask; Assert.Same(test, result); - Assert.True(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(100), $"Expected to wait ~2s, actually waited {sw.Elapsed}."); - Assert.Contains(spy.Lines, l => l != null && l.Contains("WAIT (00:00:02) >")); + Assert.True(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(100), $"Expected to delay ~2s, actually waited {sw.Elapsed}."); + Assert.Contains(spy.Lines, l => l != null && l.Contains("DELAY (00:00:02) >")); Assert.Contains(spy.Lines, l => l != null && l.Contains("Waiting for a background process to log.")); Assert.Contains("LOGGING >", spy.Lines); - Assert.Contains(spy.Lines, l => l != null && l.Contains("Background message logged during the wait window.")); + Assert.Contains(spy.Lines, l => l != null && l.Contains("Background message logged during the delay window.")); } [Fact] - public void Wait_ExcludesLoggingThatOccurredBeforeTheWaitStarted() + public void Delay_ExcludesLoggingThatOccurredBeforeTheDelayStarted() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); test.ReplaceTestFrameworkImplementor(spy); var logger = test.Services.GetRequiredService>(); - logger.LogInformation("Stale message logged before the wait started."); + logger.LogInformation("Stale message logged before the delay started."); - test.Wait("Waiting with nothing new expected.", TimeSpan.FromMilliseconds(200)); + test.Delay(TimeSpan.FromMilliseconds(200), "Waiting with nothing new expected."); Assert.Contains("LOGGING >", spy.Lines); Assert.Contains("None.", spy.Lines); - Assert.DoesNotContain(spy.Lines, l => l != null && l.Contains("Stale message logged before the wait started.")); + Assert.DoesNotContain(spy.Lines, l => l != null && l.Contains("Stale message logged before the delay started.")); } } } From 19ea68311c3590aeb7de60cbdd76bb61a4c3d730 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Thu, 24 Sep 2026 15:54:43 -0700 Subject: [PATCH 32/43] Fix ApiTesterBase.CreateHttpClient silently resetting SharedState per 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(), Http(), Http()), 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> --- CHANGELOG.md | 1 + README.md | 2 ++ nuget-publish.ps1 | 1 + .../UnitTestExAspireExtensions.cs | 6 ++-- src/UnitTestEx/AspNetCore/ApiTesterBase.cs | 10 +++++- .../Other/RequestStateDataTest.cs | 35 +++++++++++++++++++ 6 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 tests/UnitTestEx.Xunit.Test/Other/RequestStateDataTest.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e42a66e..0bd6cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Represents the **NuGet** versions. ## v5.12.0 - *Enhancement:* Added `UnitTestEx.Aspire` (NET8+) to enable testing of [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/) multi-host (inter-domain) testing alongside existing in-process `WebApplicationFactory`. The API surface between the two is consistent where appropriate. - Additionally, `HttpClient` mocking of external dependencies is supported via `AspireHttpMockClient`, which stubs a real, out-of-process [WireMock.Net](https://wiremock.org/dotnet/) server resource - see the README's [Aspire multi-host testing](./README.md#Aspire-multi-host-testing) section for the recommended, self-hosted (Docker-free) pattern. +- *Fixed:* `ApiTesterBase.CreateHttpClient` no longer resets `TestSharedState` on every request. It previously routed through `GetTestServer()`'s reset-on-access semantics, which - since `CreateHttpClient` is invoked per-request, after any `Expect*()`-style call has already registered request-scoped state - silently wiped registered expectation flags (e.g. `ExpectEvents()`) immediately before the request was sent, causing false "expectation not met" failures. ## v5.11.1 - *Fixed:* Wrapped `Assembly.LoadFrom` in `TestSetUp` with try-catch to log exceptions and prevent setup failures. diff --git a/README.md b/README.md index dee29d6..b7cd5f7 100644 --- a/README.md +++ b/README.md @@ -393,6 +393,8 @@ The same shared interface also brings across Tier 1's [YAML/JSON configuration]( _Note:_ Aspire-hosted resources are real OS processes (containers, where you opt into one), so `AspireTester` tests are inherently slower than the in-process Tier 1 testers - use them where the inter-process interaction itself is what needs proving. +_Note:_ Since Tier 2/3 resources are real, reachable URLs (not in-process fakes), a UI/frontend resource hosted in the `AppHost` can be driven directly with [Playwright](https://playwright.dev/dotnet/) - this is an ordinary consequence of the resources being real processes, not a UnitTestEx-specific feature; see Microsoft's own [Aspire + Playwright guide](https://learn.microsoft.com/en-us/dotnet/aspire/testing/write-your-first-test?tabs=xunit#creating-a-playwright-test) for the pattern. +
## Expectations diff --git a/nuget-publish.ps1 b/nuget-publish.ps1 index 103bcdb..304079b 100644 --- a/nuget-publish.ps1 +++ b/nuget-publish.ps1 @@ -49,6 +49,7 @@ param( [string]$NugetCacheFolder = "$($env:USERPROFILE)\.nuget\packages", [String[]]$ProjectsToPublish = @( "src\UnitTestEx", + "src\UnitTestEx.Aspire", "src\UnitTestEx.Azure.Functions", "src\UnitTestEx.Azure.ServiceBus", "src\UnitTestEx.MSTest", diff --git a/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs b/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs index 939f3e7..a4e06dc 100644 --- a/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs +++ b/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs @@ -47,10 +47,8 @@ public static class UnitTestExAspireExtensions /// current , such as publish mode). /// The endpoint name to resolve from (e.g. "http"). /// , for chaining. - public static IResourceBuilder WithMockHostEnvironment( - this IResourceBuilder builder, string name, IResourceBuilder? source, string endpointName) - where TDestination : IResourceWithEnvironment - where TSource : IResourceWithEndpoints + public static IResourceBuilder WithMockHostEnvironment(this IResourceBuilder builder, string name, IResourceBuilder? source, string endpointName) + where TDestination : IResourceWithEnvironment where TSource : IResourceWithEndpoints => source is null ? builder : builder.WithEnvironment(name, source.GetEndpoint(endpointName)); } } diff --git a/src/UnitTestEx/AspNetCore/ApiTesterBase.cs b/src/UnitTestEx/AspNetCore/ApiTesterBase.cs index 7f84f33..373d1c8 100644 --- a/src/UnitTestEx/AspNetCore/ApiTesterBase.cs +++ b/src/UnitTestEx/AspNetCore/ApiTesterBase.cs @@ -168,7 +168,15 @@ protected override void OnResetHost() public TestServer GetTestServer() => HostExecutionWrapper(() => GetWebApplicationFactory().Server); /// - public HttpClient CreateHttpClient(string? name = null) => new(GetTestServer().CreateHandler()) { BaseAddress = GetTestServer().BaseAddress }; + /// Deliberately bypasses (and therefore its side-effect) here: this is invoked per-request, potentially after + /// expectations (e.g. ExpectEvents) have already registered state into for the current request's . Routing + /// through the resetting wrapper on every request wipes that state immediately before the request is sent, silently discarding registered expectation flags (causing false "no events published" + /// failures). itself is left untouched for its other (construction-time, pre-expectation) callers. + public HttpClient CreateHttpClient(string? name = null) + { + var server = GetWebApplicationFactory().Server; + return new HttpClient(server.CreateHandler()) { BaseAddress = server.BaseAddress }; + } /// diff --git a/tests/UnitTestEx.Xunit.Test/Other/RequestStateDataTest.cs b/tests/UnitTestEx.Xunit.Test/Other/RequestStateDataTest.cs new file mode 100644 index 0000000..763941f --- /dev/null +++ b/tests/UnitTestEx.Xunit.Test/Other/RequestStateDataTest.cs @@ -0,0 +1,35 @@ +using System.Net.Http; +using UnitTestEx.Api; +using Xunit; +using Xunit.Abstractions; + +namespace UnitTestEx.Xunit.Test.Other +{ + // Regression coverage for a bug where ApiTesterBase.CreateHttpClient() routed through GetTestServer() -> HostExecutionWrapper() -> SharedState.Reset() on every request. Since + // CreateHttpClient() is invoked from HttpTesterBase.SendAsync() *after* any Expect*()-style call has already registered request-scoped state (e.g. CoreEx's ExpectEvents()) into + // SharedState.RequestStateData(requestId), that state was silently wiped immediately before the request was even sent - causing genuine, correctly-matched expectations to be + // reported as failed (or simply ignored) once the request actually ran. See TesterBaseCore.SharedState/TestSharedState.RequestStateData/TestSharedState.Reset. + public class RequestStateDataTest : UnitTestBase + { + public RequestStateDataTest(ITestOutputHelper output) : base(output) { } + + [Fact] + public void RequestStateData_SurvivesUntilPostRun() + { + const string flagKey = "UnitTestEx_RegressionFlag"; + + using var test = ApiTester.Create(); + var http = test.Http(); + + // Simulate what an Expect*()-style extension (e.g. CoreEx's ExpectEvents()) does: register request-scoped state, keyed by the HttpTester's own RequestId, before .Run() executes. + test.SharedState.RequestStateData(http.RequestId)[flagKey] = true; + + bool? flagStillPresentAtPostRun = null; + test.AddPostRunBeforeExpectationsAction(_ => flagStillPresentAtPostRun = test.SharedState.RequestStateData(http.RequestId).ContainsKey(flagKey)); + + http.Run(HttpMethod.Get, "Person/1").AssertOK(); + + Assert.True(flagStillPresentAtPostRun, "Request-scoped state registered before Run() must still be present once the request completes - CreateHttpClient() must not reset SharedState per request."); + } + } +} From 59b1bae19d713263bf285f474f7aa1770ec8dccb Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Sat, 26 Sep 2026 16:23:23 -0700 Subject: [PATCH 33/43] refactor(aspire): split AspireTesterBase into non-generic base + generic subclass Mirrors the existing TesterBase / TesterBase 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 (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 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> --- .../Aspire/AspireTesterBase.cs | 222 +++++------------- .../Aspire/AspireTesterBaseT.cs | 183 +++++++++++++++ .../Aspire/HttpMock/AspireHttpMockClient.cs | 4 +- .../Aspire/HttpMock/AspireHttpMockRequest.cs | 4 +- 4 files changed, 244 insertions(+), 169 deletions(-) create mode 100644 src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index 3a8c75d..7d848b0 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -1,7 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using Aspire.Hosting; -using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -23,18 +22,18 @@ namespace UnitTestEx.Aspire { /// - /// Provides the base .NET Aspire multi-host (distributed application) unit-testing capabilities. + /// Provides the non-generic, host-agnostic base for the .NET Aspire multi-host (distributed application) unit-testing capabilities - the Aspire-specific equivalent of + /// (its single-host, dependency injection (DI) counterpart). /// - /// The AppHost (a Projects.* type generated for the AppHost's ProjectReference). - /// The to support inheriting fluent-style method-chaining. /// This extends the host-agnostic directly (not ) as the underlying is a real, - /// multi-process distributed application with no single, in-process dependency injection (DI) container to reach into. Where a Tier 1 () capability is genuinely shared in concept, it is exposed here via the - /// seam rather than by inheriting 's dependency injection (DI) specific members. - public abstract class AspireTesterBase : TesterBaseCore, IHttpClientSource, IAsyncDisposable - where TAppHost : class - where TSelf : AspireTesterBase + /// multi-process distributed application with no single, in-process dependency injection (DI) container to reach into. + /// Exists primarily so an extension method (or any other code that does not know/care about the concrete AppHost or TSelf type parameters - + /// see ) can still reach the capabilities that do not depend on either - e.g. , + /// , . Fluent, method-chaining configuration members that must return TSelf + /// (e.g. /) necessarily + /// remain on only. + public abstract class AspireTesterBase : TesterBaseCore, IHttpClientSource, IAsyncDisposable { - private readonly List> _configureBuilder = []; private readonly ConcurrentDictionary> _resourceLogBuffers = new(); private readonly ConcurrentDictionary _elapsedLogLineCounts = new(); private ResourceLogCaptureProvider? _resourceLogCaptureProvider; @@ -42,87 +41,35 @@ public abstract class AspireTesterBase : TesterBaseCore, IHttpC private bool _disposed; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The . protected AspireTesterBase(TestFrameworkImplementor implementor) : base(implementor) { } /// - /// Replaces the by cloning the . + /// Gets the queued builder-configuration actions applied, in order, immediately before the underlying is built. /// - /// The - /// The to support fluent-style method-chaining. - /// Updates the and from the . - /// Unlike 's (and other Tier 1 testers') equivalent, this does not reset the underlying - /// : - the reason Tier 1 must rebuild its single, in-process dependency injection (DI) container host - has - /// no equivalent here, as the AppHost is a genuine, separately-built multi-process distributed application that does not configure. Where its properties are - /// consumed by a Tier 2 test (e.g. when sending a request via , or - /// / when asserting a response), they are read live at request/assert time, so no reset is - /// required for a subsequent call to see the change. - public TSelf UseSetUp(TestSetUp setUp) - { - SetUp = setUp?.Clone() ?? throw new ArgumentNullException(nameof(setUp)); - JsonSerializer = SetUp.JsonSerializer; - JsonComparerOptions = SetUp.JsonComparerOptions; - return (TSelf)this; - } - - /// - /// Updates (replaces) the default test . - /// - /// The test user name (a null value will reset to ). - /// The to support fluent-style method-chaining. - public TSelf UseUser(string? userName) - { - UserName = userName ?? SetUp.DefaultUserName; - return (TSelf)this; - } - - /// - /// Updates (replaces) the default test . - /// - /// The test user identifier (a null value will reset to ). - /// The to support fluent-style method-chaining. - /// The is required for the conversion to take place. - public TSelf UseUser(object? userIdentifier) - { - if (userIdentifier == null) - return UseUser(null); - - if (SetUp.UserNameConverter == null) - throw new InvalidOperationException($"The {nameof(TestSetUp)}.{nameof(TestSetUp.UserNameConverter)} must be defined to support user identifier conversion."); - - return UseUser(SetUp.UserNameConverter(userIdentifier)); - } - - /// - /// Updates the used by the itself, not any underlying resource which should be configured separately. - /// - /// The . - /// The to support fluent-style method-chaining. - public TSelf UseJsonSerializer(Json.IJsonSerializer jsonSerializer) - { - JsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); - return (TSelf)this; - } + /// Populated by . + protected List> ConfigureBuilderActions { get; } = []; /// - /// Updates the used by the itself, not any underlying resource which should be configured separately. + /// Gets a value indicating whether the underlying has already started being built (see ). /// - /// The . - /// The to support fluent-style method-chaining. - /// Where the is null then the will be used. - public TSelf UseJsonComparerOptions(Json.JsonElementComparerOptions options) - { - JsonComparerOptions = options ?? throw new ArgumentNullException(nameof(options)); - return (TSelf)this; - } + /// Used to guard configuration that must be applied before the underlying is built (see + /// /). + protected bool IsDistributedApplicationBuilding => _appTask is not null; /// - /// Gets the ; builds and starts on first access. + /// Gets the underlying ; builds and starts on first access. /// /// The started . - protected Task GetDistributedApplicationAsync() + /// This is the escape hatch for anything not already surfaced by itself - e.g. resolving a resource's connection string via + /// Aspire's own GetConnectionStringAsync(resourceName), inspecting directly, or reaching into + /// for the AppHost's own (not a resource's) dependency injection (DI) container. + /// Important: do not call DisposeAsync()/StopAsync() on the returned instance directly - this bypasses UnitTestEx's own lifecycle tracking (leaving it + /// believing the host is still instantiated when it is not) and will surface as confusing failures on a subsequent call. Use instead to tear down + /// and force a rebuild on next access. + public Task GetDistributedApplicationAsync() { lock (SyncRoot) { @@ -130,12 +77,20 @@ protected Task GetDistributedApplicationAsync() } } + /// + /// Creates the for the concrete AppHost. + /// + /// The . + /// The only step of building the underlying that depends on the concrete AppHost - a generic type parameter only + /// available on ; every other build/start-up step is host-agnostic and lives here (see ). + protected abstract Task CreateBuilderAsync(); + /// /// Creates, builds and starts the underlying . /// private async Task CreateDistributedApplicationAsync() { - var builder = await DistributedApplicationTestingBuilder.CreateAsync().ConfigureAwait(false); + var builder = await CreateBuilderAsync().ConfigureAwait(false); if (!HostDiagnosticsEnabled) // By default the AppHost's own logging (its start-up banner, DCP process management, and - critically - each resource's own console output mirrored under a @@ -152,7 +107,7 @@ private async Task CreateDistributedApplicationAsync() builder.Services.AddSingleton(_resourceLogCaptureProvider = new ResourceLogCaptureProvider($"{builder.Environment.ApplicationName}.Resources.", _resourceLogBuffers)); - foreach (var configure in _configureBuilder) + foreach (var configure in ConfigureBuilderActions) { configure(builder); } @@ -193,57 +148,11 @@ protected override void OnResetHost() } } - /// - /// Overrides an environment variable for the named resource before the underlying is built. - /// - /// The resource name (as configured within the AppHost). - /// The environment variable name. - /// The environment variable value. - /// The to support fluent-style method-chaining. - /// This is the only override surface available for a Tier 2 tester as each resource runs in its own, separate process with no shared, in-process dependency injection - /// (DI) container to reach into; must be called before the underlying has been built (see ). - public TSelf WithResourceEnvironment(string resourceName, string key, string value) - { - if (resourceName is null) throw new ArgumentNullException(nameof(resourceName)); - if (key is null) throw new ArgumentNullException(nameof(key)); - - lock (SyncRoot) - { - if (_appTask is not null) - throw new InvalidOperationException($"{nameof(WithResourceEnvironment)} must be invoked before the underlying {nameof(DistributedApplication)} has been built (i.e. before any {nameof(Http)}/{nameof(WaitForResourceAsync)} call)."); - - _configureBuilder.Add(builder => builder.CreateResourceBuilder(resourceName).WithEnvironment(key, value)); - } - - return (TSelf)this; - } - /// /// Gets a value indicating whether the AppHost's own raw logging (its start-up banner, DCP process management, and each resource's own console output mirrored via - /// 'EnableResourceLogging') is written to the test output, in addition to what UnitTestEx itself reports (see ). - /// - public bool HostDiagnosticsEnabled { get; private set; } - - /// - /// Opts back into the AppHost's own raw logging (its start-up banner, DCP process management, and each resource's own console output) being written to the test output, in addition - /// to what UnitTestEx itself reports via //the request-scoped "LOGGING >" section. + /// 'EnableResourceLogging') is written to the test output, in addition to what UnitTestEx itself reports (see ). /// - /// The to support fluent-style method-chaining. - /// By default this raw firehose is suppressed as it otherwise duplicates what UnitTestEx already surfaces cleanly (and correlated); enable it when troubleshooting an - /// AppHost/resource start-up failure that needs the full, unfiltered diagnostic output. Must be called before the underlying has been built - /// (see ). - public TSelf EnableHostDiagnostics() - { - lock (SyncRoot) - { - if (_appTask is not null) - throw new InvalidOperationException($"{nameof(EnableHostDiagnostics)} must be invoked before the underlying {nameof(DistributedApplication)} has been built (i.e. before any {nameof(Http)}/{nameof(WaitForResourceAsync)} call)."); - - HostDiagnosticsEnabled = true; - } - - return (TSelf)this; - } + public bool HostDiagnosticsEnabled { get; protected set; } /// /// Gets the default timeout used by when none is specified. @@ -266,37 +175,6 @@ public async Task WaitForResourceAsync(string resourceName, TimeSpan? timeout = await (effectiveTimeout == System.Threading.Timeout.InfiniteTimeSpan ? wait : wait.WaitAsync(effectiveTimeout)).ConfigureAwait(false); } - /// - /// Writes the specified to the test output to provide additional context (e.g. why a particular action, or wait, is being performed). - /// - /// The reason text. - /// The to support fluent-style method-chaining. - public TSelf Reason(string reason) - { - WriteReason(reason); - return (TSelf)this; - } - - /// - /// Delays for the specified , then writes any resource log messages captured (across all resources) during that time to the test output. - /// - /// The duration to delay; defaults to where not specified. - /// The reason for delaying (written to the test output for context); defaults to "No reason specified" where not specified. - /// The to support fluent-style method-chaining. - /// Useful when waiting on background/inter-resource activity (e.g. a message being processed by a downstream resource) that is not tied to a specific HTTP request/response (see - /// for the latter, request-scoped, correlation). - public TSelf Delay(TimeSpan? duration = null, string? reason = null) => WriteDelay(reason, duration).ContinueWith(_ => (TSelf)this).Result; - - /// - /// Delays for the specified , then writes any resource log messages captured (across all resources) during that time to the test output. - /// - /// The amount of time, in milliseconds, to delay. Must be a non-negative . - /// The reason for delaying (written to the test output for context); defaults to "No reason specified" where not specified. - /// The to support fluent-style method-chaining. - /// Useful when waiting on background/inter-resource activity (e.g. a message being processed by a downstream resource) that is not tied to a specific HTTP request/response (see - /// for the latter, request-scoped, correlation). - public TSelf Delay(int durationInMilliseconds, string? reason = null) => Delay(TimeSpan.FromMilliseconds(durationInMilliseconds), reason); - /// /// Combines the elapsed log messages captured (via ) across all resources since the last invocation, as background/inter-resource /// activity is not necessarily confined to a single resource; each line already carries its own owning resource name as a trailing suffix (see ), @@ -350,9 +228,9 @@ public TSelf Reason(string reason) /// remarks). /// The admin API client is built directly from a resolved (the same resolution path /// uses), rather than via the official package's own resource-type-specific helper - which keeps this resource-type-agnostic. - /// This tester's current (see ) is captured live at the point + /// This tester's current (see ) is captured live at the point /// is subsequently called - not at this method's call time - so a prior - /// call is always honoured. + /// call is always honoured. public HttpMock.AspireHttpMockClient HttpMock(string resourceName, string? endpointName = null) { if (resourceName is null) throw new ArgumentNullException(nameof(resourceName)); @@ -420,7 +298,7 @@ public void FlushPendingEntries() /// physical lines back into a single logical entry using the same style as an in-process (Tier 1) tester, re-using Aspire's own embedded timestamp (converted to local time) rather than /// substituting the capture time, so timestamps reflect when the resource itself actually logged the entry. The owning resource name is appended, in parentheses, inside the same /// trailing "[...]" bracket as the category - rather than as a separate leading prefix - so the timestamp/level/message stay column-aligned whether read via a single-resource - /// () or multi-resource () report. + /// () or multi-resource () report. /// A line that does not match the expected "{lineNumber}: {timestamp}Z " prefix (e.g. a resource that does not use the standard console logger format) is passed through /// unmodified, ANSI colour codes aside, so nothing is silently dropped. private sealed class ResourceLogger(string resourceName, ConcurrentQueue buffer) : ILogger @@ -518,9 +396,9 @@ private void FlushPendingNoLock() /// window of lines captured between (invoked immediately before the request is sent) and (invoked /// immediately after the response is received) is used as a pragmatic proxy - this naturally excludes the resource's own start-up banner noise (already buffered by the time the first /// request is sent) and correlates correctly for the typical, sequential one-request-at-a-time usage pattern. The window's baseline/end-point is tracked via the same shared, - /// per-resource watermark () that (used by ) advances, so a resource log line reported - /// here is claimed and will not also be re-reported by a subsequent call (or vice versa). - private sealed class ResourceHttpClientSource(AspireTesterBase owner, string resourceName, string? endpointName) : IHttpClientSource + /// per-resource watermark (_elapsedLogLineCounts) that (used by ) advances, so a resource + /// log line reported here is claimed and will not also be re-reported by a subsequent call (or vice versa). + private sealed class ResourceHttpClientSource(AspireTesterBase owner, string resourceName, string? endpointName) : IHttpClientSource { public HttpClient CreateHttpClient(string? name = null) { @@ -564,6 +442,8 @@ public HttpClient CreateHttpClient(string? name = null) /// /// Releases all resources. /// + /// Sealed by design - a derived tester that needs to release its own resources should override instead (matching the standard + /// .NET async-dispose pattern), not this method. public async ValueTask DisposeAsync() { if (_disposed) @@ -571,6 +451,18 @@ public async ValueTask DisposeAsync() _disposed = true; + await DisposeAsyncCore().ConfigureAwait(false); + + GC.SuppressFinalize(this); + } + + /// + /// Releases the underlying and any other managed resources. + /// + /// Override this - not - to release additional resources in a derived tester; always call await base.DisposeAsyncCore() to + /// ensure the underlying is still released. + protected virtual async ValueTask DisposeAsyncCore() + { Task? appTask; lock (SyncRoot) { diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs new file mode 100644 index 0000000..b8eef83 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs @@ -0,0 +1,183 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Testing; +using System; +using System.Threading.Tasks; +using UnitTestEx.Abstractions; + +namespace UnitTestEx.Aspire +{ + /// + /// Provides the .NET Aspire multi-host (distributed application) unit-testing capabilities bound to a specific AppHost , adding fluent-style + /// method-chaining () over the host-agnostic . + /// + /// The AppHost (a Projects.* type generated for the AppHost's ProjectReference). + /// The to support inheriting fluent-style method-chaining. + /// Only fluent, method-chaining members (those that must return ) and the one build step that is genuinely bound to the concrete + /// () live here; everything else - including , + /// , - lives on the non-generic + /// so it remains reachable (e.g. via an extension method) without needing to know either type parameter. + public abstract class AspireTesterBase : AspireTesterBase + where TAppHost : class + where TSelf : AspireTesterBase + { + /// + /// Initializes a new instance of the class. + /// + /// The . + protected AspireTesterBase(TestFrameworkImplementor implementor) : base(implementor) { } + + /// + protected override Task CreateBuilderAsync() => DistributedApplicationTestingBuilder.CreateAsync(); + + /// + /// Replaces the by cloning the . + /// + /// The + /// The to support fluent-style method-chaining. + /// Updates the and from the . + /// Unlike 's (and other Tier 1 testers') equivalent, this does not reset + /// the underlying : - the reason Tier 1 must rebuild its single, in-process dependency injection (DI) + /// container host - has no equivalent here, as the AppHost is a genuine, separately-built multi-process distributed application that does not configure. Where + /// its properties are consumed by a Tier 2 test (e.g. when sending a request via , + /// or / when asserting a response), they are read live at request/assert time, so no reset is + /// required for a subsequent call to see the change. + public TSelf UseSetUp(TestSetUp setUp) + { + SetUp = setUp?.Clone() ?? throw new ArgumentNullException(nameof(setUp)); + JsonSerializer = SetUp.JsonSerializer; + JsonComparerOptions = SetUp.JsonComparerOptions; + return (TSelf)this; + } + + /// + /// Updates (replaces) the default test . + /// + /// The test user name (a null value will reset to ). + /// The to support fluent-style method-chaining. + public TSelf UseUser(string? userName) + { + UserName = userName ?? SetUp.DefaultUserName; + return (TSelf)this; + } + + /// + /// Updates (replaces) the default test . + /// + /// The test user identifier (a null value will reset to ). + /// The to support fluent-style method-chaining. + /// The is required for the conversion to take place. + public TSelf UseUser(object? userIdentifier) + { + if (userIdentifier == null) + return UseUser(null); + + if (SetUp.UserNameConverter == null) + throw new InvalidOperationException($"The {nameof(TestSetUp)}.{nameof(TestSetUp.UserNameConverter)} must be defined to support user identifier conversion."); + + return UseUser(SetUp.UserNameConverter(userIdentifier)); + } + + /// + /// Updates the used by the itself, not any underlying resource which should be configured separately. + /// + /// The . + /// The to support fluent-style method-chaining. + public TSelf UseJsonSerializer(Json.IJsonSerializer jsonSerializer) + { + JsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); + return (TSelf)this; + } + + /// + /// Updates the used by the itself, not any underlying resource which should be configured separately. + /// + /// The . + /// The to support fluent-style method-chaining. + /// Where the is null then the will be used. + public TSelf UseJsonComparerOptions(Json.JsonElementComparerOptions options) + { + JsonComparerOptions = options ?? throw new ArgumentNullException(nameof(options)); + return (TSelf)this; + } + + /// + /// Overrides an environment variable for the named resource before the underlying is built. + /// + /// The resource name (as configured within the AppHost). + /// The environment variable name. + /// The environment variable value. + /// The to support fluent-style method-chaining. + /// This is the only override surface available for a Tier 2 tester as each resource runs in its own, separate process with no shared, in-process dependency injection + /// (DI) container to reach into; must be called before the underlying has been built (see ). + public TSelf WithResourceEnvironment(string resourceName, string key, string value) + { + if (resourceName is null) throw new ArgumentNullException(nameof(resourceName)); + if (key is null) throw new ArgumentNullException(nameof(key)); + + lock (SyncRoot) + { + if (IsDistributedApplicationBuilding) + throw new InvalidOperationException($"{nameof(WithResourceEnvironment)} must be invoked before the underlying {nameof(DistributedApplication)} has been built (i.e. before any {nameof(Http)}/{nameof(WaitForResourceAsync)} call)."); + + ConfigureBuilderActions.Add(builder => builder.CreateResourceBuilder(resourceName).WithEnvironment(key, value)); + } + + return (TSelf)this; + } + + /// + /// Opts back into the AppHost's own raw logging (its start-up banner, DCP process management, and each resource's own console output) being written to the test output, in addition + /// to what UnitTestEx itself reports via //the request-scoped "LOGGING >" section. + /// + /// The to support fluent-style method-chaining. + /// By default this raw firehose is suppressed as it otherwise duplicates what UnitTestEx already surfaces cleanly (and correlated); enable it when troubleshooting an + /// AppHost/resource start-up failure that needs the full, unfiltered diagnostic output. Must be called before the underlying has been built + /// (see ). + public TSelf EnableHostDiagnostics() + { + lock (SyncRoot) + { + if (IsDistributedApplicationBuilding) + throw new InvalidOperationException($"{nameof(EnableHostDiagnostics)} must be invoked before the underlying {nameof(DistributedApplication)} has been built (i.e. before any {nameof(Http)}/{nameof(WaitForResourceAsync)} call)."); + + HostDiagnosticsEnabled = true; + } + + return (TSelf)this; + } + + /// + /// Writes the specified to the test output to provide additional context (e.g. why a particular action, or wait, is being performed). + /// + /// The reason text. + /// The to support fluent-style method-chaining. + public TSelf Reason(string reason) + { + WriteReason(reason); + return (TSelf)this; + } + + /// + /// Delays for the specified , then writes any resource log messages captured (across all resources) during that time to the test output. + /// + /// The duration to delay; defaults to where not specified. + /// The reason for delaying (written to the test output for context); defaults to "No reason specified" where not specified. + /// The to support fluent-style method-chaining. + /// Useful when waiting on background/inter-resource activity (e.g. a message being processed by a downstream resource) that is not tied to a specific HTTP request/response (see + /// for the latter, request-scoped, correlation). + public TSelf Delay(TimeSpan? duration = null, string? reason = null) => WriteDelay(reason, duration).ContinueWith(_ => (TSelf)this).Result; + + /// + /// Delays for the specified , then writes any resource log messages captured (across all resources) during that time to the test output. + /// + /// The amount of time, in milliseconds, to delay. Must be a non-negative . + /// The reason for delaying (written to the test output for context); defaults to "No reason specified" where not specified. + /// The to support fluent-style method-chaining. + /// Useful when waiting on background/inter-resource activity (e.g. a message being processed by a downstream resource) that is not tied to a specific HTTP request/response (see + /// for the latter, request-scoped, correlation). + public TSelf Delay(int durationInMilliseconds, string? reason = null) => Delay(TimeSpan.FromMilliseconds(durationInMilliseconds), reason); + } +} diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs index a070780..0f67e26 100644 --- a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs @@ -14,7 +14,7 @@ namespace UnitTestEx.Aspire.HttpMock { /// /// Provides a thin fluent wrapper over a real, out-of-process WireMock.Net server resource - typically a self-hosted, ordinary Aspire project resource (see - /// remarks) - for stubbing HTTP responses from a Tier 2 () multi-host test. + /// remarks) - for stubbing HTTP responses from a Tier 2 () multi-host test. /// /// Unlike Tier 1's in-memory, purely synchronous (a Moq-based substitution), this issues genuine HTTP requests /// to the WireMock.Net server's admin API (a separate OS process, potentially in a container); every stub-defining and verification method is therefore asynchronous - there is no honest way @@ -65,7 +65,7 @@ internal AspireHttpMockClient(Func> adminApiFactory, Fun /// Removes all previously configured mappings and clears the recorded request log on the underlying WireMock.Net server resource. /// /// The . - /// As the underlying is expensive to rebuild (see ), the mapped + /// As the underlying is expensive to rebuild (see ), the mapped /// server resource's state otherwise persists across all tests sharing the same host instance; call this (e.g. from a per-test setup) to isolate each test's stubs. public async Task ResetAsync(CancellationToken cancellationToken = default) { diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs index da16305..03f5474 100644 --- a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs @@ -110,7 +110,7 @@ public AspireHttpMockRequestBody WithBody(string text, string mediaType) /// The to continue the fluent-style configuration. /// See the remarks regarding WireMock.Net's JSON comparison semantics versus (as used by, for /// example, ): they are not equivalent, and there is no semantic value coercion - /// (e.g. dates, GUIDs) here. Where that parity matters, target a self-hosted WireMock.Net project resource (see remarks) + /// (e.g. dates, GUIDs) here. Where that parity matters, target a self-hosted WireMock.Net project resource (see remarks) /// and use instead. /// Where is specified, the named properties are removed from the match pattern and the underlying matcher switches from an exact, /// bidirectional JsonMatcher to WireMock.Net's own JsonPartialMatcher (a subset match): properties present in the pattern must still match exactly, but the @@ -177,7 +177,7 @@ private static void RemovePath(JsonNode node, string path) /// than switching to a looser subset/partial match. /// The to continue the fluent-style configuration. /// Requires the target to have been resolved against a self-hosted WireMock.Net project resource (via - /// , targeting a small sample project the consumer copies into their own solution - see UnitTestEx's README + /// , targeting a small sample project the consumer copies into their own solution - see UnitTestEx's README /// "Aspire multi-host testing" section) that has registered - the official WireMock.Net.Aspire package's container resource has /// no way to load this custom matcher type and will reject the mapping with a "Matcher 'JsonElementComparerMatcher' is not supported" error. /// The tester's current - specifically , From 5d62a6192771e1739a46ea206c0739ef98074c1a Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Sat, 26 Sep 2026 18:00:55 -0700 Subject: [PATCH 34/43] Rename Reason to Checkpoint; add WaitForResourceAsync(string[]); move 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> --- .../Aspire/AspireTesterBase.cs | 17 +++++++- .../Aspire/AspireTesterBaseT.cs | 9 ++-- .../JsonElementComparerMatcher.cs | 6 +-- .../Aspire/WireMockConsole.cs | 1 + src/UnitTestEx/Abstractions/TesterBaseCore.cs | 41 ++++++++++++------- src/UnitTestEx/Abstractions/TesterBaseT.cs | 7 ++-- .../AspireTesterTest.cs | 8 ++-- .../AspireTesterTest.cs | 8 ++-- .../AspireTesterTest.cs | 20 ++++----- ...DelayTest.cs => CheckpointAndDelayTest.cs} | 31 ++++++++++---- ...DelayTest.cs => CheckpointAndDelayTest.cs} | 31 ++++++++++---- .../PersonControllerTest.cs | 2 +- ...DelayTest.cs => CheckpointAndDelayTest.cs} | 33 +++++++++++---- 13 files changed, 145 insertions(+), 69 deletions(-) rename src/UnitTestEx.Aspire/Aspire/{ => HttpMock}/JsonElementComparerMatcher.cs (96%) rename tests/UnitTestEx.MSTest.Test/Other/{ReasonAndDelayTest.cs => CheckpointAndDelayTest.cs} (70%) rename tests/UnitTestEx.NUnit.Test/Other/{ReasonAndDelayTest.cs => CheckpointAndDelayTest.cs} (69%) rename tests/UnitTestEx.Xunit.Test/Other/{ReasonAndDelayTest.cs => CheckpointAndDelayTest.cs} (68%) diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index 7d848b0..44ab15d 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -95,7 +95,7 @@ private async Task CreateDistributedApplicationAsync() if (!HostDiagnosticsEnabled) // By default the AppHost's own logging (its start-up banner, DCP process management, and - critically - each resource's own console output mirrored under a // "{ApplicationName}.Resources.{resourceName}" category; see 'EnableResourceLogging') writes straight to the test's Standard Output via its default provider(s) (e.g. console), - // duplicating what UnitTestEx already captures and reports cleanly (and correlated) via Reason()/Delay()/the Http() request-scoped "LOGGING >" section. Remove the AppHost's + // duplicating what UnitTestEx already captures and reports cleanly (and correlated) via Checkpoint()/Delay()/the Http() request-scoped "LOGGING >" section. Remove the AppHost's // own provider(s) so only what UnitTestEx explicitly writes reaches the test output; call EnableHostDiagnostics() before this point to opt back into the raw firehose (e.g. when // troubleshooting an AppHost/resource start-up failure). builder.Services.RemoveAll(); @@ -175,6 +175,21 @@ public async Task WaitForResourceAsync(string resourceName, TimeSpan? timeout = await (effectiveTimeout == System.Threading.Timeout.InfiniteTimeSpan ? wait : wait.WaitAsync(effectiveTimeout)).ConfigureAwait(false); } + /// + /// Waits for all of the named resources to report a healthy status, concurrently. + /// + /// The resource names (as configured within the AppHost). + /// The timeout (defaults to ) applied to each resource independently; pass + /// to wait indefinitely. + /// Equivalent to awaiting for each resource in parallel; if any resource fails to become healthy within the + /// , the resulting exception is propagated once all waits have completed (or faulted). + public async Task WaitForResourceAsync(string[] resourceNames, TimeSpan? timeout = null) + { + if (resourceNames is null) throw new ArgumentNullException(nameof(resourceNames)); + + await Task.WhenAll(resourceNames.Select(rn => WaitForResourceAsync(rn, timeout))).ConfigureAwait(false); + } + /// /// Combines the elapsed log messages captured (via ) across all resources since the last invocation, as background/inter-resource /// activity is not necessarily confined to a single resource; each line already carries its own owning resource name as a trailing suffix (see ), diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs index b8eef83..b9af7f6 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs @@ -130,7 +130,7 @@ public TSelf WithResourceEnvironment(string resourceName, string key, string val /// /// Opts back into the AppHost's own raw logging (its start-up banner, DCP process management, and each resource's own console output) being written to the test output, in addition - /// to what UnitTestEx itself reports via //the request-scoped "LOGGING >" section. + /// to what UnitTestEx itself reports via //the request-scoped "LOGGING >" section. /// /// The to support fluent-style method-chaining. /// By default this raw firehose is suppressed as it otherwise duplicates what UnitTestEx already surfaces cleanly (and correlated); enable it when troubleshooting an @@ -150,13 +150,14 @@ public TSelf EnableHostDiagnostics() } /// - /// Writes the specified to the test output to provide additional context (e.g. why a particular action, or wait, is being performed). + /// Writes the specified to the test output to provide additional context (e.g. why a particular action is being performed), then immediately writes any + /// resource log messages captured (across all resources) since the last checkpoint/delay to the test output - i.e. a zero-wait equivalent of . /// /// The reason text. /// The to support fluent-style method-chaining. - public TSelf Reason(string reason) + public TSelf Checkpoint(string reason) { - WriteReason(reason); + WriteCheckpoint(reason); return (TSelf)this; } diff --git a/src/UnitTestEx.Aspire/Aspire/JsonElementComparerMatcher.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/JsonElementComparerMatcher.cs similarity index 96% rename from src/UnitTestEx.Aspire/Aspire/JsonElementComparerMatcher.cs rename to src/UnitTestEx.Aspire/Aspire/HttpMock/JsonElementComparerMatcher.cs index 3c49ee0..99bdb69 100644 --- a/src/UnitTestEx.Aspire/Aspire/JsonElementComparerMatcher.cs +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/JsonElementComparerMatcher.cs @@ -8,18 +8,18 @@ using JsonSerializer = System.Text.Json.JsonSerializer; using UnitTestExJsonElementComparer = UnitTestEx.Json.JsonElementComparer; -namespace UnitTestEx.Aspire +namespace UnitTestEx.Aspire.HttpMock { /// /// A custom WireMock.Net that delegates JSON body matching to UnitTestEx's own , giving a - /// self-hosted WireMock.Net project resource (see remarks and the README's "Aspire multi-host testing" section) genuine JSON + /// self-hosted WireMock.Net project resource (see remarks and the README's "Aspire multi-host testing" section) genuine JSON /// comparison-semantics parity with Tier 1's in-process MockHttpClient - notably semantic value coercion for dates, GUIDs and numbers (e.g. "2024-01-01T00:00:00Z" /// and "2024-01-01T00:00:00+00:00" are considered equal) - which WireMock.Net's own JsonMatcher/JsonPartialMatcher (a textual/structural comparison) does /// not provide. /// /// Register this against the well-known matcher name via in the /// self-hosted WireMock.Net process (see UnitTestEx.Aspire.MockHost's Program.cs template); the corresponding client-side mapping is produced by - /// , which encodes the JSON pattern, any pathsToIgnore and the calling + /// , which encodes the JSON pattern, any pathsToIgnore and the calling /// test's (the comparison-affecting subset of it - see ) as a small carried in /// (WireMock.Net's admin API has no other extensibility point for passing matcher-specific configuration through to a /// CustomMatcherMappings factory). This matcher instance runs in the self-hosted WireMock.Net process - a genuinely separate OS process from the test - so it has no diff --git a/src/UnitTestEx.Aspire/Aspire/WireMockConsole.cs b/src/UnitTestEx.Aspire/Aspire/WireMockConsole.cs index 71d20c5..70a9553 100644 --- a/src/UnitTestEx.Aspire/Aspire/WireMockConsole.cs +++ b/src/UnitTestEx.Aspire/Aspire/WireMockConsole.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using UnitTestEx.Aspire.HttpMock; using WireMock.Admin.Mappings; using WireMock.Matchers; using WireMock.Server; diff --git a/src/UnitTestEx/Abstractions/TesterBaseCore.cs b/src/UnitTestEx/Abstractions/TesterBaseCore.cs index 68b6e78..e7879b7 100644 --- a/src/UnitTestEx/Abstractions/TesterBaseCore.cs +++ b/src/UnitTestEx/Abstractions/TesterBaseCore.cs @@ -256,10 +256,10 @@ public void ReplaceTestFrameworkImplementor(TestFrameworkImplementor implementor /// Gets the log messages accumulated since the last time this was invoked (draining/resetting the starting point for the next invocation). /// /// The accumulated log messages; null/empty where none. - /// Used by to correlate output that occurs during a delay period rather than a specific HTTP request/response (see - /// for the latter, request-scoped, correlation). The default (Tier 1, single in-process host) implementation surfaces any - /// logging that was not attributed to a specific HTTP request (see ) - e.g. from a background/hosted service. A Tier 2 (e.g. multi-host - /// Aspire) tester should override this to surface whatever is applicable to its own hosting model. + /// Used by / to correlate output that occurs since the last checkpoint/delay rather than a + /// specific HTTP request/response (see for the latter, request-scoped, correlation). The default (Tier 1, single in-process host) implementation + /// surfaces any logging that was not attributed to a specific HTTP request (see ) - e.g. from a + /// background/hosted service. A Tier 2 (e.g. multi-host Aspire) tester should override this to surface whatever is applicable to its own hosting model. protected virtual IEnumerable? DrainElapsedLogMessages() => SharedState.GetLoggerMessages(); /// @@ -268,18 +268,22 @@ public void ReplaceTestFrameworkImplementor(TestFrameworkImplementor implementor public static TimeSpan DefaultDelayDuration { get; } = TimeSpan.FromSeconds(1); /// - /// Writes the specified to the test output to provide additional context (e.g. why a particular action, or delay, is being performed). + /// Writes the specified to the test output to provide additional context (e.g. why a particular action is being performed), then writes any log messages + /// captured since the last checkpoint/delay (see ) to the test output - i.e. a checkpoint acts as an immediate (zero-wait) flush point, the same + /// as without the actual waiting. /// /// The reason text. - protected void WriteReason(string reason) + protected void WriteCheckpoint(string reason) { if (string.IsNullOrEmpty(reason)) return; Implementor.WriteLine(""); Implementor.WriteLine(new string('-', 80)); - Implementor.WriteLine("REASON >"); + Implementor.WriteLine("CHECKPOINT >"); Implementor.WriteLine(reason); + + WriteElapsedLogMessages(); } /// @@ -303,18 +307,25 @@ protected async Task WriteDelay(string? reason, TimeSpan? duration) await Task.Delay(effectiveDuration).ConfigureAwait(false); + WriteElapsedLogMessages(); + } + + /// + /// Writes any log messages captured since the last checkpoint/delay (see ) to the test output under a "LOGGING >" section - but only where at + /// least one message was actually captured; stays silent (no section at all) where there is nothing to report, rather than a noisy "None." placeholder. + /// + private void WriteElapsedLogMessages() + { + var logs = DrainElapsedLogMessages(); + if (logs is null || !logs.Any()) + return; + Implementor.WriteLine(""); Implementor.WriteLine("LOGGING >"); - var logs = DrainElapsedLogMessages(); - if (logs is not null && logs.Any()) + foreach (var msg in logs) { - foreach (var msg in logs) - { - Implementor.WriteLine(msg); - } + Implementor.WriteLine(msg); } - else - Implementor.WriteLine("None."); } /// diff --git a/src/UnitTestEx/Abstractions/TesterBaseT.cs b/src/UnitTestEx/Abstractions/TesterBaseT.cs index 7fab3f2..9165fdf 100644 --- a/src/UnitTestEx/Abstractions/TesterBaseT.cs +++ b/src/UnitTestEx/Abstractions/TesterBaseT.cs @@ -405,13 +405,14 @@ public TSelf UseScopedTypeSetUp(Func setupAsync) public TSelf Delay(int durationInMilliseconds, string? reason = null) => Delay(TimeSpan.FromMilliseconds(durationInMilliseconds), reason); /// - /// Writes the specified to the test output to provide additional context (e.g. why a particular action, or delay, is being performed). + /// Writes the specified to the test output to provide additional context (e.g. why a particular action is being performed), then immediately writes any log + /// messages captured since the last checkpoint/delay to the test output - i.e. a zero-wait equivalent of . /// /// The reason text. /// The to support fluent-style method-chaining. - public TSelf Reason(string reason) + public TSelf Checkpoint(string reason) { - WriteReason(reason); + WriteCheckpoint(reason); return (TSelf)this; } diff --git a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs index 4a3bce0..5d61733 100644 --- a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs @@ -42,7 +42,7 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() } [TestMethod] - public async Task Reason_And_Delay_AggregatesResourceLogs() + public async Task Checkpoint_And_Delay_AggregatesResourceLogs() { await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); @@ -52,7 +52,7 @@ public async Task Reason_And_Delay_AggregatesResourceLogs() var spy = new SpyTestFrameworkImplementor(tester.Implementor); tester.ReplaceTestFrameworkImplementor(spy); - var reasonResult = tester.Reason("Confirming Reason() writes context for Aspire multi-host testers too."); + var checkpointResult = tester.Checkpoint("Confirming Checkpoint() writes context for Aspire multi-host testers too."); // Fire a raw (uninstrumented) request against the 'api' resource part-way through the delay window to simulate genuine background/inter-resource activity that is not // tied to a tester-driven request/response (which would otherwise claim - and so report - the resource's log line itself, rather than Delay). @@ -70,10 +70,10 @@ public async Task Reason_And_Delay_AggregatesResourceLogs() await backgroundCallTask; - Assert.AreSame(tester, reasonResult); + Assert.AreSame(tester, checkpointResult); Assert.AreSame(tester, delayResult); Assert.IsTrue(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(200), $"Expected to delay ~2s, actually waited {sw.Elapsed}."); - Assert.IsTrue(spy.Lines.Contains("REASON >")); + Assert.IsTrue(spy.Lines.Contains("CHECKPOINT >")); Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("DELAY (00:00:02) >"))); Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("Waiting for a background Person lookup to complete and log."))); Assert.IsTrue(spy.Lines.Contains("LOGGING >")); diff --git a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs index 41997f0..baea15a 100644 --- a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs @@ -42,7 +42,7 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() } [Test] - public async Task Reason_And_Delay_AggregatesResourceLogs() + public async Task Checkpoint_And_Delay_AggregatesResourceLogs() { await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); @@ -52,7 +52,7 @@ public async Task Reason_And_Delay_AggregatesResourceLogs() var spy = new SpyTestFrameworkImplementor(tester.Implementor); tester.ReplaceTestFrameworkImplementor(spy); - var reasonResult = tester.Reason("Confirming Reason() writes context for Aspire multi-host testers too."); + var checkpointResult = tester.Checkpoint("Confirming Checkpoint() writes context for Aspire multi-host testers too."); // Fire a raw (uninstrumented) request against the 'api' resource part-way through the delay window to simulate genuine background/inter-resource activity that is not // tied to a tester-driven request/response (which would otherwise claim - and so report - the resource's log line itself, rather than Delay). @@ -70,10 +70,10 @@ public async Task Reason_And_Delay_AggregatesResourceLogs() await backgroundCallTask; - Assert.That(reasonResult, Is.SameAs(tester)); + Assert.That(checkpointResult, Is.SameAs(tester)); Assert.That(delayResult, Is.SameAs(tester)); Assert.That(sw.Elapsed, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(200)), $"Expected to delay ~2s, actually waited {sw.Elapsed}."); - Assert.That(spy.Lines, Does.Contain("REASON >")); + Assert.That(spy.Lines, Does.Contain("CHECKPOINT >")); Assert.That(spy.Lines.Any(l => l != null && l.Contains("DELAY (00:00:02) >")), Is.True); Assert.That(spy.Lines.Any(l => l != null && l.Contains("Waiting for a background Person lookup to complete and log.")), Is.True); Assert.That(spy.Lines, Does.Contain("LOGGING >")); diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index 76c69cd..75f4014 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -49,7 +49,7 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() } [Fact] - public async Task Reason_And_Delay_AggregatesResourceLogs() + public async Task Checkpoint_And_Delay_AggregatesResourceLogs() { await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); @@ -59,7 +59,7 @@ public async Task Reason_And_Delay_AggregatesResourceLogs() var spy = new SpyTestFrameworkImplementor(tester.Implementor); tester.ReplaceTestFrameworkImplementor(spy); - var reasonResult = tester.Reason("Confirming Reason() writes context for Aspire multi-host testers too."); + var checkpointResult = tester.Checkpoint("Confirming Checkpoint() writes context for Aspire multi-host testers too."); // Fire a raw (uninstrumented) request against the 'api' resource part-way through the delay window to simulate genuine background/inter-resource activity that is not // tied to a tester-driven request/response (which would otherwise claim - and so report - the resource's log line itself, rather than Delay). @@ -77,10 +77,10 @@ public async Task Reason_And_Delay_AggregatesResourceLogs() await backgroundCallTask; - Assert.Same(tester, reasonResult); + Assert.Same(tester, checkpointResult); Assert.Same(tester, delayResult); Assert.True(sw.Elapsed >= TimeSpan.FromSeconds(2) - TimeSpan.FromMilliseconds(200), $"Expected to delay ~2s, actually waited {sw.Elapsed}."); - Assert.Contains("REASON >", spy.Lines); + Assert.Contains("CHECKPOINT >", spy.Lines); Assert.Contains(spy.Lines, l => l != null && l.Contains("DELAY (00:00:02) >")); Assert.Contains(spy.Lines, l => l != null && l.Contains("Waiting for a background Person lookup to complete and log.")); Assert.Contains("LOGGING >", spy.Lines); @@ -94,8 +94,7 @@ public async Task HttpMock_StubsExternalMockHostDependency_Product() await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); - await tester.WaitForResourceAsync("api"); - await tester.WaitForResourceAsync("mockhost"); + await tester.WaitForResourceAsync(["api", "mockhost"]); var stub = await tester.HttpMock("mockhost") .Request(HttpMethod.Get, "/products/abc") @@ -120,8 +119,7 @@ public async Task HttpMock_WithSequenceAsync_ReturnsResponsesInOrderThenExhausts await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); - await tester.WaitForResourceAsync("api"); - await tester.WaitForResourceAsync("mockhost"); + await tester.WaitForResourceAsync(["api", "mockhost"]); var stub = await tester.HttpMock("mockhost") .Request(HttpMethod.Get, "/products/seq") @@ -146,8 +144,7 @@ public async Task HttpMock_WithSequenceAsync_ExceedingConfiguredResponses_Throws await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); - await tester.WaitForResourceAsync("api"); - await tester.WaitForResourceAsync("mockhost"); + await tester.WaitForResourceAsync(["api", "mockhost"]); var stub = await tester.HttpMock("mockhost") .Request(HttpMethod.Get, "/products/seq") @@ -191,8 +188,7 @@ public async Task HttpMock_SharedInterface_ConfiguresIdenticallyAcrossTiers() await using var tester = AspireTester.Create() .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); - await tester.WaitForResourceAsync("api"); - await tester.WaitForResourceAsync("mockhost"); + await tester.WaitForResourceAsync(["api", "mockhost"]); var stub = await HttpMockSharedConfig.ConfigureProductStubAsync(tester.HttpMock("mockhost"), "/products/shared"); diff --git a/tests/UnitTestEx.MSTest.Test/Other/ReasonAndDelayTest.cs b/tests/UnitTestEx.MSTest.Test/Other/CheckpointAndDelayTest.cs similarity index 70% rename from tests/UnitTestEx.MSTest.Test/Other/ReasonAndDelayTest.cs rename to tests/UnitTestEx.MSTest.Test/Other/CheckpointAndDelayTest.cs index 17a99be..f65ea8d 100644 --- a/tests/UnitTestEx.MSTest.Test/Other/ReasonAndDelayTest.cs +++ b/tests/UnitTestEx.MSTest.Test/Other/CheckpointAndDelayTest.cs @@ -11,20 +11,37 @@ namespace UnitTestEx.MSTest.Test.Other { [TestClass] - public class ReasonAndDelayTest + public class CheckpointAndDelayTest { [TestMethod] - public void Reason_WritesReasonToOutput() + public void Checkpoint_WritesReasonAndFlushesLogsToOutput() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); test.ReplaceTestFrameworkImplementor(spy); - var result = test.Reason("Confirming Reason() writes context to the test output."); + var result = test.Checkpoint("Confirming Checkpoint() writes context to the test output."); Assert.AreSame(test, result); - Assert.IsTrue(spy.Lines.Contains("REASON >")); - Assert.IsTrue(spy.Lines.Contains("Confirming Reason() writes context to the test output.")); + Assert.IsTrue(spy.Lines.Contains("CHECKPOINT >")); + Assert.IsTrue(spy.Lines.Contains("Confirming Checkpoint() writes context to the test output.")); + Assert.IsFalse(spy.Lines.Contains("LOGGING >")); + } + + [TestMethod] + public void Checkpoint_SurfacesLoggingCapturedSinceTheLastCheckpointOrDelay() + { + using var test = ApiTester.Create(); + var spy = new SpyTestFrameworkImplementor(test.Implementor); + test.ReplaceTestFrameworkImplementor(spy); + + var logger = test.Services.GetRequiredService>(); + logger.LogInformation("Message logged before the checkpoint."); + + test.Checkpoint("Confirming Checkpoint() also flushes background logging, like Delay() does."); + + Assert.IsTrue(spy.Lines.Contains("LOGGING >")); + Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("Message logged before the checkpoint."))); } [TestMethod] @@ -69,8 +86,8 @@ public void Delay_ExcludesLoggingThatOccurredBeforeTheDelayStarted() test.Delay(TimeSpan.FromMilliseconds(200), "Waiting with nothing new expected."); - Assert.IsTrue(spy.Lines.Contains("LOGGING >")); - Assert.IsTrue(spy.Lines.Contains("None.")); + Assert.IsFalse(spy.Lines.Contains("LOGGING >")); + Assert.IsFalse(spy.Lines.Contains("None.")); Assert.IsFalse(spy.Lines.Any(l => l != null && l.Contains("Stale message logged before the delay started."))); } } diff --git a/tests/UnitTestEx.NUnit.Test/Other/ReasonAndDelayTest.cs b/tests/UnitTestEx.NUnit.Test/Other/CheckpointAndDelayTest.cs similarity index 69% rename from tests/UnitTestEx.NUnit.Test/Other/ReasonAndDelayTest.cs rename to tests/UnitTestEx.NUnit.Test/Other/CheckpointAndDelayTest.cs index 4f2141c..2381137 100644 --- a/tests/UnitTestEx.NUnit.Test/Other/ReasonAndDelayTest.cs +++ b/tests/UnitTestEx.NUnit.Test/Other/CheckpointAndDelayTest.cs @@ -11,20 +11,37 @@ namespace UnitTestEx.NUnit.Test.Other { [TestFixture] - public class ReasonAndDelayTest + public class CheckpointAndDelayTest { [Test] - public void Reason_WritesReasonToOutput() + public void Checkpoint_WritesReasonAndFlushesLogsToOutput() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); test.ReplaceTestFrameworkImplementor(spy); - var result = test.Reason("Confirming Reason() writes context to the test output."); + var result = test.Checkpoint("Confirming Checkpoint() writes context to the test output."); Assert.That(result, Is.SameAs(test)); - Assert.That(spy.Lines, Does.Contain("REASON >")); - Assert.That(spy.Lines, Does.Contain("Confirming Reason() writes context to the test output.")); + Assert.That(spy.Lines, Does.Contain("CHECKPOINT >")); + Assert.That(spy.Lines, Does.Contain("Confirming Checkpoint() writes context to the test output.")); + Assert.That(spy.Lines, Does.Not.Contain("LOGGING >")); + } + + [Test] + public void Checkpoint_SurfacesLoggingCapturedSinceTheLastCheckpointOrDelay() + { + using var test = ApiTester.Create(); + var spy = new SpyTestFrameworkImplementor(test.Implementor); + test.ReplaceTestFrameworkImplementor(spy); + + var logger = test.Services.GetRequiredService>(); + logger.LogInformation("Message logged before the checkpoint."); + + test.Checkpoint("Confirming Checkpoint() also flushes background logging, like Delay() does."); + + Assert.That(spy.Lines, Does.Contain("LOGGING >")); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("Message logged before the checkpoint.")), Is.True); } [Test] @@ -69,8 +86,8 @@ public void Delay_ExcludesLoggingThatOccurredBeforeTheDelayStarted() test.Delay(TimeSpan.FromMilliseconds(200), "Waiting with nothing new expected."); - Assert.That(spy.Lines, Does.Contain("LOGGING >")); - Assert.That(spy.Lines, Does.Contain("None.")); + Assert.That(spy.Lines, Does.Not.Contain("LOGGING >")); + Assert.That(spy.Lines, Does.Not.Contain("None.")); Assert.That(spy.Lines.Any(l => l != null && l.Contains("Stale message logged before the delay started.")), Is.False); } } diff --git a/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs b/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs index 0f8a601..a8834f3 100644 --- a/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs +++ b/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs @@ -59,7 +59,7 @@ public void Get_Test4() public void Get_Test4_WithResetHost() { using var test = ApiTester.Create().ResetHost(); - test.Reason("Need to reset the host."); + test.Checkpoint("Need to reset the host."); test.Controller() .Run(c => c.Get(4)) .AssertNotFound(); diff --git a/tests/UnitTestEx.Xunit.Test/Other/ReasonAndDelayTest.cs b/tests/UnitTestEx.Xunit.Test/Other/CheckpointAndDelayTest.cs similarity index 68% rename from tests/UnitTestEx.Xunit.Test/Other/ReasonAndDelayTest.cs rename to tests/UnitTestEx.Xunit.Test/Other/CheckpointAndDelayTest.cs index 2e60d1b..7b41cc8 100644 --- a/tests/UnitTestEx.Xunit.Test/Other/ReasonAndDelayTest.cs +++ b/tests/UnitTestEx.Xunit.Test/Other/CheckpointAndDelayTest.cs @@ -10,22 +10,39 @@ namespace UnitTestEx.Xunit.Test.Other { - public class ReasonAndDelayTest : UnitTestBase + public class CheckpointAndDelayTest : UnitTestBase { - public ReasonAndDelayTest(ITestOutputHelper output) : base(output) { } + public CheckpointAndDelayTest(ITestOutputHelper output) : base(output) { } [Fact] - public void Reason_WritesReasonToOutput() + public void Checkpoint_WritesReasonAndFlushesLogsToOutput() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); test.ReplaceTestFrameworkImplementor(spy); - var result = test.Reason("Confirming Reason() writes context to the test output."); + var result = test.Checkpoint("Confirming Checkpoint() writes context to the test output."); Assert.Same(test, result); - Assert.Contains("REASON >", spy.Lines); - Assert.Contains("Confirming Reason() writes context to the test output.", spy.Lines); + Assert.Contains("CHECKPOINT >", spy.Lines); + Assert.Contains("Confirming Checkpoint() writes context to the test output.", spy.Lines); + Assert.DoesNotContain("LOGGING >", spy.Lines); + } + + [Fact] + public void Checkpoint_SurfacesLoggingCapturedSinceTheLastCheckpointOrDelay() + { + using var test = ApiTester.Create(); + var spy = new SpyTestFrameworkImplementor(test.Implementor); + test.ReplaceTestFrameworkImplementor(spy); + + var logger = test.Services.GetRequiredService>(); + logger.LogInformation("Message logged before the checkpoint."); + + test.Checkpoint("Confirming Checkpoint() also flushes background logging, like Delay() does."); + + Assert.Contains("LOGGING >", spy.Lines); + Assert.Contains(spy.Lines, l => l != null && l.Contains("Message logged before the checkpoint.")); } [Fact] @@ -70,8 +87,8 @@ public void Delay_ExcludesLoggingThatOccurredBeforeTheDelayStarted() test.Delay(TimeSpan.FromMilliseconds(200), "Waiting with nothing new expected."); - Assert.Contains("LOGGING >", spy.Lines); - Assert.Contains("None.", spy.Lines); + Assert.DoesNotContain("LOGGING >", spy.Lines); + Assert.DoesNotContain("None.", spy.Lines); Assert.DoesNotContain(spy.Lines, l => l != null && l.Contains("Stale message logged before the delay started.")); } } From 321a85ecb58c86adc46c3201887bd37cc27a263e Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Sun, 27 Sep 2026 10:06:17 -0700 Subject: [PATCH 35/43] Update CHANGELOG for Checkpoint/Delay/WaitForResourceAsync; dedupe WaitForResourceAsync(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> --- CHANGELOG.md | 5 +++-- src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bd6cea..f123b91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,9 @@ Represents the **NuGet** versions. ## v5.12.0 - *Enhancement:* Added `UnitTestEx.Aspire` (NET8+) to enable testing of [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/) multi-host (inter-domain) testing alongside existing in-process `WebApplicationFactory`. The API surface between the two is consistent where appropriate. - - Additionally, `HttpClient` mocking of external dependencies is supported via `AspireHttpMockClient`, which stubs a real, out-of-process [WireMock.Net](https://wiremock.org/dotnet/) server resource - see the README's [Aspire multi-host testing](./README.md#Aspire-multi-host-testing) section for the recommended, self-hosted (Docker-free) pattern. -- *Fixed:* `ApiTesterBase.CreateHttpClient` no longer resets `TestSharedState` on every request. It previously routed through `GetTestServer()`'s reset-on-access semantics, which - since `CreateHttpClient` is invoked per-request, after any `Expect*()`-style call has already registered request-scoped state - silently wiped registered expectation flags (e.g. `ExpectEvents()`) immediately before the request was sent, causing false "expectation not met" failures. + - `HttpClient` mocking of external dependencies is supported via `AspireHttpMockClient`, which stubs a real, out-of-process [WireMock.Net](https://wiremock.org/dotnet/) server resource - see the README's [Aspire multi-host testing](./README.md#Aspire-multi-host-testing) section for the recommended, self-hosted (Docker-free) pattern. + - `AspireTesterBase.WaitForResourceAsync(string[], TimeSpan?)` waits for multiple named resources to become healthy concurrently. +- *Enhancement:* Extended `TesterBase`/`AspireTesterBase.Delay` to accept an optional `reason` and to write any log messages captured since the last `Checkpoint()`/`Delay()` call to the test output (staying silent when there is nothing new to report). Added a new `Checkpoint(string reason)` method that provides the same reporting/log-flushing behavior without pausing. ## v5.11.1 - *Fixed:* Wrapped `Assembly.LoadFrom` in `TestSetUp` with try-catch to log exceptions and prevent setup failures. diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index 44ab15d..9b38d59 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -187,7 +187,7 @@ public async Task WaitForResourceAsync(string[] resourceNames, TimeSpan? timeout { if (resourceNames is null) throw new ArgumentNullException(nameof(resourceNames)); - await Task.WhenAll(resourceNames.Select(rn => WaitForResourceAsync(rn, timeout))).ConfigureAwait(false); + await Task.WhenAll(resourceNames.Distinct().Select(rn => WaitForResourceAsync(rn, timeout))).ConfigureAwait(false); } /// From 938113944b810942e41fe191dfcdc15b6890b7bb Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Mon, 28 Sep 2026 10:22:50 -0700 Subject: [PATCH 36/43] Add AspireTesterBase.BeforeStart hook to run setup before resources start BeforeStart(Func) 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> --- CHANGELOG.md | 1 + .../Aspire/AspireTesterBase.cs | 20 ++++++++++ .../Aspire/AspireTesterBaseT.cs | 30 +++++++++++++++ tests/UnitTestEx.Aspire.AppHost/AppHost.cs | 1 - .../AspireTesterTest.cs | 37 +++++++++++++++++++ .../AspireTesterTest.cs | 37 +++++++++++++++++++ .../AspireTesterTest.cs | 37 +++++++++++++++++++ 7 files changed, 162 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f123b91..ae816da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Represents the **NuGet** versions. - *Enhancement:* Added `UnitTestEx.Aspire` (NET8+) to enable testing of [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/) multi-host (inter-domain) testing alongside existing in-process `WebApplicationFactory`. The API surface between the two is consistent where appropriate. - `HttpClient` mocking of external dependencies is supported via `AspireHttpMockClient`, which stubs a real, out-of-process [WireMock.Net](https://wiremock.org/dotnet/) server resource - see the README's [Aspire multi-host testing](./README.md#Aspire-multi-host-testing) section for the recommended, self-hosted (Docker-free) pattern. - `AspireTesterBase.WaitForResourceAsync(string[], TimeSpan?)` waits for multiple named resources to become healthy concurrently. + - `AspireTesterBase.BeforeStart(Func)` registers a callback that runs after the `DistributedApplication` is built, but before any resource is started; 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. - *Enhancement:* Extended `TesterBase`/`AspireTesterBase.Delay` to accept an optional `reason` and to write any log messages captured since the last `Checkpoint()`/`Delay()` call to the test output (staying silent when there is nothing new to report). Added a new `Checkpoint(string reason)` method that provides the same reporting/log-flushing behavior without pausing. ## v5.11.1 diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index 9b38d59..4474021 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -52,6 +52,19 @@ protected AspireTesterBase(TestFrameworkImplementor implementor) : base(implemen /// Populated by . protected List> ConfigureBuilderActions { get; } = []; + /// + /// Gets the queued callbacks invoked, in order, once the underlying has been built but before it is started - i.e. before any resource + /// (project, container, executable, etc.) begins running. + /// + /// Populated by . Useful to prepare an external dependency whose connection + /// details are only resolvable via the AppHost (e.g. a connection-string resource added via the AppHost's own AddConnectionString) - such as running database + /// migrations/seeding, clearing a cache, or resetting a messaging topic/queue to a known state - before any project resource that depends on it starts running and + /// potentially races against that very same setup (e.g. connecting to a not-yet-migrated database). + /// Important: a callback must operate directly against the passed to it (e.g. app.GetConnectionStringAsync(name)) - it must + /// not call back into this tester's own (or any extension method that does, e.g. a MigrateXxxAsync helper written against + /// the tester) as the underlying is still being built/started at that point; doing so will recursively re-enter construction rather than reuse it. + protected List> BeforeStartActions { get; } = []; + /// /// Gets a value indicating whether the underlying has already started being built (see ). /// @@ -118,6 +131,13 @@ private async Task CreateDistributedApplicationAsync() try { + // Give any queued BeforeStartActions (see AspireTesterBase{TAppHost, TSelf}.BeforeStart) the chance to prepare an external dependency (e.g. migrate/seed a database resolved + // via app.GetConnectionStringAsync) before any resource is actually started/kicked off below, so it cannot race against that very same setup. + foreach (var beforeStart in BeforeStartActions) + { + await beforeStart(app).ConfigureAwait(false); + } + await app.StartAsync().ConfigureAwait(false); } catch diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs index b9af7f6..1519e4b 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs @@ -103,6 +103,36 @@ public TSelf UseJsonComparerOptions(Json.JsonElementComparerOptions options) return (TSelf)this; } + /// + /// Registers a callback to invoke once the underlying has been built but before it is started - i.e. before any resource (project, container, + /// executable, etc.) begins running. + /// + /// The callback, given the built (not yet started) . + /// The to support fluent-style method-chaining. + /// Useful to prepare an external dependency whose connection details are only resolvable via the AppHost (e.g. a connection-string resource added via the AppHost's own + /// AddConnectionString) - such as running database migrations/seeding, clearing a cache, or resetting a messaging topic/queue - before any project resource that + /// depends on it starts running and potentially races against that very same setup (e.g. connecting to a not-yet-migrated database). Registered callbacks run, in order, after + /// BuildAsync and before StartAsync; an exception thrown by any callback aborts start-up (the partially-built is disposed and the + /// exception propagates), consistent with a resource failing to start. + /// Important: the callback must operate directly against the passed to it (e.g. app.GetConnectionStringAsync(name)) - it must + /// not call back into (or any extension method that does, e.g. a MigrateXxxAsync helper written against the + /// tester) as the underlying is still being built/started at that point; doing so will recursively re-enter construction rather than reuse it. + /// Must be called before the underlying has been built (see ). + public TSelf BeforeStart(Func beforeStart) + { + if (beforeStart is null) throw new ArgumentNullException(nameof(beforeStart)); + + lock (SyncRoot) + { + if (IsDistributedApplicationBuilding) + throw new InvalidOperationException($"{nameof(BeforeStart)} must be invoked before the underlying {nameof(DistributedApplication)} has been built (i.e. before any {nameof(Http)}/{nameof(WaitForResourceAsync)} call)."); + + BeforeStartActions.Add(beforeStart); + } + + return (TSelf)this; + } + /// /// Overrides an environment variable for the named resource before the underlying is built. /// diff --git a/tests/UnitTestEx.Aspire.AppHost/AppHost.cs b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs index e423713..e8381a9 100644 --- a/tests/UnitTestEx.Aspire.AppHost/AppHost.cs +++ b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs @@ -64,4 +64,3 @@ .WithMockHostEnvironment("XXX__BaseUrl", mockhost, "http"); builder.Build().Run(); - diff --git a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs index 5d61733..b98e519 100644 --- a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs @@ -41,6 +41,43 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() .AssertValue(new Person { Id = 1, FirstName = "Bob", LastName = "Smith" }); } + [TestMethod] + public async Task BeforeStart_InvokedInOrder_BeforeAnyResourceStarts() + { + var order = new System.Collections.Generic.List(); + + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .BeforeStart(app => + { + Assert.IsNotNull(app); + order.Add("first"); + return Task.CompletedTask; + }) + .BeforeStart(app => + { + Assert.IsNotNull(app); + order.Add("second"); + return Task.CompletedTask; + }); + + // Triggers build+start; both BeforeStart callbacks must already have run (in registration order) before this returns. + await tester.WaitForResourceAsync("api"); + + CollectionAssert.AreEqual(new[] { "first", "second" }, order); + } + + [TestMethod] + public async Task BeforeStart_Throws_AbortsStartUpAndPropagates() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .BeforeStart(_ => throw new InvalidOperationException("Simulated pre-start failure.")); + + var ex = await Assert.ThrowsExceptionAsync(() => tester.WaitForResourceAsync("api")); + Assert.AreEqual("Simulated pre-start failure.", ex.Message); + } + [TestMethod] public async Task Checkpoint_And_Delay_AggregatesResourceLogs() { diff --git a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs index baea15a..2680311 100644 --- a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs @@ -41,6 +41,43 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() .AssertValue(new Person { Id = 1, FirstName = "Bob", LastName = "Smith" }); } + [Test] + public async Task BeforeStart_InvokedInOrder_BeforeAnyResourceStarts() + { + var order = new System.Collections.Generic.List(); + + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .BeforeStart(app => + { + Assert.That(app, Is.Not.Null); + order.Add("first"); + return Task.CompletedTask; + }) + .BeforeStart(app => + { + Assert.That(app, Is.Not.Null); + order.Add("second"); + return Task.CompletedTask; + }); + + // Triggers build+start; both BeforeStart callbacks must already have run (in registration order) before this returns. + await tester.WaitForResourceAsync("api"); + + Assert.That(order, Is.EqualTo(new[] { "first", "second" })); + } + + [Test] + public async Task BeforeStart_Throws_AbortsStartUpAndPropagates() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .BeforeStart(_ => throw new InvalidOperationException("Simulated pre-start failure.")); + + var ex = Assert.ThrowsAsync(() => tester.WaitForResourceAsync("api")); + Assert.That(ex.Message, Is.EqualTo("Simulated pre-start failure.")); + } + [Test] public async Task Checkpoint_And_Delay_AggregatesResourceLogs() { diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index 75f4014..0c971ea 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -48,6 +48,43 @@ public async Task WithResourceEnvironment_AppliedBeforeBuild() .AssertValue(new Person { Id = 1, FirstName = "Bob", LastName = "Smith" }); } + [Fact] + public async Task BeforeStart_InvokedInOrder_BeforeAnyResourceStarts() + { + var order = new System.Collections.Generic.List(); + + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .BeforeStart(app => + { + Assert.NotNull(app); + order.Add("first"); + return Task.CompletedTask; + }) + .BeforeStart(app => + { + Assert.NotNull(app); + order.Add("second"); + return Task.CompletedTask; + }); + + // Triggers build+start; both BeforeStart callbacks must already have run (in registration order) before this returns. + await tester.WaitForResourceAsync("api"); + + Assert.Equal(["first", "second"], order); + } + + [Fact] + public async Task BeforeStart_Throws_AbortsStartUpAndPropagates() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .BeforeStart(_ => throw new InvalidOperationException("Simulated pre-start failure.")); + + var ex = await Assert.ThrowsAsync(() => tester.WaitForResourceAsync("api")); + Assert.Equal("Simulated pre-start failure.", ex.Message); + } + [Fact] public async Task Checkpoint_And_Delay_AggregatesResourceLogs() { From 6b13583bf09f43d121433b6a6474fd210a5beffe Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Mon, 28 Sep 2026 11:18:37 -0700 Subject: [PATCH 37/43] Add AspireTesterBase.GetConnectionStringAsync for use within BeforeStart 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> --- CHANGELOG.md | 2 +- .../Aspire/AspireTesterBase.cs | 41 +++++++++++++++++-- .../Aspire/AspireTesterBaseT.cs | 10 +++-- tests/UnitTestEx.Aspire.AppHost/AppHost.cs | 6 +++ .../AspireTesterTest.cs | 16 ++++++++ .../AspireTesterTest.cs | 16 ++++++++ .../AspireTesterTest.cs | 16 ++++++++ 7 files changed, 100 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae816da..1c784a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Represents the **NuGet** versions. - *Enhancement:* Added `UnitTestEx.Aspire` (NET8+) to enable testing of [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/) multi-host (inter-domain) testing alongside existing in-process `WebApplicationFactory`. The API surface between the two is consistent where appropriate. - `HttpClient` mocking of external dependencies is supported via `AspireHttpMockClient`, which stubs a real, out-of-process [WireMock.Net](https://wiremock.org/dotnet/) server resource - see the README's [Aspire multi-host testing](./README.md#Aspire-multi-host-testing) section for the recommended, self-hosted (Docker-free) pattern. - `AspireTesterBase.WaitForResourceAsync(string[], TimeSpan?)` waits for multiple named resources to become healthy concurrently. - - `AspireTesterBase.BeforeStart(Func)` registers a callback that runs after the `DistributedApplication` is built, but before any resource is started; 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. + - `AspireTesterBase.BeforeStart(Func)` registers a callback that runs after the `DistributedApplication` is built, but before any resource is started; 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. `AspireTesterBase.GetConnectionStringAsync(DistributedApplication, string, CancellationToken)` resolves such a resource's connection string directly (Aspire's own `DistributedApplication.GetConnectionStringAsync` testing extension throws until the application has started, so cannot be used from within a `BeforeStart` callback). - *Enhancement:* Extended `TesterBase`/`AspireTesterBase.Delay` to accept an optional `reason` and to write any log messages captured since the last `Checkpoint()`/`Delay()` call to the test output (staying silent when there is nothing new to report). Added a new `Checkpoint(string reason)` method that provides the same reporting/log-flushing behavior without pausing. ## v5.11.1 diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index 4474021..cbe18fb 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -1,6 +1,7 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -60,11 +61,45 @@ protected AspireTesterBase(TestFrameworkImplementor implementor) : base(implemen /// details are only resolvable via the AppHost (e.g. a connection-string resource added via the AppHost's own AddConnectionString) - such as running database /// migrations/seeding, clearing a cache, or resetting a messaging topic/queue to a known state - before any project resource that depends on it starts running and /// potentially races against that very same setup (e.g. connecting to a not-yet-migrated database). - /// Important: a callback must operate directly against the passed to it (e.g. app.GetConnectionStringAsync(name)) - it must - /// not call back into this tester's own (or any extension method that does, e.g. a MigrateXxxAsync helper written against - /// the tester) as the underlying is still being built/started at that point; doing so will recursively re-enter construction rather than reuse it. + /// Important: a callback must operate directly against the passed to it - it must not call back into this tester's own + /// (or any extension method that does, e.g. a MigrateXxxAsync helper written against the tester) as the underlying + /// is still being built/started at that point; doing so will recursively re-enter construction rather than reuse it. It also must not + /// call Aspire's own DistributedApplication.GetConnectionStringAsync/GetEndpoint/CreateHttpClient testing extensions (Aspire.Hosting.Testing) - these + /// throw at this point as they require the application to have already started; use instead. protected List> BeforeStartActions { get; } = []; + /// + /// Resolves the connection string for the named resource directly against the 's resource model. + /// + /// The . + /// The resource name (as configured within the AppHost). + /// The . + /// The connection string, or null if the resource has none configured. + /// The resource was not found, or does not expose a connection string. + /// Unlike Aspire's own DistributedApplication.GetConnectionStringAsync(resourceName) testing extension (Aspire.Hosting.Testing) - which throws + /// unless the application has already started (i.e. its underlying IHostApplicationLifetime.ApplicationStarted has fired) - this + /// resolves the resource directly via , so it is safe to call from a callback + /// (i.e. before StartAsync has even been invoked), as well as at any other time. + /// This only 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) (the primary use case), whose value is resolved directly from + /// configuration/parameters. For a resource whose connection string is only known once the resource itself has actually started and allocated an endpoint (e.g. a container + /// resource), calling this before the application has started will return an incomplete/unresolved value; use Aspire's own GetConnectionStringAsync after the application + /// has started for those instead. + public static async Task GetConnectionStringAsync(DistributedApplication app, string resourceName, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(app); + ArgumentException.ThrowIfNullOrEmpty(resourceName); + + var model = app.Services.GetRequiredService(); + if (!model.Resources.TryGetByName(resourceName, out var resource)) + throw new ArgumentException($"Resource '{resourceName}' not found.", nameof(resourceName)); + + if (resource is not IResourceWithConnectionString resourceWithConnectionString) + throw new ArgumentException($"Resource '{resourceName}' does not expose a connection string.", nameof(resourceName)); + + return await resourceWithConnectionString.GetConnectionStringAsync(cancellationToken).ConfigureAwait(false); + } + /// /// Gets a value indicating whether the underlying has already started being built (see ). /// diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs index 1519e4b..2f088c5 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs @@ -4,6 +4,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Testing; using System; +using System.Threading; using System.Threading.Tasks; using UnitTestEx.Abstractions; @@ -114,9 +115,12 @@ public TSelf UseJsonComparerOptions(Json.JsonElementComparerOptions options) /// depends on it starts running and potentially races against that very same setup (e.g. connecting to a not-yet-migrated database). Registered callbacks run, in order, after /// BuildAsync and before StartAsync; an exception thrown by any callback aborts start-up (the partially-built is disposed and the /// exception propagates), consistent with a resource failing to start. - /// Important: the callback must operate directly against the passed to it (e.g. app.GetConnectionStringAsync(name)) - it must - /// not call back into (or any extension method that does, e.g. a MigrateXxxAsync helper written against the - /// tester) as the underlying is still being built/started at that point; doing so will recursively re-enter construction rather than reuse it. + /// Important: the callback must operate directly against the passed to it - it must not call back into + /// (or any extension method that does, e.g. a MigrateXxxAsync helper written against the tester) as the + /// underlying is still being built/started at that point; doing so will recursively re-enter construction rather than reuse it. It also must + /// not call Aspire's own DistributedApplication.GetConnectionStringAsync/GetEndpoint/CreateHttpClient testing extensions (Aspire.Hosting.Testing) + /// - these throw at this point as they require the application to have already started; use + /// instead to resolve a connection string. /// Must be called before the underlying has been built (see ). public TSelf BeforeStart(Func beforeStart) { diff --git a/tests/UnitTestEx.Aspire.AppHost/AppHost.cs b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs index e8381a9..9eb2aef 100644 --- a/tests/UnitTestEx.Aspire.AppHost/AppHost.cs +++ b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs @@ -42,6 +42,12 @@ var builder = DistributedApplication.CreateBuilder(args); +// A static, config-backed connection-string resource (no real backing service; a hardcoded value is enough) - exists solely so the +// BeforeStart_*.Test can prove AspireTesterBase.GetConnectionStringAsync resolves it before the application has started (Aspire's own +// DistributedApplication.GetConnectionStringAsync testing extension cannot - it requires the application to have already started). +builder.Configuration["ConnectionStrings:test-db"] = "Data Source=unit-test;"; +var testDb = builder.AddConnectionString("test-db"); + // dotnet dev-certs https --trust is not fully supported on Linux, so the ASP.NET Core dev cert used by the // "api" resource's https endpoint is not OS-trusted on Linux CI runners. Both the health check probe above and // AspireTesterBase's CreateHttpClient() resolve their HttpClient via this same DI container's IHttpClientFactory, diff --git a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs index b98e519..a1554b8 100644 --- a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs @@ -78,6 +78,22 @@ public async Task BeforeStart_Throws_AbortsStartUpAndPropagates() Assert.AreEqual("Simulated pre-start failure.", ex.Message); } + [TestMethod] + public async Task BeforeStart_CanResolveConnectionStringResource() + { + string? connectionString = null; + + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .BeforeStart(async app => connectionString = await AspireTesterBase.GetConnectionStringAsync(app, "test-db")); + + // Triggers build+start; GetConnectionStringAsync must have already resolved the value above - Aspire's own testing extension + // of the same name would throw here as the application has not yet started (see AspireTesterBase.GetConnectionStringAsync's remarks). + await tester.WaitForResourceAsync("api"); + + Assert.AreEqual("Data Source=unit-test;", connectionString); + } + [TestMethod] public async Task Checkpoint_And_Delay_AggregatesResourceLogs() { diff --git a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs index 2680311..7600ce1 100644 --- a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs @@ -78,6 +78,22 @@ public async Task BeforeStart_Throws_AbortsStartUpAndPropagates() Assert.That(ex.Message, Is.EqualTo("Simulated pre-start failure.")); } + [Test] + public async Task BeforeStart_CanResolveConnectionStringResource() + { + string? connectionString = null; + + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .BeforeStart(async app => connectionString = await AspireTesterBase.GetConnectionStringAsync(app, "test-db")); + + // Triggers build+start; GetConnectionStringAsync must have already resolved the value above - Aspire's own testing extension + // of the same name would throw here as the application has not yet started (see AspireTesterBase.GetConnectionStringAsync's remarks). + await tester.WaitForResourceAsync("api"); + + Assert.That(connectionString, Is.EqualTo("Data Source=unit-test;")); + } + [Test] public async Task Checkpoint_And_Delay_AggregatesResourceLogs() { diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index 0c971ea..ed483b1 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -85,6 +85,22 @@ public async Task BeforeStart_Throws_AbortsStartUpAndPropagates() Assert.Equal("Simulated pre-start failure.", ex.Message); } + [Fact] + public async Task BeforeStart_CanResolveConnectionStringResource() + { + string? connectionString = null; + + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .BeforeStart(async app => connectionString = await AspireTesterBase.GetConnectionStringAsync(app, "test-db")); + + // Triggers build+start; GetConnectionStringAsync must have already resolved the value above - Aspire's own testing extension + // of the same name would throw here as the application has not yet started (see AspireTesterBase.GetConnectionStringAsync's remarks). + await tester.WaitForResourceAsync("api"); + + Assert.Equal("Data Source=unit-test;", connectionString); + } + [Fact] public async Task Checkpoint_And_Delay_AggregatesResourceLogs() { From df8ee80c41b0eec656f7c75d9f68955427ee6287 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Mon, 28 Sep 2026 11:50:59 -0700 Subject: [PATCH 38/43] Add AfterStart hook, seal OnHostStartUp, move WaitForResourceAsync to 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> --- .../Aspire/AspireTesterBase.cs | 48 +++++++++++--- .../Aspire/AspireTesterBaseT.cs | 40 ++++++++++-- .../UnitTestExAspireExtensions.cs | 51 ++++++++++++++- .../AspireTesterTest.cs | 63 +++++++++++++++++++ .../AspireTesterTest.cs | 63 +++++++++++++++++++ .../AspireTesterTest.cs | 63 +++++++++++++++++++ 6 files changed, 313 insertions(+), 15 deletions(-) diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index cbe18fb..6d92a47 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -62,12 +62,26 @@ protected AspireTesterBase(TestFrameworkImplementor implementor) : base(implemen /// migrations/seeding, clearing a cache, or resetting a messaging topic/queue to a known state - before any project resource that depends on it starts running and /// potentially races against that very same setup (e.g. connecting to a not-yet-migrated database). /// Important: a callback must operate directly against the passed to it - it must not call back into this tester's own - /// (or any extension method that does, e.g. a MigrateXxxAsync helper written against the tester) as the underlying - /// is still being built/started at that point; doing so will recursively re-enter construction rather than reuse it. It also must not + /// (or any extension method that does, e.g. a MigrateXxxAsync helper written against the tester): that method's own construction + /// is still in-flight at this point, so awaiting it from within the callback that is part of that very construction will deadlock/hang rather than reuse it. It also must not /// call Aspire's own DistributedApplication.GetConnectionStringAsync/GetEndpoint/CreateHttpClient testing extensions (Aspire.Hosting.Testing) - these /// throw at this point as they require the application to have already started; use instead. protected List> BeforeStartActions { get; } = []; + /// + /// Gets the queued callbacks invoked, in order, once the underlying has been started - i.e. after every resource (project, container, + /// executable, etc.) has been kicked off (though not necessarily healthy/ready yet - see ). + /// + /// Populated by . This is the natural place to wait for one or more resources + /// to become healthy (e.g. via the / + /// extension methods) before a test's own set-up (e.g. OneTimeSetUp) proceeds to use them. + /// Important: a callback must operate directly against the passed to it - it must not call back into this tester's own + /// (or any instance method that does, e.g. the instance ): that method's own + /// construction is still in-flight at this point, so awaiting it from within the callback that is part of that very construction will deadlock/hang rather than reuse it; use the + /// extension method (which operates directly against the passed-in + /// ) instead. + protected List> AfterStartActions { get; } = []; + /// /// Resolves the connection string for the named resource directly against the 's resource model. /// @@ -174,6 +188,13 @@ private async Task CreateDistributedApplicationAsync() } await app.StartAsync().ConfigureAwait(false); + + // Give any queued AfterStartActions (see AspireTesterBase{TAppHost, TSelf}.AfterStart) the chance to act now every resource has been kicked off (e.g. waiting for one or + // more to become healthy via the WaitForResourceAsync extension method) before the tester itself is handed back to the caller. + foreach (var afterStart in AfterStartActions) + { + await afterStart(app).ConfigureAwait(false); + } } catch { @@ -184,6 +205,12 @@ private async Task CreateDistributedApplicationAsync() return app; } + /// + /// Sealed as the Aspire-specific extensibility point for logic that must run around start-up is the / + /// fluent pair - unlike this virtual hook (inherited from ), both receive the actual + /// instance and run at a precisely-defined point (immediately before/after StartAsync); prefer those instead. + protected sealed override void OnHostStartUp() => base.OnHostStartUp(); + /// protected override void OnResetHost() { @@ -219,15 +246,17 @@ protected override void OnResetHost() /// /// The resource name (as configured within the AppHost). /// The timeout (defaults to ); pass to wait indefinitely. - /// A resource that never becomes healthy (e.g. a misconfigured health check) should fail the test fast rather than hang it indefinitely, hence the default timeout. + /// A resource that never becomes healthy (e.g. a misconfigured health check) should fail the test fast rather than hang it indefinitely, hence the default timeout. + /// Important: do not call this from within a / + /// callback - it calls , whose own construction is still in-flight at that point, so awaiting it will deadlock/hang; use the + /// extension method (against the callback's own + /// parameter) instead. public async Task WaitForResourceAsync(string resourceName, TimeSpan? timeout = null) { - if (resourceName is null) throw new ArgumentNullException(nameof(resourceName)); + ArgumentException.ThrowIfNullOrEmpty(resourceName); - var effectiveTimeout = timeout ?? DefaultWaitForResourceTimeout; var app = await GetDistributedApplicationAsync().ConfigureAwait(false); - var wait = app.ResourceNotifications.WaitForResourceHealthyAsync(resourceName); - await (effectiveTimeout == System.Threading.Timeout.InfiniteTimeSpan ? wait : wait.WaitAsync(effectiveTimeout)).ConfigureAwait(false); + await app.WaitForResourceAsync(resourceName, timeout).ConfigureAwait(false); } /// @@ -237,7 +266,10 @@ public async Task WaitForResourceAsync(string resourceName, TimeSpan? timeout = /// The timeout (defaults to ) applied to each resource independently; pass /// to wait indefinitely. /// Equivalent to awaiting for each resource in parallel; if any resource fails to become healthy within the - /// , the resulting exception is propagated once all waits have completed (or faulted). + /// , the resulting exception is propagated once all waits have completed (or faulted). + /// Important: do not call this from within a / + /// callback - see remarks; use the + /// extension method instead. public async Task WaitForResourceAsync(string[] resourceNames, TimeSpan? timeout = null) { if (resourceNames is null) throw new ArgumentNullException(nameof(resourceNames)); diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs index 2f088c5..2fc3138 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs @@ -116,10 +116,10 @@ public TSelf UseJsonComparerOptions(Json.JsonElementComparerOptions options) /// BuildAsync and before StartAsync; an exception thrown by any callback aborts start-up (the partially-built is disposed and the /// exception propagates), consistent with a resource failing to start. /// Important: the callback must operate directly against the passed to it - it must not call back into - /// (or any extension method that does, e.g. a MigrateXxxAsync helper written against the tester) as the - /// underlying is still being built/started at that point; doing so will recursively re-enter construction rather than reuse it. It also must - /// not call Aspire's own DistributedApplication.GetConnectionStringAsync/GetEndpoint/CreateHttpClient testing extensions (Aspire.Hosting.Testing) - /// - these throw at this point as they require the application to have already started; use + /// (or any instance method that does, e.g. the instance ): + /// that method's own construction is still in-flight at this point, so awaiting it from within the callback that is part of that very construction will deadlock/hang rather than + /// reuse it. It also must not call Aspire's own DistributedApplication.GetConnectionStringAsync/GetEndpoint/CreateHttpClient testing extensions + /// (Aspire.Hosting.Testing) - these throw at this point as they require the application to have already started; use /// instead to resolve a connection string. /// Must be called before the underlying has been built (see ). public TSelf BeforeStart(Func beforeStart) @@ -137,6 +137,38 @@ public TSelf BeforeStart(Func beforeStart) return (TSelf)this; } + /// + /// Registers a callback to invoke once the underlying has been started - i.e. after every resource (project, container, executable, etc.) + /// has been kicked off. + /// + /// The callback, given the started . + /// The to support fluent-style method-chaining. + /// The natural place to wait for one or more resources to become healthy (e.g. via the + /// / + /// extension methods) - or perform any other post-start-up set-up dependent on every resource already running - before a test's own set-up (e.g. OneTimeSetUp) proceeds to use them. + /// Registered callbacks run, in order, immediately after StartAsync succeeds; an exception thrown by any callback aborts start-up (the started + /// is disposed and the exception propagates), consistent with 's behaviour. + /// Important: the callback must operate directly against the passed to it - it must not call back into + /// (or any instance method that does, e.g. the instance ): + /// that method's own construction is still in-flight at this point, so awaiting it from within the callback that is part of that very construction will deadlock/hang rather than + /// reuse it; use the extension method (which operates directly against the + /// callback's own parameter) instead. + /// Must be called before the underlying has been built (see ). + public TSelf AfterStart(Func afterStart) + { + if (afterStart is null) throw new ArgumentNullException(nameof(afterStart)); + + lock (SyncRoot) + { + if (IsDistributedApplicationBuilding) + throw new InvalidOperationException($"{nameof(AfterStart)} must be invoked before the underlying {nameof(DistributedApplication)} has been built (i.e. before any {nameof(Http)}/{nameof(WaitForResourceAsync)} call)."); + + AfterStartActions.Add(afterStart); + } + + return (TSelf)this; + } + /// /// Overrides an environment variable for the named resource before the underlying is built. /// diff --git a/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs b/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs index a4e06dc..796a38b 100644 --- a/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs +++ b/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs @@ -1,6 +1,11 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using Aspire.Hosting.ApplicationModel; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using UnitTestEx.Aspire; #pragma warning disable IDE0130 // Namespace does not match folder structure; by design. namespace Aspire.Hosting @@ -8,9 +13,10 @@ namespace Aspire.Hosting { /// /// Provides / extension methods for AppHost projects that use UnitTestEx's recommended - /// self-hosted WireMock.Net "mockhost" pattern (see the README's "Aspire multi-host testing" section and UnitTestEx.Aspire.MockHost). Deliberately placed in the - /// namespace (matching Aspire's own extension method convention) so these methods are discoverable alongside AddProject/WithEnvironment - /// without an extra using. + /// self-hosted WireMock.Net "mockhost" pattern (see the README's "Aspire multi-host testing" section and UnitTestEx.Aspire.MockHost), as well as + /// extension methods useful from test code (e.g. within a / + /// callback). Deliberately placed in the namespace (matching Aspire's own extension method + /// convention) so these methods are discoverable alongside AddProject/WithEnvironment without an extra using. /// public static class UnitTestExAspireExtensions { @@ -50,5 +56,44 @@ public static class UnitTestExAspireExtensions public static IResourceBuilder WithMockHostEnvironment(this IResourceBuilder builder, string name, IResourceBuilder? source, string endpointName) where TDestination : IResourceWithEnvironment where TSource : IResourceWithEndpoints => source is null ? builder : builder.WithEnvironment(name, source.GetEndpoint(endpointName)); + + /// + /// Waits for the named resource, within , to report a healthy status. + /// + /// The . + /// The resource name (as configured within the AppHost). + /// The timeout (defaults to ); pass to wait indefinitely. + /// Operates directly against (via ) rather than a tester's own + /// , so - unlike - it is safe to call from within + /// a / callback (most usefully the latter, once every resource + /// has actually been started/kicked off), as well as at any other time. + public static async Task WaitForResourceAsync(this DistributedApplication app, string resourceName, TimeSpan? timeout = null) + { + ArgumentNullException.ThrowIfNull(app); + ArgumentException.ThrowIfNullOrEmpty(resourceName); + + var effectiveTimeout = timeout ?? AspireTesterBase.DefaultWaitForResourceTimeout; + var wait = app.ResourceNotifications.WaitForResourceHealthyAsync(resourceName); + await (effectiveTimeout == Timeout.InfiniteTimeSpan ? wait : wait.WaitAsync(effectiveTimeout)).ConfigureAwait(false); + } + + /// + /// Waits for all of the named resources, within , to report a healthy status, concurrently. + /// + /// The . + /// The resource names (as configured within the AppHost). + /// The timeout (defaults to ) applied to each resource independently; pass + /// to wait indefinitely. + /// Equivalent to awaiting for each resource in parallel; if any resource fails to become + /// healthy within the , the resulting exception is propagated once all waits have completed (or faulted). See + /// remarks regarding safe use from a / + /// callback. + public static async Task WaitForResourceAsync(this DistributedApplication app, string[] resourceNames, TimeSpan? timeout = null) + { + ArgumentNullException.ThrowIfNull(app); + ArgumentNullException.ThrowIfNull(resourceNames); + + await Task.WhenAll(resourceNames.Distinct().Select(rn => app.WaitForResourceAsync(rn, timeout))).ConfigureAwait(false); + } } } diff --git a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs index a1554b8..a9786ef 100644 --- a/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs @@ -1,3 +1,4 @@ +using Aspire.Hosting; using System; using System.Diagnostics; using System.Linq; @@ -94,6 +95,68 @@ public async Task BeforeStart_CanResolveConnectionStringResource() Assert.AreEqual("Data Source=unit-test;", connectionString); } + [TestMethod] + public async Task AfterStart_InvokedInOrder_AfterBeforeStartAndResourcesStarted() + { + var order = new System.Collections.Generic.List(); + + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .BeforeStart(app => + { + Assert.IsNotNull(app); + order.Add("before"); + return Task.CompletedTask; + }) + .AfterStart(app => + { + Assert.IsNotNull(app); + order.Add("first"); + return Task.CompletedTask; + }) + .AfterStart(app => + { + Assert.IsNotNull(app); + order.Add("second"); + return Task.CompletedTask; + }); + + // Triggers build+start; BeforeStart, then both AfterStart callbacks (in registration order), must already have run before this returns. + await tester.WaitForResourceAsync("api"); + + CollectionAssert.AreEqual(new[] { "before", "first", "second" }, order); + } + + [TestMethod] + public async Task AfterStart_Throws_AbortsStartUpAndPropagates() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .AfterStart(_ => throw new InvalidOperationException("Simulated post-start failure.")); + + var ex = await Assert.ThrowsExceptionAsync(() => tester.WaitForResourceAsync("api")); + Assert.AreEqual("Simulated post-start failure.", ex.Message); + } + + [TestMethod] + public async Task AfterStart_CanWaitForResourceHealthy_UsingExtensionMethod() + { + var waited = false; + + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .AfterStart(async app => + { + // Safe to call from within AfterStart as it operates directly against 'app', unlike the instance WaitForResourceAsync which would deadlock here. + await app.WaitForResourceAsync("api"); + waited = true; + }); + + await tester.WaitForResourceAsync("api"); + + Assert.IsTrue(waited); + } + [TestMethod] public async Task Checkpoint_And_Delay_AggregatesResourceLogs() { diff --git a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs index 7600ce1..21f72cd 100644 --- a/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs @@ -1,3 +1,4 @@ +using Aspire.Hosting; using System; using System.Diagnostics; using System.Linq; @@ -94,6 +95,68 @@ public async Task BeforeStart_CanResolveConnectionStringResource() Assert.That(connectionString, Is.EqualTo("Data Source=unit-test;")); } + [Test] + public async Task AfterStart_InvokedInOrder_AfterBeforeStartAndResourcesStarted() + { + var order = new System.Collections.Generic.List(); + + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .BeforeStart(app => + { + Assert.That(app, Is.Not.Null); + order.Add("before"); + return Task.CompletedTask; + }) + .AfterStart(app => + { + Assert.That(app, Is.Not.Null); + order.Add("first"); + return Task.CompletedTask; + }) + .AfterStart(app => + { + Assert.That(app, Is.Not.Null); + order.Add("second"); + return Task.CompletedTask; + }); + + // Triggers build+start; BeforeStart, then both AfterStart callbacks (in registration order), must already have run before this returns. + await tester.WaitForResourceAsync("api"); + + Assert.That(order, Is.EqualTo(new[] { "before", "first", "second" })); + } + + [Test] + public async Task AfterStart_Throws_AbortsStartUpAndPropagates() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .AfterStart(_ => throw new InvalidOperationException("Simulated post-start failure.")); + + var ex = Assert.ThrowsAsync(() => tester.WaitForResourceAsync("api")); + Assert.That(ex.Message, Is.EqualTo("Simulated post-start failure.")); + } + + [Test] + public async Task AfterStart_CanWaitForResourceHealthy_UsingExtensionMethod() + { + var waited = false; + + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .AfterStart(async app => + { + // Safe to call from within AfterStart as it operates directly against 'app', unlike the instance WaitForResourceAsync which would deadlock here. + await app.WaitForResourceAsync("api"); + waited = true; + }); + + await tester.WaitForResourceAsync("api"); + + Assert.That(waited, Is.True); + } + [Test] public async Task Checkpoint_And_Delay_AggregatesResourceLogs() { diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index ed483b1..c411591 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -1,3 +1,4 @@ +using Aspire.Hosting; using System; using System.Diagnostics; using System.Net; @@ -101,6 +102,68 @@ public async Task BeforeStart_CanResolveConnectionStringResource() Assert.Equal("Data Source=unit-test;", connectionString); } + [Fact] + public async Task AfterStart_InvokedInOrder_AfterBeforeStartAndResourcesStarted() + { + var order = new System.Collections.Generic.List(); + + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .BeforeStart(app => + { + Assert.NotNull(app); + order.Add("before"); + return Task.CompletedTask; + }) + .AfterStart(app => + { + Assert.NotNull(app); + order.Add("first"); + return Task.CompletedTask; + }) + .AfterStart(app => + { + Assert.NotNull(app); + order.Add("second"); + return Task.CompletedTask; + }); + + // Triggers build+start; BeforeStart, then both AfterStart callbacks (in registration order), must already have run before this returns. + await tester.WaitForResourceAsync("api"); + + Assert.Equal(["before", "first", "second"], order); + } + + [Fact] + public async Task AfterStart_Throws_AbortsStartUpAndPropagates() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .AfterStart(_ => throw new InvalidOperationException("Simulated post-start failure.")); + + var ex = await Assert.ThrowsAsync(() => tester.WaitForResourceAsync("api")); + Assert.Equal("Simulated post-start failure.", ex.Message); + } + + [Fact] + public async Task AfterStart_CanWaitForResourceHealthy_UsingExtensionMethod() + { + var waited = false; + + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .AfterStart(async app => + { + // Safe to call from within AfterStart as it operates directly against 'app', unlike the instance WaitForResourceAsync which would deadlock here. + await app.WaitForResourceAsync("api"); + waited = true; + }); + + await tester.WaitForResourceAsync("api"); + + Assert.True(waited); + } + [Fact] public async Task Checkpoint_And_Delay_AggregatesResourceLogs() { From 94154618f05f86e96507a421e3e969bb4ac15f3d Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Mon, 28 Sep 2026 11:59:12 -0700 Subject: [PATCH 39/43] Add DistributedApplication.HttpMock extension for tester-free stub seeding 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> --- CHANGELOG.md | 3 ++ README.md | 19 +++++++++++ .../UnitTestExAspireExtensions.cs | 33 +++++++++++++++++++ .../AspireTesterTest.cs | 26 +++++++++++++++ 4 files changed, 81 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c784a0..0479de5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Represents the **NuGet** versions. - `HttpClient` mocking of external dependencies is supported via `AspireHttpMockClient`, which stubs a real, out-of-process [WireMock.Net](https://wiremock.org/dotnet/) server resource - see the README's [Aspire multi-host testing](./README.md#Aspire-multi-host-testing) section for the recommended, self-hosted (Docker-free) pattern. - `AspireTesterBase.WaitForResourceAsync(string[], TimeSpan?)` waits for multiple named resources to become healthy concurrently. - `AspireTesterBase.BeforeStart(Func)` registers a callback that runs after the `DistributedApplication` is built, but before any resource is started; 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. `AspireTesterBase.GetConnectionStringAsync(DistributedApplication, string, CancellationToken)` resolves such a resource's connection string directly (Aspire's own `DistributedApplication.GetConnectionStringAsync` testing extension throws until the application has started, so cannot be used from within a `BeforeStart` callback). + - `AspireTesterBase.AfterStart(Func)` registers a callback that runs once all resources have started (immediately after `DistributedApplication.StartAsync()` returns) - the natural place for `Aspire.Hosting.WaitForResourceAsync(this DistributedApplication, ...)` calls, since resources are guaranteed to exist by this point. As with `BeforeStart`, a throwing callback aborts start-up and propagates the exception. `AspireTesterBase.OnHostStartUp()` is now `sealed` - it is a single-host-oriented hook that does not apply to a multi-host `DistributedApplication`; use `BeforeStart`/`AfterStart` instead. + - `AspireTesterBase.WaitForResourceAsync(string, TimeSpan?)`/`WaitForResourceAsync(string[], TimeSpan?)` now delegate to new `Aspire.Hosting.WaitForResourceAsync(this DistributedApplication, ...)` extension methods, discoverable alongside `AddProject`/`WithEnvironment` etc. within an AppHost project. + - `Aspire.Hosting.HttpMock(this DistributedApplication, string, string?, JsonElementComparerOptions?)` exposes the same fluent `AspireHttpMockClient` stubbing API directly against a started `DistributedApplication`, with no `AspireTester` required - enabling an `AppHost.cs` itself to pre-seed default request/response stubs so an exploratory/manual run (outside of any test) does not fail against an un-stubbed external dependency; see the README's [Aspire multi-host testing](./README.md#Aspire-multi-host-testing) "Pre-seeding stubs from `AppHost.cs`" subsection. - *Enhancement:* Extended `TesterBase`/`AspireTesterBase.Delay` to accept an optional `reason` and to write any log messages captured since the last `Checkpoint()`/`Delay()` call to the test output (staying silent when there is nothing new to report). Added a new `Checkpoint(string reason)` method that provides the same reporting/log-flushing behavior without pausing. ## v5.11.1 diff --git a/README.md b/README.md index b7cd5f7..9c7dfc8 100644 --- a/README.md +++ b/README.md @@ -391,6 +391,25 @@ _Note:_ `WithJsonBodyUsingUnitTestExComparer` requires the target resource to ha The same shared interface also brings across Tier 1's [YAML/JSON configuration](#YAML/JSON-configuration) - `await tester.HttpMock("erp").WithRequestsFromResourceAsync("my.mock.unittestex.yaml")` loads the exact same embedded resource schema against a real WireMock.Net resource. +### Pre-seeding stubs from `AppHost.cs` + +`tester.HttpMock(resourceName)` requires an `AspireTester` - fine for tests, but not when a developer just wants to `aspire run`/`dotnet run` the `AppHost` directly (exploratory/manual use, no test in sight) - any un-stubbed external dependency will simply fail with a connection error. The `DistributedApplication.HttpMock(resourceName, endpointName?, jsonComparerOptions?)` extension method (also on `UnitTestEx.Aspire`, alongside `AddMockHostProject`/`WithMockHostEnvironment`) exposes the exact same fluent `AspireHttpMockClient` API directly against a started `DistributedApplication` - no tester required - so `AppHost.cs` itself can pre-seed sensible default stubs: + +``` csharp +// AppHost.cs +var app = builder.Build(); +await app.StartAsync(); + +await app.WaitForResourceAsync("erp"); +await app.HttpMock("erp") + .Request(HttpMethod.Get, "products/abc") + .Respond.WithJsonAsync(new { id = "Abc", description = "A blue carrot" }); + +await app.WaitForShutdownAsync(); +``` + +This replaces the simpler `await app.RunAsync();` one-liner with the equivalent `StartAsync`/`WaitForShutdownAsync` pair (standard, supported Aspire usage) so there's a point after start-up, but before the host blocks, to seed mappings. A test can still layer its own stubs over these defaults (or `ResetAsync()` first to clear them) via `tester.HttpMock(...)` as normal - the AppHost's stubs are just a starting point, not a constraint on what a test may configure. + _Note:_ Aspire-hosted resources are real OS processes (containers, where you opt into one), so `AspireTester` tests are inherently slower than the in-process Tier 1 testers - use them where the inter-process interaction itself is what needs proving. _Note:_ Since Tier 2/3 resources are real, reachable URLs (not in-process fakes), a UI/frontend resource hosted in the `AppHost` can be driven directly with [Playwright](https://playwright.dev/dotnet/) - this is an ordinary consequence of the resources being real processes, not a UnitTestEx-specific feature; see Microsoft's own [Aspire + Playwright guide](https://learn.microsoft.com/en-us/dotnet/aspire/testing/write-your-first-test?tabs=xunit#creating-a-playwright-test) for the pattern. diff --git a/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs b/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs index 796a38b..960c6eb 100644 --- a/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs +++ b/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs @@ -1,11 +1,14 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Testing; using System; using System.Linq; using System.Threading; using System.Threading.Tasks; using UnitTestEx.Aspire; +using UnitTestEx.Aspire.HttpMock; +using UnitTestEx.Json; #pragma warning disable IDE0130 // Namespace does not match folder structure; by design. namespace Aspire.Hosting @@ -95,5 +98,35 @@ public static async Task WaitForResourceAsync(this DistributedApplication app, s await Task.WhenAll(resourceNames.Distinct().Select(rn => app.WaitForResourceAsync(rn, timeout))).ConfigureAwait(false); } + + /// + /// Enables a fluent to stub HTTP responses from a real, out-of-process WireMock.Net server resource, directly against a started + /// - i.e. without requiring an /Tier 2 test at all. + /// + /// The (already started - see ) . + /// The WireMock.Net server resource name (as configured within the AppHost) to target. + /// The optional endpoint name; where not specified, the resource's default endpoint is used. + /// The optional used by ; + /// defaults to a new instance when not specified. + /// The . + /// Most useful for the AppHost project itself to pre-seed default request/response stubs - e.g. within its own entry point, after await app.StartAsync() - so an + /// exploratory/manual run of the AppHost (outside of any UnitTestEx test) does not fail against an external dependency that has not yet been stubbed for that run; this reuses the + /// exact same fluent syntax (/ etc.) a Tier 2 test uses via + /// . Equally usable from within a / + /// callback (most usefully the latter, once resources actually exist), or any other code already holding a + /// instance directly. Unlike , is captured once + /// at call time (there being no owning tester's to read live from). + public static AspireHttpMockClient HttpMock(this DistributedApplication app, string resourceName, string? endpointName = null, JsonElementComparerOptions? jsonComparerOptions = null) + { + ArgumentNullException.ThrowIfNull(app); + ArgumentException.ThrowIfNullOrEmpty(resourceName); + + var options = jsonComparerOptions ?? new JsonElementComparerOptions(); + return new AspireHttpMockClient(async () => + { + var httpClient = app.CreateHttpClient(resourceName, endpointName); + return RestEase.RestClient.For(httpClient); + }, () => options); + } } } diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index c411591..55be030 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -226,6 +226,32 @@ public async Task HttpMock_StubsExternalMockHostDependency_Product() await stub.VerifyAsync(); } + [Fact] + public async Task HttpMock_ViaDistributedApplicationExtensionMethod_WorksWithoutTester() + { + // Proves the standalone 'DistributedApplication.HttpMock' extension method (see UnitTestExAspireExtensions) configures the same underlying WireMock.Net resource + // as AspireTesterBase.HttpMock above, without needing a tester at all - e.g. usable directly from an AppHost.cs itself (after 'await app.StartAsync()') to pre-seed + // default stubs so an exploratory/manual run doesn't fail against an un-stubbed external dependency; AfterStart is used here purely to get at the raw 'app'. + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .AfterStart(async app => + { + await app.WaitForResourceAsync("mockhost"); + + _ = await app.HttpMock("mockhost") + .Request(HttpMethod.Get, "/products/xyz") + .Times(Times.Once()) + .WithAnyBody() + .Respond.WithJsonAsync(new { id = "Xyz", description = "Configured via the DistributedApplication.HttpMock extension method." }); + }); + + await tester.WaitForResourceAsync(["api", "mockhost"]); + + tester.Http("mockhost").Run(HttpMethod.Get, "/products/xyz") + .AssertOK() + .AssertValue(new { id = "Xyz", description = "Configured via the DistributedApplication.HttpMock extension method." }); + } + [Fact] public async Task HttpMock_WithSequenceAsync_ReturnsResponsesInOrderThenExhausts() { From 0ce1cb7ea040f97e4788a3bcbb53de8bee544ced Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Mon, 28 Sep 2026 17:52:46 -0700 Subject: [PATCH 40/43] Add ErrorWhenLogContains (Aspire) and ExpectLogNotContains - AspireTesterBase.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> --- CHANGELOG.md | 7 +- README.md | 17 +++ .../Aspire/AspireTesterBase.cs | 125 ++++++++++++--- .../Aspire/AspireTesterBaseT.cs | 34 +++++ .../Expectations/ExpectationsExtensions.cs | 13 ++ .../Expectations/LoggerExpectations.cs | 13 ++ .../Controllers/PersonController.cs | 25 +++ .../AspireTesterTest.cs | 143 +++++++++++++++++- .../PersonControllerTest.cs | 25 +++ 9 files changed, 381 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0479de5..b98ae3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,13 @@ Represents the **NuGet** versions. ## v5.12.0 - *Enhancement:* Added `UnitTestEx.Aspire` (NET8+) to enable testing of [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/) multi-host (inter-domain) testing alongside existing in-process `WebApplicationFactory`. The API surface between the two is consistent where appropriate. - `HttpClient` mocking of external dependencies is supported via `AspireHttpMockClient`, which stubs a real, out-of-process [WireMock.Net](https://wiremock.org/dotnet/) server resource - see the README's [Aspire multi-host testing](./README.md#Aspire-multi-host-testing) section for the recommended, self-hosted (Docker-free) pattern. - - `AspireTesterBase.WaitForResourceAsync(string[], TimeSpan?)` waits for multiple named resources to become healthy concurrently. - `AspireTesterBase.BeforeStart(Func)` registers a callback that runs after the `DistributedApplication` is built, but before any resource is started; 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. `AspireTesterBase.GetConnectionStringAsync(DistributedApplication, string, CancellationToken)` resolves such a resource's connection string directly (Aspire's own `DistributedApplication.GetConnectionStringAsync` testing extension throws until the application has started, so cannot be used from within a `BeforeStart` callback). - - `AspireTesterBase.AfterStart(Func)` registers a callback that runs once all resources have started (immediately after `DistributedApplication.StartAsync()` returns) - the natural place for `Aspire.Hosting.WaitForResourceAsync(this DistributedApplication, ...)` calls, since resources are guaranteed to exist by this point. As with `BeforeStart`, a throwing callback aborts start-up and propagates the exception. `AspireTesterBase.OnHostStartUp()` is now `sealed` - it is a single-host-oriented hook that does not apply to a multi-host `DistributedApplication`; use `BeforeStart`/`AfterStart` instead. - - `AspireTesterBase.WaitForResourceAsync(string, TimeSpan?)`/`WaitForResourceAsync(string[], TimeSpan?)` now delegate to new `Aspire.Hosting.WaitForResourceAsync(this DistributedApplication, ...)` extension methods, discoverable alongside `AddProject`/`WithEnvironment` etc. within an AppHost project. + - `AspireTesterBase.AfterStart(Func)` registers a callback that runs once all resources have started (immediately after `DistributedApplication.StartAsync()` returns) - the natural place for `Aspire.Hosting.WaitForResourceAsync(this DistributedApplication, ...)` calls, since resources are guaranteed to exist by this point. As with `BeforeStart`, a throwing callback aborts start-up and propagates the exception. `AspireTesterBase.OnHostStartUp()` is `sealed` - it is a single-host-oriented hook that does not apply to a multi-host `DistributedApplication`; use `BeforeStart`/`AfterStart` instead. + - `AspireTesterBase.WaitForResourceAsync(string, TimeSpan?)`/`WaitForResourceAsync(string[], TimeSpan?)` delegate to `Aspire.Hosting.WaitForResourceAsync(this DistributedApplication, ...)` extension methods, discoverable alongside `AddProject`/`WithEnvironment` etc. within an AppHost project. - `Aspire.Hosting.HttpMock(this DistributedApplication, string, string?, JsonElementComparerOptions?)` exposes the same fluent `AspireHttpMockClient` stubbing API directly against a started `DistributedApplication`, with no `AspireTester` required - enabling an `AppHost.cs` itself to pre-seed default request/response stubs so an exploratory/manual run (outside of any test) does not fail against an un-stubbed external dependency; see the README's [Aspire multi-host testing](./README.md#Aspire-multi-host-testing) "Pre-seeding stubs from `AppHost.cs`" subsection. + - `AspireTesterBase.ErrorWhenLogContains(LogLevel, string[]?, string[]?)` registers a continuous, streaming check that immediately fails the test the moment any resource logs an entry at or above the specified `LogLevel` (default `Error`) - checked every time resource log activity is drained, i.e. by every `Checkpoint`/`Delay`/`Http` call, not a single end-of-test scan. This is **enabled by default** at `LogLevel.Error` without ever needing to call it - pass `LogLevel.None` to opt out entirely for a test that deliberately induces a resource-level error/failure log as its subject. Accepts `*`/`?` wildcard `exclude` (known/expected noise to suppress; always wins) and `include` (narrows rather than widens what is checked) patterns, matched case-insensitively; each call fully replaces the prior configuration rather than merging with it. Since only a *drained* log entry is checked, a trailing `Test.Checkpoint("Final log check.")` is recommended at the end of a test relying on this to also catch background/hosted-service-only activity that no other call happens to drain. - *Enhancement:* Extended `TesterBase`/`AspireTesterBase.Delay` to accept an optional `reason` and to write any log messages captured since the last `Checkpoint()`/`Delay()` call to the test output (staying silent when there is nothing new to report). Added a new `Checkpoint(string reason)` method that provides the same reporting/log-flushing behavior without pausing. +- *Enhancement:* Added `ExpectLogNotContains` expectation (mirroring the existing `ExpectLogContains`) to confirm that the log output does **not** contain the specified text. ## v5.11.1 - *Fixed:* Wrapped `Assembly.LoadFrom` in `TestSetUp` with try-catch to log exceptions and prevent setup failures. diff --git a/README.md b/README.md index 9c7dfc8..9842a2b 100644 --- a/README.md +++ b/README.md @@ -410,6 +410,23 @@ await app.WaitForShutdownAsync(); This replaces the simpler `await app.RunAsync();` one-liner with the equivalent `StartAsync`/`WaitForShutdownAsync` pair (standard, supported Aspire usage) so there's a point after start-up, but before the host blocks, to seed mappings. A test can still layer its own stubs over these defaults (or `ResetAsync()` first to clear them) via `tester.HttpMock(...)` as normal - the AppHost's stubs are just a starting point, not a constraint on what a test may configure. +### Failing on unexpected resource error logs + +A real inter-domain test can pass its own assertions while a background/hosted service (or a request that was never explicitly checked via `Http`) quietly logs an error or worse elsewhere in the `DistributedApplication` - `ErrorWhenLogContains` catches this by continuously watching every resource's forwarded log output for entries at or above a given `LogLevel` (`Error` by default) and failing the test the moment one appears: + +``` csharp +await using var tester = AspireTester.Create() + .ErrorWhenLogContains(exclude: ["*a known, benign warning*"]); // Optional: only needed to change the level, or add exclude/include patterns. + +await tester.WaitForResourceAsync("shopping"); + +tester.Http("shopping").Run(HttpMethod.Get, "orders/123").AssertOK(); + +tester.Checkpoint("Final log check."); // Recommended: drains and checks any activity not otherwise seen by Http/Delay above. +``` + +This is **enabled by default** at `LogLevel.Error` for every `AspireTester` - there is no need to call `ErrorWhenLogContains` at all unless you want to change the `minimumLevel`, add wildcard (`*`/`?`) `exclude`/`include` patterns, or opt out entirely via `ErrorWhenLogContains(LogLevel.None)` (needed for a test that deliberately induces a resource-level error as its own subject, e.g. asserting a `500` response). `exclude` is the common case - known/expected noise that should not trigger a failure despite otherwise qualifying; a match here always wins. `include` is the rarer, opposite case - it *narrows* rather than widens what is checked, requiring an otherwise-qualifying entry to also match at least one `include` pattern to be reported (leave it `null`/empty, the default, to check every qualifying entry regardless of text). Each call fully **replaces** the prior configuration (level, `exclude` and `include` alike) rather than merging with it - pass the complete desired state each time. A resource log entry is only checked once *drained* - by `Checkpoint`, `Delay` or `Http` - so a trailing `Test.Checkpoint("Final log check.")` at the end of a test is recommended to also catch activity (e.g. shutdown-time logging) that nothing else happens to drain. + _Note:_ Aspire-hosted resources are real OS processes (containers, where you opt into one), so `AspireTester` tests are inherently slower than the in-process Tier 1 testers - use them where the inter-process interaction itself is what needs proving. _Note:_ Since Tier 2/3 resources are real, reachable URLs (not in-process fakes), a UI/frontend resource hosted in the `AppHost` can be driven directly with [Playwright](https://playwright.dev/dotnet/) - this is an ordinary consequence of the resources being real processes, not a UnitTestEx-specific feature; see Microsoft's own [Aspire + Playwright guide](https://learn.microsoft.com/en-us/dotnet/aspire/testing/write-your-first-test?tabs=xunit#creating-a-playwright-test) for the pattern. diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index 6d92a47..48830d9 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -35,12 +35,35 @@ namespace UnitTestEx.Aspire /// remain on only. public abstract class AspireTesterBase : TesterBaseCore, IHttpClientSource, IAsyncDisposable { - private readonly ConcurrentDictionary> _resourceLogBuffers = new(); + private readonly ConcurrentDictionary> _resourceLogBuffers = new(); private readonly ConcurrentDictionary _elapsedLogLineCounts = new(); private ResourceLogCaptureProvider? _resourceLogCaptureProvider; private Task? _appTask; private bool _disposed; + /// + /// Gets or sets the minimum that triggers an immediate test failure when a resource log entry at (or above) it is subsequently drained - see + /// . + /// + /// Enabled by default at - this is a brand-new capability with no existing tests relying on it being off, and catching an unexpected resource + /// error is almost always preferable to silently letting it slide. To opt out entirely, call + /// with (which, being numerically above , no resource log entry can ever meet or exceed). + protected LogLevel ErrorWhenLogContainsMinimumLevel { get; set; } = LogLevel.Error; + + /// + /// Gets or sets the wildcard (*/?) exclude patterns checked (case-insensitively, as a "contains" match) against an otherwise-violating resource log entry's text - a + /// match here always suppresses the entry, even where it also matches - see + /// . + /// + protected IReadOnlyList ErrorWhenLogContainsExcludePatterns { get; set; } = []; + + /// + /// Gets or sets the wildcard (*/?) include patterns that an otherwise-qualifying resource log entry's text must match at least one of (case-insensitively, as a + /// "contains" match) to be reported - see . An empty list (the default) means + /// every entry that meets qualifies; i.e. this narrows rather than widens what is checked. + /// + protected IReadOnlyList ErrorWhenLogContainsIncludePatterns { get; set; } = []; + /// /// Initializes a new instance of the class. /// @@ -280,7 +303,12 @@ public async Task WaitForResourceAsync(string[] resourceNames, TimeSpan? timeout /// /// Combines the elapsed log messages captured (via ) across all resources since the last invocation, as background/inter-resource /// activity is not necessarily confined to a single resource; each line already carries its own owning resource name as a trailing suffix (see ), - /// so no further attribution is added here. + /// so no further attribution is added here. + /// Also where is set (see ), every entry + /// drained here - across every resource, not just one directly interacted with - is checked and immediately fails the test if it violates it; a resource that never has an + /// call made against it (e.g. a background/hosted-service-only resource) is still checked here, as long as a subsequent + /// / call drains it - hence a final + /// call is recommended at the end of a test to verify whatever log activity remains un-drained. protected override IEnumerable? DrainElapsedLogMessages() { _resourceLogCaptureProvider?.FlushPendingEntries(); @@ -289,15 +317,52 @@ public async Task WaitForResourceAsync(string[] resourceNames, TimeSpan? timeout foreach (var (resourceName, buffer) in _resourceLogBuffers) { var lastCount = _elapsedLogLineCounts.GetOrAdd(resourceName, 0); - var newLines = buffer.Skip(lastCount).ToArray(); + var newEntries = buffer.Skip(lastCount).ToArray(); _elapsedLogLineCounts[resourceName] = buffer.Count; - lines.AddRange(newLines); + AssertNoErrorWhenLogContainsViolation(newEntries, resourceName); + lines.AddRange(newEntries.Select(e => e.Text)); } return lines; } + /// + /// Fails the test (via ) on the first entry at or above that (where + /// any are configured) matches at least one of them, and does not match any of the wildcard + /// - a no-op where is (the explicit opt-out), since no + /// entry can ever meet or exceed it. + /// + private void AssertNoErrorWhenLogContainsViolation(IEnumerable entries, string resourceName) + { + foreach (var entry in entries) + { + if (entry.Level < ErrorWhenLogContainsMinimumLevel) + continue; + + if (ErrorWhenLogContainsIncludePatterns.Count > 0 && !ErrorWhenLogContainsIncludePatterns.Any(pattern => IsWildcardMatch(entry.Text, pattern))) + continue; + + if (ErrorWhenLogContainsExcludePatterns.Any(pattern => IsWildcardMatch(entry.Text, pattern))) + continue; + + Implementor.AssertFail($"Resource '{resourceName}' logged an entry at '{entry.Level}' level, which is at or above the '{ErrorWhenLogContainsMinimumLevel}' level configured via ErrorWhenLogContains: {entry.Text}"); + } + } + + /// + /// Determines whether contains - a simple * (any run of characters)/? (any single character) wildcard match, + /// case-insensitive, applied anywhere within (i.e. a wildcard-aware equivalent of ). + /// + private static bool IsWildcardMatch(string? text, string pattern) + { + if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(pattern)) + return false; + + var regexPattern = string.Join(".*", pattern.Split('*').Select(segment => Regex.Escape(segment).Replace(@"\?", "."))); + return Regex.IsMatch(text, regexPattern, RegexOptions.IgnoreCase | RegexOptions.Singleline); + } + /// /// Enables a test to be sent to the named resource. /// @@ -358,7 +423,7 @@ HttpClient IHttpClientSource.CreateHttpClient(string? name) => /// /// Under , / /// are not populated (a known limitation of the testing host), so this is the only reliable way to observe a resource's own logging from a test. - private sealed class ResourceLogCaptureProvider(string resourceCategoryPrefix, ConcurrentDictionary> buffers) : ILoggerProvider + private sealed class ResourceLogCaptureProvider(string resourceCategoryPrefix, ConcurrentDictionary> buffers) : ILoggerProvider { private readonly ConcurrentBag _loggers = []; @@ -368,7 +433,7 @@ public ILogger CreateLogger(string categoryName) return NullLogger.Instance; var resourceName = categoryName[resourceCategoryPrefix.Length..]; - var logger = new ResourceLogger(resourceName, buffers.GetOrAdd(resourceName, _ => new ConcurrentQueue())); + var logger = new ResourceLogger(resourceName, buffers.GetOrAdd(resourceName, _ => new ConcurrentQueue())); _loggers.Add(logger); return logger; } @@ -403,7 +468,7 @@ public void FlushPendingEntries() /// () or multi-resource () report. /// A line that does not match the expected "{lineNumber}: {timestamp}Z " prefix (e.g. a resource that does not use the standard console logger format) is passed through /// unmodified, ANSI colour codes aside, so nothing is silently dropped. - private sealed class ResourceLogger(string resourceName, ConcurrentQueue buffer) : ILogger + private sealed class ResourceLogger(string resourceName, ConcurrentQueue buffer) : ILogger { private static readonly Regex _ansiPattern = new(@"\x1b\[[0-9;]*m", RegexOptions.Compiled); private static readonly Regex _linePrefixPattern = new(@"^\d+:\s+(?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+)Z\s?(?.*)$", RegexOptions.Compiled); @@ -415,7 +480,8 @@ private sealed class ResourceLogger(string resourceName, ConcurrentQueue _pendingMessageLines = []; @@ -432,9 +498,10 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except { if (!prefixMatch.Success) { - // Not a recognized Aspire-forwarded console line; flush anything pending and pass this through as-is rather than silently dropping it. + // Not a recognized Aspire-forwarded console line; flush anything pending and pass this through as-is rather than silently dropping it. Its own severity cannot be + // determined (there is no "{level}: " header to parse it from), so it is treated as Information - safely below any sensible ErrorWhenLogContains threshold. FlushPendingNoLock(); - buffer.Enqueue(line); + buffer.Enqueue(new ResourceLogEntry(LogLevel.Information, line)); return; } @@ -445,16 +512,32 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except // A new header line always terminates any previously pending entry. FlushPendingNoLock(); _pendingTimestamp = prefixMatch.Groups["ts"].Value; - _pendingLevel = headerMatch.Groups["level"].Value; + _pendingLevelText = headerMatch.Groups["level"].Value; + _pendingLevel = ParseLevel(_pendingLevelText); _pendingCategory = headerMatch.Groups["category"].Value; } else if (_pendingTimestamp is not null) _pendingMessageLines.Add(rest.TrimStart()); // A message/continuation line for the currently pending header. else - buffer.Enqueue(rest); // A line with no preceding header (unexpected); pass through as-is. + buffer.Enqueue(new ResourceLogEntry(LogLevel.Information, rest)); // A line with no preceding header (unexpected); pass through as-is. } } + /// + /// Maps Aspire's forwarded console-formatter level abbreviation (see ) back to its corresponding - the exact reverse of the + /// standard ASP.NET Core console formatter's own abbreviation convention. + /// + private static LogLevel ParseLevel(string levelText) => levelText switch + { + "trce" => LogLevel.Trace, + "dbug" => LogLevel.Debug, + "info" => LogLevel.Information, + "warn" => LogLevel.Warning, + "fail" => LogLevel.Error, + "crit" => LogLevel.Critical, + _ => LogLevel.Information + }; + /// /// Flushes any pending entry to the buffer, reformatted to match 's output style. /// @@ -473,23 +556,30 @@ private void FlushPendingNoLock() var ts = DateTime.Parse(_pendingTimestamp, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal).ToLocalTime(); var sb = new StringBuilder(); - sb.Append($"{ts.ToString("yyyy-MM-ddTHH:mm:ss.fffff", DateTimeFormatInfo.InvariantInfo)} {_pendingLevel}: {(_pendingMessageLines.Count > 0 ? _pendingMessageLines[0] : string.Empty)} [{_pendingCategory} ({resourceName})]"); + sb.Append($"{ts.ToString("yyyy-MM-ddTHH:mm:ss.fffff", DateTimeFormatInfo.InvariantInfo)} {_pendingLevelText}: {(_pendingMessageLines.Count > 0 ? _pendingMessageLines[0] : string.Empty)} [{_pendingCategory} ({resourceName})]"); for (var i = 1; i < _pendingMessageLines.Count; i++) { sb.AppendLine(); sb.Append($"{new string(' ', 32)}{_pendingMessageLines[i]}"); } - buffer.Enqueue(sb.ToString()); + buffer.Enqueue(new ResourceLogEntry(_pendingLevel, sb.ToString())); _pendingTimestamp = null; - _pendingLevel = null; + _pendingLevelText = null; _pendingCategory = null; _pendingMessageLines.Clear(); } } } + /// + /// A single captured resource log entry - the parsed (see ) alongside the fully + /// formatted text (matching 's output style) that every existing consumer (, ) + /// continues to see. + /// + private readonly record struct ResourceLogEntry(LogLevel Level, string? Text); + /// /// An bound to a specific named resource (and optional endpoint). /// @@ -534,10 +624,11 @@ public HttpClient CreateHttpClient(string? name = null) // (often the interesting one, e.g. the endpoint's own logging for this very request) is not lost while waiting for a line that may never come. owner._resourceLogCaptureProvider?.FlushPendingEntries(); - var newLines = buffer.Skip(baseline).ToArray(); + var newEntries = buffer.Skip(baseline).ToArray(); owner._elapsedLogLineCounts[resourceName] = buffer.Count; - return newLines; + owner.AssertNoErrorWhenLogContainsViolation(newEntries, resourceName); + return newEntries.Select(e => e.Text).ToArray(); } } diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs index 2fc3138..d50814b 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs @@ -3,6 +3,7 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Testing; +using Microsoft.Extensions.Logging; using System; using System.Threading; using System.Threading.Tasks; @@ -215,6 +216,39 @@ public TSelf EnableHostDiagnostics() return (TSelf)this; } + /// + /// Registers a continuous, streaming check that immediately fails the test the moment any resource logs an entry at or above - checked every time + /// resource log activity is drained, i.e. by every , and call from + /// this point forward (not a single, one-shot, end-of-test scan). + /// + /// The minimum that triggers a failure; defaults to - which is also the effective default even where + /// this method is never called at all (see ). Pass to opt out entirely. + /// Zero or more */? wildcard, case-insensitive patterns (matched as a "contains" against the entry's fully formatted text) for known/expected noise + /// that should not trigger a failure despite otherwise qualifying; a match here always wins, even where also matches. + /// Zero or more */? wildcard, case-insensitive patterns that narrow (rather than widen) what is checked - where specified, an otherwise-qualifying + /// entry must match at least one of these to trigger a failure. Leave null/empty (the default) to check every qualifying entry regardless of its text. + /// The to support fluent-style method-chaining. + /// Enabled by default at without needing to call this at all; calling it is only required to change the , add + /// / patterns, or opt out entirely (via ). Can be called at any time (unlike e.g. + /// /, this has no bearing on how the underlying is built, so there is no + /// "must be called before start" restriction); each call fully replaces the prior configuration (all three parameters) rather than merging with it - pass the complete desired + /// state each time. + /// Only a resource log entry that is subsequently drained (via //) + /// is checked; anything still buffered (or still pending completion by its own terminating header line) when the test ends is never seen unless a final call is + /// made - so a trailing Test.Checkpoint("Final log check.") is recommended at the end of a test that relies on this to also catch background/hosted-service-only activity that no + /// other call happens to drain. + public TSelf ErrorWhenLogContains(LogLevel minimumLevel = LogLevel.Error, string[]? exclude = null, string[]? include = null) + { + lock (SyncRoot) + { + ErrorWhenLogContainsMinimumLevel = minimumLevel; + ErrorWhenLogContainsExcludePatterns = exclude ?? []; + ErrorWhenLogContainsIncludePatterns = include ?? []; + } + + return (TSelf)this; + } + /// /// Writes the specified to the test output to provide additional context (e.g. why a particular action is being performed), then immediately writes any /// resource log messages captured (across all resources) since the last checkpoint/delay to the test output - i.e. a zero-wait equivalent of . diff --git a/src/UnitTestEx/Expectations/ExpectationsExtensions.cs b/src/UnitTestEx/Expectations/ExpectationsExtensions.cs index 310a0e1..e5c01f5 100644 --- a/src/UnitTestEx/Expectations/ExpectationsExtensions.cs +++ b/src/UnitTestEx/Expectations/ExpectationsExtensions.cs @@ -152,6 +152,19 @@ public static TSelf ExpectLogContains(this IExpectations expectati return (TSelf)expectations; } + /// + /// Expects that the will not have logged a message that contains any of the specified . + /// + /// The expectations . + /// The . + /// The text(s) that should not appear in any log message. + /// The instance to support fluent-style method-chaining. + public static TSelf ExpectLogNotContains(this IExpectations expectations, params string[] texts) where TSelf : IExpectations + { + expectations.ExpectationsArranger.GetOrAdd(() => new LoggerExpectations(expectations.ExpectationsArranger.Owner, (TSelf)expectations)).SetExpectLogNotContains(texts); + return (TSelf)expectations; + } + #endregion #region HttpResponseMessageExpectations diff --git a/src/UnitTestEx/Expectations/LoggerExpectations.cs b/src/UnitTestEx/Expectations/LoggerExpectations.cs index 32a0d29..ebe8b63 100644 --- a/src/UnitTestEx/Expectations/LoggerExpectations.cs +++ b/src/UnitTestEx/Expectations/LoggerExpectations.cs @@ -17,6 +17,7 @@ namespace UnitTestEx.Expectations public class LoggerExpectations(TesterBaseCore owner, TTester tester) : ExpectationsBase(owner, tester) { private readonly List _expectTexts = []; + private readonly List _expectNotTexts = []; /// public override string Title => "Logger expectations"; @@ -27,6 +28,12 @@ public class LoggerExpectations(TesterBaseCore owner, TTester tester) : /// The text(s) that should appear in at least one log message line. public void SetExpectLogContains(params string[] texts) => _expectTexts.AddRange(texts.Where(t => !string.IsNullOrEmpty(t))); + /// + /// Expects that the will not have logged a message that contains any of the specified . + /// + /// The text(s) that should not appear in any log message line. + public void SetExpectLogNotContains(params string[] texts) => _expectNotTexts.AddRange(texts.Where(t => !string.IsNullOrEmpty(t))); + /// protected override Task OnAssertAsync(AssertArgs args) { @@ -41,6 +48,12 @@ protected override Task OnAssertAsync(AssertArgs args) args.Tester.Implementor.AssertFail($"Expected a log text to contain '{et}' that was not found."); } + foreach (var ent in _expectNotTexts) + { + if (args.Logs is not null && args.Logs.Any(l => !string.IsNullOrEmpty(l) && l.Contains(ent, StringComparison.InvariantCultureIgnoreCase))) + args.Tester.Implementor.AssertFail($"Expected no log text to contain '{ent}' but one was found."); + } + return Task.CompletedTask; } } diff --git a/tests/UnitTestEx.Api/Controllers/PersonController.cs b/tests/UnitTestEx.Api/Controllers/PersonController.cs index a4d8fa5..a69796a 100644 --- a/tests/UnitTestEx.Api/Controllers/PersonController.cs +++ b/tests/UnitTestEx.Api/Controllers/PersonController.cs @@ -47,6 +47,31 @@ public IActionResult GetByArgs(string firstName, string lastName, [FromQuery] Li return new ObjectResult($"{firstName}-{lastName}-{(id is null ? "" : string.Join(",", id))}"); } + // Purely a test aid (see AspireTesterBase's ErrorWhenLogContains) - logs a single message at the requested level so a resource's own log capture pipeline can be exercised end-to-end. + [HttpGet("test/log/{level}")] + public IActionResult TestLog(string level, [FromQuery] string? message = null) + { + var text = message ?? $"Simulated {level} log entry."; + + switch (level.ToLowerInvariant()) + { + case "critical": + _logger.LogCritical("{Content}", text); + break; + case "error": + _logger.LogError("{Content}", text); + break; + case "warning": + _logger.LogWarning("{Content}", text); + break; + default: + _logger.LogInformation("{Content}", text); + break; + } + + return Ok(); + } + [HttpPost("{id}")] public IActionResult Update(int id, [FromBody] Person person) { diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index 55be030..cafd5e7 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -1,4 +1,5 @@ using Aspire.Hosting; +using Microsoft.Extensions.Logging; using System; using System.Diagnostics; using System.Net; @@ -13,6 +14,7 @@ using UnitTestEx.Mocking; using Xunit; using Xunit.Abstractions; +using Xunit.Sdk; namespace UnitTestEx.Aspire.Xunit.Test { @@ -203,6 +205,142 @@ public async Task Checkpoint_And_Delay_AggregatesResourceLogs() Assert.Contains(spy.Lines, l => l != null && l.Contains("Get using identifier 1.") && l.EndsWith("(api)]", StringComparison.Ordinal)); } + [Fact] + public async Task ErrorWhenLogContains_DefaultsToEnabled_ThrowsWithoutExplicitRegistration() + { + // ErrorWhenLogContains was never explicitly called - it is enabled by default (at LogLevel.Error), being a brand-new capability, so an Error-level resource log must still fail. + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + + var ex = Assert.Throws(() => tester.Http("api").Run(HttpMethod.Get, "Person/test/log/error")); + Assert.Contains("Simulated error log entry.", ex.Message); + } + + [Fact] + public async Task ErrorWhenLogContains_None_OptsOutEntirely() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .ErrorWhenLogContains(LogLevel.None); // LogLevel.None is numerically above Critical, so nothing can ever meet/exceed it - the documented opt-out. + + await tester.WaitForResourceAsync("api"); + + tester.Http("api").Run(HttpMethod.Get, "Person/test/log/error").AssertOK(); + } + + [Fact] + public async Task ErrorWhenLogContains_Http_ThrowsWhenResourceLogsAtOrAboveDefaultMinimumLevel() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .ErrorWhenLogContains(); // Defaults to LogLevel.Error. + + await tester.WaitForResourceAsync("api"); + + var ex = Assert.Throws(() => tester.Http("api").Run(HttpMethod.Get, "Person/test/log/error")); + Assert.Contains("Simulated error log entry.", ex.Message); + } + + [Fact] + public async Task ErrorWhenLogContains_BelowConfiguredMinimumLevel_DoesNotThrow() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .ErrorWhenLogContains(LogLevel.Critical); // An 'error' level log does not meet this higher 'critical' minimum. + + await tester.WaitForResourceAsync("api"); + + tester.Http("api").Run(HttpMethod.Get, "Person/test/log/error").AssertOK(); + } + + [Fact] + public async Task ErrorWhenLogContains_ExcludeWildcard_SuppressesOtherwiseViolatingEntry() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .ErrorWhenLogContains(LogLevel.Error, exclude: ["Simulated error*"]); + + await tester.WaitForResourceAsync("api"); + + tester.Http("api").Run(HttpMethod.Get, "Person/test/log/error").AssertOK(); + } + + [Fact] + public async Task ErrorWhenLogContains_IncludeWildcard_DoesNotThrowWhenEntryDoesNotMatch() + { + // 'include' narrows what is checked - an otherwise-qualifying (Error level) entry that matches none of the include patterns is not reported. + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .ErrorWhenLogContains(include: ["*a totally different subsystem*"]); + + await tester.WaitForResourceAsync("api"); + + tester.Http("api").Run(HttpMethod.Get, "Person/test/log/error").AssertOK(); + } + + [Fact] + public async Task ErrorWhenLogContains_IncludeWildcard_ThrowsWhenEntryMatches() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .ErrorWhenLogContains(include: ["Simulated error*"]); + + await tester.WaitForResourceAsync("api"); + + var ex = Assert.Throws(() => tester.Http("api").Run(HttpMethod.Get, "Person/test/log/error")); + Assert.Contains("Simulated error log entry.", ex.Message); + } + + [Fact] + public async Task ErrorWhenLogContains_ExcludeWinsOverInclude_SuppressesEvenWhenIncludeMatches() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .ErrorWhenLogContains(exclude: ["Simulated error*"], include: ["Simulated*"]); + + await tester.WaitForResourceAsync("api"); + + tester.Http("api").Run(HttpMethod.Get, "Person/test/log/error").AssertOK(); + } + + [Fact] + public async Task ErrorWhenLogContains_RepeatCall_ReplacesRatherThanMergesPriorConfiguration() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .ErrorWhenLogContains(exclude: ["Simulated error*"]) // Would otherwise suppress the entry below... + .ErrorWhenLogContains(); // ...but this second call replaces the entire prior configuration - the exclude pattern above is now gone. + + await tester.WaitForResourceAsync("api"); + + var ex = Assert.Throws(() => tester.Http("api").Run(HttpMethod.Get, "Person/test/log/error")); + Assert.Contains("Simulated error log entry.", ex.Message); + } + + [Fact] + public async Task ErrorWhenLogContains_ChecksOnFinalCheckpoint_ForActivityNotDrainedByHttp() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .ErrorWhenLogContains(); + + await tester.WaitForResourceAsync("api"); + + // Fire a raw (uninstrumented) request so the resulting Error log line is captured but not claimed/checked by any tester-driven Http() call - only a subsequent + // Checkpoint/Delay call (here, the final Checkpoint recommended at the end of a test relying on ErrorWhenLogContains) drains and checks it. + using (var client = ((IHttpClientSource)tester).CreateHttpClient("api")) + { + using var response = await client.GetAsync("Person/test/log/error"); + response.EnsureSuccessStatusCode(); + } + + await Task.Delay(300); // Allow the forwarded resource log line time to arrive/be captured before draining. + + Assert.Throws(() => tester.Checkpoint("Final log check.")); + } + [Fact] public async Task HttpMock_StubsExternalMockHostDependency_Product() { @@ -283,8 +421,11 @@ public async Task HttpMock_WithSequenceAsync_ReturnsResponsesInOrderThenExhausts [Fact] public async Task HttpMock_WithSequenceAsync_ExceedingConfiguredResponses_Throws() { + // Deliberately induces a genuine 500/Error-level resource log as the very thing under test - opts out of the (now default-on) ErrorWhenLogContains check entirely rather than + // having it collide with the intentionally-triggered failure below. await using var tester = AspireTester.Create() - .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .ErrorWhenLogContains(LogLevel.None); await tester.WaitForResourceAsync(["api", "mockhost"]); diff --git a/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs b/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs index a8834f3..1481b30 100644 --- a/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs +++ b/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs @@ -26,6 +26,31 @@ public async Task Get_Test1() .AssertValue(new Person { Id = 1, FirstName = "Bob", LastName = "Smith" }); } + [Test] + public async Task Get_Test1_ExpectLogNotContains_Succeeds() + { + using var test = ApiTester.Create().Delay(1000); + (await test.Controller() + .ExpectLogNotContains("This text is never logged.") + .RunAsync(c => c.Get(1))) + .AssertOK() + .AssertValue(new Person { Id = 1, FirstName = "Bob", LastName = "Smith" }); + } + + [Test] + public void Get_Test1_ExpectLogNotContains_Failure() + { + var ex = Assert.Throws(() => + { + using var test = ApiTester.Create(); + test.Controller() + .ExpectLogNotContains("Get using identifier 1") + .Run(c => c.Get(1)); + }); + + Assert.That(ex.Message, Does.Contain("Expected no log text to contain 'Get using identifier 1' but one was found.")); + } + [Test] public void Get_Test2() { From ed7662e4638d0a15ddcc320524ad6ba6ceddd3fd Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 29 Sep 2026 11:07:46 -0700 Subject: [PATCH 41/43] Fix WriteDelay silently discarding pre-existing log backlog 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. --- CHANGELOG.md | 4 + README.md | 65 +++++++- .../Aspire/AspireTesterBase.cs | 73 +++++++++ .../Aspire/AspireTesterBaseT.cs | 46 ++++++ .../Aspire/HttpMock/AspireHttpMockClient.cs | 2 +- .../Aspire/HttpMock/AspireHttpMockRequest.cs | 3 +- .../HttpMock/AspireWireMockServerSettings.cs | 25 +++ .../HttpMock/WireMockRequestResponseLogger.cs | 92 +++++++++++ .../Aspire/WireMockConsole.cs | 12 +- src/UnitTestEx/Abstractions/TesterBaseCore.cs | 8 +- .../Mocking/MockHttpClientFactory.cs | 18 +++ .../Mocking/MockHttpClientHandler.cs | 11 +- .../AspireTesterTest.cs | 145 ++++++++++++++++++ .../Other/CheckpointAndDelayTest.cs | 14 +- .../Other/CheckpointAndDelayTest.cs | 14 +- .../Other/CheckpointAndDelayTest.cs | 14 +- 16 files changed, 522 insertions(+), 24 deletions(-) create mode 100644 src/UnitTestEx.Aspire/Aspire/HttpMock/AspireWireMockServerSettings.cs create mode 100644 src/UnitTestEx.Aspire/Aspire/HttpMock/WireMockRequestResponseLogger.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b98ae3d..ed11020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,12 @@ Represents the **NuGet** versions. - `AspireTesterBase.WaitForResourceAsync(string, TimeSpan?)`/`WaitForResourceAsync(string[], TimeSpan?)` delegate to `Aspire.Hosting.WaitForResourceAsync(this DistributedApplication, ...)` extension methods, discoverable alongside `AddProject`/`WithEnvironment` etc. within an AppHost project. - `Aspire.Hosting.HttpMock(this DistributedApplication, string, string?, JsonElementComparerOptions?)` exposes the same fluent `AspireHttpMockClient` stubbing API directly against a started `DistributedApplication`, with no `AspireTester` required - enabling an `AppHost.cs` itself to pre-seed default request/response stubs so an exploratory/manual run (outside of any test) does not fail against an un-stubbed external dependency; see the README's [Aspire multi-host testing](./README.md#Aspire-multi-host-testing) "Pre-seeding stubs from `AppHost.cs`" subsection. - `AspireTesterBase.ErrorWhenLogContains(LogLevel, string[]?, string[]?)` registers a continuous, streaming check that immediately fails the test the moment any resource logs an entry at or above the specified `LogLevel` (default `Error`) - checked every time resource log activity is drained, i.e. by every `Checkpoint`/`Delay`/`Http` call, not a single end-of-test scan. This is **enabled by default** at `LogLevel.Error` without ever needing to call it - pass `LogLevel.None` to opt out entirely for a test that deliberately induces a resource-level error/failure log as its subject. Accepts `*`/`?` wildcard `exclude` (known/expected noise to suppress; always wins) and `include` (narrows rather than widens what is checked) patterns, matched case-insensitively; each call fully replaces the prior configuration rather than merging with it. Since only a *drained* log entry is checked, a trailing `Test.Checkpoint("Final log check.")` is recommended at the end of a test relying on this to also catch background/hosted-service-only activity that no other call happens to drain. + - `AspireTesterBase.AssertLogContains(string, string?)`/`AssertLogNotContains(string, string?)` provide an immediate, one-shot positive/negative assertion (a Tier 2/3 equivalent of Tier 1's `ExpectLogContains`/`ExpectLogNotContains`) against **every** resource log entry captured for the lifetime of the test so far - across all resources, or scoped to one via the optional resource name - regardless of whether it has already been *drained* (unlike `ErrorWhenLogContains`'s `exclude`/`include`, which only ever narrow what counts as a *violation* of its own streaming check and never confirm any text was actually seen). Matched using the same `*`/`?` wildcard, case-insensitive "contains" semantics. + - `AspireTesterBase.ResetLogs()` discards every resource log entry captured so far, across all resources, along with the drain watermarks used by `Checkpoint`/`Delay` - relevant only where an `AspireTester`/`DistributedApplication` host is deliberately reused across multiple tests (e.g. via a shared fixture), so a later test's `ErrorWhenLogContains`/`AssertLogContains`/`AssertLogNotContains` checks are not tripped up (or falsely satisfied) by an earlier test's log activity. Deliberately explicit rather than automatic, since Tier 2/3 has no single per-test Act boundary to hang an automatic reset off, and background/hosted-service activity occurring between calls is exactly what this capture exists to surface. + - `WireMockConsole.RunAsync` builds an `AspireWireMockServerSettings` - a plain `WireMockServerSettings` subclass (`WireMock.Net` deliberately leaves it unsealed) adding UnitTestEx-specific configuration as a single, discoverable extension point rather than an ever-growing `RunAsync` parameter list - and registers a `WireMockRequestResponseLogger` against it that logs every genuine stubbed request/response pair (not the admin API calls used to configure mappings) through the self-hosted resource's own console output - captured/drained the same way as any other resource log entry - giving parity with Tier 1's `MockHttpClientHandler` request/response logging; defaults to `LogLevel.Information` (configurable via `AspireWireMockServerSettings.RequestResponseLogLevel`, read live so it may be set from within the caller-supplied server factory itself, or `LogLevel.None` to disable). - *Enhancement:* Extended `TesterBase`/`AspireTesterBase.Delay` to accept an optional `reason` and to write any log messages captured since the last `Checkpoint()`/`Delay()` call to the test output (staying silent when there is nothing new to report). Added a new `Checkpoint(string reason)` method that provides the same reporting/log-flushing behavior without pausing. - *Enhancement:* Added `ExpectLogNotContains` expectation (mirroring the existing `ExpectLogContains`) to confirm that the log output does **not** contain the specified text. +- *Enhancement:* `MockHttpClientFactory`/`MockHttpClientHandler`'s request/response logging level is now configurable via the new `MockHttpClientFactory.LogLevel` property (or fluent `UseLogLevel(LogLevel)`); defaults to `LogLevel.Debug`, unchanged from prior behavior. Set to `LogLevel.None` to disable this logging entirely. ## v5.11.1 - *Fixed:* Wrapped `Assembly.LoadFrom` in `TestSetUp` with try-catch to log exceptions and prevent setup failures. diff --git a/README.md b/README.md index 9842a2b..c5291d4 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,12 @@ test.ReplaceHttpClientFactory(mcf) The `ReplaceHttpClientFactory` leverages the `Replace*` capabilities discussed earlier in [DI Mocking](#di-mocking). +Every mocked request/response pair is also logged (via `MockHttpClientHandler`) at `MockHttpClientFactory.LogLevel` - `LogLevel.Debug` by default; change it via `UseLogLevel(LogLevel)` (or set `LogLevel.None` to disable this logging entirely): + +``` csharp +var mcf = MockHttpClientFactory.Create().UseLogLevel(LogLevel.Information); +``` +
### HTTP Client configurations @@ -340,7 +346,15 @@ External-to-the-solution dependencies (an email/notification provider, an identi await WireMockConsole.RunAsync(settings => WireMockServer.Start(settings)); ``` -`WireMockConsole.RunAsync` reads the `PORT` environment variable Aspire assigns, builds the `WireMockServerSettings` with `JsonElementComparerMatcher` already registered, invokes your factory to start the actual server (any `IWireMockServer` - typically `WireMockServer.Start`), then blocks gracefully until Aspire stops the process (Ctrl+C locally, SIGTERM in orchestration), disposing the server on the way out. +`WireMockConsole.RunAsync` reads the `PORT` environment variable Aspire assigns, builds an `AspireWireMockServerSettings` (a plain `WireMockServerSettings` subclass - `WireMock.Net` deliberately leaves it unsealed - that adds UnitTestEx-specific configuration as a single, discoverable extension point rather than an ever-growing list of `RunAsync` parameters) with `JsonElementComparerMatcher` already registered, invokes your factory to start the actual server (any `IWireMockServer` - typically `WireMockServer.Start`), then blocks gracefully until Aspire stops the process (Ctrl+C locally, SIGTERM in orchestration), disposing the server on the way out. It also registers a `WireMockRequestResponseLogger` (giving Tier 2/3 parity with Tier 1's `MockHttpClientHandler` request/response logging above) that logs every genuine stubbed request/response pair - not the admin API calls used to configure mappings - through the resource's own console output at `AspireWireMockServerSettings.RequestResponseLogLevel` (`LogLevel.Information` by default), so it is captured and drained the same way as any other resource log entry (see `Checkpoint`/`Delay`/`ErrorWhenLogContains` below). Set a different level (or `LogLevel.None` to disable) directly on the settings your factory receives: + +``` csharp +await WireMockConsole.RunAsync(settings => +{ + settings.RequestResponseLogLevel = LogLevel.Debug; + return WireMockServer.Start(settings); +}); +``` ``` csharp // AppHost.cs @@ -378,7 +392,7 @@ tester.Http("api") await stub.VerifyAsync(); ``` -The fluent configuration API is intentionally near-identical to Tier 1's `MockHttpClientFactory` above (both implement the shared [`IHttpMockClient`](./src/UnitTestEx/Mocking/IHttpMockClient.cs)/`IHttpMockRequest`/`IHttpMockResponse` interfaces) - a helper method written once against these interfaces can configure request/response stubbing identically regardless of which tier it's handed. The main differences are that the terminal `With*` methods here are asynchronous (`AspireHttpMockClient` performs a real HTTP call to the WireMock.Net server's admin API to register each mapping) and must be awaited, and the underlying JSON comparison/sequence-exhaustion semantics are WireMock.Net's own (not identical to Tier 1's) - unless you use `WithJsonBodyUsingUnitTestExComparer` instead of `WithJsonBody`: +The fluent configuration API is intentionally near-identical to Tier 1's `MockHttpClientFactory` above (both implement the shared [`IHttpMockClient`](./src/UnitTestEx/Mocking/IHttpMockClient.cs)/`IHttpMockRequest`/`IHttpMockResponse` interfaces) - a helper method written once against these interfaces can configure request/response stubbing identically regardless of which tier it's handed. The main differences are that the terminal `With*` methods here are asynchronous (`AspireHttpMockClient` performs a real HTTP call to the WireMock.Net server's admin API to register each mapping) and must be awaited, and the underlying JSON comparison/sequence-exhaustion semantics are WireMock.Net's own (not identical to Tier 1's) - unless you use `WithJsonBodyUsingUnitTestExComparer` instead of `WithJsonBody`. As with Tier 1, `Request`'s `requestUri` does not need a leading `/` - one is added automatically where absent, since WireMock.Net's admin API rejects a path that doesn't start with one: ``` csharp var stub = await tester.HttpMock("erp") @@ -410,6 +424,19 @@ await app.WaitForShutdownAsync(); This replaces the simpler `await app.RunAsync();` one-liner with the equivalent `StartAsync`/`WaitForShutdownAsync` pair (standard, supported Aspire usage) so there's a point after start-up, but before the host blocks, to seed mappings. A test can still layer its own stubs over these defaults (or `ResetAsync()` first to clear them) via `tester.HttpMock(...)` as normal - the AppHost's stubs are just a starting point, not a constraint on what a test may configure. +_Important:_ this pre-seeding code only ever runs for a real `aspire run`/`dotnet run` of the AppHost - it does **not** run under an `AspireTester`-driven test. `AspireTester`/`AspireTesterBase` build the AppHost via Aspire's own `DistributedApplicationTestingBuilder.CreateAsync()`, which intercepts the AppHost's `Program.cs` at `builder.Build()` and hands the (still-unbuilt) builder straight back to the test - none of `AppHost.cs`'s own code *after* that line (the `StartAsync`/`HttpMock`/`WaitForShutdownAsync` block above) is ever reached when driven via a test, so a test relying solely on it for a stub it actually depends on will fail with a genuine (not un-stubbed-but-otherwise-passing, and not flaky/racy) connection or 404 error every time. A test that needs the same stub must register it itself, most naturally via `BeforeStart`/`AfterStart` (the latter, once the mock host resource is actually up): + +``` csharp +await using var tester = AspireTester.Create() + .AfterStart(async app => + { + await app.WaitForResourceAsync("erp"); + await app.HttpMock("erp") + .Request(HttpMethod.Get, "products/abc") + .Respond.WithJsonAsync(new { id = "Abc", description = "A blue carrot" }); + }); +``` + ### Failing on unexpected resource error logs A real inter-domain test can pass its own assertions while a background/hosted service (or a request that was never explicitly checked via `Http`) quietly logs an error or worse elsewhere in the `DistributedApplication` - `ErrorWhenLogContains` catches this by continuously watching every resource's forwarded log output for entries at or above a given `LogLevel` (`Error` by default) and failing the test the moment one appears: @@ -427,6 +454,40 @@ tester.Checkpoint("Final log check."); // Recommended: drains and checks any act This is **enabled by default** at `LogLevel.Error` for every `AspireTester` - there is no need to call `ErrorWhenLogContains` at all unless you want to change the `minimumLevel`, add wildcard (`*`/`?`) `exclude`/`include` patterns, or opt out entirely via `ErrorWhenLogContains(LogLevel.None)` (needed for a test that deliberately induces a resource-level error as its own subject, e.g. asserting a `500` response). `exclude` is the common case - known/expected noise that should not trigger a failure despite otherwise qualifying; a match here always wins. `include` is the rarer, opposite case - it *narrows* rather than widens what is checked, requiring an otherwise-qualifying entry to also match at least one `include` pattern to be reported (leave it `null`/empty, the default, to check every qualifying entry regardless of text). Each call fully **replaces** the prior configuration (level, `exclude` and `include` alike) rather than merging with it - pass the complete desired state each time. A resource log entry is only checked once *drained* - by `Checkpoint`, `Delay` or `Http` - so a trailing `Test.Checkpoint("Final log check.")` at the end of a test is recommended to also catch activity (e.g. shutdown-time logging) that nothing else happens to drain. +_Important:_ `include`/`exclude` only ever **narrow what counts as a violation** of the streaming check above - they are not a "was this text logged" positive assertion, and there is no aggregate, end-of-test tally of `include` patterns. If an `include` pattern never matches anything, the test simply never fails because of it; nothing confirms it was actually hit. For that - "assert this text *was* (or was *not*) logged, by any resource, at any point" - use `AssertLogContains`/`AssertLogNotContains` instead (below). + +### Asserting expected resource log content + +Unlike `ErrorWhenLogContains` above (a continuous check applied only as entries are subsequently *drained*), `AssertLogContains`/`AssertLogNotContains` are immediate, one-shot checks against **every** resource log entry captured for the lifetime of the test so far - drained or not, across every resource by default (or scoped to one via the optional `resourceName`): + +``` csharp +await using var tester = AspireTester.Create(); + +await tester.WaitForResourceAsync("shopping"); + +tester.Http("shopping").Run(HttpMethod.Get, "orders/123").AssertOK(); + +tester.AssertLogContains("Order 123 processed successfully."); // Across every resource. +tester.AssertLogContains("Order 123 processed successfully.", "shopping"); // Scoped to just the 'shopping' resource. +tester.AssertLogNotContains("*unexpected*"); +``` + +Because every resource log entry is retained for the whole test (not just what has been drained), there is no "did I check at the right moment" ambiguity to reason about - call this whenever (and as many times as) needed, typically right after whatever action is expected to have produced the entry; no trailing `Checkpoint` is required first (though calling it after one is perfectly fine too). Both accept the same wildcard (`*`/`?`), case-insensitive "contains" pattern matching as `ErrorWhenLogContains`'s `exclude`/`include`. + +### Resetting captured logs + +Every resource log entry captured is retained for the lifetime of the underlying `AspireTester` instance, not just a single test - normally a non-issue, since each test creates and disposes its own instance. If instead you deliberately **reuse** one `AspireTester`/`DistributedApplication` host across multiple tests (e.g. via a shared fixture, to avoid repeatedly paying its start-up cost), an earlier test's log activity would otherwise still be visible to a later test's `ErrorWhenLogContains`/`AssertLogContains`/`AssertLogNotContains` checks. Call `ResetLogs()` to discard everything captured so far - typically as the first line of such a test: + +``` csharp +tester.ResetLogs(); // Discards all previously captured resource log entries (e.g. from a prior test sharing this host). + +tester.Http("shopping").Run(HttpMethod.Get, "orders/123").AssertOK(); + +tester.AssertLogContains("Order 123 processed successfully."); // Only sees activity logged since the reset above. +``` + +This is deliberately explicit rather than automatic - unlike Tier 1, which resets its equivalent captured state automatically per Act (there being a single, well-defined Act boundary to hang it off), Tier 2/3 has no such boundary, and background/hosted-service activity occurring between calls is exactly what this capture exists to surface; an automatic reset would risk silently discarding it. `ResetLogs()` only clears previously captured log data - it does not reset any `ErrorWhenLogContains` configuration (level, `exclude`, `include`). + _Note:_ Aspire-hosted resources are real OS processes (containers, where you opt into one), so `AspireTester` tests are inherently slower than the in-process Tier 1 testers - use them where the inter-process interaction itself is what needs proving. _Note:_ Since Tier 2/3 resources are real, reachable URLs (not in-process fakes), a UI/frontend resource hosted in the `AppHost` can be driven directly with [Playwright](https://playwright.dev/dotnet/) - this is an ordinary consequence of the resources being real processes, not a UnitTestEx-specific feature; see Microsoft's own [Aspire + Playwright guide](https://learn.microsoft.com/en-us/dotnet/aspire/testing/write-your-first-test?tabs=xunit#creating-a-playwright-test) for the pattern. diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index 48830d9..1d2381c 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -327,6 +327,79 @@ public async Task WaitForResourceAsync(string[] resourceNames, TimeSpan? timeout return lines; } + /// + /// Asserts (via ) that at least one resource log entry captured so far - across every resource, or only + /// where specified - contains the wildcard (*/?) . + /// + /// The wildcard (*/?), case-insensitive "contains" pattern that at least one log entry's fully formatted text must match. + /// The optional resource name (as configured within the AppHost) to scope the check to; where not specified, every resource is checked. + /// Unlike - a continuous, streaming check applied only as entries are + /// subsequently drained - this is an immediate, one-shot check against every resource log entry captured for the lifetime of the test so far, whether already drained/reported + /// or not (any still-pending entry not yet terminated by its own subsequent header line is flushed first). There is no "was this seen by the end of the test" ambiguity to reason + /// about - call this whenever (and as many times as) needed, typically right after whatever action is expected to have produced the entry. + protected void AssertResourceLogContains(string pattern, string? resourceName) + { + if (string.IsNullOrEmpty(pattern)) throw new ArgumentException("Pattern must not be null or empty.", nameof(pattern)); + + if (!GetResourceLogEntries(resourceName).Any(e => IsWildcardMatch(e.Text, pattern))) + Implementor.AssertFail($"Expected a{(resourceName is null ? string.Empty : $" '{resourceName}'")} resource log entry to contain '{pattern}' that was not found."); + } + + /// + /// Asserts (via ) that no resource log entry captured so far - across every resource, or only + /// where specified - contains the wildcard (*/?) . + /// + /// The wildcard (*/?), case-insensitive "contains" pattern that no log entry's fully formatted text may match. + /// The optional resource name (as configured within the AppHost) to scope the check to; where not specified, every resource is checked. + /// See remarks - the same immediate, one-shot semantics apply here (inverted). + protected void AssertResourceLogNotContains(string pattern, string? resourceName) + { + if (string.IsNullOrEmpty(pattern)) throw new ArgumentException("Pattern must not be null or empty.", nameof(pattern)); + + var matches = GetResourceLogEntries(resourceName).Where(e => IsWildcardMatch(e.Text, pattern)).ToArray(); + if (matches.Length > 0) + Implementor.AssertFail($"Expected no{(resourceName is null ? string.Empty : $" '{resourceName}'")} resource log entry to contain '{pattern}' but one was found: {matches[0].Text}"); + } + + /// + /// Discards every captured so far, across all resources, along with the per-resource watermarks used by - i.e. + /// puts resource log capture back into the same state as immediately after start-up. + /// + /// Only relevant where a host is deliberately reused across multiple tests (e.g. via a shared fixture, to avoid repeatedly paying its + /// start-up cost) - since the captured log buffers otherwise accumulate for the instance's entire lifetime, a later test would otherwise still see (and could be tripped up or falsely + /// satisfied by) an earlier test's log activity when using / + /// /. + /// Deliberately explicit rather than automatic - unlike Tier 1's TesterBaseT{TSelf}.HostExecutionWrapper{T}(Func{T}) reset of - + /// since here there is no single per-test Act boundary to hang an automatic reset off, and background/hosted-service activity occurring between calls is exactly what this capture + /// exists to surface; call this explicitly (typically as the first line of a test that reuses a shared host) instead. + /// Does not reset any configuration - only previously captured log data. + protected void ResetResourceLogs() + { + _resourceLogCaptureProvider?.FlushPendingEntries(); + + // Note: each resource's ConcurrentQueue instance is bound for the lifetime of the underlying ILogger (see ResourceLogCaptureProvider.CreateLogger, only ever + // invoked once per category by the logging infrastructure) - so the queue itself must be emptied in place rather than the dictionary entry replaced/removed, otherwise any + // already-created logger would keep writing into an orphaned queue no longer reachable via _resourceLogBuffers. + foreach (var buffer in _resourceLogBuffers.Values) + buffer.Clear(); + + _elapsedLogLineCounts.Clear(); + } + + /// + /// Gets every captured so far - across every resource, or only where specified - flushing any still-pending (not yet + /// terminated by a subsequent header line) entry first so "everything captured so far" genuinely means everything, regardless of whether it has already been drained/reported via + /// //. + /// + private IEnumerable GetResourceLogEntries(string? resourceName) + { + _resourceLogCaptureProvider?.FlushPendingEntries(); + + return resourceName is null + ? _resourceLogBuffers.Values.SelectMany(buffer => buffer.ToArray()).ToArray() + : _resourceLogBuffers.TryGetValue(resourceName, out var buffer) ? buffer.ToArray() : []; + } + /// /// Fails the test (via ) on the first entry at or above that (where /// any are configured) matches at least one of them, and does not match any of the wildcard diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs index d50814b..018ec65 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs @@ -280,5 +280,51 @@ public TSelf Checkpoint(string reason) /// Useful when waiting on background/inter-resource activity (e.g. a message being processed by a downstream resource) that is not tied to a specific HTTP request/response (see /// for the latter, request-scoped, correlation). public TSelf Delay(int durationInMilliseconds, string? reason = null) => Delay(TimeSpan.FromMilliseconds(durationInMilliseconds), reason); + + /// + /// Asserts that at least one resource log entry captured so far - across every resource, or only where specified - contains the wildcard + /// (*/?) . + /// + /// The wildcard (*/?), case-insensitive "contains" pattern that at least one log entry's fully formatted text must match. + /// The optional resource name (as configured within the AppHost) to scope the check to; where not specified, every resource is checked. + /// The to support fluent-style method-chaining. + /// An immediate, one-shot check against every resource log entry captured for the lifetime of the test so far - whether already drained/reported via / + /// / or not - unlike 's continuous, drain-only streaming + /// check. Call it whenever (and as many times as) needed, typically right after whatever action is expected to have produced the entry - there is no "was this seen by the end of the + /// test" ambiguity to reason about. + public TSelf AssertLogContains(string pattern, string? resourceName = null) + { + AssertResourceLogContains(pattern, resourceName); + return (TSelf)this; + } + + /// + /// Asserts that no resource log entry captured so far - across every resource, or only where specified - contains the wildcard + /// (*/?) . + /// + /// The wildcard (*/?), case-insensitive "contains" pattern that no log entry's fully formatted text may match. + /// The optional resource name (as configured within the AppHost) to scope the check to; where not specified, every resource is checked. + /// The to support fluent-style method-chaining. + /// See remarks - the same immediate, one-shot semantics apply here (inverted). + public TSelf AssertLogNotContains(string pattern, string? resourceName = null) + { + AssertResourceLogNotContains(pattern, resourceName); + return (TSelf)this; + } + + /// + /// Discards every resource log entry captured so far, across all resources, along with the per-resource watermarks used by / + /// - i.e. puts resource log capture back into the same state as immediately after start-up. + /// + /// The to support fluent-style method-chaining. + /// Only relevant where the underlying host is deliberately reused across multiple tests (e.g. via a shared fixture, to + /// avoid repeatedly paying its start-up cost) - otherwise call this explicitly, typically as the first line of such a test, so / + /// / only ever see that test's own activity. Does not reset any + /// configuration - only previously captured log data. + public TSelf ResetLogs() + { + ResetResourceLogs(); + return (TSelf)this; + } } } diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs index 0f67e26..c6d3c01 100644 --- a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs @@ -54,7 +54,7 @@ internal AspireHttpMockClient(Func> adminApiFactory, Fun /// Begins configuration of a stubbed request/response mapping. /// /// The to match; where not specified any method will match. - /// The relative request URI (path) to match (exact match); where not specified any path will match. + /// The relative request URI (path) to match (exact match); where not specified any path will match. A leading '/' is not required; where absent one is added automatically. /// The to continue the fluent-style configuration. public AspireHttpMockRequest Request(HttpMethod? method = null, string? requestUri = null) => new(this, method, requestUri); diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs index 03f5474..1f20cab 100644 --- a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs @@ -25,13 +25,14 @@ public sealed class AspireHttpMockRequest : IHttpMockRequest /// The owning . /// The to match; where not specified any method will match. /// The relative request URI (path) to match (exact match); where not specified any path will match. + /// A leading '/' is not required; where absent one is added automatically, consistent with Tier 1 (), as WireMock.Net's admin API mandates a leading '/'. internal AspireHttpMockRequest(AspireHttpMockClient client, HttpMethod? method, string? requestUri) { Client = client ?? throw new ArgumentNullException(nameof(client)); Rule = new RequestModel { Methods = method is null ? null : [method.Method], - Path = requestUri + Path = string.IsNullOrEmpty(requestUri) || requestUri[0] == '/' ? requestUri : $"/{requestUri}" }; } diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireWireMockServerSettings.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireWireMockServerSettings.cs new file mode 100644 index 0000000..ed17a65 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireWireMockServerSettings.cs @@ -0,0 +1,25 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Microsoft.Extensions.Logging; +using WireMock.Settings; + +namespace UnitTestEx.Aspire.HttpMock +{ + /// + /// Extends (deliberately left unsealed by WireMock.Net) with UnitTestEx-specific configuration for the self-hosted Aspire resource + /// (see ) - a single, strongly-typed extension point for settings that WireMock.Net itself has no concept of, rather than + /// growing 's parameter list every time a further such setting is needed. + /// + public class AspireWireMockServerSettings : WireMockServerSettings + { + /// + /// Gets or sets the that logs each stubbed request/response pair at - giving Tier 2/3 parity with Tier 1's + /// MockHttpClientHandler request/response logging. + /// + /// Defaults to - unlike Tier 1's default of - since Tier 2/3 resource logging does not typically have + /// Debug-level output enabled, and this is otherwise silently never seen. Set to to disable this logging entirely. This is read live at the time each + /// request/response pair is logged, so it may be changed at any point after constructs the settings - including from within the + /// caller-supplied server factory itself (e.g. settings => { settings.RequestResponseLogLevel = LogLevel.Debug; return WireMockServer.Start(settings); }). + public LogLevel RequestResponseLogLevel { get; set; } = LogLevel.Information; + } +} diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/WireMockRequestResponseLogger.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/WireMockRequestResponseLogger.cs new file mode 100644 index 0000000..5888cdd --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/WireMockRequestResponseLogger.cs @@ -0,0 +1,92 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Microsoft.Extensions.Logging; +using System; +using WireMock.Admin.Requests; +using WireMock.Logging; + +namespace UnitTestEx.Aspire.HttpMock +{ + /// + /// A that logs each non-admin (i.e. genuine stubbed traffic, not the admin API calls used to configure/query mappings) request/response pair to the + /// console, giving Tier 2/3 parity with Tier 1's MockHttpClientHandler, which logs the same "sending request"/"received response" pair for every mocked + /// call it intercepts. + /// + /// Registered against by , holding a live reference to the + /// it was constructed against so that is read fresh at the time of each + /// request/response pair, rather than a value copied once up-front. Since the self-hosted WireMock.Net server is a genuinely separate OS process (see the README's "Aspire multi-host + /// testing" section), there is no in-process to write through directly - instead, each entry is written to the console formatted to match the standard ASP.NET + /// Core console-logger header/message convention (e.g. "info: WireMockRequestResponseLogger[0]" followed by an indented message line) so that + /// 's resource log capture - which already parses that exact convention to recover a real + /// per entry for - attributes the configured level correctly rather than falling + /// back to a generic, unattributed passthrough line. + /// The general-purpose /// + /// Error members are WireMock.Net's own internal diagnostic logging (server start-up, admin API activity, etc.) - deliberately left as no-ops here to keep this focused solely on + /// request/response traffic and avoid otherwise-unactionable noise; assign a different instead where that diagnostic output is + /// also wanted. + public sealed class WireMockRequestResponseLogger(AspireWireMockServerSettings settings) : IWireMockLogger + { + private readonly AspireWireMockServerSettings _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + + /// + void IWireMockLogger.Debug(string formatString, params object[] args) { /* WireMock.Net's own internal diagnostics; deliberately not surfaced - see remarks. */ } + + /// + void IWireMockLogger.Info(string formatString, params object[] args) { /* WireMock.Net's own internal diagnostics; deliberately not surfaced - see remarks. */ } + + /// + void IWireMockLogger.Warn(string formatString, params object[] args) { /* WireMock.Net's own internal diagnostics; deliberately not surfaced - see remarks. */ } + + /// + void IWireMockLogger.Error(string formatString, params object[] args) { /* WireMock.Net's own internal diagnostics; deliberately not surfaced - see remarks. */ } + + /// + void IWireMockLogger.Error(string message, Exception exception) { /* WireMock.Net's own internal diagnostics; deliberately not surfaced - see remarks. */ } + + /// + /// Ignores entries (mapping registration/reset/verification calls issued by itself) - only genuine stubbed + /// traffic (an application's real request being matched/responded to) is logged. + void IWireMockLogger.DebugRequestResponse(LogEntryModel logEntryModel, bool isAdminRequest) + { + var logLevel = _settings.RequestResponseLogLevel; + if (isAdminRequest || logLevel == LogLevel.None) + return; + + ArgumentNullException.ThrowIfNull(logEntryModel); + + if (logEntryModel.Request is not null) + Log(logLevel, $"UnitTestEx > Sending HTTP request {logEntryModel.Request.Method} {logEntryModel.Request.Path} {LogContent(logEntryModel.Request.Body)}"); + + if (logEntryModel.Response is not null) + Log(logLevel, $"UnitTestEx > Received HTTP response {logEntryModel.Response.StatusCode} {LogContent(logEntryModel.Response.Body)}"); + } + + /// + /// Writes to the console as a standard ASP.NET Core console-logger header/message pair at . + /// + private static void Log(LogLevel logLevel, string message) + { + Console.WriteLine($"{Abbreviate(logLevel)}: {nameof(WireMockRequestResponseLogger)}[0]"); + Console.WriteLine($" {message}"); + } + + /// + /// Maps a to the standard ASP.NET Core console-logger's own abbreviation convention (the exact convention 's + /// resource log capture parses back into a ). + /// + private static string Abbreviate(LogLevel level) => level switch + { + LogLevel.Trace => "trce", + LogLevel.Debug => "dbug", + LogLevel.Warning => "warn", + LogLevel.Error => "fail", + LogLevel.Critical => "crit", + _ => "info" + }; + + /// + /// Logs (and formats) the content. + /// + private static string LogContent(string? body) => string.IsNullOrEmpty(body) ? "No content." : body; + } +} diff --git a/src/UnitTestEx.Aspire/Aspire/WireMockConsole.cs b/src/UnitTestEx.Aspire/Aspire/WireMockConsole.cs index 70a9553..bf922a5 100644 --- a/src/UnitTestEx.Aspire/Aspire/WireMockConsole.cs +++ b/src/UnitTestEx.Aspire/Aspire/WireMockConsole.cs @@ -22,16 +22,18 @@ namespace UnitTestEx.Aspire public static class WireMockConsole { /// - /// Reads the PORT environment variable, builds a with registered against + /// Reads the PORT environment variable, builds an with registered against /// , invokes to start the actual WireMock.Net server, then blocks until the process is asked /// to shut down (Ctrl+C or SIGTERM), disposing the returned before returning. /// /// A factory that starts and returns the - typically settings => WireMockServer.Start(settings). The caller's /// own project must reference the full WireMock.Net (or WireMock.Net.StandAlone) package for WireMock.Server.WireMockServer itself; this method - /// only depends on WireMock.Net.Abstractions' /, which UnitTestEx.Aspire already brings in. + /// only depends on WireMock.Net.Abstractions' /, which UnitTestEx.Aspire already brings in. + /// The supplied may be further customized before starting the server - e.g. + /// is read live at log time, so it can be changed here too. /// An optional additional that also triggers shutdown (e.g. to host this in-process for a test); shutdown /// otherwise occurs on Ctrl+C or process exit. - public static async Task RunAsync(Func server, CancellationToken cancellationToken = default) + public static async Task RunAsync(Func server, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(server); @@ -39,7 +41,7 @@ public static async Task RunAsync(Func if (!int.TryParse(portText, out var port)) throw new InvalidOperationException($"The 'PORT' environment variable value '{portText}' is not a valid port number."); - var settings = new WireMockServerSettings + var settings = new AspireWireMockServerSettings { Port = port, StartAdminInterface = true, @@ -49,6 +51,8 @@ public static async Task RunAsync(Func } }; + settings.Logger = new WireMockRequestResponseLogger(settings); + using var wireMockServer = server(settings); Console.WriteLine($"{nameof(WireMockConsole)}: WireMock.Net server listening on port {port} (custom matcher '{JsonElementComparerMatcher.MatcherName}' registered)."); diff --git a/src/UnitTestEx/Abstractions/TesterBaseCore.cs b/src/UnitTestEx/Abstractions/TesterBaseCore.cs index e7879b7..fa97295 100644 --- a/src/UnitTestEx/Abstractions/TesterBaseCore.cs +++ b/src/UnitTestEx/Abstractions/TesterBaseCore.cs @@ -292,13 +292,17 @@ protected void WriteCheckpoint(string reason) ///
/// The reason for delaying (written to the test output for context); defaults to "No reason specified" where not specified. /// The duration to delay; defaults to where not specified. + /// Reports (rather than silently discards) any pre-existing backlog first - i.e. anything captured since the last checkpoint/delay but not yet reported - under its own, + /// preceding "LOGGING >" section, so it is not misattributed to this delay's window (it happened before the wait started) but is also never permanently lost; for a Tier 2/3 + /// tester (e.g. Aspire), the reported "LOGGING >" sections are the only reliable way a resource's own forwarded logging is ever surfaced to the test output, so silently discarding + /// this backlog (as opposed to merely reporting it under a different heading) would mean genuinely losing it. already stays silent when there is + /// nothing to report, so this is a no-op in the (typical) case where there is no such backlog. protected async Task WriteDelay(string? reason, TimeSpan? duration) { var effectiveReason = string.IsNullOrEmpty(reason) ? "No reason specified" : reason; var effectiveDuration = duration ?? DefaultDelayDuration; - // Drain any pre-existing/stale backlog first so only messages logged during the delay window itself are reported. - DrainElapsedLogMessages(); + WriteElapsedLogMessages(); Implementor.WriteLine(""); Implementor.WriteLine(new string('-', 80)); diff --git a/src/UnitTestEx/Mocking/MockHttpClientFactory.cs b/src/UnitTestEx/Mocking/MockHttpClientFactory.cs index dc66b62..b1b0a70 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientFactory.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientFactory.cs @@ -28,6 +28,13 @@ public sealed class MockHttpClientFactory(TestFrameworkImplementor implementor) ///
internal ILogger? Logger { get; set; } + /// + /// Gets or sets the that logs each mocked request/response pair at. + /// + /// Defaults to . Set to to disable this logging entirely, or use to set it via + /// fluent-style method-chaining. + public LogLevel LogLevel { get; set; } = LogLevel.Debug; + /// /// Gets the . /// @@ -68,6 +75,17 @@ public MockHttpClientFactory UseJsonComparerOptions(JsonElementComparerOptions o return this; } + /// + /// Updates the that logs each mocked request/response pair at. + /// + /// The . + /// The current instance to support fluent-style method-chaining. + public MockHttpClientFactory UseLogLevel(LogLevel logLevel) + { + LogLevel = logLevel; + return this; + } + /// /// Gets the optional . /// diff --git a/src/UnitTestEx/Mocking/MockHttpClientHandler.cs b/src/UnitTestEx/Mocking/MockHttpClientHandler.cs index ccf6aae..30c883a 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientHandler.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientHandler.cs @@ -10,6 +10,8 @@ namespace UnitTestEx.Mocking /// /// Provides a that will log success and failure. /// + /// Logs each mocked request/response pair at (defaults to ; configurable via + /// ) - set to to disable this logging entirely. public class MockHttpClientHandler : DelegatingHandler { private readonly MockHttpClientFactory _factory; @@ -25,11 +27,16 @@ public class MockHttpClientHandler : DelegatingHandler protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var logger = _factory.Logger ?? _factory.Implementor.CreateLoggerProvider().CreateLogger(nameof(MockHttpClientFactory)); - logger.LogDebug($"UnitTestEx > Sending HTTP request {request.Method} {request.RequestUri} {LogContent(request.Content)}"); + var logLevel = _factory.LogLevel; + + if (logLevel != LogLevel.None) + logger.Log(logLevel, $"UnitTestEx > Sending HTTP request {request.Method} {request.RequestUri} {LogContent(request.Content)}"); var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false) ?? throw new MockHttpClientException($"No corresponding MockHttpClient response found for HTTP request {request.Method} {request.RequestUri} {LogContent(request.Content)}"); - logger.LogDebug($"UnitTestEx > Received HTTP response {response.StatusCode} ({(int)response.StatusCode}) {LogContent(response.Content)}"); + if (logLevel != LogLevel.None) + logger.Log(logLevel, $"UnitTestEx > Received HTTP response {response.StatusCode} ({(int)response.StatusCode}) {LogContent(response.Content)}"); + return response; } diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index cafd5e7..1992164 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -341,6 +341,95 @@ public async Task ErrorWhenLogContains_ChecksOnFinalCheckpoint_ForActivityNotDra Assert.Throws(() => tester.Checkpoint("Final log check.")); } + [Fact] + public async Task AssertLogContains_FindsExpectedText_AcrossAllOrASpecificResource() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .ErrorWhenLogContains(LogLevel.None); // Deliberately induces an Error-level resource log as the very thing under test. + + await tester.WaitForResourceAsync("api"); + + tester.Http("api").Run(HttpMethod.Get, "Person/test/log/error").AssertOK(); + + tester.AssertLogContains("Simulated error log*"); // Across every resource. + tester.AssertLogContains("Simulated error log*", "api"); // Scoped to just the 'api' resource. + tester.AssertLogNotContains("This text was never logged."); + } + + [Fact] + public async Task AssertLogContains_ThrowsWhenTextNotFound() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + + var ex = Assert.Throws(() => tester.AssertLogContains("This text was never logged.")); + Assert.Contains("Expected a resource log entry to contain", ex.Message); + } + + [Fact] + public async Task AssertLogNotContains_ThrowsWhenTextFound() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .ErrorWhenLogContains(LogLevel.None); + + await tester.WaitForResourceAsync("api"); + + tester.Http("api").Run(HttpMethod.Get, "Person/test/log/error").AssertOK(); + + var ex = Assert.Throws(() => tester.AssertLogNotContains("Simulated error*")); + Assert.Contains("Simulated error log entry.", ex.Message); + } + + [Fact] + public async Task AssertLogContains_FindsUndrainedActivity_WithoutRequiringCheckpoint() + { + // Unlike ErrorWhenLogContains (only checked as entries are subsequently drained via Checkpoint/Delay/Http), AssertLogContains checks every resource log entry captured so + // far regardless of whether it has already been drained - so, unlike ErrorWhenLogContains_ChecksOnFinalCheckpoint_ForActivityNotDrainedByHttp above, no trailing Checkpoint + // is needed to "see" a raw, uninstrumented request's resulting log line. + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .ErrorWhenLogContains(LogLevel.None); + + await tester.WaitForResourceAsync("api"); + + using (var client = ((IHttpClientSource)tester).CreateHttpClient("api")) + { + using var response = await client.GetAsync("Person/test/log/error"); + response.EnsureSuccessStatusCode(); + } + + await Task.Delay(300); // Allow the forwarded resource log line time to arrive/be captured. + + tester.AssertLogContains("Simulated error log entry."); + } + + [Fact] + public async Task ResetLogs_DiscardsPreviouslyCapturedEntries() + { + // Simulates a shared-host scenario: log activity from "before" the reset (e.g. a prior test sharing the same host) must not be visible to any check performed "after" it. + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue") + .ErrorWhenLogContains(LogLevel.None); + + await tester.WaitForResourceAsync("api"); + + tester.Http("api").Run(HttpMethod.Get, "Person/test/log/error").AssertOK(); + tester.AssertLogContains("Simulated error log entry."); + + tester.ResetLogs(); + + var ex = Assert.Throws(() => tester.AssertLogContains("Simulated error log entry.")); + Assert.Contains("Expected a resource log entry to contain", ex.Message); + + // Fresh activity logged after the reset must still be captured/found as normal. + tester.Http("api").Run(HttpMethod.Get, "Person/test/log/error").AssertOK(); + tester.AssertLogContains("Simulated error log entry."); + } + [Fact] public async Task HttpMock_StubsExternalMockHostDependency_Product() { @@ -364,6 +453,62 @@ public async Task HttpMock_StubsExternalMockHostDependency_Product() await stub.VerifyAsync(); } + [Fact] + public async Task HttpMock_StubsExternalMockHostDependency_RequestUriWithoutLeadingSlash_IsNormalized() + { + // WireMock.Net's admin API rejects a mapping path lacking a leading '/'; AspireHttpMockRequest must add one automatically (consistent with Tier 1's MockHttpClientRequest, + // which does not require one either) so callers do not need to remember to prefix every requestUri themselves. + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync(["api", "mockhost"]); + + var stub = await tester.HttpMock("mockhost") + .Request(HttpMethod.Get, "products/abc") // Deliberately no leading '/'. + .Times(Times.Once()) + .WithAnyBody() + .Respond.WithJsonAsync(new { id = "Abc", description = "A blue carrot" }); + + tester.Http("api") + .Run(HttpMethod.Get, "Product/abc") + .AssertOK() + .AssertValue(new { id = "Abc", description = "A blue carrot" }); + + await stub.VerifyAsync(); + } + + [Fact] + public async Task HttpMock_StubbedTraffic_LogsRequestAndResponse() + { + // WireMockRequestResponseLogger gives Tier 2/3 parity with Tier 1's MockHttpClientHandler LogDebug request/response logging - here at its default (Information) LogLevel, since + // this resource logging drains through the same captured resource-log mechanism used by Checkpoint/Delay/ErrorWhenLogContains. + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync(["api", "mockhost"]); + + var spy = new SpyTestFrameworkImplementor(tester.Implementor); + tester.ReplaceTestFrameworkImplementor(spy); + + var stub = await tester.HttpMock("mockhost") + .Request(HttpMethod.Get, "/products/abc") + .Times(Times.Once()) + .WithAnyBody() + .Respond.WithJsonAsync(new { id = "Abc", description = "A blue carrot" }); + + tester.Http("api") + .Run(HttpMethod.Get, "Product/abc") + .AssertOK() + .AssertValue(new { id = "Abc", description = "A blue carrot" }); + + await stub.VerifyAsync(); + + tester.Checkpoint("Final log check."); + + Assert.Contains(spy.Lines, l => l != null && l.Contains("UnitTestEx > Sending HTTP request GET /products/abc") && l.Contains("(mockhost)]")); + Assert.Contains(spy.Lines, l => l != null && l.Contains("UnitTestEx > Received HTTP response 200") && l.Contains("(mockhost)]")); + } + [Fact] public async Task HttpMock_ViaDistributedApplicationExtensionMethod_WorksWithoutTester() { diff --git a/tests/UnitTestEx.MSTest.Test/Other/CheckpointAndDelayTest.cs b/tests/UnitTestEx.MSTest.Test/Other/CheckpointAndDelayTest.cs index f65ea8d..7ea5522 100644 --- a/tests/UnitTestEx.MSTest.Test/Other/CheckpointAndDelayTest.cs +++ b/tests/UnitTestEx.MSTest.Test/Other/CheckpointAndDelayTest.cs @@ -75,7 +75,7 @@ public async Task Delay_DelaysAndSurfacesLoggingThatOccursDuringTheDelay() } [TestMethod] - public void Delay_ExcludesLoggingThatOccurredBeforeTheDelayStarted() + public void Delay_ReportsPreExistingBacklogBeforeItsOwnWindow() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); @@ -86,9 +86,15 @@ public void Delay_ExcludesLoggingThatOccurredBeforeTheDelayStarted() test.Delay(TimeSpan.FromMilliseconds(200), "Waiting with nothing new expected."); - Assert.IsFalse(spy.Lines.Contains("LOGGING >")); - Assert.IsFalse(spy.Lines.Contains("None.")); - Assert.IsFalse(spy.Lines.Any(l => l != null && l.Contains("Stale message logged before the delay started."))); + // Pre-existing backlog is reported (never silently lost) rather than discarded - but under its own, preceding "LOGGING >" section, ahead of this delay's own "DELAY (...) >" + // marker, so it is not misattributed to having occurred during the delay's wait. + var loggingIndex = spy.Lines.FindIndex(l => l == "LOGGING >"); + var delayIndex = spy.Lines.FindIndex(l => l != null && l.StartsWith("DELAY (", StringComparison.Ordinal)); + + Assert.IsTrue(loggingIndex >= 0, "Expected a LOGGING > section reporting the pre-existing backlog."); + Assert.IsTrue(delayIndex >= 0, "Expected a DELAY (...) > marker."); + Assert.IsTrue(loggingIndex < delayIndex, "Expected the pre-existing backlog's LOGGING > section to precede the DELAY (...) > marker, not be attributed to it."); + Assert.IsTrue(spy.Lines.Any(l => l != null && l.Contains("Stale message logged before the delay started."))); } } } diff --git a/tests/UnitTestEx.NUnit.Test/Other/CheckpointAndDelayTest.cs b/tests/UnitTestEx.NUnit.Test/Other/CheckpointAndDelayTest.cs index 2381137..061a2d3 100644 --- a/tests/UnitTestEx.NUnit.Test/Other/CheckpointAndDelayTest.cs +++ b/tests/UnitTestEx.NUnit.Test/Other/CheckpointAndDelayTest.cs @@ -75,7 +75,7 @@ public async Task Delay_DelaysAndSurfacesLoggingThatOccursDuringTheDelay() } [Test] - public void Delay_ExcludesLoggingThatOccurredBeforeTheDelayStarted() + public void Delay_ReportsPreExistingBacklogBeforeItsOwnWindow() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); @@ -86,9 +86,15 @@ public void Delay_ExcludesLoggingThatOccurredBeforeTheDelayStarted() test.Delay(TimeSpan.FromMilliseconds(200), "Waiting with nothing new expected."); - Assert.That(spy.Lines, Does.Not.Contain("LOGGING >")); - Assert.That(spy.Lines, Does.Not.Contain("None.")); - Assert.That(spy.Lines.Any(l => l != null && l.Contains("Stale message logged before the delay started.")), Is.False); + // Pre-existing backlog is reported (never silently lost) rather than discarded - but under its own, preceding "LOGGING >" section, ahead of this delay's own "DELAY (...) >" + // marker, so it is not misattributed to having occurred during the delay's wait. + var loggingIndex = spy.Lines.FindIndex(l => l == "LOGGING >"); + var delayIndex = spy.Lines.FindIndex(l => l != null && l.StartsWith("DELAY (", StringComparison.Ordinal)); + + Assert.That(loggingIndex, Is.GreaterThanOrEqualTo(0), "Expected a LOGGING > section reporting the pre-existing backlog."); + Assert.That(delayIndex, Is.GreaterThanOrEqualTo(0), "Expected a DELAY (...) > marker."); + Assert.That(loggingIndex, Is.LessThan(delayIndex), "Expected the pre-existing backlog's LOGGING > section to precede the DELAY (...) > marker, not be attributed to it."); + Assert.That(spy.Lines.Any(l => l != null && l.Contains("Stale message logged before the delay started.")), Is.True); } } } diff --git a/tests/UnitTestEx.Xunit.Test/Other/CheckpointAndDelayTest.cs b/tests/UnitTestEx.Xunit.Test/Other/CheckpointAndDelayTest.cs index 7b41cc8..e91aeb6 100644 --- a/tests/UnitTestEx.Xunit.Test/Other/CheckpointAndDelayTest.cs +++ b/tests/UnitTestEx.Xunit.Test/Other/CheckpointAndDelayTest.cs @@ -76,7 +76,7 @@ public async Task Delay_DelaysAndSurfacesLoggingThatOccursDuringTheDelay() } [Fact] - public void Delay_ExcludesLoggingThatOccurredBeforeTheDelayStarted() + public void Delay_ReportsPreExistingBacklogBeforeItsOwnWindow() { using var test = ApiTester.Create(); var spy = new SpyTestFrameworkImplementor(test.Implementor); @@ -87,9 +87,15 @@ public void Delay_ExcludesLoggingThatOccurredBeforeTheDelayStarted() test.Delay(TimeSpan.FromMilliseconds(200), "Waiting with nothing new expected."); - Assert.DoesNotContain("LOGGING >", spy.Lines); - Assert.DoesNotContain("None.", spy.Lines); - Assert.DoesNotContain(spy.Lines, l => l != null && l.Contains("Stale message logged before the delay started.")); + // Pre-existing backlog is reported (never silently lost) rather than discarded - but under its own, preceding "LOGGING >" section, ahead of this delay's own "DELAY (...) >" + // marker, so it is not misattributed to having occurred during the delay's wait. + var loggingIndex = spy.Lines.FindIndex(l => l == "LOGGING >"); + var delayIndex = spy.Lines.FindIndex(l => l != null && l.StartsWith("DELAY (", StringComparison.Ordinal)); + + Assert.True(loggingIndex >= 0, "Expected a LOGGING > section reporting the pre-existing backlog."); + Assert.True(delayIndex >= 0, "Expected a DELAY (...) > marker."); + Assert.True(loggingIndex < delayIndex, "Expected the pre-existing backlog's LOGGING > section to precede the DELAY (...) > marker, not be attributed to it."); + Assert.Contains(spy.Lines, l => l != null && l.Contains("Stale message logged before the delay started.")); } } } From 3da3350b216bc84f760a2482ad37f1c407e03e75 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 29 Sep 2026 11:55:35 -0700 Subject: [PATCH 42/43] Fix racy watermark/snapshot split in AspireTesterBase log draining 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. --- _threads.json | 1 + src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs | 15 +++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 _threads.json diff --git a/_threads.json b/_threads.json new file mode 100644 index 0000000..2a68622 --- /dev/null +++ b/_threads.json @@ -0,0 +1 @@ +{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[{"id":"PRRT_kwDOGoJGsc6k2YZu","isResolved":true,"isOutdated":true,"path":"src/UnitTestEx/Abstractions/TesterBase.cs","line":null,"comments":{"nodes":[{"databaseId":4074764353,"author":{"login":"copilot-pull-request-reviewer"},"body":"The false-capability path currently only points back to `SupportsServiceConfiguration`; it does not give the actionable alternative required by #105/design section 9 (configure the distributed resource through its supported environment/configuration surface or mock an HTTP boundary). Since this exception is the first feedback a Tier 2 caller receives, include that guidance in the message rather than requiring the caller to infer it from the flag.","createdAt":"2026-09-22T18:01:01Z","path":"src/UnitTestEx/Abstractions/TesterBase.cs","diffHunk":"@@ -226,8 +235,12 @@ protected void OnHostStart(Action start, bool autoResetHost = true)\n /// A delegate for configuring .\n /// Indicates whether to automatically (passing false) when configuring the services.\n /// This can be called multiple times prior to the underlying host being instantiated. Internally, the is queued and then played in order when the host is initially instantiated.\n+ /// Thrown where is false.\n protected void ConfigureServices(Action configureServices, bool autoResetHost = true)\n {\n+ if (!SupportsServiceConfiguration)\n+ throw new NotSupportedException($\"{GetType().Name} does not support in-process service configuration/replacement because its underlying host does not run in-process (see {nameof(SupportsServiceConfiguration)}).\");"},{"databaseId":4074810141,"author":{"login":"chullybun"},"body":"Good catch — updated the exception message to point at the actual Tier 2 alternatives (configuring the resource's environment/configuration surface, or mocking the HTTP boundary via something like WireMock.Net.Aspire) instead of just referencing the flag. Also fixed the \"where\"/\"when\" doc wording, clarified in the design doc that `TestSetUp.ConfigureServices` is a separate entry point outside this guard's scope, and bumped `Common.targets` to 5.12.0 to match the CHANGELOG entry. See 26a3d18.","createdAt":"2026-09-22T18:05:52Z","path":"src/UnitTestEx/Abstractions/TesterBase.cs","diffHunk":"@@ -226,8 +235,12 @@ protected void OnHostStart(Action start, bool autoResetHost = true)\n /// A delegate for configuring .\n /// Indicates whether to automatically (passing false) when configuring the services.\n /// This can be called multiple times prior to the underlying host being instantiated. Internally, the is queued and then played in order when the host is initially instantiated.\n+ /// Thrown where is false.\n protected void ConfigureServices(Action configureServices, bool autoResetHost = true)\n {\n+ if (!SupportsServiceConfiguration)\n+ throw new NotSupportedException($\"{GetType().Name} does not support in-process service configuration/replacement because its underlying host does not run in-process (see {nameof(SupportsServiceConfiguration)}).\");"}]}},{"id":"PRRT_kwDOGoJGsc6nPJiG","isResolved":false,"isOutdated":false,"path":"src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs","line":321,"comments":{"nodes":[{"databaseId":4136849892,"author":{"login":"copilot-pull-request-reviewer"},"body":"The snapshot and watermark update are not atomic: if a resource log is enqueued after `buffer.Skip(lastCount).ToArray()` but before `buffer.Count` is read, the new count advances past an entry that was never returned or checked. That entry is then skipped forever, which can hide output and bypass the streaming error check under normal concurrent logging. Use an atomic drain/sequence boundary rather than a separate enumeration and count read.\n\nThis issue also appears in the following locations of the same file:\n- line 675\n- line 688","createdAt":"2026-09-29T18:19:29Z","path":"src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs","diffHunk":"@@ -0,0 +1,753 @@\n+// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx\n+\n+using Aspire.Hosting;\n+using Aspire.Hosting.ApplicationModel;\n+using Aspire.Hosting.Testing;\n+using Microsoft.Extensions.DependencyInjection;\n+using Microsoft.Extensions.DependencyInjection.Extensions;\n+using Microsoft.Extensions.Logging;\n+using Microsoft.Extensions.Logging.Abstractions;\n+using System;\n+using System.Collections.Concurrent;\n+using System.Collections.Generic;\n+using System.Globalization;\n+using System.Linq;\n+using System.Net.Http;\n+using System.Text;\n+using System.Text.RegularExpressions;\n+using System.Threading;\n+using System.Threading.Tasks;\n+using UnitTestEx.Abstractions;\n+using UnitTestEx.AspNetCore;\n+\n+namespace UnitTestEx.Aspire\n+{\n+ /// \n+ /// Provides the non-generic, host-agnostic base for the .NET Aspire multi-host (distributed application) unit-testing capabilities - the Aspire-specific equivalent of\n+ /// (its single-host, dependency injection (DI) counterpart).\n+ /// \n+ /// This extends the host-agnostic directly (not ) as the underlying is a real,\n+ /// multi-process distributed application with no single, in-process dependency injection (DI) container to reach into.\n+ /// Exists primarily so an extension method (or any other code that does not know/care about the concrete AppHost or TSelf type parameters -\n+ /// see ) can still reach the capabilities that do not depend on either - e.g. ,\n+ /// , . Fluent, method-chaining configuration members that must return TSelf\n+ /// (e.g. /) necessarily\n+ /// remain on only.\n+ public abstract class AspireTesterBase : TesterBaseCore, IHttpClientSource, IAsyncDisposable\n+ {\n+ private readonly ConcurrentDictionary> _resourceLogBuffers = new();\n+ private readonly ConcurrentDictionary _elapsedLogLineCounts = new();\n+ private ResourceLogCaptureProvider? _resourceLogCaptureProvider;\n+ private Task? _appTask;\n+ private bool _disposed;\n+\n+ /// \n+ /// Gets or sets the minimum that triggers an immediate test failure when a resource log entry at (or above) it is subsequently drained - see\n+ /// .\n+ /// \n+ /// Enabled by default at - this is a brand-new capability with no existing tests relying on it being off, and catching an unexpected resource\n+ /// error is almost always preferable to silently letting it slide. To opt out entirely, call \n+ /// with (which, being numerically above , no resource log entry can ever meet or exceed).\n+ protected LogLevel ErrorWhenLogContainsMinimumLevel { get; set; } = LogLevel.Error;\n+\n+ /// \n+ /// Gets or sets the wildcard (*/?) exclude patterns checked (case-insensitively, as a \"contains\" match) against an otherwise-violating resource log entry's text - a\n+ /// match here always suppresses the entry, even where it also matches - see\n+ /// .\n+ /// \n+ protected IReadOnlyList ErrorWhenLogContainsExcludePatterns { get; set; } = [];\n+\n+ /// \n+ /// Gets or sets the wildcard (*/?) include patterns that an otherwise-qualifying resource log entry's text must match at least one of (case-insensitively, as a\n+ /// \"contains\" match) to be reported - see . An empty list (the default) means\n+ /// every entry that meets qualifies; i.e. this narrows rather than widens what is checked.\n+ /// \n+ protected IReadOnlyList ErrorWhenLogContainsIncludePatterns { get; set; } = [];\n+\n+ /// \n+ /// Initializes a new instance of the class.\n+ /// \n+ /// The .\n+ protected AspireTesterBase(TestFrameworkImplementor implementor) : base(implementor) { }\n+\n+ /// \n+ /// Gets the queued builder-configuration actions applied, in order, immediately before the underlying is built.\n+ /// \n+ /// Populated by .\n+ protected List> ConfigureBuilderActions { get; } = [];\n+\n+ /// \n+ /// Gets the queued callbacks invoked, in order, once the underlying has been built but before it is started - i.e. before any resource\n+ /// (project, container, executable, etc.) begins running.\n+ /// \n+ /// Populated by . Useful to prepare an external dependency whose connection\n+ /// details are only resolvable via the AppHost (e.g. a connection-string resource added via the AppHost's own AddConnectionString) - such as running database\n+ /// migrations/seeding, clearing a cache, or resetting a messaging topic/queue to a known state - before any project resource that depends on it starts running and\n+ /// potentially races against that very same setup (e.g. connecting to a not-yet-migrated database).\n+ /// Important: a callback must operate directly against the passed to it - it must not call back into this tester's own\n+ /// (or any extension method that does, e.g. a MigrateXxxAsync helper written against the tester): that method's own construction\n+ /// is still in-flight at this point, so awaiting it from within the callback that is part of that very construction will deadlock/hang rather than reuse it. It also must not\n+ /// call Aspire's own DistributedApplication.GetConnectionStringAsync/GetEndpoint/CreateHttpClient testing extensions (Aspire.Hosting.Testing) - these\n+ /// throw at this point as they require the application to have already started; use instead.\n+ protected List> BeforeStartActions { get; } = [];\n+\n+ /// \n+ /// Gets the queued callbacks invoked, in order, once the underlying has been started - i.e. after every resource (project, container,\n+ /// executable, etc.) has been kicked off (though not necessarily healthy/ready yet - see ).\n+ /// \n+ /// Populated by . This is the natural place to wait for one or more resources\n+ /// to become healthy (e.g. via the /\n+ /// extension methods) before a test's own set-up (e.g. OneTimeSetUp) proceeds to use them.\n+ /// Important: a callback must operate directly against the passed to it - it must not call back into this tester's own\n+ /// (or any instance method that does, e.g. the instance ): that method's own\n+ /// construction is still in-flight at this point, so awaiting it from within the callback that is part of that very construction will deadlock/hang rather than reuse it; use the\n+ /// extension method (which operates directly against the passed-in\n+ /// ) instead.\n+ protected List> AfterStartActions { get; } = [];\n+\n+ /// \n+ /// Resolves the connection string for the named resource directly against the 's resource model.\n+ /// \n+ /// The .\n+ /// The resource name (as configured within the AppHost).\n+ /// The .\n+ /// The connection string, or null if the resource has none configured.\n+ /// The resource was not found, or does not expose a connection string.\n+ /// Unlike Aspire's own DistributedApplication.GetConnectionStringAsync(resourceName) testing extension (Aspire.Hosting.Testing) - which throws\n+ /// unless the application has already started (i.e. its underlying IHostApplicationLifetime.ApplicationStarted has fired) - this\n+ /// resolves the resource directly via , so it is safe to call from a callback\n+ /// (i.e. before StartAsync has even been invoked), as well as at any other time.\n+ /// This only works for resources whose connection string does not depend on a dynamically-allocated endpoint - e.g. a static, externally-hosted dependency added via the\n+ /// AppHost's own AddConnectionString(name) (the primary use case), whose value is resolved directly from\n+ /// configuration/parameters. For a resource whose connection string is only known once the resource itself has actually started and allocated an endpoint (e.g. a container\n+ /// resource), calling this before the application has started will return an incomplete/unresolved value; use Aspire's own GetConnectionStringAsync after the application\n+ /// has started for those instead.\n+ public static async Task GetConnectionStringAsync(DistributedApplication app, string resourceName, CancellationToken cancellationToken = default)\n+ {\n+ ArgumentNullException.ThrowIfNull(app);\n+ ArgumentException.ThrowIfNullOrEmpty(resourceName);\n+\n+ var model = app.Services.GetRequiredService();\n+ if (!model.Resources.TryGetByName(resourceName, out var resource))\n+ throw new ArgumentException($\"Resource '{resourceName}' not found.\", nameof(resourceName));\n+\n+ if (resource is not IResourceWithConnectionString resourceWithConnectionString)\n+ throw new ArgumentException($\"Resource '{resourceName}' does not expose a connection string.\", nameof(resourceName));\n+\n+ return await resourceWithConnectionString.GetConnectionStringAsync(cancellationToken).ConfigureAwait(false);\n+ }\n+\n+ /// \n+ /// Gets a value indicating whether the underlying has already started being built (see ).\n+ /// \n+ /// Used to guard configuration that must be applied before the underlying is built (see\n+ /// /).\n+ protected bool IsDistributedApplicationBuilding => _appTask is not null;\n+\n+ /// \n+ /// Gets the underlying ; builds and starts on first access.\n+ /// \n+ /// The started .\n+ /// This is the escape hatch for anything not already surfaced by itself - e.g. resolving a resource's connection string via\n+ /// Aspire's own GetConnectionStringAsync(resourceName), inspecting directly, or reaching into\n+ /// for the AppHost's own (not a resource's) dependency injection (DI) container.\n+ /// Important: do not call DisposeAsync()/StopAsync() on the returned instance directly - this bypasses UnitTestEx's own lifecycle tracking (leaving it\n+ /// believing the host is still instantiated when it is not) and will surface as confusing failures on a subsequent call. Use instead to tear down\n+ /// and force a rebuild on next access.\n+ public Task GetDistributedApplicationAsync()\n+ {\n+ lock (SyncRoot)\n+ {\n+ return _appTask ??= CreateDistributedApplicationAsync();\n+ }\n+ }\n+\n+ /// \n+ /// Creates the for the concrete AppHost.\n+ /// \n+ /// The .\n+ /// The only step of building the underlying that depends on the concrete AppHost - a generic type parameter only\n+ /// available on ; every other build/start-up step is host-agnostic and lives here (see ).\n+ protected abstract Task CreateBuilderAsync();\n+\n+ /// \n+ /// Creates, builds and starts the underlying .\n+ /// \n+ private async Task CreateDistributedApplicationAsync()\n+ {\n+ var builder = await CreateBuilderAsync().ConfigureAwait(false);\n+\n+ if (!HostDiagnosticsEnabled)\n+ // By default the AppHost's own logging (its start-up banner, DCP process management, and - critically - each resource's own console output mirrored under a\n+ // \"{ApplicationName}.Resources.{resourceName}\" category; see 'EnableResourceLogging') writes straight to the test's Standard Output via its default provider(s) (e.g. console),\n+ // duplicating what UnitTestEx already captures and reports cleanly (and correlated) via Checkpoint()/Delay()/the Http() request-scoped \"LOGGING >\" section. Remove the AppHost's\n+ // own provider(s) so only what UnitTestEx explicitly writes reaches the test output; call EnableHostDiagnostics() before this point to opt back into the raw firehose (e.g. when\n+ // troubleshooting an AppHost/resource start-up failure).\n+ builder.Services.RemoveAll();\n+\n+ builder.Services.Configure(options =>\n+ // Exempt our own capture provider from any ambient filtering (by provider, not category) so it always sees everything, regardless of the AppHost's own console noise level\n+ // (which the AppHost's own default logging configuration governs independently and is not something UnitTestEx overrides).\n+ options.Rules.Add(new LoggerFilterRule(typeof(ResourceLogCaptureProvider).FullName, null, LogLevel.Trace, null)));\n+\n+ builder.Services.AddSingleton(_resourceLogCaptureProvider = new ResourceLogCaptureProvider($\"{builder.Environment.ApplicationName}.Resources.\", _resourceLogBuffers));\n+\n+ foreach (var configure in ConfigureBuilderActions)\n+ {\n+ configure(builder);\n+ }\n+\n+ OnHostStartUp();\n+\n+ var app = await builder.BuildAsync().ConfigureAwait(false);\n+\n+ try\n+ {\n+ // Give any queued BeforeStartActions (see AspireTesterBase{TAppHost, TSelf}.BeforeStart) the chance to prepare an external dependency (e.g. migrate/seed a database resolved\n+ // via app.GetConnectionStringAsync) before any resource is actually started/kicked off below, so it cannot race against that very same setup.\n+ foreach (var beforeStart in BeforeStartActions)\n+ {\n+ await beforeStart(app).ConfigureAwait(false);\n+ }\n+\n+ await app.StartAsync().ConfigureAwait(false);\n+\n+ // Give any queued AfterStartActions (see AspireTesterBase{TAppHost, TSelf}.AfterStart) the chance to act now every resource has been kicked off (e.g. waiting for one or\n+ // more to become healthy via the WaitForResourceAsync extension method) before the tester itself is handed back to the caller.\n+ foreach (var afterStart in AfterStartActions)\n+ {\n+ await afterStart(app).ConfigureAwait(false);\n+ }\n+ }\n+ catch\n+ {\n+ await app.DisposeAsync().ConfigureAwait(false);\n+ throw;\n+ }\n+\n+ return app;\n+ }\n+\n+ /// \n+ /// Sealed as the Aspire-specific extensibility point for logic that must run around start-up is the /\n+ /// fluent pair - unlike this virtual hook (inherited from ), both receive the actual\n+ /// instance and run at a precisely-defined point (immediately before/after StartAsync); prefer those instead.\n+ protected sealed override void OnHostStartUp() => base.OnHostStartUp();\n+\n+ /// \n+ protected override void OnResetHost()\n+ {\n+ Task? appTask;\n+ lock (SyncRoot)\n+ {\n+ appTask = _appTask;\n+ _appTask = null;\n+ }\n+\n+ if (appTask is { IsCompletedSuccessfully: true })\n+ {\n+ appTask.Result.Dispose();\n+ Implementor.WriteLine(\"\");\n+ Implementor.WriteLine(\"** The underlying UnitTestEx 'AspireTester' distributed application host has been reset. **\");\n+ Implementor.WriteLine(\"\");\n+ }\n+ }\n+\n+ /// \n+ /// Gets a value indicating whether the AppHost's own raw logging (its start-up banner, DCP process management, and each resource's own console output mirrored via\n+ /// 'EnableResourceLogging') is written to the test output, in addition to what UnitTestEx itself reports (see ).\n+ /// \n+ public bool HostDiagnosticsEnabled { get; protected set; }\n+\n+ /// \n+ /// Gets the default timeout used by when none is specified.\n+ /// \n+ public static TimeSpan DefaultWaitForResourceTimeout { get; } = TimeSpan.FromSeconds(60);\n+\n+ /// \n+ /// Waits for the named resource to report a healthy status.\n+ /// \n+ /// The resource name (as configured within the AppHost).\n+ /// The timeout (defaults to ); pass to wait indefinitely.\n+ /// A resource that never becomes healthy (e.g. a misconfigured health check) should fail the test fast rather than hang it indefinitely, hence the default timeout.\n+ /// Important: do not call this from within a /\n+ /// callback - it calls , whose own construction is still in-flight at that point, so awaiting it will deadlock/hang; use the\n+ /// extension method (against the callback's own \n+ /// parameter) instead.\n+ public async Task WaitForResourceAsync(string resourceName, TimeSpan? timeout = null)\n+ {\n+ ArgumentException.ThrowIfNullOrEmpty(resourceName);\n+\n+ var app = await GetDistributedApplicationAsync().ConfigureAwait(false);\n+ await app.WaitForResourceAsync(resourceName, timeout).ConfigureAwait(false);\n+ }\n+\n+ /// \n+ /// Waits for all of the named resources to report a healthy status, concurrently.\n+ /// \n+ /// The resource names (as configured within the AppHost).\n+ /// The timeout (defaults to ) applied to each resource independently; pass\n+ /// to wait indefinitely.\n+ /// Equivalent to awaiting for each resource in parallel; if any resource fails to become healthy within the\n+ /// , the resulting exception is propagated once all waits have completed (or faulted).\n+ /// Important: do not call this from within a /\n+ /// callback - see remarks; use the\n+ /// extension method instead.\n+ public async Task WaitForResourceAsync(string[] resourceNames, TimeSpan? timeout = null)\n+ {\n+ if (resourceNames is null) throw new ArgumentNullException(nameof(resourceNames));\n+\n+ await Task.WhenAll(resourceNames.Distinct().Select(rn => WaitForResourceAsync(rn, timeout))).ConfigureAwait(false);\n+ }\n+\n+ /// \n+ /// Combines the elapsed log messages captured (via ) across all resources since the last invocation, as background/inter-resource\n+ /// activity is not necessarily confined to a single resource; each line already carries its own owning resource name as a trailing suffix (see ),\n+ /// so no further attribution is added here.\n+ /// Also where is set (see ), every entry\n+ /// drained here - across every resource, not just one directly interacted with - is checked and immediately fails the test if it violates it; a resource that never has an\n+ /// call made against it (e.g. a background/hosted-service-only resource) is still checked here, as long as a subsequent\n+ /// / call drains it - hence a final\n+ /// call is recommended at the end of a test to verify whatever log activity remains un-drained.\n+ protected override IEnumerable? DrainElapsedLogMessages()\n+ {\n+ _resourceLogCaptureProvider?.FlushPendingEntries();\n+\n+ var lines = new List();\n+ foreach (var (resourceName, buffer) in _resourceLogBuffers)\n+ {\n+ var lastCount = _elapsedLogLineCounts.GetOrAdd(resourceName, 0);\n+ var newEntries = buffer.Skip(lastCount).ToArray();\n+ _elapsedLogLineCounts[resourceName] = buffer.Count;"}]}},{"id":"PRRT_kwDOGoJGsc6nPJjU","isResolved":true,"isOutdated":false,"path":"src/UnitTestEx/Abstractions/TesterBaseCore.cs","line":25,"comments":{"nodes":[{"databaseId":4136850001,"author":{"login":"copilot-pull-request-reviewer"},"body":"Making `TesterBaseCore` the new declaration site for the existing public/protected tester surface is a binary breaking change, not just an inheritance refactor: previously compiled consumers reference members such as `TesterBase.get_Implementor`, `TesterBase.get_SetUp`, and `TesterBase.JsonMediaTypeNames`, but those metadata members no longer exist on `TesterBase`. Such consumers will fail with missing-method/member errors after upgrading to v5.12.0; retain forwarding members on `TesterBase` (or use a major-version breaking change) if binary compatibility is required.\n\nThis issue also appears on line 153 of the same file.","createdAt":"2026-09-29T18:19:29Z","path":"src/UnitTestEx/Abstractions/TesterBaseCore.cs","diffHunk":"@@ -0,0 +1,378 @@\n+// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx\n+\n+using Microsoft.Extensions.Logging;\n+using System;\n+using System.Collections.Generic;\n+using System.Diagnostics;\n+using System.IO;\n+using System.Linq;\n+using System.Net.Http;\n+using System.Net.Mime;\n+using System.Threading.Tasks;\n+using UnitTestEx.Expectations;\n+using UnitTestEx.Json;\n+using UnitTestEx.Logging;\n+\n+namespace UnitTestEx.Abstractions\n+{\n+ /// \n+ /// Provides the host-agnostic unit-testing capabilities that are common to every tester, irrespective of whether the underlying test target is a single in-process host\n+ /// (see ) or a multi-host distributed application (e.g. .NET Aspire).\n+ /// \n+ /// This intentionally does not expose any dependency injection (DI) related capabilities (e.g. Services, Configuration, ConfigureServices) as these are meaningful only\n+ /// where there is a single host with a single DI container; see for these capabilities. Where a capability is common in spirit, but cannot be expressed here without a DI \n+ /// dependency, it should be exposed via an interface (e.g. ) that each tier can implement appropriately.\n+ public abstract class TesterBaseCore"},{"databaseId":4137073046,"author":{"login":"chullybun"},"body":"Understood. Conscience decision to move as a testing package.","createdAt":"2026-09-29T18:44:16Z","path":"src/UnitTestEx/Abstractions/TesterBaseCore.cs","diffHunk":"@@ -0,0 +1,378 @@\n+// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx\n+\n+using Microsoft.Extensions.Logging;\n+using System;\n+using System.Collections.Generic;\n+using System.Diagnostics;\n+using System.IO;\n+using System.Linq;\n+using System.Net.Http;\n+using System.Net.Mime;\n+using System.Threading.Tasks;\n+using UnitTestEx.Expectations;\n+using UnitTestEx.Json;\n+using UnitTestEx.Logging;\n+\n+namespace UnitTestEx.Abstractions\n+{\n+ /// \n+ /// Provides the host-agnostic unit-testing capabilities that are common to every tester, irrespective of whether the underlying test target is a single in-process host\n+ /// (see ) or a multi-host distributed application (e.g. .NET Aspire).\n+ /// \n+ /// This intentionally does not expose any dependency injection (DI) related capabilities (e.g. Services, Configuration, ConfigureServices) as these are meaningful only\n+ /// where there is a single host with a single DI container; see for these capabilities. Where a capability is common in spirit, but cannot be expressed here without a DI \n+ /// dependency, it should be exposed via an interface (e.g. ) that each tier can implement appropriately.\n+ public abstract class TesterBaseCore"}]}},{"id":"PRRT_kwDOGoJGsc6nPJkI","isResolved":true,"isOutdated":false,"path":"src/UnitTestEx/Abstractions/TesterBaseT.cs","line":396,"comments":{"nodes":[{"databaseId":4136850071,"author":{"login":"copilot-pull-request-reviewer"},"body":"Replacing the existing `Delay(TimeSpan)`/`Delay(int)` methods with nullable/optional-parameter signatures is a binary breaking change in v5.12.0: already-compiled consumers still reference the old method signatures and will fail with `MissingMethodException` after upgrading. Keep the original overloads as forwarding compatibility methods and add the reason-capable overloads instead.","createdAt":"2026-09-29T18:19:30Z","path":"src/UnitTestEx/Abstractions/TesterBaseT.cs","diffHunk":"@@ -387,18 +387,34 @@ public TSelf UseScopedTypeSetUp(Func setupAsync)\n public TSelf ReplaceKeyedTransient(object? serviceKey, bool autoResetHost = true) where TService : class where TImplementation : class, TService => ConfigureServices(sc => sc.ReplaceKeyedTransient(serviceKey), autoResetHost);\n \n /// \n- /// Delays the execution of the test for the specified .\n+ /// Delays the execution of the test for the specified , then writes any log messages captured during that time (e.g. from a background/hosted service not\n+ /// attributed to a specific HTTP request) to the test output.\n /// \n- /// The amount of time to delay the operation. Must be a non-negative .\n+ /// The duration to delay; defaults to where not specified.\n+ /// The reason for delaying (written to the test output for context); defaults to \"No reason specified\" where not specified.\n /// The to support fluent-style method-chaining.\n- public TSelf Delay(TimeSpan duration) => Task.Delay(duration).ContinueWith(_ => (TSelf)this).Result;\n+ public TSelf Delay(TimeSpan? duration = null, string? reason = null) => WriteDelay(reason, duration).ContinueWith(_ => (TSelf)this).Result;"},{"databaseId":4137069724,"author":{"login":"chullybun"},"body":"Understood. Conscience decision to move as a testing package.","createdAt":"2026-09-29T18:43:54Z","path":"src/UnitTestEx/Abstractions/TesterBaseT.cs","diffHunk":"@@ -387,18 +387,34 @@ public TSelf UseScopedTypeSetUp(Func setupAsync)\n public TSelf ReplaceKeyedTransient(object? serviceKey, bool autoResetHost = true) where TService : class where TImplementation : class, TService => ConfigureServices(sc => sc.ReplaceKeyedTransient(serviceKey), autoResetHost);\n \n /// \n- /// Delays the execution of the test for the specified .\n+ /// Delays the execution of the test for the specified , then writes any log messages captured during that time (e.g. from a background/hosted service not\n+ /// attributed to a specific HTTP request) to the test output.\n /// \n- /// The amount of time to delay the operation. Must be a non-negative .\n+ /// The duration to delay; defaults to where not specified.\n+ /// The reason for delaying (written to the test output for context); defaults to \"No reason specified\" where not specified.\n /// The to support fluent-style method-chaining.\n- public TSelf Delay(TimeSpan duration) => Task.Delay(duration).ContinueWith(_ => (TSelf)this).Result;\n+ public TSelf Delay(TimeSpan? duration = null, string? reason = null) => WriteDelay(reason, duration).ContinueWith(_ => (TSelf)this).Result;"}]}},{"id":"PRRT_kwDOGoJGsc6nPJky","isResolved":true,"isOutdated":false,"path":"src/UnitTestEx/Abstractions/TesterExtensionsConfig.cs","line":38,"comments":{"nodes":[{"databaseId":4136850131,"author":{"login":"copilot-pull-request-reviewer"},"body":"Changing this public virtual hook's parameter from `TesterBase` to `TesterBaseCore` silently stops existing `TesterExtensionsConfig` overrides from being invoked: compiled extensions retain the old override, while `HttpResponseMessageAssertorBase` now dispatches to the new no-op method. This can make custom response-value enrichment disappear without a compile error in consuming tests; preserve/bridge the old virtual hook while adding the Aspire-capable path.","createdAt":"2026-09-29T18:19:30Z","path":"src/UnitTestEx/Abstractions/TesterExtensionsConfig.cs","diffHunk":"@@ -31,11 +31,11 @@ public virtual void ConfigureServices(TesterBase owner, IServiceCollection servi\n /// Updates the from the (where applicable).\n ///
\n /// The value .\n- /// The owning .\n+ /// The owning .\n /// The .\n /// The deserialized value (override where applicable).\n /// The value will have already been deserialized.\n- public virtual void UpdateValueFromHttpResponseMessage(TesterBase owner, HttpResponseMessage response, ref TValue? value) { }\n+ public virtual void UpdateValueFromHttpResponseMessage(TesterBaseCore owner, HttpResponseMessage response, ref TValue? value) { }"},{"databaseId":4137067149,"author":{"login":"chullybun"},"body":"Understood. Conscience decision to move as a testing package.","createdAt":"2026-09-29T18:43:38Z","path":"src/UnitTestEx/Abstractions/TesterExtensionsConfig.cs","diffHunk":"@@ -31,11 +31,11 @@ public virtual void ConfigureServices(TesterBase owner, IServiceCollection servi\n /// Updates the from the (where applicable).\n ///
\n /// The value .\n- /// The owning .\n+ /// The owning .\n /// The .\n /// The deserialized value (override where applicable).\n /// The value will have already been deserialized.\n- public virtual void UpdateValueFromHttpResponseMessage(TesterBase owner, HttpResponseMessage response, ref TValue? value) { }\n+ public virtual void UpdateValueFromHttpResponseMessage(TesterBaseCore owner, HttpResponseMessage response, ref TValue? value) { }"}]}},{"id":"PRRT_kwDOGoJGsc6nPJlp","isResolved":true,"isOutdated":false,"path":"src/UnitTestEx/AspNetCore/HttpTesterBase.cs","line":40,"comments":{"nodes":[{"databaseId":4136850211,"author":{"login":"copilot-pull-request-reviewer"},"body":"Changing these public/protected constructors from `TestServer` to `IHttpClientSource` is source-breaking for consumers who derive from `HttpTesterBase`/`HttpTester` or construct `ControllerTester` directly. Existing Tier 1 extensions cannot compile against the new package even though the release is a minor version; please retain compatibility overloads that adapt a `TestServer`, or otherwise document/version this as a breaking change.","createdAt":"2026-09-29T18:19:30Z","path":"src/UnitTestEx/AspNetCore/HttpTesterBase.cs","diffHunk":"@@ -33,24 +32,24 @@ public abstract class HttpTesterBase : IExpectations\n /// \n /// Initializes a new class.\n /// \n- /// The owning .\n- /// The .\n- public HttpTesterBase(TesterBase owner, TestServer testServer)\n+ /// The owning .\n+ /// The .\n+ public HttpTesterBase(TesterBaseCore owner, IHttpClientSource httpClientSource)\n {\n Owner = owner ?? throw new ArgumentNullException(nameof(owner));\n- TestServer = testServer ?? throw new ArgumentNullException(nameof(testServer));\n+ HttpClientSource = httpClientSource ?? throw new ArgumentNullException(nameof(httpClientSource));"},{"databaseId":4137060889,"author":{"login":"chullybun"},"body":"Understood. Conscience decision to move as a testing package.","createdAt":"2026-09-29T18:42:56Z","path":"src/UnitTestEx/AspNetCore/HttpTesterBase.cs","diffHunk":"@@ -33,24 +32,24 @@ public abstract class HttpTesterBase : IExpectations\n /// \n /// Initializes a new class.\n /// \n- /// The owning .\n- /// The .\n- public HttpTesterBase(TesterBase owner, TestServer testServer)\n+ /// The owning .\n+ /// The .\n+ public HttpTesterBase(TesterBaseCore owner, IHttpClientSource httpClientSource)\n {\n Owner = owner ?? throw new ArgumentNullException(nameof(owner));\n- TestServer = testServer ?? throw new ArgumentNullException(nameof(testServer));\n+ HttpClientSource = httpClientSource ?? throw new ArgumentNullException(nameof(httpClientSource));"}]}},{"id":"PRRT_kwDOGoJGsc6nPJmU","isResolved":true,"isOutdated":false,"path":"src/UnitTestEx/AspNetCore/HttpTesterT.cs","line":19,"comments":{"nodes":[{"databaseId":4136850277,"author":{"login":"copilot-pull-request-reviewer"},"body":"This changes the public constructor signature from `(TesterBase, TestServer)` to `(TesterBaseCore, IHttpClientSource)`. Existing consumers that compiled against the former constructor (including custom `HttpTester` subclasses) will fail at runtime with `MissingMethodException` when this minor-version package is upgraded; retain a compatibility constructor or make the breaking API change explicit with an appropriate major-version strategy. The same issue applies to the other public tester/assertor constructors changed to `TesterBaseCore`.","createdAt":"2026-09-29T18:19:31Z","path":"src/UnitTestEx/AspNetCore/HttpTesterT.cs","diffHunk":"@@ -15,9 +14,9 @@ namespace UnitTestEx.AspNetCore\n /// Provides send testing with an expected response value.\n ///
\n /// The expected response value .\n- /// The .\n- /// The .\n- public class HttpTester(TesterBase owner, TestServer testServer) : HttpTesterBase>(owner, testServer)\n+ /// The .\n+ /// The .\n+ public class HttpTester(TesterBaseCore owner, IHttpClientSource httpClientSource) : HttpTesterBase>(owner, httpClientSource)"},{"databaseId":4137055071,"author":{"login":"chullybun"},"body":"Understood. Conscience decision to move as a testing package.","createdAt":"2026-09-29T18:42:18Z","path":"src/UnitTestEx/AspNetCore/HttpTesterT.cs","diffHunk":"@@ -15,9 +14,9 @@ namespace UnitTestEx.AspNetCore\n /// Provides send testing with an expected response value.\n ///
\n /// The expected response value .\n- /// The .\n- /// The .\n- public class HttpTester(TesterBase owner, TestServer testServer) : HttpTesterBase>(owner, testServer)\n+ /// The .\n+ /// The .\n+ public class HttpTester(TesterBaseCore owner, IHttpClientSource httpClientSource) : HttpTesterBase>(owner, httpClientSource)"}]}}]}}}}} diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index 1d2381c..b2c05c8 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -317,8 +317,12 @@ public async Task WaitForResourceAsync(string[] resourceNames, TimeSpan? timeout foreach (var (resourceName, buffer) in _resourceLogBuffers) { var lastCount = _elapsedLogLineCounts.GetOrAdd(resourceName, 0); - var newEntries = buffer.Skip(lastCount).ToArray(); - _elapsedLogLineCounts[resourceName] = buffer.Count; + + // Derive the new entries and the advanced watermark from the *same* snapshot rather than a separate enumeration and Count read; otherwise a resource log enqueued in the gap + // between the two could advance the watermark past an entry that was never included in newEntries, silently and permanently skipping it from all future drains. + var snapshot = buffer.ToArray(); + var newEntries = snapshot.Skip(lastCount).ToArray(); + _elapsedLogLineCounts[resourceName] = snapshot.Length; AssertNoErrorWhenLogContainsViolation(newEntries, resourceName); lines.AddRange(newEntries.Select(e => e.Text)); @@ -697,8 +701,11 @@ public HttpClient CreateHttpClient(string? name = null) // (often the interesting one, e.g. the endpoint's own logging for this very request) is not lost while waiting for a line that may never come. owner._resourceLogCaptureProvider?.FlushPendingEntries(); - var newEntries = buffer.Skip(baseline).ToArray(); - owner._elapsedLogLineCounts[resourceName] = buffer.Count; + // Derive the new entries and the advanced watermark from the *same* snapshot rather than a separate enumeration and Count read; see DrainElapsedLogMessages for why the + // split form is racy against a concurrently-enqueued entry. + var snapshot = buffer.ToArray(); + var newEntries = snapshot.Skip(baseline).ToArray(); + owner._elapsedLogLineCounts[resourceName] = snapshot.Length; owner.AssertNoErrorWhenLogContainsViolation(newEntries, resourceName); return newEntries.Select(e => e.Text).ToArray(); From 0428196788884d12529779a89bf45ec2ee3d4623 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 29 Sep 2026 11:55:45 -0700 Subject: [PATCH 43/43] Remove accidentally committed scratch file --- _threads.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 _threads.json diff --git a/_threads.json b/_threads.json deleted file mode 100644 index 2a68622..0000000 --- a/_threads.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[{"id":"PRRT_kwDOGoJGsc6k2YZu","isResolved":true,"isOutdated":true,"path":"src/UnitTestEx/Abstractions/TesterBase.cs","line":null,"comments":{"nodes":[{"databaseId":4074764353,"author":{"login":"copilot-pull-request-reviewer"},"body":"The false-capability path currently only points back to `SupportsServiceConfiguration`; it does not give the actionable alternative required by #105/design section 9 (configure the distributed resource through its supported environment/configuration surface or mock an HTTP boundary). Since this exception is the first feedback a Tier 2 caller receives, include that guidance in the message rather than requiring the caller to infer it from the flag.","createdAt":"2026-09-22T18:01:01Z","path":"src/UnitTestEx/Abstractions/TesterBase.cs","diffHunk":"@@ -226,8 +235,12 @@ protected void OnHostStart(Action start, bool autoResetHost = true)\n /// A delegate for configuring .\n /// Indicates whether to automatically (passing false) when configuring the services.\n /// This can be called multiple times prior to the underlying host being instantiated. Internally, the is queued and then played in order when the host is initially instantiated.\n+ /// Thrown where is false.\n protected void ConfigureServices(Action configureServices, bool autoResetHost = true)\n {\n+ if (!SupportsServiceConfiguration)\n+ throw new NotSupportedException($\"{GetType().Name} does not support in-process service configuration/replacement because its underlying host does not run in-process (see {nameof(SupportsServiceConfiguration)}).\");"},{"databaseId":4074810141,"author":{"login":"chullybun"},"body":"Good catch — updated the exception message to point at the actual Tier 2 alternatives (configuring the resource's environment/configuration surface, or mocking the HTTP boundary via something like WireMock.Net.Aspire) instead of just referencing the flag. Also fixed the \"where\"/\"when\" doc wording, clarified in the design doc that `TestSetUp.ConfigureServices` is a separate entry point outside this guard's scope, and bumped `Common.targets` to 5.12.0 to match the CHANGELOG entry. See 26a3d18.","createdAt":"2026-09-22T18:05:52Z","path":"src/UnitTestEx/Abstractions/TesterBase.cs","diffHunk":"@@ -226,8 +235,12 @@ protected void OnHostStart(Action start, bool autoResetHost = true)\n /// A delegate for configuring .\n /// Indicates whether to automatically (passing false) when configuring the services.\n /// This can be called multiple times prior to the underlying host being instantiated. Internally, the is queued and then played in order when the host is initially instantiated.\n+ /// Thrown where is false.\n protected void ConfigureServices(Action configureServices, bool autoResetHost = true)\n {\n+ if (!SupportsServiceConfiguration)\n+ throw new NotSupportedException($\"{GetType().Name} does not support in-process service configuration/replacement because its underlying host does not run in-process (see {nameof(SupportsServiceConfiguration)}).\");"}]}},{"id":"PRRT_kwDOGoJGsc6nPJiG","isResolved":false,"isOutdated":false,"path":"src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs","line":321,"comments":{"nodes":[{"databaseId":4136849892,"author":{"login":"copilot-pull-request-reviewer"},"body":"The snapshot and watermark update are not atomic: if a resource log is enqueued after `buffer.Skip(lastCount).ToArray()` but before `buffer.Count` is read, the new count advances past an entry that was never returned or checked. That entry is then skipped forever, which can hide output and bypass the streaming error check under normal concurrent logging. Use an atomic drain/sequence boundary rather than a separate enumeration and count read.\n\nThis issue also appears in the following locations of the same file:\n- line 675\n- line 688","createdAt":"2026-09-29T18:19:29Z","path":"src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs","diffHunk":"@@ -0,0 +1,753 @@\n+// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx\n+\n+using Aspire.Hosting;\n+using Aspire.Hosting.ApplicationModel;\n+using Aspire.Hosting.Testing;\n+using Microsoft.Extensions.DependencyInjection;\n+using Microsoft.Extensions.DependencyInjection.Extensions;\n+using Microsoft.Extensions.Logging;\n+using Microsoft.Extensions.Logging.Abstractions;\n+using System;\n+using System.Collections.Concurrent;\n+using System.Collections.Generic;\n+using System.Globalization;\n+using System.Linq;\n+using System.Net.Http;\n+using System.Text;\n+using System.Text.RegularExpressions;\n+using System.Threading;\n+using System.Threading.Tasks;\n+using UnitTestEx.Abstractions;\n+using UnitTestEx.AspNetCore;\n+\n+namespace UnitTestEx.Aspire\n+{\n+ /// \n+ /// Provides the non-generic, host-agnostic base for the .NET Aspire multi-host (distributed application) unit-testing capabilities - the Aspire-specific equivalent of\n+ /// (its single-host, dependency injection (DI) counterpart).\n+ /// \n+ /// This extends the host-agnostic directly (not ) as the underlying is a real,\n+ /// multi-process distributed application with no single, in-process dependency injection (DI) container to reach into.\n+ /// Exists primarily so an extension method (or any other code that does not know/care about the concrete AppHost or TSelf type parameters -\n+ /// see ) can still reach the capabilities that do not depend on either - e.g. ,\n+ /// , . Fluent, method-chaining configuration members that must return TSelf\n+ /// (e.g. /) necessarily\n+ /// remain on only.\n+ public abstract class AspireTesterBase : TesterBaseCore, IHttpClientSource, IAsyncDisposable\n+ {\n+ private readonly ConcurrentDictionary> _resourceLogBuffers = new();\n+ private readonly ConcurrentDictionary _elapsedLogLineCounts = new();\n+ private ResourceLogCaptureProvider? _resourceLogCaptureProvider;\n+ private Task? _appTask;\n+ private bool _disposed;\n+\n+ /// \n+ /// Gets or sets the minimum that triggers an immediate test failure when a resource log entry at (or above) it is subsequently drained - see\n+ /// .\n+ /// \n+ /// Enabled by default at - this is a brand-new capability with no existing tests relying on it being off, and catching an unexpected resource\n+ /// error is almost always preferable to silently letting it slide. To opt out entirely, call \n+ /// with (which, being numerically above , no resource log entry can ever meet or exceed).\n+ protected LogLevel ErrorWhenLogContainsMinimumLevel { get; set; } = LogLevel.Error;\n+\n+ /// \n+ /// Gets or sets the wildcard (*/?) exclude patterns checked (case-insensitively, as a \"contains\" match) against an otherwise-violating resource log entry's text - a\n+ /// match here always suppresses the entry, even where it also matches - see\n+ /// .\n+ /// \n+ protected IReadOnlyList ErrorWhenLogContainsExcludePatterns { get; set; } = [];\n+\n+ /// \n+ /// Gets or sets the wildcard (*/?) include patterns that an otherwise-qualifying resource log entry's text must match at least one of (case-insensitively, as a\n+ /// \"contains\" match) to be reported - see . An empty list (the default) means\n+ /// every entry that meets qualifies; i.e. this narrows rather than widens what is checked.\n+ /// \n+ protected IReadOnlyList ErrorWhenLogContainsIncludePatterns { get; set; } = [];\n+\n+ /// \n+ /// Initializes a new instance of the class.\n+ /// \n+ /// The .\n+ protected AspireTesterBase(TestFrameworkImplementor implementor) : base(implementor) { }\n+\n+ /// \n+ /// Gets the queued builder-configuration actions applied, in order, immediately before the underlying is built.\n+ /// \n+ /// Populated by .\n+ protected List> ConfigureBuilderActions { get; } = [];\n+\n+ /// \n+ /// Gets the queued callbacks invoked, in order, once the underlying has been built but before it is started - i.e. before any resource\n+ /// (project, container, executable, etc.) begins running.\n+ /// \n+ /// Populated by . Useful to prepare an external dependency whose connection\n+ /// details are only resolvable via the AppHost (e.g. a connection-string resource added via the AppHost's own AddConnectionString) - such as running database\n+ /// migrations/seeding, clearing a cache, or resetting a messaging topic/queue to a known state - before any project resource that depends on it starts running and\n+ /// potentially races against that very same setup (e.g. connecting to a not-yet-migrated database).\n+ /// Important: a callback must operate directly against the passed to it - it must not call back into this tester's own\n+ /// (or any extension method that does, e.g. a MigrateXxxAsync helper written against the tester): that method's own construction\n+ /// is still in-flight at this point, so awaiting it from within the callback that is part of that very construction will deadlock/hang rather than reuse it. It also must not\n+ /// call Aspire's own DistributedApplication.GetConnectionStringAsync/GetEndpoint/CreateHttpClient testing extensions (Aspire.Hosting.Testing) - these\n+ /// throw at this point as they require the application to have already started; use instead.\n+ protected List> BeforeStartActions { get; } = [];\n+\n+ /// \n+ /// Gets the queued callbacks invoked, in order, once the underlying has been started - i.e. after every resource (project, container,\n+ /// executable, etc.) has been kicked off (though not necessarily healthy/ready yet - see ).\n+ /// \n+ /// Populated by . This is the natural place to wait for one or more resources\n+ /// to become healthy (e.g. via the /\n+ /// extension methods) before a test's own set-up (e.g. OneTimeSetUp) proceeds to use them.\n+ /// Important: a callback must operate directly against the passed to it - it must not call back into this tester's own\n+ /// (or any instance method that does, e.g. the instance ): that method's own\n+ /// construction is still in-flight at this point, so awaiting it from within the callback that is part of that very construction will deadlock/hang rather than reuse it; use the\n+ /// extension method (which operates directly against the passed-in\n+ /// ) instead.\n+ protected List> AfterStartActions { get; } = [];\n+\n+ /// \n+ /// Resolves the connection string for the named resource directly against the 's resource model.\n+ /// \n+ /// The .\n+ /// The resource name (as configured within the AppHost).\n+ /// The .\n+ /// The connection string, or null if the resource has none configured.\n+ /// The resource was not found, or does not expose a connection string.\n+ /// Unlike Aspire's own DistributedApplication.GetConnectionStringAsync(resourceName) testing extension (Aspire.Hosting.Testing) - which throws\n+ /// unless the application has already started (i.e. its underlying IHostApplicationLifetime.ApplicationStarted has fired) - this\n+ /// resolves the resource directly via , so it is safe to call from a callback\n+ /// (i.e. before StartAsync has even been invoked), as well as at any other time.\n+ /// This only works for resources whose connection string does not depend on a dynamically-allocated endpoint - e.g. a static, externally-hosted dependency added via the\n+ /// AppHost's own AddConnectionString(name) (the primary use case), whose value is resolved directly from\n+ /// configuration/parameters. For a resource whose connection string is only known once the resource itself has actually started and allocated an endpoint (e.g. a container\n+ /// resource), calling this before the application has started will return an incomplete/unresolved value; use Aspire's own GetConnectionStringAsync after the application\n+ /// has started for those instead.\n+ public static async Task GetConnectionStringAsync(DistributedApplication app, string resourceName, CancellationToken cancellationToken = default)\n+ {\n+ ArgumentNullException.ThrowIfNull(app);\n+ ArgumentException.ThrowIfNullOrEmpty(resourceName);\n+\n+ var model = app.Services.GetRequiredService();\n+ if (!model.Resources.TryGetByName(resourceName, out var resource))\n+ throw new ArgumentException($\"Resource '{resourceName}' not found.\", nameof(resourceName));\n+\n+ if (resource is not IResourceWithConnectionString resourceWithConnectionString)\n+ throw new ArgumentException($\"Resource '{resourceName}' does not expose a connection string.\", nameof(resourceName));\n+\n+ return await resourceWithConnectionString.GetConnectionStringAsync(cancellationToken).ConfigureAwait(false);\n+ }\n+\n+ /// \n+ /// Gets a value indicating whether the underlying has already started being built (see ).\n+ /// \n+ /// Used to guard configuration that must be applied before the underlying is built (see\n+ /// /).\n+ protected bool IsDistributedApplicationBuilding => _appTask is not null;\n+\n+ /// \n+ /// Gets the underlying ; builds and starts on first access.\n+ /// \n+ /// The started .\n+ /// This is the escape hatch for anything not already surfaced by itself - e.g. resolving a resource's connection string via\n+ /// Aspire's own GetConnectionStringAsync(resourceName), inspecting directly, or reaching into\n+ /// for the AppHost's own (not a resource's) dependency injection (DI) container.\n+ /// Important: do not call DisposeAsync()/StopAsync() on the returned instance directly - this bypasses UnitTestEx's own lifecycle tracking (leaving it\n+ /// believing the host is still instantiated when it is not) and will surface as confusing failures on a subsequent call. Use instead to tear down\n+ /// and force a rebuild on next access.\n+ public Task GetDistributedApplicationAsync()\n+ {\n+ lock (SyncRoot)\n+ {\n+ return _appTask ??= CreateDistributedApplicationAsync();\n+ }\n+ }\n+\n+ /// \n+ /// Creates the for the concrete AppHost.\n+ /// \n+ /// The .\n+ /// The only step of building the underlying that depends on the concrete AppHost - a generic type parameter only\n+ /// available on ; every other build/start-up step is host-agnostic and lives here (see ).\n+ protected abstract Task CreateBuilderAsync();\n+\n+ /// \n+ /// Creates, builds and starts the underlying .\n+ /// \n+ private async Task CreateDistributedApplicationAsync()\n+ {\n+ var builder = await CreateBuilderAsync().ConfigureAwait(false);\n+\n+ if (!HostDiagnosticsEnabled)\n+ // By default the AppHost's own logging (its start-up banner, DCP process management, and - critically - each resource's own console output mirrored under a\n+ // \"{ApplicationName}.Resources.{resourceName}\" category; see 'EnableResourceLogging') writes straight to the test's Standard Output via its default provider(s) (e.g. console),\n+ // duplicating what UnitTestEx already captures and reports cleanly (and correlated) via Checkpoint()/Delay()/the Http() request-scoped \"LOGGING >\" section. Remove the AppHost's\n+ // own provider(s) so only what UnitTestEx explicitly writes reaches the test output; call EnableHostDiagnostics() before this point to opt back into the raw firehose (e.g. when\n+ // troubleshooting an AppHost/resource start-up failure).\n+ builder.Services.RemoveAll();\n+\n+ builder.Services.Configure(options =>\n+ // Exempt our own capture provider from any ambient filtering (by provider, not category) so it always sees everything, regardless of the AppHost's own console noise level\n+ // (which the AppHost's own default logging configuration governs independently and is not something UnitTestEx overrides).\n+ options.Rules.Add(new LoggerFilterRule(typeof(ResourceLogCaptureProvider).FullName, null, LogLevel.Trace, null)));\n+\n+ builder.Services.AddSingleton(_resourceLogCaptureProvider = new ResourceLogCaptureProvider($\"{builder.Environment.ApplicationName}.Resources.\", _resourceLogBuffers));\n+\n+ foreach (var configure in ConfigureBuilderActions)\n+ {\n+ configure(builder);\n+ }\n+\n+ OnHostStartUp();\n+\n+ var app = await builder.BuildAsync().ConfigureAwait(false);\n+\n+ try\n+ {\n+ // Give any queued BeforeStartActions (see AspireTesterBase{TAppHost, TSelf}.BeforeStart) the chance to prepare an external dependency (e.g. migrate/seed a database resolved\n+ // via app.GetConnectionStringAsync) before any resource is actually started/kicked off below, so it cannot race against that very same setup.\n+ foreach (var beforeStart in BeforeStartActions)\n+ {\n+ await beforeStart(app).ConfigureAwait(false);\n+ }\n+\n+ await app.StartAsync().ConfigureAwait(false);\n+\n+ // Give any queued AfterStartActions (see AspireTesterBase{TAppHost, TSelf}.AfterStart) the chance to act now every resource has been kicked off (e.g. waiting for one or\n+ // more to become healthy via the WaitForResourceAsync extension method) before the tester itself is handed back to the caller.\n+ foreach (var afterStart in AfterStartActions)\n+ {\n+ await afterStart(app).ConfigureAwait(false);\n+ }\n+ }\n+ catch\n+ {\n+ await app.DisposeAsync().ConfigureAwait(false);\n+ throw;\n+ }\n+\n+ return app;\n+ }\n+\n+ /// \n+ /// Sealed as the Aspire-specific extensibility point for logic that must run around start-up is the /\n+ /// fluent pair - unlike this virtual hook (inherited from ), both receive the actual\n+ /// instance and run at a precisely-defined point (immediately before/after StartAsync); prefer those instead.\n+ protected sealed override void OnHostStartUp() => base.OnHostStartUp();\n+\n+ /// \n+ protected override void OnResetHost()\n+ {\n+ Task? appTask;\n+ lock (SyncRoot)\n+ {\n+ appTask = _appTask;\n+ _appTask = null;\n+ }\n+\n+ if (appTask is { IsCompletedSuccessfully: true })\n+ {\n+ appTask.Result.Dispose();\n+ Implementor.WriteLine(\"\");\n+ Implementor.WriteLine(\"** The underlying UnitTestEx 'AspireTester' distributed application host has been reset. **\");\n+ Implementor.WriteLine(\"\");\n+ }\n+ }\n+\n+ /// \n+ /// Gets a value indicating whether the AppHost's own raw logging (its start-up banner, DCP process management, and each resource's own console output mirrored via\n+ /// 'EnableResourceLogging') is written to the test output, in addition to what UnitTestEx itself reports (see ).\n+ /// \n+ public bool HostDiagnosticsEnabled { get; protected set; }\n+\n+ /// \n+ /// Gets the default timeout used by when none is specified.\n+ /// \n+ public static TimeSpan DefaultWaitForResourceTimeout { get; } = TimeSpan.FromSeconds(60);\n+\n+ /// \n+ /// Waits for the named resource to report a healthy status.\n+ /// \n+ /// The resource name (as configured within the AppHost).\n+ /// The timeout (defaults to ); pass to wait indefinitely.\n+ /// A resource that never becomes healthy (e.g. a misconfigured health check) should fail the test fast rather than hang it indefinitely, hence the default timeout.\n+ /// Important: do not call this from within a /\n+ /// callback - it calls , whose own construction is still in-flight at that point, so awaiting it will deadlock/hang; use the\n+ /// extension method (against the callback's own \n+ /// parameter) instead.\n+ public async Task WaitForResourceAsync(string resourceName, TimeSpan? timeout = null)\n+ {\n+ ArgumentException.ThrowIfNullOrEmpty(resourceName);\n+\n+ var app = await GetDistributedApplicationAsync().ConfigureAwait(false);\n+ await app.WaitForResourceAsync(resourceName, timeout).ConfigureAwait(false);\n+ }\n+\n+ /// \n+ /// Waits for all of the named resources to report a healthy status, concurrently.\n+ /// \n+ /// The resource names (as configured within the AppHost).\n+ /// The timeout (defaults to ) applied to each resource independently; pass\n+ /// to wait indefinitely.\n+ /// Equivalent to awaiting for each resource in parallel; if any resource fails to become healthy within the\n+ /// , the resulting exception is propagated once all waits have completed (or faulted).\n+ /// Important: do not call this from within a /\n+ /// callback - see remarks; use the\n+ /// extension method instead.\n+ public async Task WaitForResourceAsync(string[] resourceNames, TimeSpan? timeout = null)\n+ {\n+ if (resourceNames is null) throw new ArgumentNullException(nameof(resourceNames));\n+\n+ await Task.WhenAll(resourceNames.Distinct().Select(rn => WaitForResourceAsync(rn, timeout))).ConfigureAwait(false);\n+ }\n+\n+ /// \n+ /// Combines the elapsed log messages captured (via ) across all resources since the last invocation, as background/inter-resource\n+ /// activity is not necessarily confined to a single resource; each line already carries its own owning resource name as a trailing suffix (see ),\n+ /// so no further attribution is added here.\n+ /// Also where is set (see ), every entry\n+ /// drained here - across every resource, not just one directly interacted with - is checked and immediately fails the test if it violates it; a resource that never has an\n+ /// call made against it (e.g. a background/hosted-service-only resource) is still checked here, as long as a subsequent\n+ /// / call drains it - hence a final\n+ /// call is recommended at the end of a test to verify whatever log activity remains un-drained.\n+ protected override IEnumerable? DrainElapsedLogMessages()\n+ {\n+ _resourceLogCaptureProvider?.FlushPendingEntries();\n+\n+ var lines = new List();\n+ foreach (var (resourceName, buffer) in _resourceLogBuffers)\n+ {\n+ var lastCount = _elapsedLogLineCounts.GetOrAdd(resourceName, 0);\n+ var newEntries = buffer.Skip(lastCount).ToArray();\n+ _elapsedLogLineCounts[resourceName] = buffer.Count;"}]}},{"id":"PRRT_kwDOGoJGsc6nPJjU","isResolved":true,"isOutdated":false,"path":"src/UnitTestEx/Abstractions/TesterBaseCore.cs","line":25,"comments":{"nodes":[{"databaseId":4136850001,"author":{"login":"copilot-pull-request-reviewer"},"body":"Making `TesterBaseCore` the new declaration site for the existing public/protected tester surface is a binary breaking change, not just an inheritance refactor: previously compiled consumers reference members such as `TesterBase.get_Implementor`, `TesterBase.get_SetUp`, and `TesterBase.JsonMediaTypeNames`, but those metadata members no longer exist on `TesterBase`. Such consumers will fail with missing-method/member errors after upgrading to v5.12.0; retain forwarding members on `TesterBase` (or use a major-version breaking change) if binary compatibility is required.\n\nThis issue also appears on line 153 of the same file.","createdAt":"2026-09-29T18:19:29Z","path":"src/UnitTestEx/Abstractions/TesterBaseCore.cs","diffHunk":"@@ -0,0 +1,378 @@\n+// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx\n+\n+using Microsoft.Extensions.Logging;\n+using System;\n+using System.Collections.Generic;\n+using System.Diagnostics;\n+using System.IO;\n+using System.Linq;\n+using System.Net.Http;\n+using System.Net.Mime;\n+using System.Threading.Tasks;\n+using UnitTestEx.Expectations;\n+using UnitTestEx.Json;\n+using UnitTestEx.Logging;\n+\n+namespace UnitTestEx.Abstractions\n+{\n+ /// \n+ /// Provides the host-agnostic unit-testing capabilities that are common to every tester, irrespective of whether the underlying test target is a single in-process host\n+ /// (see ) or a multi-host distributed application (e.g. .NET Aspire).\n+ /// \n+ /// This intentionally does not expose any dependency injection (DI) related capabilities (e.g. Services, Configuration, ConfigureServices) as these are meaningful only\n+ /// where there is a single host with a single DI container; see for these capabilities. Where a capability is common in spirit, but cannot be expressed here without a DI \n+ /// dependency, it should be exposed via an interface (e.g. ) that each tier can implement appropriately.\n+ public abstract class TesterBaseCore"},{"databaseId":4137073046,"author":{"login":"chullybun"},"body":"Understood. Conscience decision to move as a testing package.","createdAt":"2026-09-29T18:44:16Z","path":"src/UnitTestEx/Abstractions/TesterBaseCore.cs","diffHunk":"@@ -0,0 +1,378 @@\n+// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx\n+\n+using Microsoft.Extensions.Logging;\n+using System;\n+using System.Collections.Generic;\n+using System.Diagnostics;\n+using System.IO;\n+using System.Linq;\n+using System.Net.Http;\n+using System.Net.Mime;\n+using System.Threading.Tasks;\n+using UnitTestEx.Expectations;\n+using UnitTestEx.Json;\n+using UnitTestEx.Logging;\n+\n+namespace UnitTestEx.Abstractions\n+{\n+ /// \n+ /// Provides the host-agnostic unit-testing capabilities that are common to every tester, irrespective of whether the underlying test target is a single in-process host\n+ /// (see ) or a multi-host distributed application (e.g. .NET Aspire).\n+ /// \n+ /// This intentionally does not expose any dependency injection (DI) related capabilities (e.g. Services, Configuration, ConfigureServices) as these are meaningful only\n+ /// where there is a single host with a single DI container; see for these capabilities. Where a capability is common in spirit, but cannot be expressed here without a DI \n+ /// dependency, it should be exposed via an interface (e.g. ) that each tier can implement appropriately.\n+ public abstract class TesterBaseCore"}]}},{"id":"PRRT_kwDOGoJGsc6nPJkI","isResolved":true,"isOutdated":false,"path":"src/UnitTestEx/Abstractions/TesterBaseT.cs","line":396,"comments":{"nodes":[{"databaseId":4136850071,"author":{"login":"copilot-pull-request-reviewer"},"body":"Replacing the existing `Delay(TimeSpan)`/`Delay(int)` methods with nullable/optional-parameter signatures is a binary breaking change in v5.12.0: already-compiled consumers still reference the old method signatures and will fail with `MissingMethodException` after upgrading. Keep the original overloads as forwarding compatibility methods and add the reason-capable overloads instead.","createdAt":"2026-09-29T18:19:30Z","path":"src/UnitTestEx/Abstractions/TesterBaseT.cs","diffHunk":"@@ -387,18 +387,34 @@ public TSelf UseScopedTypeSetUp(Func setupAsync)\n public TSelf ReplaceKeyedTransient(object? serviceKey, bool autoResetHost = true) where TService : class where TImplementation : class, TService => ConfigureServices(sc => sc.ReplaceKeyedTransient(serviceKey), autoResetHost);\n \n /// \n- /// Delays the execution of the test for the specified .\n+ /// Delays the execution of the test for the specified , then writes any log messages captured during that time (e.g. from a background/hosted service not\n+ /// attributed to a specific HTTP request) to the test output.\n /// \n- /// The amount of time to delay the operation. Must be a non-negative .\n+ /// The duration to delay; defaults to where not specified.\n+ /// The reason for delaying (written to the test output for context); defaults to \"No reason specified\" where not specified.\n /// The to support fluent-style method-chaining.\n- public TSelf Delay(TimeSpan duration) => Task.Delay(duration).ContinueWith(_ => (TSelf)this).Result;\n+ public TSelf Delay(TimeSpan? duration = null, string? reason = null) => WriteDelay(reason, duration).ContinueWith(_ => (TSelf)this).Result;"},{"databaseId":4137069724,"author":{"login":"chullybun"},"body":"Understood. Conscience decision to move as a testing package.","createdAt":"2026-09-29T18:43:54Z","path":"src/UnitTestEx/Abstractions/TesterBaseT.cs","diffHunk":"@@ -387,18 +387,34 @@ public TSelf UseScopedTypeSetUp(Func setupAsync)\n public TSelf ReplaceKeyedTransient(object? serviceKey, bool autoResetHost = true) where TService : class where TImplementation : class, TService => ConfigureServices(sc => sc.ReplaceKeyedTransient(serviceKey), autoResetHost);\n \n /// \n- /// Delays the execution of the test for the specified .\n+ /// Delays the execution of the test for the specified , then writes any log messages captured during that time (e.g. from a background/hosted service not\n+ /// attributed to a specific HTTP request) to the test output.\n /// \n- /// The amount of time to delay the operation. Must be a non-negative .\n+ /// The duration to delay; defaults to where not specified.\n+ /// The reason for delaying (written to the test output for context); defaults to \"No reason specified\" where not specified.\n /// The to support fluent-style method-chaining.\n- public TSelf Delay(TimeSpan duration) => Task.Delay(duration).ContinueWith(_ => (TSelf)this).Result;\n+ public TSelf Delay(TimeSpan? duration = null, string? reason = null) => WriteDelay(reason, duration).ContinueWith(_ => (TSelf)this).Result;"}]}},{"id":"PRRT_kwDOGoJGsc6nPJky","isResolved":true,"isOutdated":false,"path":"src/UnitTestEx/Abstractions/TesterExtensionsConfig.cs","line":38,"comments":{"nodes":[{"databaseId":4136850131,"author":{"login":"copilot-pull-request-reviewer"},"body":"Changing this public virtual hook's parameter from `TesterBase` to `TesterBaseCore` silently stops existing `TesterExtensionsConfig` overrides from being invoked: compiled extensions retain the old override, while `HttpResponseMessageAssertorBase` now dispatches to the new no-op method. This can make custom response-value enrichment disappear without a compile error in consuming tests; preserve/bridge the old virtual hook while adding the Aspire-capable path.","createdAt":"2026-09-29T18:19:30Z","path":"src/UnitTestEx/Abstractions/TesterExtensionsConfig.cs","diffHunk":"@@ -31,11 +31,11 @@ public virtual void ConfigureServices(TesterBase owner, IServiceCollection servi\n /// Updates the from the (where applicable).\n ///
\n /// The value .\n- /// The owning .\n+ /// The owning .\n /// The .\n /// The deserialized value (override where applicable).\n /// The value will have already been deserialized.\n- public virtual void UpdateValueFromHttpResponseMessage(TesterBase owner, HttpResponseMessage response, ref TValue? value) { }\n+ public virtual void UpdateValueFromHttpResponseMessage(TesterBaseCore owner, HttpResponseMessage response, ref TValue? value) { }"},{"databaseId":4137067149,"author":{"login":"chullybun"},"body":"Understood. Conscience decision to move as a testing package.","createdAt":"2026-09-29T18:43:38Z","path":"src/UnitTestEx/Abstractions/TesterExtensionsConfig.cs","diffHunk":"@@ -31,11 +31,11 @@ public virtual void ConfigureServices(TesterBase owner, IServiceCollection servi\n /// Updates the from the (where applicable).\n ///
\n /// The value .\n- /// The owning .\n+ /// The owning .\n /// The .\n /// The deserialized value (override where applicable).\n /// The value will have already been deserialized.\n- public virtual void UpdateValueFromHttpResponseMessage(TesterBase owner, HttpResponseMessage response, ref TValue? value) { }\n+ public virtual void UpdateValueFromHttpResponseMessage(TesterBaseCore owner, HttpResponseMessage response, ref TValue? value) { }"}]}},{"id":"PRRT_kwDOGoJGsc6nPJlp","isResolved":true,"isOutdated":false,"path":"src/UnitTestEx/AspNetCore/HttpTesterBase.cs","line":40,"comments":{"nodes":[{"databaseId":4136850211,"author":{"login":"copilot-pull-request-reviewer"},"body":"Changing these public/protected constructors from `TestServer` to `IHttpClientSource` is source-breaking for consumers who derive from `HttpTesterBase`/`HttpTester` or construct `ControllerTester` directly. Existing Tier 1 extensions cannot compile against the new package even though the release is a minor version; please retain compatibility overloads that adapt a `TestServer`, or otherwise document/version this as a breaking change.","createdAt":"2026-09-29T18:19:30Z","path":"src/UnitTestEx/AspNetCore/HttpTesterBase.cs","diffHunk":"@@ -33,24 +32,24 @@ public abstract class HttpTesterBase : IExpectations\n /// \n /// Initializes a new class.\n /// \n- /// The owning .\n- /// The .\n- public HttpTesterBase(TesterBase owner, TestServer testServer)\n+ /// The owning .\n+ /// The .\n+ public HttpTesterBase(TesterBaseCore owner, IHttpClientSource httpClientSource)\n {\n Owner = owner ?? throw new ArgumentNullException(nameof(owner));\n- TestServer = testServer ?? throw new ArgumentNullException(nameof(testServer));\n+ HttpClientSource = httpClientSource ?? throw new ArgumentNullException(nameof(httpClientSource));"},{"databaseId":4137060889,"author":{"login":"chullybun"},"body":"Understood. Conscience decision to move as a testing package.","createdAt":"2026-09-29T18:42:56Z","path":"src/UnitTestEx/AspNetCore/HttpTesterBase.cs","diffHunk":"@@ -33,24 +32,24 @@ public abstract class HttpTesterBase : IExpectations\n /// \n /// Initializes a new class.\n /// \n- /// The owning .\n- /// The .\n- public HttpTesterBase(TesterBase owner, TestServer testServer)\n+ /// The owning .\n+ /// The .\n+ public HttpTesterBase(TesterBaseCore owner, IHttpClientSource httpClientSource)\n {\n Owner = owner ?? throw new ArgumentNullException(nameof(owner));\n- TestServer = testServer ?? throw new ArgumentNullException(nameof(testServer));\n+ HttpClientSource = httpClientSource ?? throw new ArgumentNullException(nameof(httpClientSource));"}]}},{"id":"PRRT_kwDOGoJGsc6nPJmU","isResolved":true,"isOutdated":false,"path":"src/UnitTestEx/AspNetCore/HttpTesterT.cs","line":19,"comments":{"nodes":[{"databaseId":4136850277,"author":{"login":"copilot-pull-request-reviewer"},"body":"This changes the public constructor signature from `(TesterBase, TestServer)` to `(TesterBaseCore, IHttpClientSource)`. Existing consumers that compiled against the former constructor (including custom `HttpTester` subclasses) will fail at runtime with `MissingMethodException` when this minor-version package is upgraded; retain a compatibility constructor or make the breaking API change explicit with an appropriate major-version strategy. The same issue applies to the other public tester/assertor constructors changed to `TesterBaseCore`.","createdAt":"2026-09-29T18:19:31Z","path":"src/UnitTestEx/AspNetCore/HttpTesterT.cs","diffHunk":"@@ -15,9 +14,9 @@ namespace UnitTestEx.AspNetCore\n /// Provides send testing with an expected response value.\n ///
\n /// The expected response value .\n- /// The .\n- /// The .\n- public class HttpTester(TesterBase owner, TestServer testServer) : HttpTesterBase>(owner, testServer)\n+ /// The .\n+ /// The .\n+ public class HttpTester(TesterBaseCore owner, IHttpClientSource httpClientSource) : HttpTesterBase>(owner, httpClientSource)"},{"databaseId":4137055071,"author":{"login":"chullybun"},"body":"Understood. Conscience decision to move as a testing package.","createdAt":"2026-09-29T18:42:18Z","path":"src/UnitTestEx/AspNetCore/HttpTesterT.cs","diffHunk":"@@ -15,9 +14,9 @@ namespace UnitTestEx.AspNetCore\n /// Provides send testing with an expected response value.\n ///
\n /// The expected response value .\n- /// The .\n- /// The .\n- public class HttpTester(TesterBase owner, TestServer testServer) : HttpTesterBase>(owner, testServer)\n+ /// The .\n+ /// The .\n+ public class HttpTester(TesterBaseCore owner, IHttpClientSource httpClientSource) : HttpTesterBase>(owner, httpClientSource)"}]}}]}}}}}