From 619582918c81709076a1822a78bfa0d3dd2e4212 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Thu, 5 Nov 2026 15:18:37 -0800 Subject: [PATCH] Prepare v6.1.0 Functions testing enhancements Add caller-owned HttpClient support for Functions webhooks, shared Functions fixtures and framework factories, updated documentation, and build-output cleanup tooling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 + Common.targets | 2 +- clean-build-outputs.ps1 | 96 ++++++++++++ docs/aspire.md | 11 ++ docs/azure-functions.md | 46 ++++++ .../Aspire/FunctionsWebhookClient.cs | 21 ++- .../Azure/Functions/FunctionTesterT.cs | 8 +- .../FunctionTester.cs | 6 +- .../WithFunctionTester.cs | 55 +++++++ .../FunctionsWebhookClientTest.cs | 129 +++++++++++++++ .../WithFunctionTesterTest.cs | 148 ++++++++++++++++++ .../WithFunctionTesterTest.cs | 43 +++++ 12 files changed, 562 insertions(+), 7 deletions(-) create mode 100644 clean-build-outputs.ps1 create mode 100644 src/UnitTestEx.Azure.Functions/WithFunctionTester.cs create mode 100644 tests/UnitTestEx.Aspire.NUnit.Test/FunctionsWebhookClientTest.cs create mode 100644 tests/UnitTestEx.NUnit.Test/WithFunctionTesterTest.cs create mode 100644 tests/UnitTestEx.Xunit.Test/WithFunctionTesterTest.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 778570f..eba8243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Represents the **NuGet** versions. +## v6.1.0 +- *Enhancement:* Added `FunctionsWebhookClient(HttpClient)` for posting Functions webhook events without an Aspire fixture; the caller retains ownership of the supplied client and returned responses. +- *Enhancement:* Added framework-neutral `WithFunctionTester` for fixture-level testing and disposal, with an optional `TestFrameworkImplementor` factory also supported by `FunctionTester.Create` and the concrete tester constructor. + ## 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). diff --git a/Common.targets b/Common.targets index 2b366e8..0728ded 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 6.0.0 + 6.1.0 latest Avanade Avanade diff --git a/clean-build-outputs.ps1 b/clean-build-outputs.ps1 new file mode 100644 index 0000000..8ef7177 --- /dev/null +++ b/clean-build-outputs.ps1 @@ -0,0 +1,96 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS +Removes generated Debug and Release build outputs across the repository to recover disk space. + +.DESCRIPTION +Deletes the per-configuration directories beneath each project's bin and obj directories (for example +bin\Debug, bin\Release, obj\Debug and obj\Release). NuGet restore assets directly within obj (such as +project.assets.json) are retained, so a subsequent build does not require a restore. + +Source, NuGet caches, containers and database volumes are never touched. All outputs are regenerated on +the next build; the trade-off is losing incremental-build state, so that build is slower. + +Use -WhatIf to preview what would be removed. + +.PARAMETER Configuration +The build configurations to remove. Defaults to Debug and Release. + +.PARAMETER ShutdownBuildServers +Shuts down MSBuild/compiler build servers first to release any file locks. + +.EXAMPLE +./clean-build-outputs.ps1 +./clean-build-outputs.ps1 -WhatIf +./clean-build-outputs.ps1 -Configuration Release +./clean-build-outputs.ps1 -ShutdownBuildServers +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [string[]]$Configuration = @("Debug", "Release"), + [switch]$ShutdownBuildServers = $false +) + +$ErrorActionPreference = "Stop" + +$repoRoot = $PSScriptRoot +$searchRoots = @("src", "tests") | + ForEach-Object { Join-Path $repoRoot $_ } | + Where-Object { Test-Path $_ } + +if ($ShutdownBuildServers -and $PSCmdlet.ShouldProcess("dotnet build servers", "Shut down")) { + dotnet build-server shutdown | Out-Null +} + +$targets = foreach ($root in $searchRoots) { + Get-ChildItem -LiteralPath $root -Directory -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Name -in @("bin", "obj") } | + ForEach-Object { + foreach ($config in $Configuration) { + $path = Join-Path $_.FullName $config + if (Test-Path -LiteralPath $path) { Get-Item -LiteralPath $path } + } + } +} + +if (-not $targets) { + Write-Output "Nothing to clean for configuration(s): $($Configuration -join ', ')." + return +} + +# Drop targets nested within another target (e.g. obj\Debug\net8.0\WorkerExtensions\bin\Release); they are removed along with their parent. +$targets = @($targets | Sort-Object FullName -Unique) +$targets = $targets | Where-Object { + $path = $_.FullName + -not ($targets | Where-Object { $path.StartsWith($_.FullName + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) }) +} + +$totalBytes = 0L +$removed = 0 +$failed = @() + +foreach ($target in $targets) { + $bytes = (Get-ChildItem -LiteralPath $target.FullName -Recurse -File -Force -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum + $relative = $target.FullName.Substring($repoRoot.Length).TrimStart('\', '/') + + if ($PSCmdlet.ShouldProcess($relative, "Remove ($([math]::Round($bytes / 1MB, 1)) MB)")) { + try { + Remove-Item -LiteralPath $target.FullName -Recurse -Force + $totalBytes += $bytes + $removed++ + } + catch { + $failed += $relative + Write-Warning "Could not remove '$relative' (likely locked by a running process): $($_.Exception.Message)" + } + } +} + +if ($WhatIfPreference) { return } + +Write-Output "Removed $removed director$(if ($removed -eq 1) { 'y' } else { 'ies' }); recovered $([math]::Round($totalBytes / 1GB, 2)) GB." +if ($failed) { + Write-Warning "$($failed.Count) director$(if ($failed.Count -eq 1) { 'y' } else { 'ies' }) could not be removed. Close Visual Studio/running hosts, or rerun with -ShutdownBuildServers." + exit 1 +} diff --git a/docs/aspire.md b/docs/aspire.md index feb001a..3bf8aad 100644 --- a/docs/aspire.md +++ b/docs/aspire.md @@ -211,6 +211,17 @@ 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. +To use an already-running Functions host without creating an Aspire fixture, construct `UnitTestEx.Aspire.FunctionsWebhookClient` with an existing `HttpClient`. This still requires the `UnitTestEx.Aspire` package: + +```csharp +using var httpClient = new HttpClient { BaseAddress = new Uri("http://localhost:7071/") }; +var webhookClient = new UnitTestEx.Aspire.FunctionsWebhookClient(httpClient); +using var response = await webhookClient.PostBlobCreatedAsync("Host.Functions.CustomerImport", created); +response.EnsureSuccessStatusCode(); +``` + +Set `BaseAddress` to the Functions host endpoint (including a trailing `/` for any path prefix) and configure any required authentication on the supplied client. The caller retains ownership: `FunctionsWebhookClient` never disposes or reconfigures that client, including after failed or canceled requests. It can be reused for multiple webhook calls; dispose it only after those calls finish. Each returned response must also be disposed by the caller. The Aspire `tester.FunctionsWebhook(...)` path continues to create and dispose its own HTTP client per request. + `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 diff --git a/docs/azure-functions.md b/docs/azure-functions.md index e246522..1342754 100644 --- a/docs/azure-functions.md +++ b/docs/azure-functions.md @@ -58,6 +58,52 @@ Scopes created by `HttpTrigger`, `ServiceBusTrigger`, and `Trigger` belong to th
+## Fixture-level testing + +`WithFunctionTester` (in the `UnitTestEx` namespace, package `UnitTestEx.Azure.Functions`) exposes a `Test` property and implements `IDisposable` to release the tester, its trigger scopes and its host. The host is built lazily, so configure `Test` before accessing its services or invoking a function. There are no application-start lifecycle hooks because the worker is never started. + +For an NUnit fixture that shares one tester across its test methods: + +```csharp +[TestFixture] +public sealed class ProductFunctionTests : WithFunctionTester +{ + [Test] + public void GetProduct() + { + Test.HttpTrigger() + .Run(f => f.Run(Test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "abc")) + .AssertOK(); + } +} +``` + +The protected constructor accepts an optional `Func createFactory`, like `WithAspireTester`. Passing `null` uses the normal framework resolution. An xUnit test class can pass its output helper explicitly: + +```csharp +public sealed class ProductFunctionTests : WithFunctionTester +{ + public ProductFunctionTests(ITestOutputHelper output) + : base(() => XunitTestImplementor.Create(output)) { } + + [Fact] + public void GetProduct() + { + Test.HttpTrigger() + .Run(f => f.Run(Test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "abc")) + .AssertOK(); + } +} +``` + +`XunitTestImplementor` is in `UnitTestEx.Xunit.Internal`; `ITestOutputHelper` is in `Xunit.Abstractions`. The explicit factory is also available through `FunctionTester.Create(createFactory: ...)` and the concrete tester constructor; existing configuration arguments retain their order. + +Sharing follows the framework's fixture lifetime. xUnit creates a test-class instance per test, so inheriting this base does **not** share the host across methods. Use an xUnit class or collection fixture for that lifetime; rebind its shared tester to the current test's output with `fixture.Test.ReplaceTestFrameworkImplementor(XunitTestImplementor.Create(output))` in the test-class constructor, rather than retaining one test's output helper. Ensure the owning fixture is disposed. Shared hosts and tester state can contaminate later tests; isolate mutable state, use `Test.ResetHost()` when needed, and do not use the shared tester concurrently. + +Examples and lifecycle regressions: [`WithFunctionTesterTest` (NUnit)](../tests/UnitTestEx.NUnit.Test/WithFunctionTesterTest.cs), [`WithFunctionTesterTest` (xUnit)](../tests/UnitTestEx.Xunit.Test/WithFunctionTesterTest.cs). + +
+ ## HTTP triggers Both HTTP programming models are supported: diff --git a/src/UnitTestEx.Aspire/Aspire/FunctionsWebhookClient.cs b/src/UnitTestEx.Aspire/Aspire/FunctionsWebhookClient.cs index ea6bc0c..4c6deda 100644 --- a/src/UnitTestEx.Aspire/Aspire/FunctionsWebhookClient.cs +++ b/src/UnitTestEx.Aspire/Aspire/FunctionsWebhookClient.cs @@ -18,11 +18,29 @@ namespace UnitTestEx.Aspire public sealed class FunctionsWebhookClient { private readonly Func> _httpClientFactory; + private readonly bool _disposeHttpClient = true; + + /// + /// Creates a webhook client using an existing HTTP client without starting an Aspire application. + /// + /// The HTTP client with set to the Functions host endpoint. + /// + /// The caller retains ownership of and is responsible for disposing it. + /// This webhook client never disposes or reconfigures it. The caller must also dispose each returned response. + /// + /// is . + public FunctionsWebhookClient(HttpClient httpClient) + { + ArgumentNullException.ThrowIfNull(httpClient); + _httpClientFactory = () => Task.FromResult(httpClient); + _disposeHttpClient = false; + } /// /// Creates a webhook client that obtains its HTTP client from the specified factory. /// /// The factory that creates an HTTP client for the Functions resource. + /// Each factory-created HTTP client is disposed after its request completes. internal FunctionsWebhookClient(Func> httpClientFactory) => _httpClientFactory = httpClientFactory; /// @@ -77,7 +95,8 @@ private async Task PostEventsAsync(string route, string fun var payload = JsonSerializer.Serialize(eventArray.Select(ToEventGridSchemaEvent)); var requestUri = $"{route}?functionName={Uri.EscapeDataString(functionName)}"; - using var client = await _httpClientFactory().ConfigureAwait(false); + var client = await _httpClientFactory().ConfigureAwait(false); + using var ownedClient = _disposeHttpClient ? client : null; using var request = new HttpRequestMessage(HttpMethod.Post, requestUri) { Content = new StringContent(payload, Encoding.UTF8, "application/json") diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterT.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterT.cs index 54a5bd9..51479da 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterT.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterT.cs @@ -1,18 +1,20 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +using System; using System.Collections.Generic; using UnitTestEx.Abstractions; namespace UnitTestEx.Azure.Functions { /// - /// Provides the NUnit implementation. + /// Provides the concrete implementation. /// /// Any within the Azure Functions isolated-worker application assembly. /// Indicates whether to include 'appsettings.unittest.json' configuration file. /// Indicates whether to include user secrets. /// Additional configuration values to add/override. - public class FunctionTester(bool? includeUnitTestConfiguration, bool? includeUserSecrets, IEnumerable>? additionalConfiguration) - : FunctionTesterBase>(TestFrameworkImplementor.Create(), includeUnitTestConfiguration, includeUserSecrets, additionalConfiguration) where TEntryPoint : class + /// The optional function to create the instance. + public class FunctionTester(bool? includeUnitTestConfiguration, bool? includeUserSecrets, IEnumerable>? additionalConfiguration, Func? createFactory = null) + : FunctionTesterBase>(TestFrameworkImplementor.Create(createFactory), includeUnitTestConfiguration, includeUserSecrets, additionalConfiguration) where TEntryPoint : class { } } \ No newline at end of file diff --git a/src/UnitTestEx.Azure.Functions/FunctionTester.cs b/src/UnitTestEx.Azure.Functions/FunctionTester.cs index 55ea6b4..e91201c 100644 --- a/src/UnitTestEx.Azure.Functions/FunctionTester.cs +++ b/src/UnitTestEx.Azure.Functions/FunctionTester.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using UnitTestEx.Abstractions; using UnitTestEx.Azure.Functions; namespace UnitTestEx @@ -18,9 +19,10 @@ public static class FunctionTester /// Indicates whether to include 'appsettings.unittest.json' configuration file. /// Indicates whether to include user secrets. /// Additional configuration values to add/override. + /// The optional function to create the instance. /// The . - public static FunctionTester Create(bool? includeUnitTestConfiguration = null, bool? includeUserSecrets = null, IEnumerable>? additionalConfiguration = null) + public static FunctionTester Create(bool? includeUnitTestConfiguration = null, bool? includeUserSecrets = null, IEnumerable>? additionalConfiguration = null, Func? createFactory = null) where TEntryPoint : class - => new(includeUnitTestConfiguration, includeUserSecrets, additionalConfiguration); + => new(includeUnitTestConfiguration, includeUserSecrets, additionalConfiguration, createFactory); } } \ No newline at end of file diff --git a/src/UnitTestEx.Azure.Functions/WithFunctionTester.cs b/src/UnitTestEx.Azure.Functions/WithFunctionTester.cs new file mode 100644 index 0000000..8c8fee4 --- /dev/null +++ b/src/UnitTestEx.Azure.Functions/WithFunctionTester.cs @@ -0,0 +1,55 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using System; +using UnitTestEx.Abstractions; +using UnitTestEx.Azure.Functions; + +namespace UnitTestEx +{ + /// + /// Provides a shared for tests that reuse the same underlying Azure Functions application host. + /// + /// Any within the Azure Functions isolated-worker application assembly. + /// Implements to dispose the tester and its host. The host is built lazily and never started. + /// Sharing depends on the test framework's fixture lifetime; isolate mutable state and do not use the shared tester concurrently. + public abstract class WithFunctionTester : IDisposable where TEntryPoint : class + { + private FunctionTester? _functionTester; + + /// + /// Initializes a new instance of the class. + /// + /// The optional function to create the instance. + protected WithFunctionTester(Func? createFactory = null) + { + _functionTester = FunctionTester.Create(createFactory: createFactory); + } + + /// + /// Gets the underlying for testing. + /// + /// The fixture has been disposed. + public FunctionTester Test => _functionTester ?? throw new ObjectDisposedException(nameof(Test)); + + /// + /// Releases the managed resources when disposing. + /// + /// Indicates whether to release managed resources. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + var tester = _functionTester; + _functionTester = null; + tester?.Dispose(); + } + } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} diff --git a/tests/UnitTestEx.Aspire.NUnit.Test/FunctionsWebhookClientTest.cs b/tests/UnitTestEx.Aspire.NUnit.Test/FunctionsWebhookClientTest.cs new file mode 100644 index 0000000..4aa7123 --- /dev/null +++ b/tests/UnitTestEx.Aspire.NUnit.Test/FunctionsWebhookClientTest.cs @@ -0,0 +1,129 @@ +using Azure.Messaging.EventGrid; +using NUnit.Framework; +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace UnitTestEx.Aspire.NUnit.Test +{ + [TestFixture] + public class FunctionsWebhookClientTest + { + [Test] + public void Constructor_RejectsNullHttpClient() + { + var exception = Assert.Throws(() => new FunctionsWebhookClient((HttpClient)null!)); + Assert.That(exception!.ParamName, Is.EqualTo("httpClient")); + } + + [TestCase("blob", null)] + [TestCase("blobs", "")] + [TestCase("eventGrid", "/subscriptions/test/topics/events")] + public async Task ExistingHttpClient_PostsEventsAndRemainsCallerOwned(string operation, string? topic) + { + var eventGridEvent = new EventGridEvent("test-subject", "test-event", "1.0", BinaryData.FromObjectAsJson(new { value = "payload" })); + if (topic is not null) + eventGridEvent.Topic = topic; + + var route = operation == "eventGrid" ? "EventGrid" : "blobs"; + var count = operation == "blobs" ? 2 : 1; + using var handler = new TestHandler(async (request, _) => + { + Assert.That(request.Method, Is.EqualTo(HttpMethod.Post)); + Assert.That(request.RequestUri!.AbsoluteUri, Is.EqualTo($"http://localhost:7071/runtime/webhooks/{route}?functionName=Host.Functions.Test%20%26%2F")); + Assert.That(request.Headers.GetValues("aeg-event-type"), Is.EqualTo(new[] { "Notification" })); + Assert.That(request.Headers.GetValues("x-test"), Is.EqualTo(new[] { "caller-header" })); + Assert.That(request.Content!.Headers.ContentType!.MediaType, Is.EqualTo("application/json")); + Assert.That(request.Content.Headers.ContentType.CharSet, Is.EqualTo("utf-8")); + using var payload = JsonDocument.Parse(await request.Content.ReadAsStringAsync()); + Assert.That(payload.RootElement.GetArrayLength(), Is.EqualTo(count)); + foreach (var item in payload.RootElement.EnumerateArray()) + { + Assert.That(item.GetProperty("id").GetString(), Is.EqualTo(eventGridEvent.Id)); + Assert.That(item.GetProperty("topic").GetString(), Is.EqualTo(string.IsNullOrEmpty(topic) ? "UnitTestEx" : topic)); + Assert.That(item.GetProperty("subject").GetString(), Is.EqualTo(eventGridEvent.Subject)); + Assert.That(item.GetProperty("eventType").GetString(), Is.EqualTo(eventGridEvent.EventType)); + Assert.That(item.GetProperty("eventTime").GetDateTimeOffset(), Is.EqualTo(eventGridEvent.EventTime)); + Assert.That(item.GetProperty("dataVersion").GetString(), Is.EqualTo(eventGridEvent.DataVersion)); + Assert.That(item.GetProperty("metadataVersion").GetString(), Is.EqualTo("1")); + Assert.That(item.GetProperty("data").GetProperty("value").GetString(), Is.EqualTo("payload")); + } + + return new HttpResponseMessage(HttpStatusCode.Accepted) { Content = new StringContent("accepted") }; + }); + using var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost:7071/") }; + httpClient.DefaultRequestHeaders.Add("x-test", "caller-header"); + var webhookClient = new FunctionsWebhookClient(httpClient); + + for (var i = 0; i < 2; i++) + { + using var response = operation switch + { + "blob" => await webhookClient.PostBlobCreatedAsync("Host.Functions.Test &/", eventGridEvent), + "blobs" => await webhookClient.PostBlobCreatedEventsAsync("Host.Functions.Test &/", [eventGridEvent, eventGridEvent]), + _ => await webhookClient.PostEventGridAsync("Host.Functions.Test &/", [eventGridEvent]) + }; + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Accepted)); + Assert.That(await response.Content.ReadAsStringAsync(), Is.EqualTo("accepted")); + Assert.That(handler.IsDisposed, Is.False); + } + + Assert.That(httpClient.BaseAddress, Is.EqualTo(new Uri("http://localhost:7071/"))); + Assert.That(httpClient.DefaultRequestHeaders.GetValues("x-test").Single(), Is.EqualTo("caller-header")); + httpClient.Dispose(); + Assert.That(handler.IsDisposed, Is.True); + } + + [TestCase(false)] + [TestCase(true)] + public async Task ExistingHttpClient_RemainsUsableAfterFailure(bool cancel) + { + using var cancellation = new CancellationTokenSource(); + using var handler = new TestHandler((_, token) => + { + if (cancel) + { + Assert.That(token.CanBeCanceled, Is.True); + cancellation.Cancel(); + token.ThrowIfCancellationRequested(); + } + + throw new HttpRequestException("Simulated failure."); + }); + using var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost:7071/") }; + var webhookClient = new FunctionsWebhookClient(httpClient); + var eventGridEvent = new EventGridEvent("subject", "event", "1.0", BinaryData.FromObjectAsJson(new { })); + + if (cancel) + Assert.CatchAsync(() => webhookClient.PostBlobCreatedAsync("Host.Functions.Test", eventGridEvent, cancellation.Token)); + else + Assert.ThrowsAsync(() => webhookClient.PostBlobCreatedAsync("Host.Functions.Test", eventGridEvent)); + + Assert.That(handler.IsDisposed, Is.False); + handler.OnSend = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + using var response = await httpClient.GetAsync("health"); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + private sealed class TestHandler(Func> send) : HttpMessageHandler + { + public Func> OnSend { get; set; } = send; + + public bool IsDisposed { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => OnSend(request, cancellationToken); + + protected override void Dispose(bool disposing) + { + if (disposing) + IsDisposed = true; + + base.Dispose(disposing); + } + } + } +} diff --git a/tests/UnitTestEx.NUnit.Test/WithFunctionTesterTest.cs b/tests/UnitTestEx.NUnit.Test/WithFunctionTesterTest.cs new file mode 100644 index 0000000..e4b7e26 --- /dev/null +++ b/tests/UnitTestEx.NUnit.Test/WithFunctionTesterTest.cs @@ -0,0 +1,148 @@ +#nullable enable + +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using System; +using System.Net.Http; +using UnitTestEx.Abstractions; +using UnitTestEx.Azure.Functions; +using UnitTestEx.IsolatedFunction; + +namespace UnitTestEx.NUnit.Test +{ + [TestFixture] + public class WithFunctionTesterTest + { + [Test] + public void Fixture_ReusesTesterAndLazyHostWithDefaultFramework() + { + using var fixture = new FunctionFixture(); + var test = fixture.Test; + Assert.That(fixture.Test, Is.SameAs(test)); + Assert.That(test.IsHostInstantiated, Is.False); + + test.HttpTrigger() + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "/"))) + .AssertOK() + .AssertValue("Welcome to Azure Functions!"); + var services = test.Services; + + test.HttpTrigger() + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "/"))) + .AssertOK() + .AssertValue("Welcome to Azure Functions!"); + Assert.That(test.Services, Is.SameAs(services)); + } + + [Test] + public void Fixture_UsesExplicitFactoryOnceWithoutBuildingHost() + { + var implementor = new NullTestImplementor(); + var calls = 0; + using var fixture = new FunctionFixture(() => + { + calls++; + return implementor; + }); + + Assert.That(fixture.Test.Implementor, Is.SameAs(implementor)); + Assert.That(fixture.Test.IsHostInstantiated, Is.False); + Assert.That(calls, Is.EqualTo(1)); + } + + [TestCase(false)] + [TestCase(true)] + public void Create_PreservesConfigurationWithExplicitFactory(bool directConstruction) + { + var implementor = new NullTestImplementor(); + var calls = 0; + TestFrameworkImplementor CreateImplementor() + { + calls++; + return implementor; + } + + using var test = directConstruction + ? new FunctionTester(false, false, [new("FixtureKey", "override")], CreateImplementor) + : FunctionTester.Create(includeUnitTestConfiguration: false, includeUserSecrets: false, + additionalConfiguration: [new("FixtureKey", "override")], createFactory: CreateImplementor); + + Assert.That(test.Implementor, Is.SameAs(implementor)); + Assert.That(test.Configuration["FixtureKey"], Is.EqualTo("override")); + Assert.That(calls, Is.EqualTo(1)); + } + + [Test] + public void Fixture_RejectsFactoryReturningNull() + { + var exception = Assert.Throws(() => new FunctionFixture(() => null!)); + Assert.That(exception!.Message, Does.Contain("createFactory").And.Contain("returned null")); + } + + [Test] + public void Fixture_PropagatesFactoryFailure() + { + var failure = new InvalidOperationException("factory failed"); + var exception = Assert.Throws(() => new FunctionFixture(() => throw failure)); + Assert.That(exception, Is.SameAs(failure)); + } + + [Test] + public void Fixture_DisposalWithoutHostIsIdempotent() + { + using var fixture = new FunctionFixture(); + var test = fixture.Test; + fixture.Dispose(); + fixture.Dispose(); + + Assert.That(test.IsHostInstantiated, Is.False); + Assert.Throws(() => _ = fixture.Test); + Assert.Throws(() => _ = test.Services); + } + + [TestCase(false)] + [TestCase(true)] + public void Fixture_DisposalReleasesScopesAndHostEvenWhenScopeThrows(bool throwOnDispose) + { + using var fixture = new FunctionFixture(); + var test = fixture.Test.ConfigureServices(sc => + { + sc.AddKeyedSingleton("host", (_, _) => new DisposableResource()); + sc.AddScoped(_ => new DisposableResource(throwOnDispose)); + }); + var hostResource = test.Services.GetRequiredKeyedService("host"); + var trigger = test.Trigger(); + var scopedResource = trigger.Services.GetRequiredService(); + + if (throwOnDispose) + { + var exception = Assert.Throws(() => fixture.Dispose()); + Assert.That(exception!.Message, Is.EqualTo("scope disposal failed")); + } + else + fixture.Dispose(); + + Assert.Throws(() => _ = fixture.Test); + Assert.Throws(() => _ = test.Services); + Assert.Throws(() => trigger.Services.GetRequiredService()); + Assert.DoesNotThrow(() => fixture.Dispose()); + Assert.That(hostResource.DisposeCount, Is.EqualTo(1)); + Assert.That(scopedResource.DisposeCount, Is.EqualTo(1)); + } + + private sealed class FunctionFixture(Func? createFactory = null) + : WithFunctionTester(createFactory) { } + + private sealed class DisposableResource(bool throwOnDispose = false) : IDisposable + { + public int DisposeCount { get; private set; } + + public void Dispose() + { + DisposeCount++; + if (throwOnDispose) + throw new InvalidOperationException("scope disposal failed"); + } + } + } +} diff --git a/tests/UnitTestEx.Xunit.Test/WithFunctionTesterTest.cs b/tests/UnitTestEx.Xunit.Test/WithFunctionTesterTest.cs new file mode 100644 index 0000000..ded9ac6 --- /dev/null +++ b/tests/UnitTestEx.Xunit.Test/WithFunctionTesterTest.cs @@ -0,0 +1,43 @@ +using Moq; +using System.Net.Http; +using UnitTestEx.IsolatedFunction; +using UnitTestEx.Xunit.Internal; +using Xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace UnitTestEx.Xunit.Test +{ + public class WithFunctionTesterTest : WithFunctionTester + { + public WithFunctionTesterTest(ITestOutputHelper output) : base(() => XunitTestImplementor.Create(output)) { } + + [Fact] + public void ExplicitFactory_UsesXunitAssertionsAndCapturedOutput() + { + var output = new Mock(); + using var fixture = new FunctionFixture(output.Object); + Assert.IsType(fixture.Test.Implementor); + + var result = fixture.Test.HttpTrigger() + .Run(f => f.Run(fixture.Test.CreateHttpRequest(HttpMethod.Get, "/"))) + .AssertOK(); + + Assert.Throws(() => result.AssertValue("wrong value")); + output.Verify(o => o.WriteLine("{0}", It.IsAny()), Times.AtLeastOnce()); + } + + [Fact] + public void DerivedTestClass_InvokesFunctionWithXunitImplementor() + { + Assert.IsType(Test.Implementor); + Test.HttpTrigger() + .Run(f => f.Run(Test.CreateHttpRequest(HttpMethod.Get, "/"))) + .AssertOK() + .AssertValue("Welcome to Azure Functions!"); + } + + private sealed class FunctionFixture(ITestOutputHelper output) + : WithFunctionTester(() => XunitTestImplementor.Create(output)) { } + } +}