diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dba2709..dce3af3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,52 +11,57 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v6 with: dotnet-version: | - 6.0.x - 7.0.x 8.0.x 9.0.x 10.0.x + - name: Setup Azure Functions Core Tools + run: npm install --global azure-functions-core-tools@4 --unsafe-perm true + - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --no-restore - - name: Explicit MSTest test + - name: MSTest (net8.0, net9.0, net10.0) + run: | + for framework in net8.0 net9.0 net10.0; do + dotnet test tests/UnitTestEx.MSTest.Test/UnitTestEx.MSTest.Test.csproj --framework "$framework" --configuration Debug --no-build --no-restore --verbosity normal + done + + - name: NUnit (net8.0, net9.0, net10.0) + run: | + for framework in net8.0 net9.0 net10.0; do + dotnet test tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj --framework "$framework" --configuration Debug --no-build --no-restore --verbosity normal + done + + - name: xUnit (net8.0, net9.0, net10.0) + run: | + for framework in net8.0 net9.0 net10.0; do + dotnet test tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj --framework "$framework" --configuration Debug --no-build --no-restore --verbosity normal + done + + - name: Aspire MSTest (net8.0, net9.0, net10.0) run: | - cp tests/UnitTestEx.Api/bin/Debug/net8.0/UnitTestEx.Api.deps.json tests/UnitTestEx.MSTest.Test/bin/Debug/net8.0 - cd tests/UnitTestEx.MSTest.Test/bin/Debug/net8.0 - dotnet test UnitTestEx.MSTest.Test.dll --no-build --verbosity normal + for framework in net8.0 net9.0 net10.0; do + dotnet test tests/UnitTestEx.Aspire.MSTest.Test/UnitTestEx.Aspire.MSTest.Test.csproj --framework "$framework" --configuration Debug --no-build --no-restore --verbosity normal + done - - name: Explicit NUnit test + - name: Aspire NUnit (net8.0, net9.0, net10.0) run: | - cp tests/UnitTestEx.Api/bin/Debug/net8.0/UnitTestEx.Api.deps.json tests/UnitTestEx.NUnit.Test/bin/Debug/net8.0 - cd tests/UnitTestEx.NUnit.Test/bin/Debug/net8.0 - dotnet test UnitTestEx.NUnit.Test.dll --no-build --verbosity normal + for framework in net8.0 net9.0 net10.0; do + dotnet test tests/UnitTestEx.Aspire.NUnit.Test/UnitTestEx.Aspire.NUnit.Test.csproj --framework "$framework" --configuration Debug --no-build --no-restore --verbosity normal + done - - name: Explicit Xunit test + - name: Aspire xUnit (net8.0, net9.0, net10.0) run: | - cp tests/UnitTestEx.Api/bin/Debug/net8.0/UnitTestEx.Api.deps.json tests/UnitTestEx.Xunit.Test/bin/Debug/net8.0 - cd tests/UnitTestEx.Xunit.Test/bin/Debug/net8.0 - dotnet test UnitTestEx.Xunit.Test.dll --no-build --verbosity normal --tests MockHttpClientTest - dotnet test UnitTestEx.Xunit.Test.dll --no-build --verbosity normal --tests PersonFunctionTest - 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 - - - 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 + for framework in net8.0 net9.0 net10.0; do + dotnet test tests/UnitTestEx.Aspire.Xunit.Test/UnitTestEx.Aspire.Xunit.Test.csproj --framework "$framework" --configuration Debug --no-build --no-restore --verbosity normal + done \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ed11020..778570f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,38 @@ Represents the **NuGet** versions. +## v6.0.0 +- *Breaking:* Target frameworks are now `net8.0`, `net9.0` and `net10.0` only; `net6.0` and `net7.0` (both out of support) have been removed. +- *Breaking:* `UnitTestEx.Azure.Functions` supports the Azure Functions **v4 isolated worker** model only; in-process (WebJobs) support has been removed, including the `FunctionsStartup` entry point and `CreateWebJobsServiceBus*MessageActions`. See [Migrating from v5 to v6](./docs/migration-v6.md). +- *Breaking:* `UnitTestEx.Azure.ServiceBus` has been removed; its `CreateServiceBusMessage*` helpers now live in `UnitTestEx.Azure.Functions` (same `UnitTestEx` namespace). +- *Enhancement:* `FunctionTester` runs the function app's real `Program.cs` (top-level statements supported; no test-specific structure required), capturing the host at `Build()` without starting the worker; test configuration and service overrides are applied. `TEntryPoint` is any type within the function app assembly. +- *Enhancement:* `HttpTrigger` supports both ASP.NET Core integration (`HttpRequest` → `IActionResult`/`IResult`) and built-in (`HttpRequestData` → `HttpResponseData`) HTTP models, asserted consistently via `HttpResponseMessageAssertor`; method and route are validated against the worker `HttpTriggerAttribute`. +- *Enhancement:* `Trigger` enables any other trigger type to be tested, with `FunctionOutputAssertor` for multi-output bindings. +- *Enhancement:* Added Event Grid builders: `CreateEventGridEvent*` and `CreateCloudEvent*`. +- *Enhancement:* Added Event Hubs `EventData` builders for values, JSON, resources and raw bodies, with broker metadata support. +- *Enhancement:* Added Aspire integration coverage and documentation for a Service Bus-triggered Functions app using Microsoft's Service Bus emulator. +- *Enhancement:* `FunctionTester` now runs the function app's worker middleware pipeline (including the ASP.NET Core integration) around the invoked method, so middleware logging, short-circuiting and exception mapping can be asserted. Use `UseWorkerMiddleware(false)` to invoke the method directly. +- *Enhancement:* Added `SnapshotHttpResponse(HttpResponse)` to invocation assertors for already-written HTTP responses, without re-executing returned results; preserves buffered bytes, headers, stream ownership and position. +- *Fixed:* `TypeTester` now creates each tested service within the scope for that `Run`, preventing scoped dependencies from being reused after disposal. +- *Fixed:* UTF-8 request bodies created by `TesterBase.CreateHttpRequest` now report the encoded byte length rather than the UTF-16 character count. +- *Fixed:* `JsonElementComparer` hash codes now follow its configured semantic equality rules and tolerate valid numbers outside the decimal range. +- *Fixed:* `ApiTester` applies its test environment and settings only while the host is being built, then restores the process environment; host construction is serialized to prevent UnitTestEx API testers from racing. It also avoids building a duplicate service provider to capture `IHttpContextAccessor`. +- *Fixed:* `FunctionTester` now reads `local.settings.json` values into in-memory configuration and no longer writes a plaintext settings sidecar into the current directory. +- *Fixed:* Deferred MVC/Minimal API response conversion now restores the original response stream on success and failure rather than leaving a disposed temporary stream on the context. Content headers are preserved alongside response headers. +- *Breaking:* Added `IHttpMockRequest.WithNoBody()` (implemented by `MockHttpClientRequest` and `AspireHttpMockRequest`), which matches a request with no body: absent or zero-length. The two are indistinguishable on the wire, so both tiers treat them identically. As a result: + - Tier 1's default (no body configured) now also matches zero-length content, where previously only `Content == null` matched. + - The shared `IHttpMockClient.WithRequestsFromResourceAsync` loader now treats an omitted `body` as `WithNoBody()`, consistent with Tier 1's native loader. Previously it matched any body, so Aspire stubs that omit `body` no longer match requests that have one; use `body: ^` to match any non-empty body. + - `WithAnyBody()` and `body: ^` require a non-empty body, regardless of its content; absent and zero-length bodies match `WithNoBody()` instead. + - On Aspire, WireMock.Net does not read `GET`/`HEAD` request bodies by default. Therefore `WithAnyBody()` cannot match those methods, and `WithNoBody()` cannot enforce the no-body constraint for rules restricted exclusively to `GET`/`HEAD`. Unrestricted or mixed-method `WithNoBody()` rules apply the no-body constraint. +- *Documentation:* README restructured into a front door with a capability matrix linking to example tests; detailed documentation moved to [`docs/`](./docs). + ## 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. + - `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](./docs/aspire.md) 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. + - `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](./docs/aspire.md) "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. diff --git a/Common.targets b/Common.targets index 4b8740c..2b366e8 100644 --- a/Common.targets +++ b/Common.targets @@ -1,7 +1,7 @@  - 5.12.0 - preview + 6.0.0 + latest Avanade Avanade Avanade (c) diff --git a/README.md b/README.md index c5291d4..0d1daaa 100644 --- a/README.md +++ b/README.md @@ -6,143 +6,40 @@ ## Introduction -_UnitTestEx_ provides [.NET testing](https://docs.microsoft.com/en-us/dotnet/core/testing/) extensions to the most popular testing frameworks: [MSTest](https://github.com/Microsoft/testfx-docs), [NUnit](https://nunit.org/) and [Xunit](https://xunit.net/). +_UnitTestEx_ provides [.NET testing](https://learn.microsoft.com/en-us/dotnet/core/testing/) extensions for [MSTest](https://github.com/Microsoft/testfx-docs), [NUnit](https://nunit.org/) and [xUnit](https://xunit.net/). It hosts your **real** application (API, Azure Function, or any DI-composed code) inside the test, lets you replace or mock its dependencies, and gives you fluent _Arrange, Act, Assert_ testing with captured logging and detailed JSON-diff assertions. -The scenarios that _UnitTestEx_ looks to address is the end-to-end unit-style testing of the following whereby the capabilities look to adhere to the _AAA_ pattern of unit testing; Arrange, Act and Assert. - -- [API Controller](#API-Controller) -- [HTTP-triggered Azure Function](#HTTP-triggered-Azure-Function) -- [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) +> **v6** targets .NET 8, 9 and 10, and the Azure Functions **v4 isolated worker** model only. Upgrading? See [Migrating from v5 to v6](./docs/migration-v6.md).
## Status -The build and packaging status is as follows. - -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) +CI | `UnitTestEx` | `UnitTestEx.Azure.Functions` | `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.Azure.Functions.svg)](https://badge.fury.io/nu/UnitTestEx.Azure.Functions) | [![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. +The [change log](CHANGELOG.md) details all key changes per published version.
-## API Controller +## Which tester? -Leverages the [`WebApplicationFactory`](https://docs.microsoft.com/en-us/aspnet/core/test/integration-tests) (WAF) as a means to host a test server in process to invoke APIs directly using HTTP requests. This has the benefit of validating the HTTP pipeline and all Dependency Injection (DI) configuration within. External system interactions can be mocked accordingly. +Testing... | Use | Package(s) | Hosts | Docs +-|-|-|-|- +An ASP.NET Core API (controllers or minimal APIs) | `ApiTester` | `UnitTestEx` + framework package | `WebApplicationFactory`, full HTTP pipeline | [API testing](./docs/api-testing.md) +An Azure Function (isolated worker) | `FunctionTester` | `UnitTestEx.Azure.Functions` | Your real `Program.cs` host, built but not started; the function is invoked directly | [Azure Functions](./docs/azure-functions.md) +Any DI-composed class or service | `GenericTester` | `UnitTestEx` | Lightweight Generic Host | [Generic testing](./docs/generic-testing.md) +Multiple real services together | `AspireTester` | `UnitTestEx.Aspire` | .NET Aspire `DistributedApplication` | [Aspire](./docs/aspire.md) -_UnitTestEx_ encapsulates the `WebApplicationFactory` providing a simple means to arrange the input, execute (act), and assert the response. The following is an [example](./tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs). +The framework package (`UnitTestEx.NUnit`, `UnitTestEx.MSTest` or `UnitTestEx.Xunit`) wires output and assertions into your test framework. -``` csharp -using var test = ApiTester.Create(); -test.ReplaceHttpClientFactory(mcf) - .Controller() - .Run(c => c.Get("abc")) - .AssertOK() - .Assert(new { id = "Abc", description = "A blue carrot" }); -``` +> **Functions proof boundary:** `FunctionTester` proves invocation behavior, not trigger indexing, binding conversion, broker delivery or final runtime HTTP behavior. Use Aspire/Core Tools with the backing service or emulator for those checks. Posting a BlobCreated webhook simulates notification delivery; it does not test Azure Event Grid. Start with the [Functions guide](./docs/azure-functions.md#proof-boundary-invocation-versus-runtime) and [runnable Blob example](./docs/azure-functions.md#runnable-blob-integration-example).
-## HTTP-triggered Azure Function +## Quick start -Unfortunately, at time of writing, there is no `WebApplicationFactory` equivalent for Azure functions. _UnitTestEx_ looks to emulate by self-hosting the function, managing Dependency Injection (DI) configuration, and invocation of the specified method. _UnitTestEx_ when invoking verifies usage of [`HttpTriggerAttribute`](https://docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-http-webhook-trigger?tabs=csharp) and ensures a `Task` result. - -The following is an [example](./tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs). - -``` csharp -using var test = FunctionTester.Create(); -test.ReplaceHttpClientFactory(mcf) - .HttpTrigger() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person/abc", null), "abc", test.Logger)) - .AssertOK() - .Assert(new { id = "Abc", description = "A blue carrot" }); -``` - -Both the [_Isolated worker model_](https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide) and [_In-process model_](https://learn.microsoft.com/en-us/azure/azure-functions/functions-dotnet-class-library) are supported. - -Additionally, where an `HttpRequest` is used the passed `HttpRequest.PathAndQuery` is checked against that defined by the corresponding `HttpTriggerAttribute.Route` and will result in an error where different. The `HttpTrigger.WithRouteChecK` and `WithNoRouteCheck` methods control the path and query checking as needed. - -
- -## Service Bus-trigger Azure Function - -As above, there is currently no easy means to integration (in-process) test Azure functions that rely on the [Azure Service Bus](https://azure.microsoft.com/en-us/services/service-bus/). _UnitTestEx_ looks to emulate by self-hosting the function, managing Dependency Injection (DI) configuration, and invocation of the specified method and verifies usage of the [`ServiceBusTriggerAttribute`](https://docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-service-bus-trigger?tabs=csharp). - -The following is an [example](./tests/UnitTestEx.NUnit.Test/ServiceBusFunctionTest.cs) of invoking the function method directly passing in a `ServiceBusReceivedMessage` created using `test.CreateServiceBusMessageFromValue` (this creates a message as if coming from Azure Service Bus). - -``` csharp -using var test = FunctionTester.Create(); -test.ReplaceHttpClientFactory(mcf) - .ServiceBusTrigger() - .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = "Smith" }), test.Logger)) - .AssertSuccess(); -``` - -Both the [_Isolated worker model_](https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide) and [_In-process model_](https://learn.microsoft.com/en-us/azure/azure-functions/functions-dotnet-class-library) are supported. - -
- -## Generic Azure Function Type - -To support testing of any generic `Type` within an Azure Fuction, _UnitTestEx_ looks to simulate by self-hosting the function, managing Dependency Injection (DI) configuration, and invocation of the specified method. - -The following is an [example](./tests/UnitTestEx.NUnit.Test/ServiceBusFunctionTest.cs). - -``` csharp -using var test = FunctionTester.Create(); -test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = "Smith" }), test.Logger)) - .AssertSuccess(); -``` - -
- -## Generic Type - -To test a component that relies on Dependency Injection (DI) directly without the runtime expense of instantiating the underlying host (e.g. ASP.NET Core) the `GenericTester` enables any `Type` to be tested. - -``` csharp -using var test = GenericTester.Create().ConfigureServices(services => services.AddSingleton()); -test.Run(gin => gin.Pour()) - .AssertSuccess() - .AssertValue(1); -``` - -Additionally, where the `TEntryPoint` is specified and implements `ConfigureApplication` (`.NET8.0` or above) this will be invoked automatically to perform any additional configuration. - -``` csharp -using var test = GenericTester.Create(); -test.Run(gin => gin.Pour()) - .AssertSuccess() - .AssertValue(1); - -public class Startup -{ - public void ConfigureApplication(IHostApplicationBuilder builder) => builder.Services.AddSingleton(); -} -``` - -
- -## DI Mocking - -Each of the aforementioned test capabilities support Dependency Injection (DI) mocking. This is achieved by replacing the registered services with mocks, stubs, or fakes. The [`TesterBase`](./src/UnitTestEx/Abstractions/TesterBaseT.cs) enables using the `Mock*`, `Replace*` and `ConfigureServices` methods. - -The underlying `Services` property also provides access to the `IServiceCollection` within the underlying test host to enable further configuration as required. - -
- -## HTTP Client mocking - -Where invoking a down-stream system using an [`HttpClient`](https://docs.microsoft.com/en-us/dotnet/api/system.net.http.httpclient) within a unit test context this should generally be mocked. To enable _UnitTestEx_ provides a [`MockHttpClientFactory`](./src/UnitTestEx/Mocking/MockHttpClientFactory.cs) to manage each `HttpClient` (one or more), and mock a response based on the configured request. This leverages the [Moq](https://github.com/moq/moq4) framework internally to enable. One or more requests can also be configured per `HttpClient`. - -The following is an [example](./tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs). +**API**: send a real HTTP request through the pipeline, with a downstream `HttpClient` mocked. ``` csharp var mcf = MockHttpClientFactory.Create(); @@ -154,387 +51,86 @@ test.ReplaceHttpClientFactory(mcf) .Controller() .Run(c => c.Get("abc")) .AssertOK() - .Assert(new { id = "Abc", description = "A blue carrot" }); -``` - -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 - -Any configuration specified as part of the registering the `HttpClient` services from a Dependency Injection (DI) perspective is ignored by default when creating an `HttpClient` using the `MockHttpClientFactory`. This default behavior is intended to potentially minimize any side-effect behavior that may occur that is not intended for the unit testing. For example, a `DelegatingHandler` may be configured that requests a token from an identity provider which is not needed for the unit test, or may fail due to lack of access from the unit testing environment. - -``` csharp -// Startup service (DI) configuration. -services.AddHttpClient("XXX", hc => hc.BaseAddress = new System.Uri("https://somesys")) // This is HttpClient configuration. - .AddHttpMessageHandler(_ => new MessageProcessingHandler()) // This is HttpMessageHandler configuration. - .ConfigureHttpClient(hc => hc.DefaultRequestVersion = new Version(1, 2)); // This is further HttpClient configuration. -``` - -However, where the configuration is required then the `MockHttpClient` can be configured _explicitly_ to include the configuration; the following methods enable: - -Method | Description --|- -`WithConfigurations` | Indicates that the `HttpMessageHandler` and `HttpClient` configurations are to be used. * -`WithoutConfigurations` | Indicates that the `HttpMessageHandler` and `HttpClient` configurations are _not_ to be used (this is the default state). -`WithHttpMessageHandlers` | Indicates that the `HttpMessageHandler` configurations are to be used. * -`WithoutHttpMessageHandlers` | Indicates that the `HttpMessageHandler` configurations are _not_ to be used. -`WithHttpClientConfigurations` | Indicates that the `HttpClient` configurations are to be used. -`WithoutHttpClientConfigurations` | Indicates that the `HttpClient` configurations are to be used. --- | -- -`WithoutMocking` | Indicates that the underlying `HttpClient` is **not** to be mocked; i.e. will result in an actual/real HTTP request to the specified endpoint. This is useful to achieve a level of testing where both mocked and real requests are required. Note that an `HttpClient` cannot support both, these would need to be tested separately. - -_Note:_ `*` above denotes that an array of `DelegatingHandler` types to be excluded can be specified; with the remainder being included within the order specified. - -``` csharp -// Mock with configurations. -var mcf = MockHttpClientFactory.Create(); -mcf.CreateClient("XXX").WithConfigurations() - .Request(HttpMethod.Get, "products/xyz").Respond.With(HttpStatusCode.NotFound); - -// No mocking, real request. -var mcf = MockHttpClientFactory.Create(); -mcf.CreateClient("XXX").WithoutMocking(); -``` - -
- -### Times - -To verify the number of times that a request/response is performed _UnitTestEx_ support MOQ [`Times`](https://github.com/moq/moq4/blob/main/src/Moq/Times.cs), as follows: - -``` csharp -var mcf = MockHttpClientFactory.Create(); -var mc = mcf.CreateClient("XXX", new Uri("https://d365test")); -mc.Request(HttpMethod.Post, "products/xyz").Times(Times.Exactly(2)).WithJsonBody(new Person { FirstName = "Bob", LastName = "Jane" }) - .Respond.WithJsonResource("MockHttpClientTest-UriAndBody_WithJsonResponse3.json", HttpStatusCode.Accepted); -``` - -
- -### Sequeuce - -To support different responses per execution MOQ supports [sequences](https://github.com/moq/moq4/blob/main/src/Moq/SequenceSetup.cs). This capability has been extended for _UnitTestEx_. - -``` csharp -var mcf = MockHttpClientFactory.Create(); -var mc = mcf.CreateClient("XXX", new Uri("https://d365test")); -mc.Request(HttpMethod.Get, "products/xyz").Respond.WithSequence(s => -{ - s.Respond().With(HttpStatusCode.NotModified); - s.Respond().With(HttpStatusCode.NotFound); -}); -``` - -
- -### Delay - -A delay (sleep) can be simulated so a response is not always immediated. This can be specified as a fixed value, or randomly generated using a from and to. - -``` csharp -var mcf = MockHttpClientFactory.Create(); -var mc = mcf.CreateClient("XXX", new Uri("https://d365test")); -mc.Request(HttpMethod.Get, "products/xyz").Respond.Delay(500).With(HttpStatusCode.NotFound); -mc.Request(HttpMethod.Get, "products/kjl").Respond.WithSequence(s => -{ - s.Respond().Delay(250).With(HttpStatusCode.NotModified); - s.Respond().Delay(100, 200).With(HttpStatusCode.NotFound); -}); -``` - -
- -### YAML/JSON configuration - -The Request/Response configuration can also be specified within an embedded resource using YAML/JSON as required. The [`mock.unittestex.json`](./src/UnitTestEx/Schema/mock.unittestex.json) JSON schema defines content; where the file is named `*.unittestex.yaml` or `*.unittestex.json` then the schema-based intellisense and validation will occur within the likes of Visual Studio. - -To reference the YAML/JSON from a unit test the following is required: - -``` csharp -var mcf = MockHttpClientFactory.Create(); -mcf.CreateClient("XXX", new Uri("https://unit-test")).WithRequestsFromResource("my.mock.unittestex.yaml"); -``` - -The following represents a YAML example for one-to-one request/responses: - -``` yaml -- method: post - uri: products/xyz - body: ^ - response: - status: 202 - body: | - {"product":"xyz","quantity":1} - -- method: get - uri: people/123 - response: - headers: - ETag: Abc123 - body: | - { - "first":"Bob", - "last":"Jane" - } -``` - -The following represents a YAML example for a request/response with sequences: - -``` yaml -- method: get - uri: people/123 - sequence: - - body: | - { - "first":"Bob", - "last":"Jane" - } - - body: | - { - "first":"Sarah", - "last":"Johns" - } -``` - -_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"); + .AssertValue(new { id = "Abc", description = "A blue carrot" }); ``` -`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: +**Azure Function**: `Program.cs` runs as-is, with no test-only structure needed. ``` 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") +using var test = FunctionTester.Create(); +test.ReplaceHttpClientFactory(mcf) + .HttpTrigger() + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "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*"); +test.ServiceBusTrigger() + .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = "Smith" }))) + .AssertSuccess(); ``` -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: +**Generic**: any service, with expectations declared up front. ``` 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. +using var test = GenericTester.Create(); +test.ExpectException().Type() + .Run(gin => gin.Stir()); ``` -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 - -By default _UnitTestEx_ provides out-of-the-box `Assert*` capabilities that are applied after execution to verify the test results. However, by adding the `UnitTestEx.Expectations` namespace in a test additional `Expect*` capabilities will be enabled (where applicable). These allow expectations to be defined prior to the execution which are automatically asserted on execution. +## Capabilities -The following is an [example](./tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs). +Each row links to the docs and to a test that shows it working. -``` csharp -using var test = ApiTester.Create(); -test.Controller() - .ExpectStatusCode(System.Net.HttpStatusCode.BadRequest) - .ExpectErrors( - "First name is required.", - "Last name is required.") - .Run(c => c.Update(1, new Person { FirstName = null, LastName = null })); -``` +Capability | Applies to | Example +-|-|- +Controller tests from a method expression; route, method and body are inferred | API | [`Get_Test1`](./tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs) +Raw HTTP requests, typed responses | API | [`Http_Update_Http2`](./tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs) +`ValidationProblemDetails` / error assertions | API, Function | [`ValidationProblemDetails`](./tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs) +HTTP trigger: `HttpRequest` → `IActionResult` / `IResult` | Function | [`HttpTrigger_ActionResult_Sync`](./tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`HttpTrigger_Result_Async`](./tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs) +HTTP trigger: `HttpRequestData` → `HttpResponseData` | Function | [`HttpTrigger_RequestData_Json`](./tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs) +HTTP method/route validated against `[HttpTrigger]` | Function | [`HttpTrigger_RequestData_RouteCheck`](./tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`WithPathAndQueryCheck`](./tests/UnitTestEx.NUnit.Test/PersonFunctionTest.cs) +Multi-output bindings | Function | [`HttpTrigger_MultiOutput`](./tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs) +Service Bus trigger and message settlement | Function | [`ServiceBusMessage_Success`](./tests/UnitTestEx.NUnit.Test/ServiceBusFunctionTest.cs), [`ServiceBusMessage_AssertActions`](./tests/UnitTestEx.NUnit.Test/ServiceBusFunctionTest.cs) +Event Grid (`EventGridEvent` / `CloudEvent`) builders | Function | [`EventGridEvent_FromValue`](./tests/UnitTestEx.NUnit.Test/EventGridExtensionMethodsTest.cs), [`CloudEvent_FromJson`](./tests/UnitTestEx.NUnit.Test/EventGridExtensionMethodsTest.cs) +Worker middleware pipeline (logging, short-circuit, exception mapping) | Function | [`Middleware_ShortCircuit_ActionResult`](./tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`Middleware_HandlesException_HttpResponseData`](./tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs) +Other triggers (Timer, Blob, Queue, ...) | Function | [`TimerTriggered`](./tests/UnitTestEx.NUnit.Test/ProductFunctionTest.cs), [`BlobTrigger_Bytes`](./tests/UnitTestEx.NUnit.Test/StorageTriggerTest.cs), [`QueueTrigger_Person`](./tests/UnitTestEx.NUnit.Test/StorageTriggerTest.cs), [`GenericTrigger_ServiceBus`](./tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs) +Real `Program.cs` with test overrides; worker never started | Function | [`Program_TestOverrides`](./tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`Program_ServicesUsed_WorkerNotStarted`](./tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs) +Run any service, sync or async | Generic | [`Run_Service`](./tests/UnitTestEx.NUnit.Test/Other/GenericTest.cs) +Scoped services (`ScopedType`) | All | [`ScopedType_RunsWithinServiceScope`](./tests/UnitTestEx.NUnit.Test/Other/DependencyInjectionTest.cs) +Replace / mock DI registrations | All | [`ReplaceSingleton_OverridesRegistration`](./tests/UnitTestEx.NUnit.Test/Other/DependencyInjectionTest.cs), [`MockSingleton_UsesMoqObject`](./tests/UnitTestEx.NUnit.Test/Other/DependencyInjectionTest.cs) +Configuration overrides, `appsettings.unittest.json` | All | [`Configuration_Overrride_Use`](./tests/UnitTestEx.NUnit.Test/Other/GenericTest.cs) +`HttpClient` mocking: match, sequence, delay, times, YAML | All | [`MockSequence`](./tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs), [`WithYamlRequests`](./tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs) +`Expect*` before or `Assert*` after | All | [`Update_Test5_ExpectationFailure`](./tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs), [`ExpectValue_WithFunc`](./tests/UnitTestEx.NUnit.Test/Other/ExpectationsTest.cs) +JSON-diff comparison with paths to ignore | All | [`PathsToIgnore_PropertyName`](./tests/UnitTestEx.NUnit.Test/Other/ObjectComparerTest.cs) +Log capture and log assertions | All | [`Get_Test1_ExpectLogNotContains_Failure`](./tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs), [`Checkpoint_WritesReasonAndFlushesLogsToOutput`](./tests/UnitTestEx.NUnit.Test/Other/CheckpointAndDelayTest.cs) +One-off assembly set-up and tester extensions | All | [`TesterExtensions_FunctionTester`](./tests/UnitTestEx.NUnit.Test/Other/OneOffTestSetUpTest.cs) +Multi-host tests with WireMock stubs and log checks | Aspire | [`HttpMock_StubsExternalMockHostDependency_Product`](./tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs), [`ErrorWhenLogContains_DefaultsToEnabled_ThrowsWithoutExplicitRegistration`](./tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs)
-## appsettings.unittest.json +## Documentation -_UnitTestEx_ supports the addition of a `appsettings.unittest.json` within the test project that will get loaded automatically when executing tests. This enables settings to be added or modified specifically for the unit testing external to the referenced projects being tested. - -Additionally, this can also be used to change the default JSON Serializer for the tests. Defaults to `UnitTestEx.Json.JsonSerializer` (leverages `System.Text.Json`). By adding the following setting the default JSON serializer will be updated at first test execution and will essentially override for all tests. To change serializer for a specific test then use the test classes to specify explicitly. - -``` json -{ - "DefaultJsonSerializer": "UnitTestEx.MSTest.Test.NewtonsoftJsonSerializer, UnitTestEx.MSTest.Test" -} -``` +- [API testing](./docs/api-testing.md) +- [Azure Functions testing](./docs/azure-functions.md), including [limitations](./docs/azure-functions.md#limitations) +- [Generic testing](./docs/generic-testing.md) +- [Dependency injection and configuration](./docs/dependency-injection-and-configuration.md) +- [HTTP client mocking](./docs/http-client-mocking.md) +- [Assertions, expectations and logging](./docs/assertions-and-expectations.md) +- [Test set-up and extensibility](./docs/test-setup-and-extensibility.md) +- [Aspire multi-host testing](./docs/aspire.md) +- [Migrating from v5 to v6](./docs/migration-v6.md)
## Examples -As _UnitTestEx_ is intended for testing, look at the tests for further details on how to leverage: -- [UnitTestEx.MSTest.Test](./tests/UnitTestEx.MSTest.Test) +The tests in this repository are the reference examples. Equivalent tests exist for each framework, with only framework-specific differences: - [UnitTestEx.NUnit.Test](./tests/UnitTestEx.NUnit.Test) +- [UnitTestEx.MSTest.Test](./tests/UnitTestEx.MSTest.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. +- [UnitTestEx.Aspire.Xunit.Test](./tests/UnitTestEx.Aspire.Xunit.Test), [UnitTestEx.Aspire.NUnit.Test](./tests/UnitTestEx.Aspire.NUnit.Test), [UnitTestEx.Aspire.MSTest.Test](./tests/UnitTestEx.Aspire.MSTest.Test)
diff --git a/UnitTestEx.sln b/UnitTestEx.sln index b699669..839d92a 100644 --- a/UnitTestEx.sln +++ b/UnitTestEx.sln @@ -15,8 +15,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{2C4ABD6C EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTestEx.MSTest.Test", "tests\UnitTestEx.MSTest.Test\UnitTestEx.MSTest.Test.csproj", "{C49839FB-E54C-464C-B4EE-7310D1B5239C}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTestEx.Function", "tests\UnitTestEx.Function\UnitTestEx.Function.csproj", "{5523990C-1E7B-483E-9823-15057139BBA8}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTestEx.Api", "tests\UnitTestEx.Api\UnitTestEx.Api.csproj", "{C0060492-6AE0-43C9-874D-71CF8D27FF96}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{50CEF01C-FEC0-4AAD-96B8-45387EA360D5}" @@ -44,8 +42,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestEx.IsolatedFunction EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestEx.Azure.Functions", "src\UnitTestEx.Azure.Functions\UnitTestEx.Azure.Functions.csproj", "{6132D56E-60B2-4607-98F2-E5AA7FBB8BFB}" 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}" @@ -58,6 +54,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestEx.Aspire.MSTest.Te EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestEx.Aspire.MockHost", "tests\UnitTestEx.Aspire.MockHost\UnitTestEx.Aspire.MockHost.csproj", "{E66415A4-85F2-4A18-97F5-EB6A99C9F5C3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestEx.Aspire.ServiceBus.AppHost", "tests\UnitTestEx.Aspire.ServiceBus.AppHost\UnitTestEx.Aspire.ServiceBus.AppHost.csproj", "{D0B416B9-0D4B-43F6-A1DE-713BC9CB53D9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestEx.Aspire.ServiceBus.Function", "tests\UnitTestEx.Aspire.ServiceBus.Function\UnitTestEx.Aspire.ServiceBus.Function.csproj", "{A608ECF8-43F7-4A9D-AFCB-08BB73BF7E22}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -116,18 +116,6 @@ Global {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 @@ -200,18 +188,6 @@ Global {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 @@ -284,6 +260,30 @@ Global {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 + {D0B416B9-0D4B-43F6-A1DE-713BC9CB53D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0B416B9-0D4B-43F6-A1DE-713BC9CB53D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0B416B9-0D4B-43F6-A1DE-713BC9CB53D9}.Debug|x64.ActiveCfg = Debug|Any CPU + {D0B416B9-0D4B-43F6-A1DE-713BC9CB53D9}.Debug|x64.Build.0 = Debug|Any CPU + {D0B416B9-0D4B-43F6-A1DE-713BC9CB53D9}.Debug|x86.ActiveCfg = Debug|Any CPU + {D0B416B9-0D4B-43F6-A1DE-713BC9CB53D9}.Debug|x86.Build.0 = Debug|Any CPU + {D0B416B9-0D4B-43F6-A1DE-713BC9CB53D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0B416B9-0D4B-43F6-A1DE-713BC9CB53D9}.Release|Any CPU.Build.0 = Release|Any CPU + {D0B416B9-0D4B-43F6-A1DE-713BC9CB53D9}.Release|x64.ActiveCfg = Release|Any CPU + {D0B416B9-0D4B-43F6-A1DE-713BC9CB53D9}.Release|x64.Build.0 = Release|Any CPU + {D0B416B9-0D4B-43F6-A1DE-713BC9CB53D9}.Release|x86.ActiveCfg = Release|Any CPU + {D0B416B9-0D4B-43F6-A1DE-713BC9CB53D9}.Release|x86.Build.0 = Release|Any CPU + {A608ECF8-43F7-4A9D-AFCB-08BB73BF7E22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A608ECF8-43F7-4A9D-AFCB-08BB73BF7E22}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A608ECF8-43F7-4A9D-AFCB-08BB73BF7E22}.Debug|x64.ActiveCfg = Debug|Any CPU + {A608ECF8-43F7-4A9D-AFCB-08BB73BF7E22}.Debug|x64.Build.0 = Debug|Any CPU + {A608ECF8-43F7-4A9D-AFCB-08BB73BF7E22}.Debug|x86.ActiveCfg = Debug|Any CPU + {A608ECF8-43F7-4A9D-AFCB-08BB73BF7E22}.Debug|x86.Build.0 = Debug|Any CPU + {A608ECF8-43F7-4A9D-AFCB-08BB73BF7E22}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A608ECF8-43F7-4A9D-AFCB-08BB73BF7E22}.Release|Any CPU.Build.0 = Release|Any CPU + {A608ECF8-43F7-4A9D-AFCB-08BB73BF7E22}.Release|x64.ActiveCfg = Release|Any CPU + {A608ECF8-43F7-4A9D-AFCB-08BB73BF7E22}.Release|x64.Build.0 = Release|Any CPU + {A608ECF8-43F7-4A9D-AFCB-08BB73BF7E22}.Release|x86.ActiveCfg = Release|Any CPU + {A608ECF8-43F7-4A9D-AFCB-08BB73BF7E22}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -293,20 +293,20 @@ Global {3A031EFF-2CA2-47B6-8F97-8A529A17638E} = {B424F961-83B6-44FB-B6A9-BF9825361EA2} {12DB0D2A-ADA5-479E-BCFD-DB5438C2D048} = {B424F961-83B6-44FB-B6A9-BF9825361EA2} {C49839FB-E54C-464C-B4EE-7310D1B5239C} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} - {5523990C-1E7B-483E-9823-15057139BBA8} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} {C0060492-6AE0-43C9-874D-71CF8D27FF96} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} {F0146507-A0C4-4E7A-9CD4-DDFB56591C39} = {B424F961-83B6-44FB-B6A9-BF9825361EA2} {63F25714-C654-4FB7-B6A1-05E7197F416D} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} {5A9A5401-0868-4D31-8B9C-E98FF70F0AA5} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} {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} + {D0B416B9-0D4B-43F6-A1DE-713BC9CB53D9} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} + {A608ECF8-43F7-4A9D-AFCB-08BB73BF7E22} = {2C4ABD6C-8AE4-41E6-803F-02B963752FFF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5A6E4D99-0084-441B-8349-CB320963E931} diff --git a/docs/api-testing.md b/docs/api-testing.md new file mode 100644 index 0000000..b191d39 --- /dev/null +++ b/docs/api-testing.md @@ -0,0 +1,94 @@ +# API testing + +`ApiTester` (package `UnitTestEx`, plus your test framework package) wraps ASP.NET Core's [`WebApplicationFactory`](https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests) to host your API in-process. Requests go through the **full ASP.NET Core pipeline**: middleware, routing, model binding, filters, authorization and serialization. External dependencies are replaced through DI. + +[← Back to README](../README.md) + +
+ +## Create + +``` csharp +using var test = ApiTester.Create(); // Or ApiTester.Create() for minimal hosting. +``` + +Where the API's `appsettings.json` isn't next to the test assembly, use `UseSolutionRelativeContentRoot("tests/My.Api")`. Example: [`Success2`](../tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs). + +
+ +## Controller tests + +`Controller().Run(c => c.Method(args))` takes the method call, works out the HTTP method, route, query string and body from the controller's attributes, and sends a **real HTTP request** through `TestServer`. This gives refactor-safe, strongly-typed tests that still exercise the whole pipeline. + +``` csharp +test.Controller() + .Run(c => c.Get(1)) + .AssertOK() + .AssertValue(new Person { Id = 1, FirstName = "Bob", LastName = "Smith" }); +``` + +Example: [`Get_Test1`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs), [`Get_Test4`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs) (not found), [`Update_Test1`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs), [`GetByArgs_Test1`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs). + +
+ +## Raw HTTP tests + +`Http()` sends an explicit request. `Http()` also gives the response a type, so `ExpectValue` and `AssertValue` are strongly typed. + +``` csharp +test.Http() + .Run(HttpMethod.Post, "Person/1", new Person { FirstName = "Bob", LastName = "Smith" }) + .AssertOK(); + +test.Http() + .ExpectStatusCode(HttpStatusCode.OK) + .ExpectValue(new Person { Id = 1, FirstName = "Bob", LastName = "Smith" }) + .Run(HttpMethod.Post, "Person/1", new Person { FirstName = "Bob", LastName = "Smith" }); +``` + +Example: [`Http_Get1`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs), [`Http_Update1`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs), [`Http_Update_Http2`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs), [`Http_Update_Http3_AsJson`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs). + +
+ +## Errors and problem details + +`AssertErrors` understands both `ValidationProblemDetails` and the simple `{ field: [messages] }` error shape. `ApiError` also converts implicitly from a `(field, message)` tuple. + +``` csharp +test.Http().Run(HttpMethod.Get, "product/test/problem").AssertErrors(new ApiError("id", "Not specified.")); +test.Http().Run(HttpMethod.Post, "Person/88", p).Assert(HttpStatusCode.BadRequest, "No can do eighty-eight."); +``` + +Example: [`ValidationProblemDetails`](../tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs), [`Http_Update_Http5`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs), [`Update_Test6`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs). + +
+ +## `IActionResult` without HTTP + +To check an `IActionResult` created outside the pipeline, wrap it in a `ValueAssertor`. Then call `ToActionResultAssertor()`, or `ToHttpResponseMessageAssertor(httpRequest)` to run the result's `ExecuteResultAsync` and assert the HTTP response it produces. Example: [`Type_IActionResult`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs). + +
+ +## Security + +Method | Description +-|- +`UseUser(userName)` / `UseUser(object)` | Sets the test user name for all requests; it defaults to `TestSetUp.DefaultUserName` (`"Anonymous"`). UnitTestEx doesn't create claims or tokens itself. The user name is passed to `TestSetUp.OnBeforeHttpRequestMessageSendAsync`, where you add whatever your API's authentication expects, such as a bearer token or header. +`Http().WithUser(...)` / `Controller().WithUser(...)` | Sets the user for a single tester. +`BypassAuthorization()` | Replaces `IAuthorizationHandler` with a handler that succeeds every requirement. Use it when a test isn't about authorization. + +
+ +## Cross-cutting run actions + +`AddPreRunAction`, `AddPostRunBeforeExpectationsAction`, `AddPostRunAfterExpectationsAction` and `AddPostRunAction` register logic that runs for **every** `Run` on that tester. Use them for things like adding a standard expectation, without repeating it in each test. + +
+ +## Accessing the host + +`test.Services` (the built `IServiceProvider`), `test.Configuration`, `test.GetTestServer()` and `test.GetLogger()` give direct access for any arrangement or assertion the fluent API doesn't cover. Example: [`ServiceProvider`](../tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs), [`Configuration`](../tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs). + +See also: [Dependency injection and configuration](./dependency-injection-and-configuration.md), [Assertions and expectations](./assertions-and-expectations.md), [HTTP client mocking](./http-client-mocking.md). + +[← Back to README](../README.md) diff --git a/docs/aspire.md b/docs/aspire.md new file mode 100644 index 0000000..feb001a --- /dev/null +++ b/docs/aspire.md @@ -0,0 +1,291 @@ +# Aspire multi-host testing + +[← Back to README](../README.md) + + +The single-host testers (`ApiTester`, `FunctionTester`, `GenericTester`, etc.) hosts a **single** system/service in-process via the likes of `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" }); +``` + +For an NUnit fixture that deliberately shares one AppHost across its test methods, derive from `WithAspireTester` and use its `Test` property. The base exposes `OnBeforeStartAsync` and `OnAfterStartAsync` hooks and asynchronously disposes the shared tester when the fixture is torn down: + +```csharp +public sealed class ShoppingTests : WithAspireTester +{ + [OneTimeSetUp] + public async Task StartAsync() + { + Test.WithResourceEnvironment("shopping", "FeatureFlag", "enabled"); + await Test.WaitForResourceAsync("shopping"); + } + + [Test] + public void CanReadAnOrder() + => Test.Http("shopping").Run(HttpMethod.Get, "orders/123").AssertOK(); +} +``` + +This is a lifecycle convenience, not automatic test isolation: resources, external data, and captured logs live across methods for as long as the shared AppHost is running. Reset mutable external state and call `Test.ResetLogs()` where a test depends on a clean starting point. Use a fresh `AspireTester.Create()` per test when isolation matters more than AppHost startup time. The base implements `IAsyncDisposable`; dispose it from the test framework's asynchronous fixture teardown. + +Disposal does not rethrow an already-failed AppHost construction/startup task. Failures disposing a successfully created application are propagated to the caller, including from asynchronous fixture teardown. + +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](./http-client-mocking.md#yamljson-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`). + +### Driving Azure Functions triggers and waiting for background work + +For a complete package/namespace map and a runnable upload-to-output test, see the [Functions Blob integration guide](./azure-functions.md#runnable-blob-integration-example). `CreateBlobCreatedEvent` requires `UnitTestEx.Azure.Functions`; the webhook client requires `UnitTestEx.Aspire`, even though both use the `UnitTestEx` namespace. + +Some Azure Functions triggers are delivered by the Functions host's HTTP webhook rather than by a normal HTTP endpoint. `FunctionsWebhook(resourceName)` resolves the resource's Aspire endpoint and can post Event Grid-schema events to either the blob-trigger route (`/runtime/webhooks/blobs`) or the Event Grid trigger route (`/runtime/webhooks/EventGrid`). The helper adds the `aeg-event-type: Notification` header and returns the raw response so the test can assert it: + +```csharp +var created = EventGridExtensionMethods.CreateBlobCreatedEvent( + "imports", blob.Name, blob.Uri, properties.ETag.ToString(), "text/csv", properties.ContentLength); + +using var response = await tester.FunctionsWebhook("imports") + .PostBlobCreatedAsync("Host.Functions.CustomerImport", created); +response.EnsureSuccessStatusCode(); +``` + +Webhook payloads preserve each event's `Topic`; when it is absent or empty, the helper supplies `UnitTestEx` as the placeholder required by the runtime webhook. + +`WaitForResourceAsync("functions")` only waits for Aspire's resource health check; an HTTP health endpoint can respond before the Functions isolated worker has finished indexing. Use `WaitForFunctionsAsync` when a test needs function registration to be ready: + +```csharp +var functionNames = await tester.WaitForFunctionsAsync( + "functions", + expectedFunctionNames: ["CustomerImport", "HealthCheck"]); +``` + +This calls the Functions host's `/admin/functions` API, waits for the host to expose its indexed function list, and optionally verifies that the named functions are present. It does not inspect startup log text. A function omitted from the list (for example, one disabled by an unresolved `%SettingName%` binding expression) causes the expected-name check to fail. The method confirms function indexing, not successful connections to trigger services or end-to-end trigger delivery. + +For a Blob Storage trigger configured with `Source = BlobTriggerSource.EventGrid`, distinguish **trigger execution** from **Event Grid delivery**: + +- The Functions host receives the notification at its blob-trigger webhook, resolves the blob, applies the binding, invokes the function, and performs its output bindings. An Aspire test that posts to this webhook exercises those real host/runtime behaviors. +- Azurite emulates Blob Storage; it does **not** publish `Microsoft.Storage.BlobCreated` events or deliver them through an Event Grid subscription. The test must arrange delivery itself: upload the blob to Azurite, create the corresponding event, then post that event with `FunctionsWebhook`. This simulates the notification/delivery step, not the function execution. +- Consequently, this test does not verify Azure Event Grid subscription configuration, filtering, retries, or delivery semantics. Those require a real Azure Event Grid subscription or an Event Grid-compatible emulator that supports the desired scenario. + +The repository's [Azurite integration test](../tests/UnitTestEx.Aspire.NUnit.Test/Functions/AzuriteBlobTriggerTest.cs) follows that split: it uploads a real blob, manually posts its BlobCreated notification to the running Functions host, then waits for and verifies a real output blob. By contrast, the in-process trigger tests under `UnitTestEx.NUnit.Test` invoke the function method directly and do not test host binding or notification delivery. + +For an end-to-end import test, upload the blob and read the resulting report using the storage client and connection string already owned by the test/AppHost. `EventuallyAsync` polls a caller-supplied operation and returns its first result satisfying the condition; handle expected "not ready yet" outcomes (such as a 404 for a report that has not been written) in that operation. It does not depend on Azure Storage, so the same helper works for queues, databases, and other eventual side effects: + +```csharp +var report = await tester.EventuallyAsync( + async cancellationToken => await TryReadReportAsync(reportBlob, cancellationToken), + value => value is not null, + timeout: TimeSpan.FromSeconds(60)); +``` + +Timeout and caller cancellation bound the wait even when the asynchronous operation ignores its cancellation token. They do not forcibly stop that operation; pass the token to the underlying client API so it can also stop its work. + +The Event Grid builder lives in `UnitTestEx.Azure.Functions`; the webhook sender and generic polling helper are separate. UnitTestEx.Aspire does not manage Azurite, upload blobs, or add Azure Storage SDK dependencies. Configure the emulator/resource in the AppHost, then use the application's storage client to arrange and assert storage state. + +The isolated AppHost for this proof starts Azurite and Azure Functions Core Tools without changing the existing container-free Aspire test host. Core Tools is restricted to `BlobTrigger` so unrelated Service Bus and Event Hubs triggers do not require broker connections. Run it with `dotnet test tests/UnitTestEx.Aspire.NUnit.Test/UnitTestEx.Aspire.NUnit.Test.csproj --framework net8.0 --filter FullyQualifiedName~AzuriteBlobTriggerTest`; Podman and Azure Functions Core Tools v4 must be installed and available on `PATH`, and ports `7071` and `10000`-`10002` must be free. + +### Service Bus emulator integration + +The [Service Bus emulator test](../tests/UnitTestEx.Aspire.NUnit.Test/Functions/ServiceBusEmulatorTest.cs) starts a separate Functions host and an Aspire-managed Service Bus emulator. It sends a message to the emulator, lets the real Functions host receive and bind it, then verifies a message written to an output queue and checks the captured function log. This tests the host-to-broker integration; it does not validate Azure Service Bus delivery guarantees or every cloud feature. + +The AppHost also starts the emulator's SQL Edge dependency and Azurite for Functions host storage, and mounts the emulator's entity configuration. The containers require a working Aspire-compatible container runtime (Podman locally; the GitHub-hosted CI runner provides Docker), and the first run downloads their images. Microsoft's emulator prerequisites list at least 2 GB RAM and 5 GB disk. The AppHost sets the emulator and SQL `ACCEPT_EULA` values because the repository's tests are accepting those terms for this test-only use. The emulator is for development/testing, not production, and is not a substitute for Azure smoke tests. + +The resources are declared directly in the dedicated Aspire AppHost rather than maintained in a second `compose.yml`; the same integration test starts them locally and in CI. Run only this test with `dotnet test tests/UnitTestEx.Aspire.NUnit.Test/UnitTestEx.Aspire.NUnit.Test.csproj --framework net8.0 --filter FullyQualifiedName~ServiceBusEmulatorTest`. Ports `5672`, `5300`, `7072` and `10000`-`10002` must be free. Run these emulator fixtures and target frameworks sequentially because they use fixed ports. + +### Automatic failure diagnostics + +`EventuallyAsync` failures report the attempt count and the last completed observation, using its `ToString()` value bounded to 1024 characters. A timeout before the first result explicitly reports that no observation completed. Return a descriptive observation (rather than only `false`) when you need useful polling context. + +For an `AspireTester`, polling failures, instance `WaitForResourceAsync` failures, and failures during `BeforeStart`/application start/`AfterStart` also write current resource states and recent captured logs automatically. The log report includes at most 50 entries per resource, truncated to 4096 characters each. It includes previously drained entries and flushes pending entries without changing log watermarks or triggering `ErrorWhenLogContains`; diagnostics must not hide the original failure. Resource state is reported as unavailable when Aspire has not published it or the name identifies multiple replicas. + +This is not a test-framework-wide failure hook: arbitrary assertions, SDK operations outside polling, and direct `DistributedApplication` extension calls after startup still need an explicit `Checkpoint` or other failure handling. If construction fails before a `DistributedApplication` exists, resource states are not available. Diagnostic output failures are traced rather than replacing the original exception. Captured logs retain their existing lifetime; these limits bound the failure **report**, not the in-memory log history. Logs and observations may contain application data, so apply the same redaction/access controls as normal test output. + +Failed startup still disposes the built application; if cleanup also fails, both errors are reported in an `AggregateException`. `ResetHost()` can clear a failed startup for a retry, disposal is idempotent, and accessing an already-disposed tester throws rather than starting another application. These guarantees do not establish that Core Tools child-process trees are cleaned up under every Windows failure mode; verify those with real process-level lifecycle tests on the target environment. + +_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. + +
+ + +
+ +## Examples + +Capability | Example (xUnit; NUnit and MSTest equivalents live in [UnitTestEx.Aspire.NUnit.Test](../tests/UnitTestEx.Aspire.NUnit.Test) and [UnitTestEx.Aspire.MSTest.Test](../tests/UnitTestEx.Aspire.MSTest.Test)) +-|- +Basic request through a real resource | [`Http_Get_ReturnsSuccess`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs) +Resource environment / lifecycle hooks | [`WithResourceEnvironment_AppliedBeforeBuild`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs), [`BeforeStart_InvokedInOrder_BeforeAnyResourceStarts`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs), [`AfterStart_CanWaitForResourceHealthy_UsingExtensionMethod`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs) +Log aggregation (Checkpoint/Delay) | [`Checkpoint_And_Delay_AggregatesResourceLogs`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs) +Automatic failure diagnostics and failed-start reset/disposal | [`FailureDiagnosticsTest`](../tests/UnitTestEx.Aspire.NUnit.Test/FailureDiagnosticsTest.cs), [`AspireTesterTest`](../tests/UnitTestEx.Aspire.NUnit.Test/AspireTesterTest.cs) (NUnit). +Failing on resource error logs | [`ErrorWhenLogContains_DefaultsToEnabled_ThrowsWithoutExplicitRegistration`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs), [`ErrorWhenLogContains_ExcludeWildcard_SuppressesOtherwiseViolatingEntry`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs), [`ErrorWhenLogContains_ChecksOnFinalCheckpoint_ForActivityNotDrainedByHttp`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs) +Asserting log content | [`AssertLogContains_FindsExpectedText_AcrossAllOrASpecificResource`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs), [`AssertLogNotContains_ThrowsWhenTextFound`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs), [`ResetLogs_DiscardsPreviouslyCapturedEntries`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs) +Stubbing external dependencies (WireMock) | [`HttpMock_StubsExternalMockHostDependency_Product`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs), [`HttpMock_WithSequenceAsync_ReturnsResponsesInOrderThenExhausts`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs), [`HttpMock_WithJsonBody_PathsToIgnore_IgnoresSpecifiedProperty`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs) +Shared stubs across tiers (IHttpMockClient, YAML) | [`HttpMock_SharedInterface_ConfiguresIdenticallyAcrossTiers`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs), [`HttpMock_WithRequestsFromResourceAsync_LoadsStubsFromYaml_IdenticallyToTier1`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs) +UnitTestEx JSON comparer in a self-hosted mock | [`HttpMock_WithJsonBodyUsingUnitTestExComparer_SelfHostedMockHost_MatchesUsingJsonElementComparerSemantics`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs) +User / set-up flow | [`UseSetUp_And_UseUser_FlowThroughToHttpRequestSend`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs) +Azure Functions blob webhook + Azurite | [`BlobCreatedWebhook_RunsFunction_AndWritesProcessedBlob`](../tests/UnitTestEx.Aspire.NUnit.Test/Functions/AzuriteBlobTriggerTest.cs) +Azure Functions Service Bus emulator | [`ServiceBusMessage_RunsFunction_AndWritesOutputQueue`](../tests/UnitTestEx.Aspire.NUnit.Test/Functions/ServiceBusEmulatorTest.cs) + +[← Back to README](../README.md) diff --git a/docs/assertions-and-expectations.md b/docs/assertions-and-expectations.md new file mode 100644 index 0000000..ca3e18a --- /dev/null +++ b/docs/assertions-and-expectations.md @@ -0,0 +1,85 @@ +# Assertions, expectations and logging + +[← Back to README](../README.md) + +
+ +## Assert after, or Expect before + +Each `Run` returns an **assertor** for chained `Assert*` calls. Add `using UnitTestEx.Expectations;` and you can instead declare `Expect*` calls **before** `Run`; they are checked automatically when the run completes. The two styles are equivalent. Expectations read well for "given/when/then" tests, and they also let a library add cross-cutting expectations through `TesterExtensionsConfig.ExpectationAssertAsync`. + +``` csharp +// Assert after. +test.Controller().Run(c => c.Get(1)).AssertOK().AssertValue(expected); + +// Expect before. +test.Controller() + .ExpectStatusCode(HttpStatusCode.BadRequest) + .ExpectErrors("First name is required.", "Last name is required.") + .Run(c => c.Update(1, new Person())); +``` + +Example: [`Update_Test1`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs), [`Update_Test5_ExpectationFailure`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs), [`ExpectValue_Simple`](../tests/UnitTestEx.NUnit.Test/Other/ExpectationsTest.cs), [`ExpectValue_WithFunc`](../tests/UnitTestEx.NUnit.Test/Other/ExpectationsTest.cs). + +
+ +## Expectations + +Expectation | Checks +-|- +`ExpectSuccess()` | No exception; for HTTP, a success status code. +`ExpectStatusCode(code)` | HTTP status code. +`ExpectException(message)` / `ExpectException().Any()` / `ExpectException().Type(message?)` | An exception is thrown: with a given message, of any kind, or of a given type. +`ExpectError(message)` / `ExpectErrors(...)` | API errors (`ValidationProblemDetails` or a simple error dictionary), by message or by `ApiError(field, message)`. +`ExpectValue(value, pathsToIgnore)` / `ExpectValue(t => value)` | Result value; compared as JSON. The function form receives the tester, so the expected value can use its state. +`ExpectJson(json)` / `ExpectJsonFromResource(name)` | Result value against JSON text or an embedded resource. +`ExpectLogContains(text)` / `ExpectLogNotContains(text)` | Logs captured during the run. + +Example: [`ExceptionSuccess_ExpectException_Type`](../tests/UnitTestEx.NUnit.Test/Other/ExpectationsTest.cs), [`ExpectError_None`](../tests/UnitTestEx.NUnit.Test/Other/ExpectationsTest.cs), [`Get_Test1`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs) (`ExpectLogContains`), [`Get_Test1_ExpectLogNotContains_Failure`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs). + +
+ +## Assertors + +Assertor | Returned by | Highlights +-|-|- +`VoidAssertor` | `Run(Action)`, void methods | `AssertSuccess`, `AssertException(message)` +`ValueAssertor` | `Run(Func)`, `Type().Run(...)` | `AssertValue`, `AssertJson`, `AssertJsonFromResource`, `AssertValueFromJsonResource`, `ToActionResultAssertor`, `ToHttpResponseMessageAssertor` +`HttpResponseMessageAssertor` (``) | API tests, Function HTTP triggers | `AssertOK`, `AssertCreated`, `AssertNotFound`, ... (one per common status), `AssertErrors`, `AssertContentType*`, `AssertLocationHeader`, `AssertETagHeader`, `AssertNamedHeader`, `AssertValue`, `AssertJson` +`ActionResultAssertor` | `IActionResult` results | Status and value assertions over `IActionResult`; `ToHttpResponseMessageAssertor` executes the result to get a real HTTP response. +`HttpResultAssertor` | `IResult` results | `ToHttpResponseMessageAssertor` + +Every assertor exposes `LogMessages`, the logs captured during that run, so you can write custom assertions on them. + +For in-process invocation assertors, use `SnapshotHttpResponse(request.HttpContext.Response)` when the response was **already written** by the function or adapter. It asserts invocation success and snapshots status, headers and the complete readable, seekable body without executing the returned result. The stream remains caller-owned and its position is preserved. By contrast, `ToHttpResponseMessageAssertor(request)` executes a **deferred** MVC/Minimal API result in a fresh buffer, excludes previously written bytes, and restores the original response stream even on failure. See [HTTP rendering and snapshots](./azure-functions.md#asserting-an-already-written-http-response) for setup and limitations. + +
+ +## Comparing objects and JSON + +All value and JSON comparisons serialize both sides to JSON and compare them with `JsonElementComparer`. This tolerates property ordering, and it reports **every** difference with its JSON path, not just the first. Exclude volatile members with `pathsToIgnore`, e.g. `"etag"`, `"changeLog"` or `"items.id"`. + +Use `ObjectComparer.Assert(expected, actual, pathsToIgnore)` and `ObjectComparer.AssertJson(...)` for ad-hoc comparisons outside a tester. `UseJsonComparerOptions(...)` / `TestSetUp.JsonComparerOptions` control behaviour such as null and default handling. + +`ValueAssertor.AssertJson` and both `AssertJsonFromResource` overloads compare the expected JSON structure with the serialized actual value, using the tester's serializer and comparer options. Object property order and whitespace are insignificant, array order matters, and `pathsToIgnore` applies to object/array paths. A returned string is still a string value: use a JSON string literal as its expected JSON, even when that string happens to contain JSON text. + +Example: [`MatchyMatchy`](../tests/UnitTestEx.NUnit.Test/Other/ObjectComparerTest.cs), [`PathsToIgnore_PropertyName`](../tests/UnitTestEx.NUnit.Test/Other/ObjectComparerTest.cs), [`PathsToIgnore_Json`](../tests/UnitTestEx.NUnit.Test/Other/ObjectComparerTest.cs), [`Get_Test5_AnonymousType_OK`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs). + +
+ +## Embedded resources + +`Resource.GetString`, `GetJson`, `GetJsonValue`, `GetStream`, `DeserializeJson` and `DeserializeYaml` read embedded resources from the test assembly. Use them for large expected payloads and request bodies, e.g. `CreateJsonHttpRequestFromResource`, `AssertJsonFromResource`, or `MockHttpClient` `WithJsonResource`. + +
+ +## Logging capture + +Every tester routes the app's `ILogger` output to the test framework's output. NUnit/MSTest write to `TestContext`, and xUnit to `ITestOutputHelper`. This includes scopes and multi-line messages, and is filtered by `TestSetUp.MinimumLogLevel`. Logs are also captured per run for `LogMessages` and `ExpectLog*`. + +- `Checkpoint(reason)` writes a marker and flushes logs captured so far. This is useful in multi-step tests. +- `Delay(duration, reason)` waits, then shows any logging that happened meanwhile, e.g. from background work. + +Example: [`Checkpoint_WritesReasonAndFlushesLogsToOutput`](../tests/UnitTestEx.NUnit.Test/Other/CheckpointAndDelayTest.cs), [`Delay_DelaysAndSurfacesLoggingThatOccursDuringTheDelay`](../tests/UnitTestEx.NUnit.Test/Other/CheckpointAndDelayTest.cs), [`Test`](../tests/UnitTestEx.NUnit.Test/Other/LoggerTest.cs) (scopes and multi-line). + +[← Back to README](../README.md) diff --git a/docs/azure-functions.md b/docs/azure-functions.md new file mode 100644 index 0000000..e246522 --- /dev/null +++ b/docs/azure-functions.md @@ -0,0 +1,374 @@ +# Azure Functions testing + +`FunctionTester` (package `UnitTestEx.Azure.Functions`) tests Azure Functions written for the **v4 isolated worker model** on .NET 8, 9 and 10. The in-process (WebJobs) model is not supported from v6 onwards; see [Migrating to v6](./migration-v6.md). + +[← Back to README](../README.md) + +
+ +## Proof boundary: invocation versus runtime + +**A passing `FunctionTester` test is not proof that the Functions runtime can index, bind or deliver the trigger, or render the final HTTP response.** Choose the tier for the claim: + +Claim | Test with +-|- +Function logic, worker middleware, scoped DI, already-converted SDK arguments and explicit settlement calls | `FunctionTester`. +Trigger indexing, binding conversion, output bindings, actual HTTP rendering, broker retries/redelivery and Blob poison behavior | A running Functions host (Aspire/Core Tools) and its backing services or emulators. +Azure Event Grid subscription configuration, filtering and delivery/retry behavior | Azure Event Grid, not a manually posted webhook. + +An Aspire test using Azurite and `FunctionsWebhook` runs the real Functions host against real emulator storage, but **simulates the notification delivery step**. It does not turn Azurite into Event Grid. Likewise, recorded Service Bus settlement calls prove what your function requested, not that a broker accepted those calls or redelivered a message. + +The [runnable Blob integration example](#runnable-blob-integration-example) shows the runtime tier; the invocation examples below intentionally stop at the function boundary. + +
+ +## How it works + +1. `FunctionTester.Create()` runs the function app's **own** entry point (`Program.cs`, top-level statements and all). It does this through `HostFactoryResolver`, the same mechanism `WebApplicationFactory` uses. You don't need a `Program` class, a `BuildHost` method or any other test-only structure. +2. The host is captured at `Build()` and **never started**, so no worker gRPC channel, no Functions runtime and no hosted services run. +3. Before the host is built, test configuration is applied: `appsettings.unittest.json`, `UseAdditionalConfiguration`, and the environment set by `TestSetUp.Environment` (default `Development`). Service overrides (`Replace*`, `Mock*`, `ConfigureServices`, `ReplaceHttpClientFactory`) are applied too. +4. The function class is resolved from DI (or created with `ActivatorUtilities`). The method in your `Run` lambda is invoked through the app's **worker middleware pipeline**, so registered middleware runs before and after it (see [Worker middleware](#worker-middleware)). + +`TEntryPoint` can be any type in the function app's assembly. It is only used to locate the assembly whose entry point is executed. + +``` csharp +using var test = FunctionTester.Create(); +``` + +Example: [`Program_TestOverrides`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`Program_ServicesUsed_WorkerNotStarted`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`Program_ConcurrentHostsAreIsolated`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs). The sample function app is [`UnitTestEx.IsolatedFunction`](../tests/UnitTestEx.IsolatedFunction/Program.cs). + +Test configuration and service overrides are applied at the `Build()` boundary. Configuration values read eagerly, service-registration decisions made from those values, and other side effects executed earlier in `Program.cs` cannot be retroactively changed by this hook. `Program_TestOverrides` verifies the final configuration and replaced services, not arbitrary pre-`Build()` reads. + +The worker reads `AZURE_FUNCTIONS_ENVIRONMENT` when its builder is created. `FunctionTester` temporarily sets this process-wide variable to `TestSetUp.Environment` during host construction and restores the previous value (including an absent value) before returning, even if construction fails. Host construction is serialized across all `FunctionTester` entry-point types. This does not isolate unrelated code that reads or writes the process environment during construction; serialize those tests too. A timed-out entry point cannot be forcibly stopped and may continue running after the environment is restored. `TestSetUp.Environment` is shared configuration: set it before starting tests, not concurrently during host construction. + +
+ +## Choosing a tester + +Method | Use for | Trigger validation +-|-|- +`HttpTrigger()` | HTTP functions | Validates `[HttpTrigger]`, plus the HTTP method and route (configurable). +`ServiceBusTrigger()` | Service Bus functions | Validates `[ServiceBusTrigger]`. +`Trigger()` | Any other worker trigger, e.g. Event Grid, Blob, Queue, Timer, Event Hubs or Cosmos DB | Validates that the method has a worker `*TriggerAttribute`. +`Type()` | Any method, with no trigger checks | None. + +All four support synchronous and asynchronous methods, instance and `static` methods, expectations (`Expect*`), log capture, and the standard assertors. + +Scopes created by `HttpTrigger`, `ServiceBusTrigger`, and `Trigger` belong to the owning `FunctionTester`. They remain usable for subsequent invocations, response reading and assertions until that tester is reset or disposed. Reset/disposal releases these scopes before the host, including async-only scoped dependencies; individual trigger testers do not need to be disposed. Do not retain a trigger tester across an owner reset. Cleanup attempts all scopes and the host and reports disposal failures rather than silently ignoring them. + +
+ +## HTTP triggers + +Both HTTP programming models are supported: + +Model | Request | Response +-|-|- +ASP.NET Core integration | `HttpRequest` (`test.CreateHttpRequest`, `test.CreateJsonHttpRequest`) | `IActionResult` / `IResult` +Worker-native | `HttpRequestData` (`test.CreateHttpRequestData`, `test.CreateJsonHttpRequestData`) | `HttpResponseData` + +``` csharp +using var test = FunctionTester.Create(); +test.ReplaceHttpClientFactory(mcf) + .HttpTrigger() + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "abc")) + .AssertOK() + .AssertValue(new { id = "Abc", description = "A blue carrot" }); +``` + +Worker-native responses are converted to an `HttpResponseMessage`. This covers the status code, headers, cookies and body, so they can be checked with the full `HttpResponseMessageAssertor`: + +``` csharp +test.HttpTrigger() + .Run(f => f.DataJson(test.CreateJsonHttpRequestData(HttpMethod.Post, "data/json", new Person("Jane", "Doe"), null))) + .ToHttpResponseMessageAssertor() + .AssertOK() + .AssertNamedHeader("X-Test", "abc") + .AssertValue(new Person("Doe", "Jane")); +``` + +Scenario | Example +-|- +`HttpRequest` → `IActionResult` | [`Success`](../tests/UnitTestEx.NUnit.Test/ProductFunctionTest.cs), [`HttpTrigger_ActionResult_Sync`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs) +`IActionResult` → `HttpResponseMessage` | [`HttpTrigger_ActionResult_ToHttpResponseMessage`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs) +`HttpRequest` → `IResult` | [`HttpTrigger_Result_Async`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`HttpTrigger_Result_Sync`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs) +`HttpRequestData` → `HttpResponseData` (JSON) | [`HttpTrigger_RequestData_Json`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs) +`HttpRequestData` → `HttpResponseData` (text, route parameter) | [`HttpTrigger_RequestData_SyncText_RouteParameter`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs) +`HttpRequestData.FunctionContext.GetHttpContext()` | [`HttpTrigger_RequestData_GetHttpContext`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs) +JSON request body | [`ValidJson`](../tests/UnitTestEx.NUnit.Test/PersonFunctionTest.cs) +Assert against an embedded JSON resource | [`ValidJsonResource`](../tests/UnitTestEx.NUnit.Test/PersonFunctionTest.cs) +Validation errors (`AssertErrors`) | [`BadRequest2`](../tests/UnitTestEx.NUnit.Test/PersonFunctionTest.cs) +Unhandled exception | [`Exception`](../tests/UnitTestEx.NUnit.Test/ProductFunctionTest.cs) + +### Method and route checks + +By default `HttpTrigger` checks that the request's HTTP method is one the `[HttpTrigger]` allows, and that the request path matches its `Route`. This catches tests that call a function with a request it would never actually receive. + +Method | Effect +-|- +`WithNoMethodCheck()` / `WithMethodCheck()` | Disables or enables the HTTP method check. +`WithNoRouteCheck()` | Disables the route check. +`WithRouteCheck(RouteCheckOption)` | Sets the route check: `Path`, `PathAndQuery`, `PathAndQueryStartsWith`, etc. + +Example: [`HttpTrigger_RequestData_MethodCheck`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`HttpTrigger_RequestData_RouteCheck`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`WithPathAndQueryCheck`](../tests/UnitTestEx.NUnit.Test/PersonFunctionTest.cs), [`WithPathAndQueryStartsWithCheck`](../tests/UnitTestEx.NUnit.Test/PersonFunctionTest.cs). + +### What HTTP response conversion executes + +`ToHttpResponseMessageAssertor(request)` is **in-process result execution**, not a request sent through the Functions HTTP server: + +Result | What happens +-|- +`IActionResult` | Calls `ExecuteResultAsync` with an `ActionContext` (empty route data and action descriptor). +`IResult` | Calls `ExecuteAsync` with the selected `HttpContext`. +`HttpResponseData` | Copies the already-written status, headers, cookies and body; it does not execute an ASP.NET Core result. This overload takes no request. +Multi-output `[HttpResult]` property | Uses the corresponding conversion above for the property's value. + +For `IActionResult`/`IResult`, passing the **same request used for invocation** reuses its `HttpContext`: request headers (such as `Accept`), method, query, features, scoped `RequestServices`, and response headers already written by middleware remain available to the result executor. Without a request, conversion creates a fresh `DefaultHttpContext` using the tester's root services; it does not automatically recover the invocation request or its scope. + +```csharp +var request = test.CreateHttpRequest(HttpMethod.Get, "action/sync"); +test.HttpTrigger() + .Run(function => function.ActionSync(request)) + .ToHttpResponseMessageAssertor(request) + .AssertOK(); +``` + +Conversion temporarily replaces `HttpContext.Response.Body`, executes the result synchronously into a fresh buffer, then copies the status, headers and rendered bytes into an `HttpResponseMessage`. The original response stream is restored in `finally`, including when execution throws, and is never disposed by conversion. Previously written bytes are **not** appended to the deferred result. Repeated conversion re-executes that result, so avoid doing it for results with side effects. + +**Response-start/completion callbacks are not driven by this helper.** In the normal `DefaultHttpContext`, registering `Response.OnStarting` or `OnCompleted` does not cause the callback to run during conversion. Supplied custom response features or result code may implement their own behavior, but the helper does not simulate the server lifecycle. It also does not run the full Functions HTTP server pipeline, routing, authentication, transport or platform header handling. Keep real HTTP assertions in Aspire tests for those behaviors. + +### Asserting an already-written HTTP response + +If a function or adapter writes `HttpContext.Response` directly (or executes a result eagerly) and returns `EmptyResult`/`Results.Empty`, **snapshot the response instead of executing the returned result**: + +```csharp +using var test = FunctionTester.Create(); +var request = test.CreateHttpRequest(HttpMethod.Post, "eager-response-proof"); +using var body = new MemoryStream(); +request.HttpContext.Response.Body = body; + +var invocation = test.HttpTrigger() + .Run(function => function.Invoke(request)); + +invocation.SnapshotHttpResponse(request.HttpContext.Response) + .AssertCreated() + .AssertNamedHeader("X-Probe", "already-written") + .AssertValue(new { value = "example" }); +``` + +`SnapshotHttpResponse(HttpResponse)` is available on invocation assertors, including `ActionResultAssertor`, `HttpResultAssertor`, `ValueAssertor` and `FunctionOutputAssertor`. It first asserts invocation success, then copies status, headers and the **entire** buffered body from position zero. It does not invoke the function again, execute the returned result, run response callbacks, or guess rendering state from `EmptyResult`, status, headers or content type. Supplying the actual response is mandatory; a fresh context would not contain the written bytes. + +The stream must remain readable and seekable. Set a `MemoryStream` before invocation, flush any pending `Response.BodyWriter` output before snapshotting, and keep the stream open until afterward. Snapshotting restores its position, never replaces/disposes it, and returns independent bytes and headers. Repeated snapshots do not duplicate output or re-execute rendering. Empty buffered responses and the default `Stream.Null` yield an empty body; unreadable, disposed or non-seekable streams produce an actionable `NotSupportedException`, not a silently empty response. + +`ToHttpResponseMessageAssertor(request)` retains its deferred-execution semantics: an `EmptyResult` writes no bytes, even if the original response buffer contains a body. Use the snapshot path for eager responses; ordinary deferred `ObjectResult`, `ContentResult` and `IResult` tests continue to use conversion. + +Examples and ownership regressions: [`HttpRenderingTest`](../tests/UnitTestEx.NUnit.Test/Other/HttpRenderingTest.cs). Real Core Tools/Aspire HTTP tests remain necessary to prove the final wire response. + +### Multi-output functions + +A function can return a POCO containing an `[HttpResult]` plus other output bindings, such as `[ServiceBusOutput]`. `Output(x => x.Property)` asserts a non-HTTP output, and `ToHttpResponseMessageAssertor()` asserts the HTTP result: + +``` csharp +test.HttpTrigger() + .Run(f => f.MultiOutput(test.CreateHttpRequestData(HttpMethod.Get, "multi-output"))) + .Output(x => x.Message) + .AssertValue("multi-output message"); +``` + +Example: [`HttpTrigger_MultiOutput`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs). + +
+ +## Service Bus triggers + +`test.CreateServiceBusMessageFromValue`, `...FromJson`, `...FromResource` and `CreateServiceBusMessage` create a `ServiceBusReceivedMessage` that looks as if Azure Service Bus delivered it, with a lock token, delivery count, enqueued time and so on. + +``` csharp +using var test = FunctionTester.Create(); +test.ReplaceHttpClientFactory(mcf) + .ServiceBusTrigger() + .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = "Smith" }))) + .AssertSuccess(); +``` + +Functions that settle messages themselves can be given `test.CreateWorkerServiceBusMessageActions()`. It records what the function did, which can then be asserted with `AssertComplete`, `AssertAbandon`, `AssertDeadLetter`, `AssertDefer` or `AssertNone`: + +``` csharp +var msg = test.CreateServiceBusMessageFromValue(new Person { FirstName = null, LastName = "Smith" }); +var actions = test.CreateWorkerServiceBusMessageActions(); + +test.ServiceBusTrigger() + .Run(f => f.Run3(msg, actions)) + .AssertSuccess(); + +actions.AssertDeadLetter("Validation error", "First name is required"); +``` + +Scenario | Example +-|- +Message from a value | [`ServiceBusMessage_Success`](../tests/UnitTestEx.NUnit.Test/ServiceBusFunctionTest.cs) +POCO-bound trigger | [`Object_Success`](../tests/UnitTestEx.NUnit.Test/ServiceBusFunctionTest.cs) +Message settlement (`ServiceBusMessageActions`) | [`ServiceBusMessage_AssertActions`](../tests/UnitTestEx.NUnit.Test/ServiceBusFunctionTest.cs) +Downstream HTTP failure | [`ServiceBusMessage_HttpClientError`](../tests/UnitTestEx.NUnit.Test/ServiceBusFunctionTest.cs) +Exception assertion | [`ServiceBusMessage_ThrowsException`](../tests/UnitTestEx.NUnit.Test/ServiceBusFunctionTest.cs) + +
+ +## Event Grid triggers + +`CreateEventGridEventFromValue/FromJson/FromResource` and `CreateCloudEventFromValue/FromJson/FromResource` build an `EventGridEvent` or a `CloudEvent` (JSON data) to pass to an Event Grid-triggered function through `Trigger()`: + +``` csharp +var eventGridEvent = test.CreateEventGridEventFromValue(new Person("Jane", "Doe"), "/people/1", "People.Created"); +var cloudEvent = test.CreateCloudEventFromResource("person-created.json", "/people", "People.Created"); + +test.Trigger() + .Run(f => f.Run(eventGridEvent)) + .AssertSuccess(); +``` + +These helpers only create events. `EventGridExtensionMethods.CreateBlobCreatedEvent(...)` also builds the Azure Storage `Microsoft.Storage.BlobCreated` event shape from a container/blob name, URI and properties. It does not emulate delivery, retries or filtering. With Aspire and Azurite, upload the blob to the emulator and post the event yourself through `tester.FunctionsWebhook("functions").PostBlobCreatedAsync(...)`: the Functions host then handles the webhook, binding, invocation, and output, but Azurite does not publish or deliver Event Grid events. This verifies the function/runtime path, not Event Grid subscription configuration or delivery semantics. Test those separately against Azure Event Grid or a suitable emulator. Use `EventuallyAsync(...)` to wait for the function's external side effect. See [Aspire testing](./aspire.md#driving-azure-functions-triggers-and-waiting-for-background-work). + +Example: [`EventGridEvent_FromValue`](../tests/UnitTestEx.NUnit.Test/EventGridExtensionMethodsTest.cs), [`CloudEvent_FromResource`](../tests/UnitTestEx.NUnit.Test/EventGridExtensionMethodsTest.cs). + +
+ +## Event Hubs triggers + +`CreateEventDataFromValue/FromJson/FromResource` build received `EventData` values using the test's JSON serializer or an embedded JSON resource. `CreateEventData` accepts raw `BinaryData` and supports setting partition key, sequence number, offset, enqueue time and custom event properties: + +```csharp +var eventData = test.CreateEventDataFromValue( + new Person("Jane", "Doe"), + message => message.Properties["source"] = "unit-test"); + +test.Trigger() + .Run(function => function.Process(eventData)) + .AssertSuccess(); +``` + +Batch-trigger functions accept an `EventData[]` directly. These helpers exercise the function and UnitTestEx expectations, middleware and log capture; they do not emulate Event Hubs delivery, partition assignment, checkpointing or retries. Use an Event Hubs emulator or Azure for those integration behaviors. + +Example: [`EventHubsTest`](../tests/UnitTestEx.NUnit.Test/EventHubsTest.cs). + +
+ +## Other triggers + +For triggers without a dedicated helper, use `Trigger()`, or `Type()` to skip trigger validation. Pass the trigger payload directly: a `string`, POCO, `BinaryData`, `TimerInfo` and so on. + +Blob and Storage Queue triggers use this same generic tester; no trigger-specific UnitTestEx tester or message builder is needed. The function app still references the corresponding isolated-worker storage extension package, which supplies the trigger attributes. Pass the value the function receives after Azure's binding conversion: + +``` csharp +var content = BinaryData.FromString("""{"firstName":"Jane","lastName":"Doe"}"""); + +test.Trigger() + .Run(f => StorageTriggerFunctions.ProcessBlob(content, "people.json")) + .AssertValue("""people.json:{"firstName":"Jane","lastName":"Doe"}"""); + +test.Trigger() + .Run(f => StorageTriggerFunctions.ProcessQueuePerson(new Person("Jane", "Doe"))) + .AssertValue("Jane Doe"); +``` + +These are function-invocation unit tests: they do not test Azure's blob/queue binding conversion, storage connectivity, or delivery behavior. Use Aspire with Azurite for those integration scenarios. + +``` csharp +test.Trigger() + .Run(f => ServiceBusFunction.Run4(new Person("Jane", "Doe"))) // static methods are supported. + .AssertSuccess(); + +test.Type() + .ExpectLogContains("(DI)") + .Run(f => f.DailyRun(default!)) // Timer trigger. + .AssertSuccess(); +``` + +Example: [`BlobTrigger_Bytes`](../tests/UnitTestEx.NUnit.Test/StorageTriggerTest.cs), [`QueueTrigger_Person`](../tests/UnitTestEx.NUnit.Test/StorageTriggerTest.cs), [`QueueTrigger_String`](../tests/UnitTestEx.NUnit.Test/StorageTriggerTest.cs), [`GenericTrigger_ServiceBus`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`TimerTriggered`](../tests/UnitTestEx.NUnit.Test/ProductFunctionTest.cs). + +
+ +## Worker middleware + +Middleware registered in `Program.cs` (`builder.UseMiddleware()`, `UseWhen`, inline `Use`) runs around the function method, **in the same order as in production**. This includes the ASP.NET Core integration middleware from `ConfigureFunctionsWebApplication()`. No test-specific setup is needed. This covers: + +- **Logging**: middleware logs are captured, so `ExpectLogContains` works. +- **Short-circuiting**: when middleware returns without calling `next`, for example to deny access, the function method isn't invoked and the response is what you assert on. This works for `HttpResponseData` (set via `GetInvocationResult().Value`) and for `HttpRequest`/`IActionResult` (written to `HttpContext.Response`). +- **Exception mapping**: when middleware catches an exception and writes a response, that response is what you assert on. If middleware doesn't handle the exception, it surfaces as normal (`AssertException`). +- **Context**: `context.FunctionDefinition.Name`, `Parameters`, `InputBindings` and `OutputBindings` are derived from the method's `[Function]` and binding attributes. `GetHttpRequestDataAsync()`, `GetHttpContext()` and `GetInvocationResult()` all work. + +``` csharp +var req = test.CreateHttpRequest(HttpMethod.Get, "action/sync", r => r.Headers["X-Middleware"] = "deny"); +test.HttpTrigger() + .Run(f => f.ActionSync(req)) // middleware short-circuits; ActionSync is never invoked. + .ToHttpResponseMessageAssertor() + .AssertUnauthorized(); +``` + +To invoke the method directly and bypass middleware, use `FunctionTester.Create().UseWorkerMiddleware(false)`. + +Example: [`Middleware_Logs_Captured_Http`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`Middleware_Logs_Captured_NonHttp`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`Middleware_ShortCircuit_ActionResult`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`Middleware_ShortCircuit_HttpResponseData`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`Middleware_HandlesException_ActionResult`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`Middleware_UnhandledException_Propagates`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`Middleware_Bypassed`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs). The sample middleware is [`TestMiddleware`](../tests/UnitTestEx.IsolatedFunction/TestMiddleware.cs). + +> The pipeline integration implements some internal worker contracts (`IFunctionBindingsFeature`, and the ASP.NET Core integration's `IHttpCoordinator`) using `DispatchProxy`. It is verified against Worker.Core 1.19+ and Http.AspNetCore 2.x. Middleware that depends on trigger metadata or raw input binding data (`BindingContext.BindingData`, `BindInputAsync` for non-HTTP bindings) sees empty values. + +
+ +## Limitations + +`FunctionTester` invokes your function method through the worker middleware pipeline inside a real, built (but not started) DI host. That keeps tests fast and deterministic, but some parts of a real Functions invocation are skipped: + +Not exercised | Why it matters | Where to test it instead +-|-|- +**Trigger indexing** | Checking an attribute is not Functions host discovery/indexing or listener startup. | `WaitForFunctionsAsync` plus a real delivery test in [Aspire](./aspire.md). +**Final runtime HTTP behavior** | Result execution against a test context does not drive the server lifecycle or response-start callbacks. | A real HTTP request to the Aspire-hosted Functions app. +**Trigger metadata / raw binding data** | Middleware sees empty `BindingContext.BindingData` and trigger metadata, because the payload is passed as already-converted arguments. | [Aspire testing](./aspire.md). +**Input/output binding conversion** | Real binding converters, such as JSON-to-POCO, `[BlobInput]` and output bindings, aren't run. You supply the already-converted arguments, and outputs are returned values. | [Aspire testing](./aspire.md) with Azurite or emulators. +**Trigger delivery semantics** | Retries, poison queues, Service Bus auto-complete, Event Grid filtering and batching are runtime behaviour. | [Aspire testing](./aspire.md) with emulators. +**`host.json` / `local.settings.json`** | These are read by the Functions host, not your app's configuration. Use `appsettings.unittest.json` or `UseAdditionalConfiguration` for test values. | - +**Hosted services** | The host is never started, so `IHostedService` instances don't run. | Test them with `GenericTester`, or use Aspire. +**Code after `Build()` in `Program.cs`** | The host is captured at `Build()`. Code after it, such as `host.Run()`, never runs. | - + +`FunctionContext`, where a function needs one, is a test `FunctionContext` backed by the test host's `IServiceProvider`. `HttpRequestData.FunctionContext.GetHttpContext()` is supported. + +## Runnable Blob integration example + +Use the existing checked-in example rather than inventing a test-only `Program` or a second infrastructure setup: + +Project | References and purpose +-|- +[`UnitTestEx.IsolatedFunction`](../tests/UnitTestEx.IsolatedFunction/UnitTestEx.IsolatedFunction.csproj) | Worker SDK, `Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs`; [`StorageTriggerFunctions`](../tests/UnitTestEx.IsolatedFunction/StorageTriggerFunctions.cs) reads `unittestex` with `Source = BlobTriggerSource.EventGrid` and writes `processed/result.txt`. +[`UnitTestEx.Aspire.Functions.AppHost`](../tests/UnitTestEx.Aspire.Functions.AppHost/UnitTestEx.Aspire.Functions.AppHost.csproj) | `Aspire.Hosting.AppHost` and the function project; [`AppHost.cs`](../tests/UnitTestEx.Aspire.Functions.AppHost/AppHost.cs) provisions Azurite and starts Core Tools against the built function output, restricted to `BlobTrigger`. +[`UnitTestEx.Aspire.NUnit.Test`](../tests/UnitTestEx.Aspire.NUnit.Test/UnitTestEx.Aspire.NUnit.Test.csproj) | `UnitTestEx.Aspire`, **`UnitTestEx.Azure.Functions`**, `UnitTestEx.NUnit`, `Azure.Storage.Blobs` and the AppHost project. + +Both helpers below use the **`UnitTestEx` namespace**, but they ship in different packages: + +```csharp +using UnitTestEx; // Both helpers; reference both packages. +using Aspire.Hosting.Testing; // app.GetEndpoint(...) +using Azure.Storage; +using Azure.Storage.Blobs; +using System.Text; + +// After uploading inputBlob and creating the output container: +var createdEvent = EventGridExtensionMethods.CreateBlobCreatedEvent( // UnitTestEx.Azure.Functions + "unittestex", "customers.csv", inputBlob.Uri, + upload.Value.ETag.ToString(), "text/csv", Encoding.UTF8.GetByteCount(content)); + +using var response = await tester.FunctionsWebhook("functions") // UnitTestEx.Aspire + .PostBlobCreatedAsync("Host.Functions.BlobTrigger", createdEvent); +response.EnsureSuccessStatusCode(); +``` + +The complete [`AzuriteBlobTriggerTest`](../tests/UnitTestEx.Aspire.NUnit.Test/Functions/AzuriteBlobTriggerTest.cs) includes all imports, emulator credentials, storage endpoint resolution, container provisioning, upload, notification delivery and output polling. It first calls `WaitForFunctionsAsync("functions", ["BlobTrigger"])`, then uses the upload's actual ETag and UTF-8 byte length. It handles only an expected storage 404 while waiting; other storage errors fail the test. + +From the repository root on Windows, with .NET SDKs for the repository's targets, an Aspire-compatible container runtime (Docker or Podman), and Azure Functions Core Tools v4 on `PATH`: + +```powershell +dotnet test tests\UnitTestEx.Aspire.NUnit.Test\UnitTestEx.Aspire.NUnit.Test.csproj --framework net8.0 --filter FullyQualifiedName~AzuriteBlobTriggerTest +``` + +Keep ports `7071` and `10000`-`10002` free and run fixed-port emulator fixtures/frameworks sequentially. The AppHost starts and disposes its resources; the test does not require a separately running `func start`. + +For JSON report blobs, deserialize with options matching the producer's contract, for example `JsonSerializer.Deserialize(text, new JsonSerializerOptions(JsonSerializerDefaults.Web))` for a web-default producer. UnitTestEx's assertion serializer does not configure independent storage SDK or `System.Text.Json` calls. For Blob poison inspection, configure `QueueClientOptions.MessageEncoding = QueueMessageEncoding.Base64` when the host writes Base64 queue messages (or decode explicitly once). Do not deserialize encoded text or decode a message twice. The sample above verifies text output; it does not claim poison/retry coverage. + +[← Back to README](../README.md) diff --git a/docs/dependency-injection-and-configuration.md b/docs/dependency-injection-and-configuration.md new file mode 100644 index 0000000..377fb10 --- /dev/null +++ b/docs/dependency-injection-and-configuration.md @@ -0,0 +1,70 @@ +# Dependency injection and configuration + +Every tester (API, Function, Generic) runs the **real** host composition from your app, then lets you override parts of it. Test configuration and service overrides are layered **on top of** the app's own, so the override always wins. + +[← Back to README](../README.md) + +
+ +## Replacing services + +All `Replace*` and `Mock*` methods remove any existing registration for the service type, then register the replacement. + +Method | Description +-|- +`ReplaceSingleton` / `ReplaceScoped` / `ReplaceTransient` | Replace with an instance, a factory, a type, or a `` pair. +`ReplaceKeyedSingleton` / `ReplaceKeyedScoped` / `ReplaceKeyedTransient` | The same for keyed services. +`MockSingleton` / `MockScoped` / `MockTransient` | Register a Moq `Mock` and resolve its `.Object`. +`ReplaceHttpClientFactory(mcf)` | Swap `IHttpClientFactory` for a [`MockHttpClientFactory`](./http-client-mocking.md). +`ConfigureServices(sc => ...)` | Any other `IServiceCollection` change. The extension methods `sc.ReplaceSingleton(...)` etc. work here too. + +``` csharp +var mock = new Mock(); +mock.Setup(x => x.Greet()).Returns("mocked"); + +using var test = GenericTester.Create().ConfigureServices(sc => sc.AddSingleton()); +test.MockSingleton(mock) + .Run(g => g.Greet()) + .AssertValue("mocked"); +``` + +Example: [`ReplaceSingleton_OverridesRegistration`](../tests/UnitTestEx.NUnit.Test/Other/DependencyInjectionTest.cs), [`ReplaceScoped_OverridesRegistration`](../tests/UnitTestEx.NUnit.Test/Other/DependencyInjectionTest.cs), [`MockSingleton_UsesMoqObject`](../tests/UnitTestEx.NUnit.Test/Other/DependencyInjectionTest.cs). + +
+ +## Host lifetime + +The host is built lazily, the first time `Services`, `Configuration` or a `Run` needs it, and is then reused across runs on the same tester. Each `Replace*` / `Mock*` / `ConfigureServices` call has an `autoResetHost` parameter (default `true`), so the next run rebuilds the host with the change applied. + +- `ResetHost()` forces a rebuild; `ResetHost(resetConfiguredServices: true)` also throws away the registered overrides. Example: [`Get_Test4_WithResetHost`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs). +- `OnHostStart(() => ...)` registers logic that runs each time the host is (re)built. +- `Services` is the built `IServiceProvider`. Use it to arrange or inspect state directly. Example: [`ServiceProvider`](../tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs). + +
+ +## Configuration + +Configuration sources, layered on top of the app's own: + +Source | Notes +-|- +`appsettings.unittest.json` | In the **test** project, copied to output. Loaded automatically, so you can change settings for tests without touching the app under test. +Environment variables | Standard host behaviour. +`UseAdditionalConfiguration(key, value)` / `UseAdditionalConfiguration(pairs)` | Per-tester overrides; these take precedence. +`TestSetUp.Environment` | Host environment name; default `Development`. + +`test.Configuration` exposes the resulting `IConfiguration`. Example: [`Configuration_Overrride_Use`](../tests/UnitTestEx.NUnit.Test/Other/GenericTest.cs), [`Configuration`](../tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs), [`Configuration_Use`](../tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs). + +`appsettings.unittest.json` can also set the default JSON serializer for every test: + +``` json +{ + "DefaultJsonSerializer": "UnitTestEx.MSTest.Test.NewtonsoftJsonSerializer, UnitTestEx.MSTest.Test" +} +``` + +The default is `UnitTestEx.Json.JsonSerializer` (System.Text.Json). Use `UseJsonSerializer(...)` to change it for one tester. + +See also: [Test set-up and extensibility](./test-setup-and-extensibility.md). + +[← Back to README](../README.md) diff --git a/docs/generic-testing.md b/docs/generic-testing.md new file mode 100644 index 0000000..089dc7c --- /dev/null +++ b/docs/generic-testing.md @@ -0,0 +1,72 @@ +# Generic testing + +`GenericTester` (package `UnitTestEx`) runs any code inside a lightweight .NET Generic Host. You get UnitTestEx's DI, configuration, logging capture, expectations and assertions without needing an API or Function. Use it for domain services, repositories, message handlers and other plain classes. + +[← Back to README](../README.md) + +
+ +## Create + +``` csharp +using var test = GenericTester.Create(); // Empty host; only test configuration and logging. +using var test = GenericTester.Create(); // Host configured by an entry point class. +``` + +The entry point is any class that has **one or more** of the following methods. Each can be static or an instance method, and they're found by name using reflection, so no interface is needed: + +Method | Purpose +-|- +`ConfigureApplication(IHostApplicationBuilder builder)` | Preferred; matches the modern `Host.CreateApplicationBuilder` style. +`ConfigureServices(IServiceCollection services)` | Service registrations. +`ConfigureAppConfiguration(HostBuilderContext context, IConfigurationBuilder config)` | App configuration. +`ConfigureHostConfiguration(IConfigurationBuilder config)` | Host configuration. + +Example: [`Run_Service`](../tests/UnitTestEx.NUnit.Test/Other/GenericTest.cs) with its `EntryPoint` class. + +`new GenericTester()` gives the tester a value type, so `ExpectValue` is strongly typed. + +
+ +## Run + +`Run` accepts a function or action, either sync or async (`Task`, `ValueTask`). The `Run` / `Run` overloads resolve `TService` from the host for you: + +``` csharp +test.Run(() => 1).AssertSuccess().AssertValue(1); +test.Run(gin => gin.Pour()).AssertValue(1); +test.Run(gin => gin.Stir()).AssertException("As required by Bond; shaken, not stirred."); +``` + +Example: [`Run_Success`](../tests/UnitTestEx.NUnit.Test/Other/GenericTest.cs), [`Run_Exception`](../tests/UnitTestEx.NUnit.Test/Other/GenericTest.cs), [`Run_Service`](../tests/UnitTestEx.NUnit.Test/Other/GenericTest.cs). + +> `Run` resolves from the **root** provider, so a **scoped** service throws `Cannot resolve scoped service from root provider`. Use `ScopedType` instead (see below). + +
+ +## `Type` and `ScopedType` + +These are available on **every** tester (API, Function, Generic), so you can test any service registered in the host under test. + +- `Type()` resolves the service from DI. If it isn't registered, it is constructed instead, with each constructor parameter resolved from DI. Then it runs methods on it. +- `ScopedType(t => ...)` creates a DI scope, resolves the service inside it, runs your test actions, then disposes the scope. Use this for scoped services, such as a `DbContext` or anything that depends on request-scoped state. `UseScopedTypeSetUp(sp => ...)` runs arrangement code inside every such scope first. + +Example: [`ScopedType_RunsWithinServiceScope`](../tests/UnitTestEx.NUnit.Test/Other/DependencyInjectionTest.cs), [`Type_IActionResult`](../tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs). + +
+ +## Base classes + +To reuse one tester across a whole fixture, inherit from the base class for your framework. It creates the tester once and exposes it as `Test`: + +Framework | API | Generic +-|-|- +NUnit | `WithApiTester` | `WithGenericTester` +MSTest | `WithApiTester` | `WithGenericTester` +xUnit | `WithApiTester` (with `ApiTestFixture`) | `UnitTestBase` + `GenericTester.Create()` + +Example: [`Run_Success_AssertJSON`](../tests/UnitTestEx.NUnit.Test/Other/WithGenericTesterTest.cs). + +See also: [Dependency injection and configuration](./dependency-injection-and-configuration.md), [Assertions and expectations](./assertions-and-expectations.md). + +[← Back to README](../README.md) diff --git a/docs/http-client-mocking.md b/docs/http-client-mocking.md new file mode 100644 index 0000000..b1e28dd --- /dev/null +++ b/docs/http-client-mocking.md @@ -0,0 +1,227 @@ +# HTTP client mocking + +`MockHttpClientFactory` (package `UnitTestEx`) replaces `IHttpClientFactory` so that downstream HTTP calls made through named or typed `HttpClient`s return mocked responses. It is built on [Moq](https://github.com/moq/moq4). It works with every tester, and the same request/response definitions can be shared with Aspire tests through `IHttpMockClient`. + +[← Back to README](../README.md) + +
+ +## Basics + +Where invoking a down-stream system using an [`HttpClient`](https://docs.microsoft.com/en-us/dotnet/api/system.net.http.httpclient) within a unit test context this should generally be mocked. To enable _UnitTestEx_ provides a [`MockHttpClientFactory`](../src/UnitTestEx/Mocking/MockHttpClientFactory.cs) to manage each `HttpClient` (one or more), and mock a response based on the configured request. This leverages the [Moq](https://github.com/moq/moq4) framework internally to enable. One or more requests can also be configured per `HttpClient`. + +The following is an [example](../tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs). + +``` csharp +var mcf = MockHttpClientFactory.Create(); +mcf.CreateClient("XXX", new Uri("https://somesys")) + .Request(HttpMethod.Get, "products/abc").Respond.WithJson(new { id = "Abc", description = "A blue carrot" }); + +using var test = ApiTester.Create(); +test.ReplaceHttpClientFactory(mcf) + .Controller() + .Run(c => c.Get("abc")) + .AssertOK() + .Assert(new { id = "Abc", description = "A blue carrot" }); +``` + +The `ReplaceHttpClientFactory` leverages the `Replace*` capabilities described in [Dependency injection and configuration](./dependency-injection-and-configuration.md). + +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 + +Any configuration specified as part of the registering the `HttpClient` services from a Dependency Injection (DI) perspective is ignored by default when creating an `HttpClient` using the `MockHttpClientFactory`. This default behavior is intended to potentially minimize any side-effect behavior that may occur that is not intended for the unit testing. For example, a `DelegatingHandler` may be configured that requests a token from an identity provider which is not needed for the unit test, or may fail due to lack of access from the unit testing environment. + +``` csharp +// Startup service (DI) configuration. +services.AddHttpClient("XXX", hc => hc.BaseAddress = new System.Uri("https://somesys")) // This is HttpClient configuration. + .AddHttpMessageHandler(_ => new MessageProcessingHandler()) // This is HttpMessageHandler configuration. + .ConfigureHttpClient(hc => hc.DefaultRequestVersion = new Version(1, 2)); // This is further HttpClient configuration. +``` + +However, where the configuration is required then the `MockHttpClient` can be configured _explicitly_ to include the configuration; the following methods enable: + +Method | Description +-|- +`WithConfigurations` | Indicates that the `HttpMessageHandler` and `HttpClient` configurations are to be used. * +`WithoutConfigurations` | Indicates that the `HttpMessageHandler` and `HttpClient` configurations are _not_ to be used (this is the default state). +`WithHttpMessageHandlers` | Indicates that the `HttpMessageHandler` configurations are to be used. * +`WithoutHttpMessageHandlers` | Indicates that the `HttpMessageHandler` configurations are _not_ to be used. +`WithHttpClientConfigurations` | Indicates that the `HttpClient` configurations are to be used. +`WithoutHttpClientConfigurations` | Indicates that the `HttpClient` configurations are to be used. +-- | -- +`WithoutMocking` | Indicates that the underlying `HttpClient` is **not** to be mocked; i.e. will result in an actual/real HTTP request to the specified endpoint. This is useful to achieve a level of testing where both mocked and real requests are required. Note that an `HttpClient` cannot support both, these would need to be tested separately. + +_Note:_ `*` above denotes that an array of `DelegatingHandler` types to be excluded can be specified; with the remainder being included within the order specified. + +``` csharp +// Mock with configurations. +var mcf = MockHttpClientFactory.Create(); +mcf.CreateClient("XXX").WithConfigurations() + .Request(HttpMethod.Get, "products/xyz").Respond.With(HttpStatusCode.NotFound); + +// No mocking, real request. +var mcf = MockHttpClientFactory.Create(); +mcf.CreateClient("XXX").WithoutMocking(); +``` + +
+ +## Request body matching + +A request matches on method and URI, and then on body: + +Method | Matches +-|- +_(none)_ / `WithNoBody()` | No body: absent or zero-length. In-process (Tier 1), this is the default when no body method is called; Aspire requires an explicit body method. On Aspire, rules restricted exclusively to `GET`/`HEAD` apply no body constraint, as WireMock.Net does not read those bodies by default. +`WithAnyBody()` | Any non-empty body, regardless of content. +`WithBody(text)` / `WithBody(text, mediaType)` | The exact text (and media type, where specified). +`WithJsonBody(json \| value, pathsToIgnore)` | Semantically equal JSON (via `JsonElementComparer`), ignoring the specified paths. +`WithJsonResourceBody(resourceName, ...)` | As `WithJsonBody`, with the JSON loaded from an embedded resource. + +``` csharp +mc.Request(HttpMethod.Post, "products/xyz").WithAnyBody().Respond.With(HttpStatusCode.Accepted); +mc.Request(HttpMethod.Post, "products/abc").WithNoBody().Respond.With(HttpStatusCode.NoContent); +``` + +> In-process (Tier 1), where more than one stub matches a request, the **most recently** configured one wins (Moq semantics), so register catch-alls such as `WithAnyBody()` first and more specific stubs after. + +
+ +## Times + +To verify the number of times that a request/response is performed _UnitTestEx_ support MOQ [`Times`](https://github.com/moq/moq4/blob/main/src/Moq/Times.cs), as follows: + +``` csharp +var mcf = MockHttpClientFactory.Create(); +var mc = mcf.CreateClient("XXX", new Uri("https://d365test")); +mc.Request(HttpMethod.Post, "products/xyz").Times(Times.Exactly(2)).WithJsonBody(new Person { FirstName = "Bob", LastName = "Jane" }) + .Respond.WithJsonResource("MockHttpClientTest-UriAndBody_WithJsonResponse3.json", HttpStatusCode.Accepted); +``` + +
+ +## Sequence + +To support different responses per execution MOQ supports [sequences](https://github.com/moq/moq4/blob/main/src/Moq/SequenceSetup.cs). This capability has been extended for _UnitTestEx_. + +``` csharp +var mcf = MockHttpClientFactory.Create(); +var mc = mcf.CreateClient("XXX", new Uri("https://d365test")); +mc.Request(HttpMethod.Get, "products/xyz").Respond.WithSequence(s => +{ + s.Respond().With(HttpStatusCode.NotModified); + s.Respond().With(HttpStatusCode.NotFound); +}); +``` + +
+ +## Delay + +A delay (sleep) can be simulated so a response is not always immediate. This can be specified as a fixed value, or randomly generated using a from and to. + +``` csharp +var mcf = MockHttpClientFactory.Create(); +var mc = mcf.CreateClient("XXX", new Uri("https://d365test")); +mc.Request(HttpMethod.Get, "products/xyz").Respond.Delay(500).With(HttpStatusCode.NotFound); +mc.Request(HttpMethod.Get, "products/kjl").Respond.WithSequence(s => +{ + s.Respond().Delay(250).With(HttpStatusCode.NotModified); + s.Respond().Delay(100, 200).With(HttpStatusCode.NotFound); +}); +``` + +
+ +## YAML/JSON configuration + +The Request/Response configuration can also be specified within an embedded resource using YAML/JSON as required. The [`mock.unittestex.json`](../src/UnitTestEx/Schema/mock.unittestex.json) JSON schema defines content; where the file is named `*.unittestex.yaml` or `*.unittestex.json` then the schema-based intellisense and validation will occur within the likes of Visual Studio. + +To reference the YAML/JSON from a unit test the following is required: + +``` csharp +var mcf = MockHttpClientFactory.Create(); +mcf.CreateClient("XXX", new Uri("https://unit-test")).WithRequestsFromResource("my.mock.unittestex.yaml"); +``` + +The following represents a YAML example for one-to-one request/responses: + +``` yaml +- method: post + uri: products/xyz + body: ^ + response: + status: 202 + body: | + {"product":"xyz","quantity":1} + +- method: get + uri: people/123 + response: + headers: + ETag: Abc123 + body: | + { + "first":"Bob", + "last":"Jane" + } +``` + +The following represents a YAML example for a request/response with sequences: + +``` yaml +- method: get + uri: people/123 + sequence: + - body: | + { + "first":"Bob", + "last":"Jane" + } + - body: | + { + "first":"Sarah", + "last":"Johns" + } +``` + +_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.md) 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"); +``` + +Both loaders apply the same request `body` semantics: where `body` is omitted, the request must have **no body**, meaning absent or zero-length (`WithNoBody()`). Use `body: ^` to match any **non-empty** body (`WithAnyBody()`). An absent body and a zero-length body both send `Content-Length: 0` on the wire, so neither tier distinguishes them. One Aspire-tier caveat: by default WireMock.Net does not read `GET`/`HEAD` request bodies at all. Consequently, `WithAnyBody()` cannot match those methods, and `WithNoBody()` applies no body constraint for rules whose configured methods are exclusively `GET`/`HEAD`; for unrestricted or mixed-method rules, the no-body constraint is applied. + +
+ +
+ +## Examples + +Capability | Example +-|- +URI-only match | [`UriOnly_Single`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs), [`UriOnly_Multi`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs) +URI and body (string/JSON/XML/any) | [`UriAndBody_Json_Single`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs), [`UriAndBody_WithXmlRequest`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs), [`UriAndAnyBody`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs) +No body (absent or zero-length) | [`Interface_Request_WithNoBody_MatchesAbsentOrEmptyBodyOnly`](../tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs), [`HttpMock_WithNoBody_MatchesAbsentOrEmptyBodyOnly`](../tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs) (Aspire) +JSON response from resource | [`UriAndBody_WithJsonResponse3`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs) +Verify call counts (Times) | [`VerifyMock_Executed`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs), [`VerifyMock_NotExecuted_Times`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs) +Sequence | [`MockSequence`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs), [`MockSequence_Body`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs) +Delay | [`MockDelay`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs), [`MockSequenceDelay`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs) +Reuse / reset / change on the fly | [`MockReuseSameAndReset`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs), [`MockOnTheFlyChange`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs) +Default (unnamed) HttpClient | [`DefaultHttpClient`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs) +YAML configuration | [`WithYamlRequests`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs), [`WithYamlSequence`](../tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs) +Used within an API test | [`Success`](../tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs) + +[← Back to README](../README.md) diff --git a/docs/migration-v6.md b/docs/migration-v6.md new file mode 100644 index 0000000..92a3e78 --- /dev/null +++ b/docs/migration-v6.md @@ -0,0 +1,62 @@ +# Migrating from v5 to v6 + +v6 is a **breaking** release. It drops end-of-life platforms and the Azure Functions in-process model. Most API and generic tests compile unchanged. The work is mostly in Azure Functions test projects. + +[← Back to README](../README.md) + +
+ +## Platform + +v5 | v6 +-|- +`net6.0`, `net7.0`, `net8.0`, `net9.0`, `net10.0` | `net8.0`, `net9.0`, `net10.0` only + +net6.0 and net7.0 are out of support, and their target-specific code paths have been removed. + +
+ +## Azure Functions: isolated worker only + +Microsoft ends support for the in-process model on **10 November 2026**. v6 supports only the **v4 isolated worker** model. + +Removed | Replacement +-|- +In-process / WebJobs support, including the `FunctionsStartup` entry point and `Microsoft.Azure.WebJobs.*` dependencies | Isolated worker. `FunctionTester` now runs your app's **real** `Program.cs`. +`CreateWebJobsServiceBusMessageActions()` / `CreateWebJobsServiceBusSessionMessageActions()` and their WebJobs assertors | `CreateWorkerServiceBusMessageActions()` → `WorkerServiceBusMessageActionsAssertor`. Isolated-worker session message actions aren't supported yet. +`UnitTestEx.Azure.ServiceBus` package | The `CreateServiceBusMessage*` helpers now live in `UnitTestEx.Azure.Functions`, namespace `UnitTestEx`. Remove the package reference; your `using` stays the same. + +### `TEntryPoint` + +In v5, `FunctionTester.Create()` needed a `Startup` class (`FunctionsStartup` or similar) with `new()`. In v6, `TEntryPoint` is **any type in the function app's assembly** and is used only to find the entry point. Pass a function class. You don't need a `Program` class, `BuildHost` method or other test-only structure: top-level statements in `Program.cs` work as-is. + +``` csharp +// v5 +using var test = FunctionTester.Create(); + +// v6 +using var test = FunctionTester.Create(); +``` + +The `Program.cs` host is captured when `Build()` is called and is never started, so `host.Run()` doesn't block the test. See [How it works](./azure-functions.md#how-it-works). + +### New in v6 + +- `HttpTrigger()` works with **both** HTTP styles: ASP.NET Core integration (`HttpRequest` → `IActionResult`/`IResult`) and the built-in model (`HttpRequestData` → `HttpResponseData`). Both are asserted through the same `HttpResponseMessageAssertor`. +- `Trigger()` runs any other trigger type (Timer, Blob, Queue, Event Hubs, Cosmos DB, ...) and gives multi-output assertions via `FunctionOutputAssertor`. +- Event Grid builders: `CreateEventGridEvent*` and `CreateCloudEvent*`. +- A test `FunctionContext`, backed by the test host's services, is available for functions that take a `FunctionContext` parameter. + +See [Azure Functions testing](./azure-functions.md) for details, including the [Limitations](./azure-functions.md#limitations). + +
+ +## Checklist + +1. Change test project `TargetFramework`s to `net8.0`+. +2. Migrate the function app to the isolated worker, following [Microsoft's guide](https://learn.microsoft.com/en-us/azure/azure-functions/migrate-dotnet-to-isolated-model). +3. Replace `FunctionTester.Create()` with `FunctionTester.Create()`. +4. Remove the `UnitTestEx.Azure.ServiceBus` package reference. +5. Replace `CreateWebJobsServiceBus*MessageActions()` with `CreateWorkerServiceBusMessageActions()`. + +[← Back to README](../README.md) diff --git a/docs/test-setup-and-extensibility.md b/docs/test-setup-and-extensibility.md new file mode 100644 index 0000000..ffd7408 --- /dev/null +++ b/docs/test-setup-and-extensibility.md @@ -0,0 +1,66 @@ +# Test set-up and extensibility + +[← Back to README](../README.md) + +
+ +## `TestSetUp` + +`TestSetUp.Default` holds the defaults that every new tester copies via `UseSetUp`. Change it once per test assembly. A tester can use a different set-up with `UseSetUp(customSetUp)`; `TestSetUp.Clone()` is a handy starting point. + +Property | Default | Purpose +-|-|- +`DefaultUserName` | `"Anonymous"` | Used when `UseUser` / `WithUser` isn't called. +`UserNameConverter` | `null` | Converts the object passed to `UseUser(object)` into a user name. +`OnBeforeHttpRequestMessageSendAsync` | `null` | Runs before each API test request, and receives the user name. Use it to add auth headers or tokens, correlation IDs, etc. +`OnBeforeHttpRequestSendAsync` | `null` | The same for `HttpRequest`-based Function triggers. +`ConfigureServices` | `null` | Extra service configuration applied to **every** tester. +`JsonSerializer` / `JsonComparerOptions` | System.Text.Json | Serialization and JSON comparison defaults. +`MinimumLogLevel` | `Debug` | Lowest log level that gets captured. +`ConcurrencyErrorETag` | `"ZZZZZZZZZZZZ"` | Fake ETag for concurrency-error tests. +`Properties` | empty | Free-form bag for extensions. + +Static settings: `TestSetUp.Environment` (host environment, default `Development`) and `TestSetUp.Extensions` (see below). + +
+ +## One-off assembly set-up + +Mark the test assembly with `[assembly: OneOffTestSetUp(typeof(MySetUp))]`, where `MySetUp : OneOffTestSetUpBase`. UnitTestEx runs `SetUp()` **once** per assembly, automatically, when `TestSetUp` first initializes or the assembly loads. Use it to set `TestSetUp.Default` and register extensions. You can also call `OneOffTestSetUpAttribute.SetUp(assembly)` explicitly to force it early. That re-runs `SetUp()`, so keep your set-up idempotent. + +Example: [`SetUp_SetDefaultUserName`](../tests/UnitTestEx.NUnit.Test/Other/OneOffTestSetUpTest.cs). + +
+ +## Repeatable set-up (e.g. database reset) + +`TestSetUp.Default.RegisterSetUp(...)` or `RegisterAutoSetUp(...)` registers a function. Each `SetUp()` / `SetUpAsync()` call then runs it, thread-safely, and passes how many times it has run so far, so you can do "full reset on first run, cheap reset afterwards". `RegisterAutoSetUp` also fails the test automatically if set-up fails, and logs its output in the next test. + +
+ +## Tester extensions + +`TesterExtensionsConfig` is the hook for **libraries** that build on UnitTestEx (CoreEx.UnitTesting uses it). Add your subclass to `TestSetUp.Extensions`. It can: + +Override | Purpose +-|- +`OnUseSetUp(owner)` | React when a tester applies a `TestSetUp`. +`ConfigureServices(owner, services)` | Add services to **every** tester's host. +`UpdateValueFromHttpResponseMessage` / `UpdateValueFromActionResult` | Fill in a deserialized value from the response, e.g. copy an ETag header into the model. +`ExpectationAssertAsync(expectation, args)` | Add custom assertions to the expectations pipeline. + +Example: [`TesterExtensions_ApiTester`](../tests/UnitTestEx.NUnit.Test/Other/OneOffTestSetUpTest.cs), [`TesterExtensions_FunctionTester`](../tests/UnitTestEx.NUnit.Test/Other/OneOffTestSetUpTest.cs), [`TesterExtensions_GenericTester`](../tests/UnitTestEx.NUnit.Test/Other/OneOffTestSetUpTest.cs). + +
+ +## Test framework support + +Package | Base classes | Notes +-|-|- +`UnitTestEx.NUnit` | `WithApiTester`, `WithGenericTester` | Writes output to `TestContext`. +`UnitTestEx.MSTest` | `WithApiTester`, `WithGenericTester` | Writes output to `TestContext`. +`UnitTestEx.Xunit` | `UnitTestBase`, `ApiTestFixture`, `WithApiTester` | Pass `ITestOutputHelper` to the constructor. Inside a `UnitTestBase`, create testers as usual, e.g. `GenericTester.Create()`, so output is routed to xUnit. + +The test code is near-identical across frameworks. Compare [UnitTestEx.NUnit.Test](../tests/UnitTestEx.NUnit.Test), [UnitTestEx.MSTest.Test](../tests/UnitTestEx.MSTest.Test) and [UnitTestEx.Xunit.Test](../tests/UnitTestEx.Xunit.Test). + +[← Back to README](../README.md) diff --git a/global.json b/global.json new file mode 100644 index 0000000..c591544 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.402", + "rollForward": "latestPatch", + "allowPrerelease": false + } +} diff --git a/nuget-publish.ps1 b/nuget-publish.ps1 index 304079b..2794d48 100644 --- a/nuget-publish.ps1 +++ b/nuget-publish.ps1 @@ -1,15 +1,24 @@ <# .SYNOPSIS - Generates nuget pacakges and publishes if server values are provided + Builds NuGet packages and publishes them when server credentials are provided. + + .DESCRIPTION + Builds and packs all supported target frameworks using Release by default. + Overrides GeneratePackageOnBuild so packing builds rather than reusing existing binaries. + Preserves existing packages except when replacing the same package filename. + Remote publishing uploads only packages produced by this invocation. .PARAMETER PublishFolder - Local folder to publish the built nuget pacakges to + Local folder to publish the built NuGet packages to. + + .PARAMETER Configuration + Build configuration. Defaults to Release independently of the Visual Studio configuration. .PARAMETER IncludeSymbols Create nuget symbols package .PARAMETER IncludeSource - Include source in unget package + Include source in NuGet package. .PARAMETER ApiKey ApiKey for publishing to server @@ -21,22 +30,22 @@ List of projects to Build and publish. .PARAMETER DeleteFromlocalCache - Should the newly built packages be deleted from tle Nuget Cache Folder. + Removes matching newly packed package versions from the local NuGet cache by default. + Pass -DeleteFromlocalCache:$false to preserve cached copies. .PARAMETER NugetCacheFolder Path to Nuget Cache folder. .INPUTS - None. You cannot pipe objects to Add-Extension. + None. .Example C:\PS> .\nuget-publish.ps1 -configuration 'Debug' -IncludeSymbols -IncludeSource - Build all and publish locally. - Used to test locally changes, Create + Build all packages in Debug and publish locally. .Example - C:\PS> .\nuget-publish.ps1 -NugetServer 'https://api.nuget.org/v3/index.json' -ApiKey 'key' -IncludeSymbols -IncludeSource -ProjectsToPublish @('UnitTestEx') - Build selected pacakges and push to remote server. + C:\PS> .\nuget-publish.ps1 -NugetServer 'https://api.nuget.org/v3/index.json' -ApiKey 'key' -IncludeSymbols -IncludeSource -ProjectsToPublish @('src\UnitTestEx') + Build selected packages and push to the remote server. #> param( [string]$PublishFolder = "$($env:USERPROFILE)\nuget-publish", @@ -45,87 +54,79 @@ param( [switch]$IncludeSource, [string]$NugetServer, [string]$ApiKey, - [switch]$DeleteFromlocalCache, + [switch]$DeleteFromlocalCache = $true, [string]$NugetCacheFolder = "$($env:USERPROFILE)\.nuget\packages", [String[]]$ProjectsToPublish = @( "src\UnitTestEx", "src\UnitTestEx.Aspire", "src\UnitTestEx.Azure.Functions", - "src\UnitTestEx.Azure.ServiceBus", "src\UnitTestEx.MSTest", "src\UnitTestEx.Xunit", "src\UnitTestEx.NUnit") ) -$ShouldPublishRemote = (![string]::IsNullOrEmpty($apiKey) -and ![string]::IsNullOrEmpty($NugetServer)) - -if ($ShouldPublishRemote) { - Write-Warning "APIKey and NugetServer provided, All existing NuGet packages in publish folder will be deleted.`r`n" +$hasApiKey = ![string]::IsNullOrWhiteSpace($ApiKey) +$hasNugetServer = ![string]::IsNullOrWhiteSpace($NugetServer) +if ($hasApiKey -ne $hasNugetServer) { + throw 'ApiKey and NugetServer must both be provided for remote publishing.' } +$ShouldPublishRemote = $hasApiKey -and $hasNugetServer -# Create publish folder if it doesn't exist; otherwise, delete existing. -if (!(Test-Path -Path $publishFolder)) { - Write-Host "Publish folder doesn't exist - creating folder." -ForegroundColor Yellow - New-Item -ItemType directory -Path $publishFolder +if (!(Test-Path -LiteralPath $PublishFolder)) { + Write-Host "Publish folder doesn't exist - creating folder." -ForegroundColor Yellow + New-Item -ItemType Directory -Path $PublishFolder -ErrorAction Stop | Out-Null } -else { - if($ShouldPublishRemote) - { - Write-Host 'Deleting existing NuGet packages from publish folder.' -ForegroundColor Yellow - $removeFiles = Join-Path $publishFolder '*.nupkg' - Remove-Item $removeFiles + +$stagingFolder = Join-Path ([IO.Path]::GetTempPath()) ("UnitTestEx-nuget-publish-" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $stagingFolder -ErrorAction Stop | Out-Null +$packageDetailsRegexp = [regex] '^(?.+?)\.(?\d+\.\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$' + +try { + foreach ($project in $ProjectsToPublish) { + $projectPath = if ([IO.Path]::IsPathRooted($project)) { $project } else { Join-Path $PSScriptRoot $project } + $packArguments = @('pack', $projectPath, '-o', $stagingFolder, '-c', $configuration, '-p:GeneratePackageOnBuild=false', '--force') + if ($IncludeSource) { $packArguments += '--include-source' } + if ($IncludeSymbols) { $packArguments += '--include-symbols' } + + Write-Host "`r`nPacking $project ($configuration, all target frameworks)." -ForegroundColor Yellow + & dotnet @packArguments + if ($LASTEXITCODE -ne 0) { throw "Failed to pack $project (exit $LASTEXITCODE)." } } -} -# Generate NuGet packages using dotnet pack. -$successPackageRegexp = [regex] 'Successfully\screated\spackage\s''(?.*?)''.' -$packageDetailsRegexp = [regex] '(?.*?)\.(?\d*\.\d*\.\d*\.\d*.*)(? _elapsedLogLineCounts = new(); private ResourceLogCaptureProvider? _resourceLogCaptureProvider; private Task? _appTask; + private DistributedApplication? _diagnosticApplication; private bool _disposed; /// @@ -158,6 +161,7 @@ public Task GetDistributedApplicationAsync() { lock (SyncRoot) { + ObjectDisposedException.ThrowIf(_disposed, this); return _appTask ??= CreateDistributedApplicationAsync(); } } @@ -219,15 +223,86 @@ private async Task CreateDistributedApplicationAsync() await afterStart(app).ConfigureAwait(false); } } - catch + catch (Exception ex) { - await app.DisposeAsync().ConfigureAwait(false); + ReportApplicationFailure(app, $"AppHost startup failed: {ex.GetType().Name}: {ex.Message}"); + try + { + await app.DisposeAsync().ConfigureAwait(false); + } + catch (Exception cleanupException) + { + throw new AggregateException("AppHost startup and cleanup both failed.", ex, cleanupException); + } + throw; } + _diagnosticApplication = app; return app; } + /// + /// Writes failure context with resource states and bounded recent resource logs. + /// + /// The failure context. + protected override void WriteFailureDiagnostics(string reason) + { + base.WriteFailureDiagnostics(reason); + WriteApplicationDiagnostics(_diagnosticApplication); + } + + /// + /// Reports startup diagnostics before cleanup without replacing the startup failure. + /// + /// The built application whose startup failed. + /// The failure context. + private void ReportApplicationFailure(DistributedApplication app, string reason) + { + try + { + base.WriteFailureDiagnostics(reason); + WriteApplicationDiagnostics(app); + } + catch (Exception ex) + { + System.Diagnostics.Trace.TraceError($"UnitTestEx could not write Aspire failure diagnostics: {ex}"); + } + } + + /// + /// Writes available resource states and bounded recent logs without draining or asserting them. + /// + /// The built application, or when unavailable. + private void WriteApplicationDiagnostics(DistributedApplication? app) + { + Implementor.WriteLine("RESOURCE STATES >"); + if (app is null) + Implementor.WriteLine("No built application is available."); + else + { + foreach (var resource in app.Services.GetRequiredService().Resources.OrderBy(r => r.Name, StringComparer.Ordinal)) + { + if (app.ResourceNotifications.TryGetCurrentState(resource.Name, out var current) && current is not null) + { + var state = current.Snapshot.State?.Text; + Implementor.WriteLine($"{resource.Name}: {(string.IsNullOrEmpty(state) ? "unknown" : state)}; exit code: {current.Snapshot.ExitCode?.ToString(CultureInfo.InvariantCulture) ?? "none"}"); + } + else + Implementor.WriteLine($"{resource.Name}: state unavailable (not yet reported or multiple replicas)."); + } + } + + _resourceLogCaptureProvider?.FlushPendingEntries(); + Implementor.WriteLine("RECENT RESOURCE LOGS > (last 50 entries per resource, at most 4096 characters each)"); + foreach (var (resourceName, buffer) in _resourceLogBuffers.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + Implementor.WriteLine($"Resource '{resourceName}':"); + foreach (var entry in buffer.TakeLast(50)) + Implementor.WriteLine(entry.Text is { Length: > 4096 } text ? text[..4096] + " [truncated]" : entry.Text); + } + } + /// /// 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 @@ -244,9 +319,17 @@ protected override void OnResetHost() _appTask = null; } - if (appTask is { IsCompletedSuccessfully: true }) + if (appTask is not null) { - appTask.Result.Dispose(); + try + { + if (!appTask.IsFaulted && !appTask.IsCanceled) + appTask.GetAwaiter().GetResult().Dispose(); + } + finally + { + _diagnosticApplication = null; + } Implementor.WriteLine(""); Implementor.WriteLine("** The underlying UnitTestEx 'AspireTester' distributed application host has been reset. **"); Implementor.WriteLine(""); @@ -279,7 +362,15 @@ public async Task WaitForResourceAsync(string resourceName, TimeSpan? timeout = ArgumentException.ThrowIfNullOrEmpty(resourceName); var app = await GetDistributedApplicationAsync().ConfigureAwait(false); - await app.WaitForResourceAsync(resourceName, timeout).ConfigureAwait(false); + try + { + await app.WaitForResourceAsync(resourceName, timeout).ConfigureAwait(false); + } + catch (Exception ex) + { + ReportFailureDiagnostics($"Waiting for resource '{resourceName}' failed: {ex.GetType().Name}: {ex.Message}"); + throw; + } } /// @@ -300,6 +391,101 @@ public async Task WaitForResourceAsync(string[] resourceNames, TimeSpan? timeout await Task.WhenAll(resourceNames.Distinct().Select(rn => WaitForResourceAsync(rn, timeout))).ConfigureAwait(false); } + /// + /// Waits for the named Azure Functions resource's admin API to return its indexed functions, then returns their names. + /// + /// The Functions resource name (as configured within the AppHost). + /// Optional function names that must have been indexed. + /// The maximum wait (defaults to ); pass to wait indefinitely. + /// The cancellation token. + /// The function names reported by the Functions host after indexing. + /// The host returned an invalid function list, or one or more expected functions are missing. + /// The Functions host admin API returned a non-success status other than a transient not-found or service-unavailable status. + /// The Functions host did not expose its function list within . + /// Uses the Functions host's /admin/functions endpoint rather than parsing log output or assuming that Aspire's resource health means functions are registered. + /// It verifies indexing and can check expected function names, but does not guarantee that trigger listeners can connect to their backing services or that external trigger delivery works. + public async Task> WaitForFunctionsAsync( + string resourceName, + IEnumerable? expectedFunctionNames = null, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(resourceName); + + var expected = expectedFunctionNames?.ToArray() ?? []; + if (expected.Any(string.IsNullOrWhiteSpace)) + throw new ArgumentException("Expected function names must not be null or whitespace.", nameof(expectedFunctionNames)); + + var app = await GetDistributedApplicationAsync().ConfigureAwait(false); + using var client = app.CreateHttpClient(resourceName); + string? lastTransientResponse = null; + IReadOnlyList lastFunctionNames = []; + IReadOnlyList? functionNames; + try + { + functionNames = await EventuallyAsync?>( + async token => + { + HttpResponseMessage response; + try + { + response = await client.GetAsync("admin/functions", token).ConfigureAwait(false); + } + catch (HttpRequestException ex) when (ex.StatusCode is null) + { + lastTransientResponse = ex.Message; + return null; + } + + using (response) + { + if (response.StatusCode is System.Net.HttpStatusCode.NotFound or System.Net.HttpStatusCode.ServiceUnavailable) + { + lastTransientResponse = $"{(int)response.StatusCode} ({response.ReasonPhrase}): {await response.Content.ReadAsStringAsync(token).ConfigureAwait(false)}"; + return null; + } + + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(token).ConfigureAwait(false); + throw new HttpRequestException( + $"Functions resource '{resourceName}' returned {(int)response.StatusCode} ({response.ReasonPhrase}) from /admin/functions: {body}", + null, + response.StatusCode); + } + + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false), cancellationToken: token).ConfigureAwait(false); + if (document.RootElement.ValueKind != JsonValueKind.Array) + throw new InvalidOperationException($"Functions resource '{resourceName}' returned an invalid /admin/functions response; expected a JSON array."); + + var names = document.RootElement.EnumerateArray() + .Select(function => function.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.String ? name.GetString() : null) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Cast() + .Distinct(StringComparer.Ordinal) + .ToArray(); + lastFunctionNames = names; + return names; + } + }, + names => names is not null && expected.All(expectedName => names.Contains(expectedName, StringComparer.Ordinal)), + timeout ?? DefaultWaitForResourceTimeout, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException ex) + { + throw new TimeoutException( + $"Functions resource '{resourceName}' did not expose its function list{(expected.Length == 0 ? string.Empty : $" containing the expected function(s): {string.Join(", ", expected)}")} within {timeout ?? DefaultWaitForResourceTimeout}. Last indexed functions: {string.Join(", ", lastFunctionNames)}. Last transient response: {lastTransientResponse ?? "none"}", + ex); + } + + var missing = expected.Except(functionNames!, StringComparer.Ordinal).ToArray(); + if (missing.Length > 0) + throw new InvalidOperationException($"Functions resource '{resourceName}' initialized without the expected function(s): {string.Join(", ", missing)}. Indexed functions: {string.Join(", ", functionNames!)}."); + + return functionNames!; + } + /// /// 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 ), @@ -448,6 +634,23 @@ private static bool IsWildcardMatch(string? text, string pattern) /// The . public HttpTester Http(string resourceName, string? endpointName = null) => new(this, new ResourceHttpClientSource(this, resourceName, endpointName)); + /// + /// Creates a client for posting Event Grid notifications to an Azure Functions resource. + /// + /// The Functions resource name (as configured within the AppHost). + /// The optional endpoint name. Where not specified, Aspire selects the resource's default HTTP endpoint. + /// A client that posts to the Functions Event Grid and blob-trigger webhook routes. + public FunctionsWebhookClient FunctionsWebhook(string resourceName, string? endpointName = null) + { + ArgumentException.ThrowIfNullOrEmpty(resourceName); + + return new FunctionsWebhookClient(async () => + { + var app = await GetDistributedApplicationAsync().ConfigureAwait(false); + return app.CreateHttpClient(resourceName, endpointName); + }); + } + /// /// Enables a test to be sent to the named resource with an expected response value . /// @@ -626,6 +829,9 @@ public void FlushPending() } } + /// + /// Flushes the pending log entry to the buffer; the caller must hold _lock. + /// private void FlushPendingNoLock() { if (_pendingTimestamp is null) @@ -746,14 +952,24 @@ protected virtual async ValueTask DisposeAsyncCore() if (appTask is null) return; + DistributedApplication app; try { - var app = await appTask.ConfigureAwait(false); - await app.DisposeAsync().ConfigureAwait(false); + app = await appTask.ConfigureAwait(false); } catch { // Swallow; there is nothing meaningful to dispose where the underlying host failed to build/start. + return; + } + + try + { + await app.DisposeAsync().ConfigureAwait(false); + } + finally + { + _diagnosticApplication = null; } } } diff --git a/src/UnitTestEx.Aspire/Aspire/FunctionsWebhookClient.cs b/src/UnitTestEx.Aspire/Aspire/FunctionsWebhookClient.cs new file mode 100644 index 0000000..ea6bc0c --- /dev/null +++ b/src/UnitTestEx.Aspire/Aspire/FunctionsWebhookClient.cs @@ -0,0 +1,110 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Azure.Messaging.EventGrid; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace UnitTestEx.Aspire +{ + /// + /// Posts Event Grid notifications to the runtime webhook routes exposed by an Azure Functions resource. + /// + public sealed class FunctionsWebhookClient + { + private readonly Func> _httpClientFactory; + + /// + /// Creates a webhook client that obtains its HTTP client from the specified factory. + /// + /// The factory that creates an HTTP client for the Functions resource. + internal FunctionsWebhookClient(Func> httpClientFactory) => _httpClientFactory = httpClientFactory; + + /// + /// Posts a BlobCreated event to the Azure Functions blob-trigger webhook. + /// + /// The Functions host function name (e.g. Host.Functions.CustomerImport). + /// The BlobCreated event. + /// The . + /// The webhook response; the caller remains responsible for asserting the status and disposing the response. + public Task PostBlobCreatedAsync(string functionName, EventGridEvent blobCreatedEvent, CancellationToken cancellationToken = default) + => PostBlobCreatedEventsAsync(functionName, [blobCreatedEvent], cancellationToken); + + /// + /// Posts one or more events to the Azure Functions blob-trigger webhook. + /// + /// The Functions host function name (e.g. Host.Functions.CustomerImport). + /// The events to post. + /// The . + /// The webhook response; the caller remains responsible for asserting the status and disposing the response. + public Task PostBlobCreatedEventsAsync(string functionName, IEnumerable events, CancellationToken cancellationToken = default) + => PostEventsAsync("runtime/webhooks/blobs", functionName, events, cancellationToken); + + /// + /// Posts one or more events to the Azure Functions Event Grid trigger webhook. + /// + /// The Functions host function name (e.g. Host.Functions.MyEventGridFunction). + /// The events to post. + /// The . + /// The webhook response; the caller remains responsible for asserting the status and disposing the response. + public Task PostEventGridAsync(string functionName, IEnumerable events, CancellationToken cancellationToken = default) + => PostEventsAsync("runtime/webhooks/EventGrid", functionName, events, cancellationToken); + + /// + /// Serializes and posts Event Grid-schema notifications to a Functions runtime webhook route. + /// + /// The relative runtime webhook route. + /// The host-qualified function name. + /// The events to serialize and post. + /// The cancellation token. + /// The HTTP response from the Functions host. + private async Task PostEventsAsync(string route, string functionName, IEnumerable events, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(functionName); + ArgumentNullException.ThrowIfNull(events); + + var eventArray = events.ToArray(); + if (eventArray.Length == 0) + throw new ArgumentException("At least one event is required.", nameof(events)); + if (eventArray.Any(e => e is null)) + throw new ArgumentException("Events must not contain null values.", nameof(events)); + + var payload = JsonSerializer.Serialize(eventArray.Select(ToEventGridSchemaEvent)); + var requestUri = $"{route}?functionName={Uri.EscapeDataString(functionName)}"; + + using var client = await _httpClientFactory().ConfigureAwait(false); + using var request = new HttpRequestMessage(HttpMethod.Post, requestUri) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + request.Headers.Add("aeg-event-type", "Notification"); + return await client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); + } + + /// + /// Converts an SDK event into the Event Grid schema payload expected by the Functions runtime webhook. + /// + /// The SDK Event Grid event. + /// An object containing the Event Grid schema fields and cloned event data. + private static object ToEventGridSchemaEvent(EventGridEvent eventGridEvent) + { + using var data = JsonDocument.Parse(eventGridEvent.Data.ToString()); + return new + { + id = eventGridEvent.Id, + topic = string.IsNullOrEmpty(eventGridEvent.Topic) ? "UnitTestEx" : eventGridEvent.Topic, + subject = eventGridEvent.Subject, + eventType = eventGridEvent.EventType, + eventTime = eventGridEvent.EventTime, + dataVersion = eventGridEvent.DataVersion, + metadataVersion = "1", + data = data.RootElement.Clone() + }; + } + } +} diff --git a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs index 1f20cab..03a23f1 100644 --- a/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs +++ b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs @@ -3,6 +3,7 @@ using Moq; using System; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Net.Http; using System.Reflection; using System.Text.Json; @@ -65,10 +66,48 @@ public AspireHttpMockRequest Times(Times times) } /// - /// Indicates that the request will match regardless of the body content. + /// Indicates that the request must have a non-empty body, regardless of its content. /// /// The to continue the fluent-style configuration. - public AspireHttpMockRequestBody WithAnyBody() => new(this); + /// By default WireMock.Net does not read the request body for GET and HEAD (unless AllowBodyForAllHttpMethods is enabled), so this matcher cannot match + /// those methods by default. + public AspireHttpMockRequestBody WithAnyBody() + { + Rule.Body = new BodyModel { Matcher = new MatcherModel { Name = "NotNullOrEmptyMatcher" } }; + ClearContentTypeMatcher(); + return new(this); + } + + /// + /// Indicates that the request must have no body; i.e. the content is absent or zero-length. + /// + /// The to continue the fluent-style configuration. + /// An absent body and a zero-length body are indistinguishable on the wire (both send Content-Length: 0), so cannot be differentiated by the WireMock.Net server. + /// By default WireMock.Net does not read the request body for GET and HEAD (unless AllowBodyForAllHttpMethods is enabled), so no body constraint is + /// applied when the configured methods are exclusively GET/HEAD. For an unrestricted or mixed-method rule, the no-body matcher is applied. + public AspireHttpMockRequestBody WithNoBody() + { + Rule.Body = Rule.Methods is { Length: > 0 } methods && methods.All(IsGetOrHead) + ? null + : new BodyModel { Matcher = new MatcherModel { Name = "NotNullOrEmptyMatcher", RejectOnMatch = true } }; + + ClearContentTypeMatcher(); + return new(this); + } + + private static bool IsGetOrHead(string method) => + string.Equals(method, HttpMethod.Get.Method, StringComparison.OrdinalIgnoreCase) || + string.Equals(method, HttpMethod.Head.Method, StringComparison.OrdinalIgnoreCase); + + private void ClearContentTypeMatcher() + { + if (Rule.Headers is null) + return; + + Rule.Headers = Rule.Headers.Where(header => !string.Equals(header.Name, "Content-Type", StringComparison.OrdinalIgnoreCase)).ToList(); + if (Rule.Headers.Count == 0) + Rule.Headers = null; + } /// /// Indicates that the request body must match the specified exactly. @@ -239,6 +278,9 @@ public AspireHttpMockRequestBody WithJsonBodyUsingUnitTestExComparer([StringSynt /// IHttpMockRequestBody IHttpMockRequest.WithAnyBody() => WithAnyBody(); + /// + IHttpMockRequestBody IHttpMockRequest.WithNoBody() => WithNoBody(); + /// IHttpMockRequestBody IHttpMockRequest.WithBody(string body, string mediaType) => WithBody(body, mediaType); diff --git a/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj b/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj index da3d40c..cb091e5 100644 --- a/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj +++ b/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj @@ -10,7 +10,9 @@ + +