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/CHANGELOG.md b/CHANGELOG.md index bd55491..ed11020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ 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.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 `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. + - `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/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/README.md b/README.md index 04cb91a..c5291d4 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)
@@ -22,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. @@ -158,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 @@ -299,6 +306,192 @@ 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 + +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. + +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(); + +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. 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 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 +var email = builder.AddMockHostProject("email"); +var auth = builder.AddMockHostProject("auth"); +var erp = builder.AddMockHostProject("erp"); + +builder.AddProject("api") + .WithMockHostEnvironment("Email__BaseUrl", email, "http") + .WithMockHostEnvironment("Auth__BaseUrl", auth, "http") + .WithMockHostEnvironment("Erp__BaseUrl", erp, "http"); +``` + +`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(); + +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) - 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") + .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. + +### 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. + +_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: + +``` 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. + +_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. +
## Expectations @@ -339,6 +532,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. diff --git a/UnitTestEx.sln b/UnitTestEx.sln index 945a484..b699669 100644 --- a/UnitTestEx.sln +++ b/UnitTestEx.sln @@ -46,60 +46,244 @@ 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 +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 + 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 + {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 @@ -117,6 +301,12 @@ 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} + {E66415A4-85F2-4A18-97F5-EB6A99C9F5C3} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5A6E4D99-0084-441B-8349-CB320963E931} 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/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs new file mode 100644 index 0000000..b2c05c8 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -0,0 +1,760 @@ +// 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; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +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; + +namespace UnitTestEx.Aspire +{ + /// + /// 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). + /// + /// 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. + /// 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 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. + /// + /// The . + protected AspireTesterBase(TestFrameworkImplementor implementor) : base(implementor) { } + + /// + /// Gets the queued builder-configuration actions applied, in order, immediately before the underlying is built. + /// + /// 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 - 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): 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. + /// + /// 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 ). + /// + /// Used to guard configuration that must be applied before the underlying is built (see + /// /). + protected bool IsDistributedApplicationBuilding => _appTask is not null; + + /// + /// Gets the underlying ; builds and starts on first access. + /// + /// The started . + /// 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) + { + return _appTask ??= CreateDistributedApplicationAsync(); + } + } + + /// + /// 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 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 + // "{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 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(); + + builder.Services.Configure(options => + // 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)); + + foreach (var configure in ConfigureBuilderActions) + { + configure(builder); + } + + OnHostStartUp(); + + var app = await builder.BuildAsync().ConfigureAwait(false); + + 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); + + // 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 + { + await app.DisposeAsync().ConfigureAwait(false); + throw; + } + + 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() + { + 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(""); + } + } + + /// + /// 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; protected set; } + + /// + /// Gets the default timeout 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 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. + /// 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) + { + ArgumentException.ThrowIfNullOrEmpty(resourceName); + + var app = await GetDistributedApplicationAsync().ConfigureAwait(false); + await app.WaitForResourceAsync(resourceName, timeout).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). + /// 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)); + + await Task.WhenAll(resourceNames.Distinct().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 ), + /// 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(); + + var lines = new List(); + foreach (var (resourceName, buffer) in _resourceLogBuffers) + { + var lastCount = _elapsedLogLineCounts.GetOrAdd(resourceName, 0); + + // 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)); + } + + 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 + /// - 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. + /// + /// 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)); + + /// + /// 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 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)); + + return new HttpMock.AspireHttpMockClient(async () => + { + var app = await GetDistributedApplicationAsync().ConfigureAwait(false); + var httpClient = app.CreateHttpClient(resourceName, endpointName); + return RestEase.RestClient.For(httpClient); + }, () => JsonComparerOptions); + } + + /// + /// 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 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, 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..]; + var logger = new ResourceLogger(resourceName, 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(); + } + } + + /// + /// 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} ({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. 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(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); + 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? _pendingLevelText; + private LogLevel _pendingLevel; + private string? _pendingCategory; + private readonly List _pendingMessageLines = []; + + 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 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. 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(new ResourceLogEntry(LogLevel.Information, 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; + _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(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. + /// + 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)} {_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(new ResourceLogEntry(_pendingLevel, sb.ToString())); + + _pendingTimestamp = 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). + /// + /// 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. The window's baseline/end-point is tracked via the same shared, + /// 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) + { + 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, 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(); + 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, 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. + private async Task> GetNewResourceLogMessagesAsync() + { + if (!owner._resourceLogBuffers.TryGetValue(resourceName, out var buffer)) + return []; + + 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(); + + // 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(); + } + } + + /// + /// 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) + return; + + _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) + { + 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/AspireTesterBaseT.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs new file mode 100644 index 0000000..018ec65 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBaseT.cs @@ -0,0 +1,330 @@ +// 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.Logging; +using System; +using System.Threading; +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; + } + + /// + /// 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 - 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. 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) + { + 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; + } + + /// + /// 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. + /// + /// 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; + } + + /// + /// 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 . + /// + /// The reason text. + /// The to support fluent-style method-chaining. + public TSelf Checkpoint(string reason) + { + WriteCheckpoint(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); + + /// + /// 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/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/Aspire/HttpMock/AspireHttpMockClient.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs new file mode 100644 index 0000000..c6d3c01 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockClient.cs @@ -0,0 +1,121 @@ +// 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 UnitTestEx.Mocking; +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 - 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 + /// 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 + { + private readonly Func> _adminApiFactory; + private readonly Func _jsonComparerOptionsFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The factory function used to (asynchronously) resolve the underlying . + /// 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). + /// + /// 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. 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); + + /// + 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. + /// + /// 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..1f20cab --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs @@ -0,0 +1,251 @@ +// 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 UnitTestEx.Mocking; +using WireMock.Admin.Mappings; + +namespace UnitTestEx.Aspire.HttpMock +{ + /// + /// Represents the request-matching configuration for a stubbed mapping. + /// + /// 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. + /// + /// 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 = string.IsNullOrEmpty(requestUri) || requestUri[0] == '/' ? requestUri : $"/{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 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). + /// + /// 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 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 + /// 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 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). + /// + /// 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); + + /// + 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 new file mode 100644 index 0000000..2b59f01 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequestBody.cs @@ -0,0 +1,31 @@ +// 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 : IHttpMockRequestBody + { + /// + /// 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); + + /// + IHttpMockResponse IHttpMockRequestBody.Respond => Respond; + } +} diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponse.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponse.cs new file mode 100644 index 0000000..1ef8745 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponse.cs @@ -0,0 +1,236 @@ +// 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 UnitTestEx.Mocking; +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. + /// 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(); + + /// + /// 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); + } + + /// + 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 new file mode 100644 index 0000000..240016f --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequence.cs @@ -0,0 +1,40 @@ +// 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 +{ + /// + /// 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. + /// 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 = []; + + /// + /// 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); + } + + /// + IHttpMockResponseSequenceItem IHttpMockResponseSequence.Respond() => Respond(); + } +} diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequenceItem.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequenceItem.cs new file mode 100644 index 0000000..1980173 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockResponseSequenceItem.cs @@ -0,0 +1,136 @@ +// 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 UnitTestEx.Mocking; +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 ). + /// 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; + + /// + /// 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); + + /// + 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 new file mode 100644 index 0000000..b749061 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockedRequest.cs @@ -0,0 +1,126 @@ +// 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 UnitTestEx.Mocking; +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. + /// + /// 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; + 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/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/JsonElementComparerMatcher.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/JsonElementComparerMatcher.cs new file mode 100644 index 0000000..99bdb69 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/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.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 + /// 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/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/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 new file mode 100644 index 0000000..bf922a5 --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/WireMockConsole.cs @@ -0,0 +1,76 @@ +// 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 UnitTestEx.Aspire.HttpMock; +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 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. + /// 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) + { + 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 AspireWireMockServerSettings + { + Port = port, + StartAdminInterface = true, + CustomMatcherMappings = new Dictionary> + { + [JsonElementComparerMatcher.MatcherName] = model => new JsonElementComparerMatcher(model) + } + }; + + 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)."); + + // 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/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..da3d40c --- /dev/null +++ b/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj @@ -0,0 +1,29 @@ + + + + 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/UnitTestExAspireExtensions.cs b/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs new file mode 100644 index 0000000..960c6eb --- /dev/null +++ b/src/UnitTestEx.Aspire/UnitTestExAspireExtensions.cs @@ -0,0 +1,132 @@ +// 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 +#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), 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 + { + /// + /// 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)); + + /// + /// 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); + } + + /// + /// 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/src/UnitTestEx.Aspire/strong-name-key.snk b/src/UnitTestEx.Aspire/strong-name-key.snk new file mode 100644 index 0000000..5bced39 Binary files /dev/null and b/src/UnitTestEx.Aspire/strong-name-key.snk differ 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..55ee50c --- /dev/null +++ b/src/UnitTestEx/Abstractions/IHttpClientSource.cs @@ -0,0 +1,31 @@ +// 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 +{ + /// + /// 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); + + /// + /// 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/Abstractions/TesterBase.cs b/src/UnitTestEx/Abstractions/TesterBase.cs index c90e0f0..be53a30 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,41 +51,16 @@ 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. /// @@ -175,48 +69,10 @@ public 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(); } } @@ -240,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) @@ -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..fa97295 --- /dev/null +++ b/src/UnitTestEx/Abstractions/TesterBaseCore.cs @@ -0,0 +1,378 @@ +// 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 System.Threading.Tasks; +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; protected 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; 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; protected 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)); + } + + /// + /// 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 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(); + + /// + /// Gets the default duration used by when none is specified; defaults to 1 second. + /// + 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 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 WriteCheckpoint(string reason) + { + if (string.IsNullOrEmpty(reason)) + return; + + Implementor.WriteLine(""); + Implementor.WriteLine(new string('-', 80)); + Implementor.WriteLine("CHECKPOINT >"); + Implementor.WriteLine(reason); + + WriteElapsedLogMessages(); + } + + /// + /// 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 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; + + WriteElapsedLogMessages(); + + Implementor.WriteLine(""); + Implementor.WriteLine(new string('-', 80)); + Implementor.WriteLine($"DELAY ({effectiveDuration}) >"); + Implementor.WriteLine(effectiveReason); + + 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 >"); + foreach (var msg in logs) + { + Implementor.WriteLine(msg); + } + } + + /// + /// 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/TesterBaseT.cs b/src/UnitTestEx/Abstractions/TesterBaseT.cs index d88e470..9165fdf 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); @@ -387,18 +387,34 @@ 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 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 Checkpoint(string reason) + { + WriteCheckpoint(reason); + return (TSelf)this; + } /// /// Wraps the host execution to perform required start-up style activities; specifically resetting the . 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..373d1c8 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,18 @@ protected override void ResetHost() /// The . public TestServer GetTestServer() => HostExecutionWrapper(() => GetWebApplicationFactory().Server); + /// + /// 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 }; + } + + /// /// 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..fd9254e 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,18 +223,38 @@ 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); + + /// + /// 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); - /// - private Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => _client.SendAsync(request, 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.HttpClientSource.GetRequestLogMessages(_httpTester.RequestId) ?? _httpTester.Owner.SharedState.GetLoggerMessages(_httpTester.RequestId); + _httpTester.LogResponse(res, sw, _httpTester.LastLogs); + return res; + } /// /// 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/src/UnitTestEx/AspNetCore/HttpTesterBaseT.cs b/src/UnitTestEx/AspNetCore/HttpTesterBaseT.cs index 245fa53..52c6718 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 +// 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 . @@ -28,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. @@ -39,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 8c1bba9..f00de7f 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 +// 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 . @@ -29,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. @@ -40,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/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/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/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..ebe8b63 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,11 +12,12 @@ 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 = []; + private readonly List _expectNotTexts = []; /// public override string Title => "Logger expectations"; @@ -27,6 +28,12 @@ public class LoggerExpectations(TesterBase owner, TTester tester) : Exp /// 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/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."); } 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 new file mode 100644 index 0000000..0779921 --- /dev/null +++ b/src/UnitTestEx/Mocking/IHttpMockClient.cs @@ -0,0 +1,47 @@ +// 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 +{ + /// + /// 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. 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 + { + /// + /// 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); + + /// + /// 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/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/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/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/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 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.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 new file mode 100644 index 0000000..9eb2aef --- /dev/null +++ b/tests/UnitTestEx.Aspire.AppHost/AppHost.cs @@ -0,0 +1,72 @@ +// 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'. +// +// 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 +// tests attempt to call the resource. +// +// 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); + +// 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, +// so disabling certificate validation here (dev/test-only AppHost, never shipped) covers both. +builder.Services.ConfigureHttpClientDefaults(http => + http.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + })); + +// 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") + // "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 new file mode 100644 index 0000000..e6d134d --- /dev/null +++ b/tests/UnitTestEx.Aspire.AppHost/UnitTestEx.Aspire.AppHost.csproj @@ -0,0 +1,36 @@ + + + + + + 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..a9786ef --- /dev/null +++ b/tests/UnitTestEx.Aspire.MSTest.Test/AspireTesterTest.cs @@ -0,0 +1,200 @@ +using Aspire.Hosting; +using System; +using System.Diagnostics; +using System.Linq; +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 +{ + [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" }); + } + + [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 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 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() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + + var spy = new SpyTestFrameworkImplementor(tester.Implementor); + tester.ReplaceTestFrameworkImplementor(spy); + + 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). + var backgroundCallTask = Task.Run(async () => + { + await Task.Delay(300); + using var client = ((IHttpClientSource)tester).CreateHttpClient("api"); + using var response = await client.GetAsync("Person/1"); + response.EnsureSuccessStatusCode(); + }); + + var sw = Stopwatch.StartNew(); + var delayResult = tester.Delay(TimeSpan.FromSeconds(2), "Waiting for a background Person lookup to complete and log."); + sw.Stop(); + + await backgroundCallTask; + + 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("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 >")); + 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 new file mode 100644 index 0000000..30af2b3 --- /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/Wait 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.MSTest.Test/UnitTestEx.Aspire.MSTest.Test.csproj b/tests/UnitTestEx.Aspire.MSTest.Test/UnitTestEx.Aspire.MSTest.Test.csproj new file mode 100644 index 0000000..ec91905 --- /dev/null +++ b/tests/UnitTestEx.Aspire.MSTest.Test/UnitTestEx.Aspire.MSTest.Test.csproj @@ -0,0 +1,28 @@ + + + + net8.0;net9.0;net10.0 + false + true + true + enable + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + 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 new file mode 100644 index 0000000..21f72cd --- /dev/null +++ b/tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs @@ -0,0 +1,200 @@ +using Aspire.Hosting; +using System; +using System.Diagnostics; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using NUnit.Framework; +using UnitTestEx.Abstractions; +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" }); + } + + [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 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 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() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + + var spy = new SpyTestFrameworkImplementor(tester.Implementor); + tester.ReplaceTestFrameworkImplementor(spy); + + 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). + var backgroundCallTask = Task.Run(async () => + { + await Task.Delay(300); + using var client = ((IHttpClientSource)tester).CreateHttpClient("api"); + using var response = await client.GetAsync("Person/1"); + response.EnsureSuccessStatusCode(); + }); + + var sw = Stopwatch.StartNew(); + var delayResult = tester.Delay(TimeSpan.FromSeconds(2), "Waiting for a background Person lookup to complete and log."); + sw.Stop(); + + await backgroundCallTask; + + 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("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 >")); + 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 new file mode 100644 index 0000000..a42e885 --- /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/Wait 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/UnitTestEx.Aspire.NUnit.Test.csproj b/tests/UnitTestEx.Aspire.NUnit.Test/UnitTestEx.Aspire.NUnit.Test.csproj new file mode 100644 index 0000000..258efb2 --- /dev/null +++ b/tests/UnitTestEx.Aspire.NUnit.Test/UnitTestEx.Aspire.NUnit.Test.csproj @@ -0,0 +1,30 @@ + + + + net8.0;net9.0;net10.0 + false + enable + + + + + + + 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..1992164 --- /dev/null +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -0,0 +1,838 @@ +using Aspire.Hosting; +using Microsoft.Extensions.Logging; +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.Json; +using UnitTestEx.Mocking; +using Xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +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" }); + } + + [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 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 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() + { + await using var tester = AspireTester.Create() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync("api"); + + var spy = new SpyTestFrameworkImplementor(tester.Implementor); + tester.ReplaceTestFrameworkImplementor(spy); + + 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). + var backgroundCallTask = Task.Run(async () => + { + await Task.Delay(300); + using var client = ((IHttpClientSource)tester).CreateHttpClient("api"); + using var response = await client.GetAsync("Person/1"); + response.EnsureSuccessStatusCode(); + }); + + var sw = Stopwatch.StartNew(); + var delayResult = tester.Delay(TimeSpan.FromSeconds(2), "Waiting for a background Person lookup to complete and log."); + sw.Stop(); + + await backgroundCallTask; + + 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("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); + 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 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() + { + // 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", "mockhost"]); + + 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(); + } + + [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() + { + // 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() + { + // 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() + .WithResourceEnvironment("api", "SpecialKey", "VerySpecialValue"); + + await tester.WaitForResourceAsync(["api", "mockhost"]); + + var stub = await tester.HttpMock("mockhost") + .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() + { + // 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") + .ErrorWhenLogContains(LogLevel.None); + + await tester.WaitForResourceAsync(["api", "mockhost"]); + + var stub = await tester.HttpMock("mockhost") + .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 - 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("mockhost"); + + 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("mockhost").Run(HttpMethod.Post, "/echo", new { id = "Abc", eTag = Guid.NewGuid().ToString() }) + .AssertOK() + .AssertValue(new { id = "Abc", description = "A blue carrot" }); + + tester.Http("mockhost").Run(HttpMethod.Post, "/echo", new { id = "Xyz", eTag = "whatever" }).AssertNotFound(); + + 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", "mockhost"]); + + var stub = await HttpMockSharedConfig.ConfigureProductStubAsync(tester.HttpMock("mockhost"), "/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_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 mockhost resource - closing the feature gap between the two tiers. + await using var tester = AspireTester.Create(); + + await tester.WaitForResourceAsync("mockhost"); + + IHttpMockClient client = tester.HttpMock("mockhost"); + await client.WithRequestsFromResourceAsync("AspireTesterTest-mock.unittestex.yaml"); + + tester.Http("mockhost").Run(HttpMethod.Get, "/resource-config/simple") + .AssertOK() + .AssertValue(new { id = "FromResource", description = "Loaded from shared YAML resource." }); + + tester.Http("mockhost").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() + { + // 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("mockhost"); + + IHttpMockClient client = tester.HttpMock("mockhost"); + + 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("mockhost").Run(HttpMethod.Post, "/interface/with-body", "plain-text-body", MediaTypeNames.Text.Plain) + .AssertOK() + .AssertContent("received"); + + tester.Http("mockhost").Run(HttpMethod.Get, "/interface/no-content").AssertNoContent(); + + tester.Http("mockhost").Run(HttpMethod.Post, "/interface/json-body", new { id = "Abc" }) + .AssertOK() + .AssertValue(new { id = "Abc", description = "Configured via IHttpMockRequest.WithJsonBody." }); + + 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/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.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/SpyTestFrameworkImplementor.cs b/tests/UnitTestEx.Aspire.Xunit.Test/SpyTestFrameworkImplementor.cs new file mode 100644 index 0000000..5cfdb6f --- /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/Wait 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/UnitTestEx.Aspire.Xunit.Test.csproj b/tests/UnitTestEx.Aspire.Xunit.Test/UnitTestEx.Aspire.Xunit.Test.csproj new file mode 100644 index 0000000..fbaf81a --- /dev/null +++ b/tests/UnitTestEx.Aspire.Xunit.Test/UnitTestEx.Aspire.Xunit.Test.csproj @@ -0,0 +1,37 @@ + + + + net8.0;net9.0;net10.0 + false + enable + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + 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.MSTest.Test/Other/CheckpointAndDelayTest.cs b/tests/UnitTestEx.MSTest.Test/Other/CheckpointAndDelayTest.cs new file mode 100644 index 0000000..7ea5522 --- /dev/null +++ b/tests/UnitTestEx.MSTest.Test/Other/CheckpointAndDelayTest.cs @@ -0,0 +1,100 @@ +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 CheckpointAndDelayTest + { + [TestMethod] + public void Checkpoint_WritesReasonAndFlushesLogsToOutput() + { + using var test = ApiTester.Create(); + var spy = new SpyTestFrameworkImplementor(test.Implementor); + test.ReplaceTestFrameworkImplementor(spy); + + var result = test.Checkpoint("Confirming Checkpoint() writes context to the test output."); + + Assert.AreSame(test, result); + 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] + public async Task Delay_DelaysAndSurfacesLoggingThatOccursDuringTheDelay() + { + 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 delay window."); + }); + + var sw = Stopwatch.StartNew(); + 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 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 delay window."))); + } + + [TestMethod] + public void Delay_ReportsPreExistingBacklogBeforeItsOwnWindow() + { + 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 delay started."); + + test.Delay(TimeSpan.FromMilliseconds(200), "Waiting with nothing new expected."); + + // 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.MSTest.Test/Other/SpyTestFrameworkImplementor.cs b/tests/UnitTestEx.MSTest.Test/Other/SpyTestFrameworkImplementor.cs new file mode 100644 index 0000000..a798f10 --- /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/Wait 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/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.NUnit.Test/Other/CheckpointAndDelayTest.cs b/tests/UnitTestEx.NUnit.Test/Other/CheckpointAndDelayTest.cs new file mode 100644 index 0000000..061a2d3 --- /dev/null +++ b/tests/UnitTestEx.NUnit.Test/Other/CheckpointAndDelayTest.cs @@ -0,0 +1,100 @@ +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 CheckpointAndDelayTest + { + [Test] + public void Checkpoint_WritesReasonAndFlushesLogsToOutput() + { + using var test = ApiTester.Create(); + var spy = new SpyTestFrameworkImplementor(test.Implementor); + test.ReplaceTestFrameworkImplementor(spy); + + var result = test.Checkpoint("Confirming Checkpoint() writes context to the test output."); + + Assert.That(result, Is.SameAs(test)); + 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] + public async Task Delay_DelaysAndSurfacesLoggingThatOccursDuringTheDelay() + { + 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 delay window."); + }); + + var sw = Stopwatch.StartNew(); + 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 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 delay window.")), Is.True); + } + + [Test] + public void Delay_ReportsPreExistingBacklogBeforeItsOwnWindow() + { + 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 delay started."); + + test.Delay(TimeSpan.FromMilliseconds(200), "Waiting with nothing new expected."); + + // 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.NUnit.Test/Other/SpyTestFrameworkImplementor.cs b/tests/UnitTestEx.NUnit.Test/Other/SpyTestFrameworkImplementor.cs new file mode 100644 index 0000000..cb8142c --- /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/Wait 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/PersonControllerTest.cs b/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs index 7540548..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() { @@ -59,6 +84,7 @@ public void Get_Test4() public void Get_Test4_WithResetHost() { using var test = ApiTester.Create().ResetHost(); + test.Checkpoint("Need to reset the host."); test.Controller() .Run(c => c.Get(4)) .AssertNotFound(); diff --git a/tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs b/tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs new file mode 100644 index 0000000..16a7628 --- /dev/null +++ b/tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs @@ -0,0 +1,184 @@ +// 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_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() + { + 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/Other/CheckpointAndDelayTest.cs b/tests/UnitTestEx.Xunit.Test/Other/CheckpointAndDelayTest.cs new file mode 100644 index 0000000..e91aeb6 --- /dev/null +++ b/tests/UnitTestEx.Xunit.Test/Other/CheckpointAndDelayTest.cs @@ -0,0 +1,101 @@ +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 CheckpointAndDelayTest : UnitTestBase + { + public CheckpointAndDelayTest(ITestOutputHelper output) : base(output) { } + + [Fact] + public void Checkpoint_WritesReasonAndFlushesLogsToOutput() + { + using var test = ApiTester.Create(); + var spy = new SpyTestFrameworkImplementor(test.Implementor); + test.ReplaceTestFrameworkImplementor(spy); + + var result = test.Checkpoint("Confirming Checkpoint() writes context to the test output."); + + Assert.Same(test, result); + 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] + public async Task Delay_DelaysAndSurfacesLoggingThatOccursDuringTheDelay() + { + 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 delay window."); + }); + + var sw = Stopwatch.StartNew(); + 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 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 delay window.")); + } + + [Fact] + public void Delay_ReportsPreExistingBacklogBeforeItsOwnWindow() + { + 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 delay started."); + + test.Delay(TimeSpan.FromMilliseconds(200), "Waiting with nothing new expected."); + + // 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.")); + } + } +} 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."); + } + } +} diff --git a/tests/UnitTestEx.Xunit.Test/Other/SpyTestFrameworkImplementor.cs b/tests/UnitTestEx.Xunit.Test/Other/SpyTestFrameworkImplementor.cs new file mode 100644 index 0000000..da30f69 --- /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/Wait 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/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() { 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 @@ +