From 8a3bd677fead026615c688fa74ca628f396b1bbc Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 28 Oct 2026 14:22:25 -0700 Subject: [PATCH 01/18] v6.0.0: net8+ only, isolated worker host support - Target net8.0/net9.0/net10.0 only; remove net6/net7 conditional code. - Remove UnitTestEx.Azure.ServiceBus; move ServiceBus helpers into UnitTestEx.Azure.Functions (no reflection). - FunctionTester builds the isolated worker host by executing the app's own entry point (top-level Program.cs as-is), capturing the host at Build() and never starting the worker. - Relax FunctionTester TEntryPoint new() constraint; any app type can be the marker. - Isolated sample uses template-style Program.cs and Worker 2.x; add isolated tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- UnitTestEx.sln | 3 - .../Azure/Functions/FunctionHostFactory.cs | 130 +++++++++++++++++ .../Azure/Functions/FunctionTesterBase.cs | 71 +++++++++- .../Azure/Functions/FunctionTesterT.cs | 4 +- .../FunctionTester.cs | 4 +- .../ServiceBusExtensionMethods.cs | 90 ++++++++++++ .../UnitTestEx.Azure.Functions.csproj | 3 +- .../ExtensionMethods.cs | 131 ------------------ .../UnitTestEx.Azure.ServiceBus.csproj | 22 --- .../UnitTestEx.MSTest.csproj | 2 +- src/UnitTestEx.NUnit/UnitTestEx.NUnit.csproj | 2 +- src/UnitTestEx.Xunit/UnitTestEx.Xunit.csproj | 2 +- src/UnitTestEx/Abstractions/TesterBase.cs | 48 ------- src/UnitTestEx/AspNetCore/HttpTester.cs | 24 ---- src/UnitTestEx/AspNetCore/HttpTesterBase.cs | 24 ---- src/UnitTestEx/AspNetCore/HttpTesterT.cs | 24 ---- .../Assertors/ActionResultAssertor.cs | 4 - .../HttpResponseMessageAssertorBaseT.cs | 6 +- .../Assertors/HttpResultAssertor.cs | 4 - src/UnitTestEx/Assertors/ValueAssertor.cs | 10 -- .../Expectations/ExpectationsExtensions.cs | 6 +- src/UnitTestEx/Generic/GenericTesterCore.cs | 36 ----- src/UnitTestEx/Hosting/EntryPoint.cs | 8 -- src/UnitTestEx/Json/JsonElementComparer.cs | 4 - src/UnitTestEx/Logging/LoggerBase.cs | 4 - src/UnitTestEx/Mocking/IHttpMockRequest.cs | 6 +- src/UnitTestEx/Mocking/MockHttpClient.cs | 4 - .../Mocking/MockHttpClientRequest.cs | 4 - .../Mocking/MockHttpClientResponse.cs | 4 - src/UnitTestEx/ObjectComparer.cs | 16 --- src/UnitTestEx/UnitTestEx.csproj | 14 +- tests/UnitTestEx.IsolatedFunction/Program.cs | 14 +- tests/UnitTestEx.IsolatedFunction/Startup.cs | 12 -- .../UnitTestEx.IsolatedFunction.csproj | 16 +-- .../UnitTestEx.MSTest.Test.csproj | 1 - .../IsolatedFunctionTest.cs | 63 ++++++++- .../UnitTestEx.NUnit.Test.csproj | 1 - .../UnitTestEx.Xunit.Test.csproj | 1 - 38 files changed, 372 insertions(+), 450 deletions(-) create mode 100644 src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionHostFactory.cs create mode 100644 src/UnitTestEx.Azure.Functions/ServiceBusExtensionMethods.cs delete mode 100644 src/UnitTestEx.Azure.ServiceBus/ExtensionMethods.cs delete mode 100644 src/UnitTestEx.Azure.ServiceBus/UnitTestEx.Azure.ServiceBus.csproj delete mode 100644 tests/UnitTestEx.IsolatedFunction/Startup.cs diff --git a/UnitTestEx.sln b/UnitTestEx.sln index b699669..2f21bac 100644 --- a/UnitTestEx.sln +++ b/UnitTestEx.sln @@ -44,8 +44,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}" @@ -300,7 +298,6 @@ Global {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} diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionHostFactory.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionHostFactory.cs new file mode 100644 index 0000000..bc8d836 --- /dev/null +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionHostFactory.cs @@ -0,0 +1,130 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// Adapted from: https://github.com/dotnet/runtime/blob/v10.0.0/src/libraries/Microsoft.Extensions.HostFactoryResolver/src/HostFactoryResolver.cs + +using Microsoft.Extensions.Hosting; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace UnitTestEx.Azure.Functions +{ + /// + /// Executes an application's entry point (e.g. top-level statements) and captures the at Build(), aborting before Run(); i.e. the worker is never started. + /// + internal sealed class FunctionHostFactory : IObserver, IObserver> + { + private static readonly AsyncLocal _current = new(); + private readonly TaskCompletionSource _host = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Action? _configure; + private IDisposable? _hostingSubscription; + private bool _abandoned; + + private FunctionHostFactory(Action? configure) => _configure = configure; + + /// + /// Indicates whether the has an entry point that can be used to build the host. + /// + public static bool CanBuildHost(Assembly assembly) => assembly.EntryPoint is not null; + + /// + /// Builds (but does not start) the by invoking the entry point. + /// + public static IHost BuildHost(Assembly assembly, string[] args, Action? configure = null, TimeSpan? timeout = null) + { + var entryPoint = assembly.EntryPoint ?? throw new InvalidOperationException($"Assembly '{assembly.GetName().Name}' has no entry point; unable to build the host."); + var factory = new FunctionHostFactory(configure); + using var subscription = DiagnosticListener.AllListeners.Subscribe(factory); + var thread = new Thread(() => + { + try + { + _current.Value = factory; + entryPoint.Invoke(null, entryPoint.GetParameters().Length == 0 ? null : [args]); + factory._host.TrySetException(new InvalidOperationException($"The entry point for assembly '{assembly.GetName().Name}' exited without building an {nameof(IHost)}.")); + } + catch (TargetInvocationException ex) when (ex.InnerException is HostAbortedException && (factory._abandoned || factory._host.Task.IsCompletedSuccessfully)) + { + // Expected; the HostBuilt callback deliberately aborts before the application's Run call. + } + catch (TargetInvocationException ex) + { + factory._host.TrySetException(ex.InnerException ?? ex); + } + catch (Exception ex) + { + factory._host.TrySetException(ex); + } + finally + { + _current.Value = null; + factory._hostingSubscription?.Dispose(); + } + }) + { + IsBackground = true + }; + + thread.Start(); + var wait = timeout ?? TimeSpan.FromSeconds(30); + if (!thread.Join(wait)) + { + lock (factory._host) + { + factory._abandoned = true; + if (factory._host.Task.IsCompletedSuccessfully) + factory._host.Task.GetAwaiter().GetResult().Dispose(); + } + + throw new TimeoutException($"The entry point for assembly '{assembly.GetName().Name}' did not build its host within {wait}."); + } + + return factory._host.Task.GetAwaiter().GetResult(); + } + + /// + void IObserver.OnNext(DiagnosticListener value) + { + if (_current.Value == this && value.Name == "Microsoft.Extensions.Hosting") + _hostingSubscription = value.Subscribe(this); + } + + /// + void IObserver>.OnNext(KeyValuePair value) + { + if (_current.Value != this) + return; + + if (value.Key == "HostBuilding") + _configure?.Invoke((IHostBuilder)value.Value!); + else if (value.Key == "HostBuilt") + { + var host = (IHost)value.Value!; + lock (_host) + { + if (_abandoned) + host.Dispose(); + else + _host.SetResult(host); + } + + throw new HostAbortedException(); + } + } + + /// + void IObserver.OnCompleted() { } + + /// + void IObserver>.OnCompleted() => _hostingSubscription?.Dispose(); + + /// + void IObserver.OnError(Exception error) { } + + /// + void IObserver>.OnError(Exception error) => _host.TrySetException(error); + } +} diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs index 0c62861..38db321 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs @@ -135,11 +135,76 @@ private IHost GetHost() if (_host != null) return _host; + _host = typeof(TEntryPoint).IsSubclassOf(typeof(FunctionsStartup)) || !FunctionHostFactory.CanBuildHost(typeof(TEntryPoint).Assembly) + ? BuildStartupHost() + : BuildApplicationHost(); + + OnHostStartUp(); + + return _host; + } + } + + /// + /// Builds the by executing the application's own entry point (e.g. Program.cs top-level statements) as-is, capturing the host at Build() and applying the test configuration; the worker is never started. + /// + private IHost BuildApplicationHost() + { + // The isolated worker builder determines its environment from 'AZURE_FUNCTIONS_ENVIRONMENT' on construction (as set by the Functions host); simulate accordingly. + System.Environment.SetEnvironmentVariable("AZURE_FUNCTIONS_ENVIRONMENT", UnitTestEx.TestSetUp.Environment); + + return FunctionHostFactory.BuildHost(typeof(TEntryPoint).Assembly, [], hb => hb + .ConfigureLogging(lb => { lb.SetMinimumLevel(SetUp.MinimumLogLevel); lb.ClearProviders(); lb.AddProvider(LoggerProvider); }) + .ConfigureAppConfiguration((_, cb) => + { + cb.SetBasePath(Environment.CurrentDirectory).AddJsonFile(GetLocalSettingsJson(), optional: true); + ConfigureTestAppConfiguration(cb); + }) + .ConfigureServices(ConfigureTestServices)); + } + + /// + /// Adds the unit-testing specific application configuration. + /// + private void ConfigureTestAppConfiguration(IConfigurationBuilder cb) + { + if (!_includeUserSecrets.HasValue && TestSetUp.FunctionTesterIncludeUserSecrets || _includeUserSecrets.HasValue && _includeUserSecrets.Value) + cb.AddUserSecrets(typeof(TEntryPoint).Assembly, optional: true); + + cb.AddEnvironmentVariables(); + + if (!_includeUnitTestConfiguration.HasValue && TestSetUp.FunctionTesterIncludeUnitTestConfiguration || _includeUnitTestConfiguration.HasValue && _includeUnitTestConfiguration.Value) + cb.AddJsonFile("appsettings.unittest.json", optional: true); + + if (AdditionalConfiguration != null) + cb.AddInMemoryCollection(AdditionalConfiguration); + } + + /// + /// Adds the unit-testing specific services (these override any existing registrations). + /// + private void ConfigureTestServices(IServiceCollection sc) + { + sc.ReplaceScoped(_ => SharedState); + + foreach (var tec in UnitTestEx.TestSetUp.Extensions) + tec.ConfigureServices(this, sc); + + SetUp.ConfigureServices?.Invoke(sc); + AddConfiguredServices(sc); + } + + /// + /// Builds the using the legacy (or convention) approach. + /// + private IHost BuildStartupHost() + { + { var ep = Activator.CreateInstance(); var ep2 = ep as FunctionsStartup; var ep3 = new EntryPoint(ep); - _host = new HostBuilder() + return new HostBuilder() .UseEnvironment(UnitTestEx.TestSetUp.Environment) .ConfigureLogging((lb) => { lb.SetMinimumLevel(SetUp.MinimumLogLevel); lb.ClearProviders(); lb.AddProvider(LoggerProvider); }) .ConfigureHostConfiguration(cb => @@ -180,10 +245,6 @@ private IHost GetHost() SetUp.ConfigureServices?.Invoke(sc); AddConfiguredServices(sc); }).Build(); - - OnHostStartUp(); - - return _host; } } diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterT.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterT.cs index 0e790dc..c00d50a 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterT.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterT.cs @@ -8,11 +8,11 @@ namespace UnitTestEx.Azure.Functions /// /// Provides the NUnit implementation. /// - /// The Function startup . + /// Any within the Azure Functions (isolated worker) application assembly; or the legacy in-process FunctionsStartup . /// 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, new() + : FunctionTesterBase>(TestFrameworkImplementor.Create(), 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 15818af..6e92ae0 100644 --- a/src/UnitTestEx.Azure.Functions/FunctionTester.cs +++ b/src/UnitTestEx.Azure.Functions/FunctionTester.cs @@ -14,13 +14,13 @@ public static class FunctionTester /// /// Creates a new instance of the class. /// - /// The Function startup . + /// Any within the Azure Functions (isolated worker) application assembly (e.g. a function class); the application's own entry point (Program.cs) is executed as-is to build the host. For the legacy in-process model this is the FunctionsStartup . /// Indicates whether to include 'appsettings.unittest.json' configuration file. /// Indicates whether to include user secrets. /// Additional configuration values to add/override. /// The . public static FunctionTester Create(bool? includeUnitTestConfiguration = null, bool? includeUserSecrets = null, IEnumerable>? additionalConfiguration = null) - where TEntryPoint : class, new() + where TEntryPoint : class => new(includeUnitTestConfiguration, includeUserSecrets, additionalConfiguration); } } \ No newline at end of file diff --git a/src/UnitTestEx.Azure.Functions/ServiceBusExtensionMethods.cs b/src/UnitTestEx.Azure.Functions/ServiceBusExtensionMethods.cs new file mode 100644 index 0000000..54424bd --- /dev/null +++ b/src/UnitTestEx.Azure.Functions/ServiceBusExtensionMethods.cs @@ -0,0 +1,90 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Azure.Core.Amqp; +using Azure.Messaging.ServiceBus; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Net.Mime; +using System.Reflection; +using System.Text; +using UnitTestEx.Abstractions; + +namespace UnitTestEx +{ + /// + /// Provides Service Bus message test helpers. + /// + public static class ServiceBusExtensionMethods + { + /// + /// Creates a where the body contains the serialized as JSON. + /// + public static ServiceBusReceivedMessage CreateServiceBusMessageFromValue(this TesterBase tester, T value) + => CreateServiceBusMessageFromJson(tester, tester.JsonSerializer.Serialize(value)); + + /// + /// Creates a where the body contains the serialized as JSON. + /// + public static ServiceBusReceivedMessage CreateServiceBusMessageFromValue(this TesterBase tester, T value, Action? messageModify = null) + => CreateServiceBusMessageFromJson(tester, tester.JsonSerializer.Serialize(value), messageModify); + + /// + /// Creates a from a JSON embedded resource. + /// + public static ServiceBusReceivedMessage CreateServiceBusMessageFromResource(this TesterBase tester, string resourceName, Action? messageModify = null) + => CreateServiceBusMessageFromResource(tester, resourceName, messageModify, typeof(TAssembly).Assembly); + + /// + /// Creates a from a JSON embedded resource. + /// + public static ServiceBusReceivedMessage CreateServiceBusMessageFromResource(this TesterBase tester, string resourceName, Action? messageModify = null, Assembly? assembly = null) + => CreateServiceBusMessageFromJson(tester, Resource.GetJson(resourceName, assembly ?? Assembly.GetCallingAssembly()), messageModify); + + /// + /// Creates a from JSON. + /// + public static ServiceBusReceivedMessage CreateServiceBusMessageFromJson(this TesterBase tester, [StringSyntax(StringSyntaxAttribute.Json)] string json, Action? messageModify = null) + { + ArgumentNullException.ThrowIfNull(json); + var message = new AmqpAnnotatedMessage(AmqpMessageBody.FromData([Encoding.UTF8.GetBytes(json)])); + message.Properties.ContentType = MediaTypeNames.Application.Json; + message.Properties.MessageId = new AmqpMessageId(Guid.NewGuid().ToString()); + return CreateServiceBusMessage(tester, message, messageModify); + } + + /// + /// Creates a from a . + /// + public static ServiceBusReceivedMessage CreateServiceBusMessage(this TesterBase tester, ServiceBusMessage message) + => CreateServiceBusMessage(tester, (message ?? throw new ArgumentNullException(nameof(message))).GetRawAmqpMessage(), null); + + /// + /// Creates a from a . + /// + public static ServiceBusReceivedMessage CreateServiceBusMessage(this TesterBase tester, ServiceBusMessage message, Action? messageModify) + => CreateServiceBusMessage(tester, (message ?? throw new ArgumentNullException(nameof(message))).GetRawAmqpMessage(), messageModify); + + /// + /// Creates a from an AMQP message. + /// + public static ServiceBusReceivedMessage CreateServiceBusMessage(this TesterBase tester, AmqpAnnotatedMessage message) + => CreateServiceBusMessage(tester, message, null); + + /// + /// Creates a from an AMQP message. + /// + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Needed to act as an extension method.")] + public static ServiceBusReceivedMessage CreateServiceBusMessage(this TesterBase tester, AmqpAnnotatedMessage message, Action? messageModify) + { + ArgumentNullException.ThrowIfNull(message); + message.Header.DeliveryCount ??= 1; + message.Header.Durable ??= true; + message.Header.Priority ??= 1; + message.Header.TimeToLive ??= TimeSpan.FromSeconds(60); + + messageModify?.Invoke(message); + + return ServiceBusReceivedMessage.FromAmqpMessage(message, BinaryData.Empty); + } + } +} diff --git a/src/UnitTestEx.Azure.Functions/UnitTestEx.Azure.Functions.csproj b/src/UnitTestEx.Azure.Functions/UnitTestEx.Azure.Functions.csproj index edb620b..5848b9c 100644 --- a/src/UnitTestEx.Azure.Functions/UnitTestEx.Azure.Functions.csproj +++ b/src/UnitTestEx.Azure.Functions/UnitTestEx.Azure.Functions.csproj @@ -1,7 +1,7 @@  - net6.0;net7.0;net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0 UnitTestEx UnitTestEx UnitTestEx Azure Functions Test Extensions. @@ -13,6 +13,7 @@ + diff --git a/src/UnitTestEx.Azure.ServiceBus/ExtensionMethods.cs b/src/UnitTestEx.Azure.ServiceBus/ExtensionMethods.cs deleted file mode 100644 index 40b4788..0000000 --- a/src/UnitTestEx.Azure.ServiceBus/ExtensionMethods.cs +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx - -using Azure.Core.Amqp; -using Azure.Messaging.ServiceBus; -using System; -using System.Diagnostics.CodeAnalysis; -using System.Net.Mime; -using System.Reflection; -using System.Text; -using UnitTestEx.Abstractions; - -namespace UnitTestEx -{ - /// - /// Provides the UnitTestEx extension methods. - /// - public static class ExtensionMethods - { - /// - /// Creates a where the will contain the as serialized JSON. - /// - /// The . - /// The . - /// The value. - /// The . - public static ServiceBusReceivedMessage CreateServiceBusMessageFromValue(this TesterBase tester, T value) => CreateServiceBusMessageFromJson(tester, tester.JsonSerializer.Serialize(value)); - - /// - /// Creates a where the will contain the as serialized JSON. - /// - /// The . - /// The . - /// The value. - /// Optional modifier than enables the message to be further configured. - /// The . - public static ServiceBusReceivedMessage CreateServiceBusMessageFromValue(this TesterBase tester, T value, Action? messageModify = null) - => CreateServiceBusMessageFromJson(tester, tester.JsonSerializer.Serialize(value), messageModify); - - /// - /// Creates a where the will contain the JSON formatted embedded resource as the content (). - /// - /// The to infer for the embedded resources. - /// The . - /// The embedded resource name (matches to the end of the fully qualified resource name). - /// Optional modifier than enables the message to be further configured. - /// The . - public static ServiceBusReceivedMessage CreateServiceBusMessageFromResource(this TesterBase tester, string resourceName, Action? messageModify = null) - => CreateServiceBusMessageFromResource(tester, resourceName, messageModify, typeof(TAssembly).Assembly); - - /// - /// Creates a where the will contain the JSON formatted embedded resource as the content (). - /// - /// The . - /// The embedded resource name (matches to the end of the fully qualified resource name). - /// Optional modifier than enables the message to be further configured. - /// The that contains the embedded resource; defaults to . - /// The . - public static ServiceBusReceivedMessage CreateServiceBusMessageFromResource(this TesterBase tester, string resourceName, Action? messageModify = null, Assembly? assembly = null) - => CreateServiceBusMessageFromJson(tester, Resource.GetJson(resourceName, assembly ?? Assembly.GetCallingAssembly()), messageModify); - - /// - /// Creates a where the will contain the serialized . - /// - /// The . - /// The JSON body. - /// Optional modifier than enables the message to be further configured. - /// The . -#if NET7_0_OR_GREATER - public static ServiceBusReceivedMessage CreateServiceBusMessageFromJson(this TesterBase tester, [StringSyntax(StringSyntaxAttribute.Json)] string json, Action? messageModify = null) -#else - public static ServiceBusReceivedMessage CreateServiceBusMessageFromJson(this TesterBase tester, string json, Action? messageModify = null) -#endif - { - var message = new AmqpAnnotatedMessage(AmqpMessageBody.FromData([Encoding.UTF8.GetBytes(json ?? throw new ArgumentNullException(nameof(json)))])); - message.Properties.ContentType = MediaTypeNames.Application.Json; - message.Properties.MessageId = new AmqpMessageId(Guid.NewGuid().ToString()); - return CreateServiceBusMessage(tester, message, messageModify); - } - - /// - /// Creates a from the . - /// - /// The . - /// The . - /// The . - public static ServiceBusReceivedMessage CreateServiceBusMessage(this TesterBase tester, ServiceBusMessage message) - => CreateServiceBusMessage(tester, (message ?? throw new ArgumentNullException(nameof(message))).GetRawAmqpMessage(), null); - - /// - /// Creates a from the . - /// - /// The . - /// The . - /// Optional modifier than enables the message to be further configured. - /// The . - public static ServiceBusReceivedMessage CreateServiceBusMessage(this TesterBase tester, ServiceBusMessage message, Action? messageModify) - => CreateServiceBusMessage(tester, (message ?? throw new ArgumentNullException(nameof(message))).GetRawAmqpMessage(), messageModify); - - /// - /// Creates a from the . - /// - /// The . - /// The . - /// The . - public static ServiceBusReceivedMessage CreateServiceBusMessage(this TesterBase tester, AmqpAnnotatedMessage message) => CreateServiceBusMessage(tester, message, null); - - /// - /// Creates a from the . - /// - /// The . - /// The . - /// Optional modifier than enables the message to be further configured. - /// The . - [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Needed to act as an extension method.")] - public static ServiceBusReceivedMessage CreateServiceBusMessage(this TesterBase tester, AmqpAnnotatedMessage message, Action? messageModify) - { - message.Header.DeliveryCount ??= 1; - message.Header.Durable ??= true; - message.Header.Priority ??= 1; - message.Header.TimeToLive ??= TimeSpan.FromSeconds(60); - - messageModify?.Invoke(message); - - var t = typeof(ServiceBusReceivedMessage); - var c = t.GetConstructor(System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, null, [typeof(AmqpAnnotatedMessage)], null); - return c == null - ? throw new InvalidOperationException($"'{typeof(ServiceBusReceivedMessage).Name}' constructor that accepts Type '{typeof(AmqpAnnotatedMessage).Name}' parameter was not found.") - : (ServiceBusReceivedMessage)c.Invoke([message]); - } - } -} \ No newline at end of file diff --git a/src/UnitTestEx.Azure.ServiceBus/UnitTestEx.Azure.ServiceBus.csproj b/src/UnitTestEx.Azure.ServiceBus/UnitTestEx.Azure.ServiceBus.csproj deleted file mode 100644 index 3893c71..0000000 --- a/src/UnitTestEx.Azure.ServiceBus/UnitTestEx.Azure.ServiceBus.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net6.0;net7.0;net8.0;net9.0;net10.0 - UnitTestEx - UnitTestEx - UnitTestEx Azure Functions Test Extensions. - UnitTestEx Azure ServiceBus Test Extensions. - unittestex azure messaging servicebus unit test unittest - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/UnitTestEx.MSTest/UnitTestEx.MSTest.csproj b/src/UnitTestEx.MSTest/UnitTestEx.MSTest.csproj index 3f61882..c35b12b 100644 --- a/src/UnitTestEx.MSTest/UnitTestEx.MSTest.csproj +++ b/src/UnitTestEx.MSTest/UnitTestEx.MSTest.csproj @@ -1,7 +1,7 @@  - net6.0;net7.0;net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0 UnitTestEx.MSTest UnitTestEx MSTest UnitTestEx MSTest Test Extensions. diff --git a/src/UnitTestEx.NUnit/UnitTestEx.NUnit.csproj b/src/UnitTestEx.NUnit/UnitTestEx.NUnit.csproj index 6957f22..e9706bb 100644 --- a/src/UnitTestEx.NUnit/UnitTestEx.NUnit.csproj +++ b/src/UnitTestEx.NUnit/UnitTestEx.NUnit.csproj @@ -1,7 +1,7 @@  - net6.0;net7.0;net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0 UnitTestEx.NUnit UnitTestEx NUnit UnitTestEx NUnit Test Extensions. diff --git a/src/UnitTestEx.Xunit/UnitTestEx.Xunit.csproj b/src/UnitTestEx.Xunit/UnitTestEx.Xunit.csproj index 4a11daa..2891609 100644 --- a/src/UnitTestEx.Xunit/UnitTestEx.Xunit.csproj +++ b/src/UnitTestEx.Xunit/UnitTestEx.Xunit.csproj @@ -1,7 +1,7 @@  - net6.0;net7.0;net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0 UnitTestEx.Xunit UnitTestEx Xunit UnitTestEx Xunit Test Extensions. diff --git a/src/UnitTestEx/Abstractions/TesterBase.cs b/src/UnitTestEx/Abstractions/TesterBase.cs index be53a30..262cd3c 100644 --- a/src/UnitTestEx/Abstractions/TesterBase.cs +++ b/src/UnitTestEx/Abstractions/TesterBase.cs @@ -118,11 +118,7 @@ protected void AddConfiguredServices(IServiceCollection services) /// The . /// The request uri. /// The . -#if NET7_0_OR_GREATER public HttpRequest CreateHttpRequest(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri) -#else - public HttpRequest CreateHttpRequest(HttpMethod httpMethod, string? requestUri) -#endif => CreateHttpRequest(httpMethod, requestUri, null, MediaTypeNames.Text.Plain, null); /// @@ -132,11 +128,7 @@ public HttpRequest CreateHttpRequest(HttpMethod httpMethod, string? requestUri) /// The request uri. /// The optional body content. /// The . -#if NET7_0_OR_GREATER public HttpRequest CreateHttpRequest(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, string? body) -#else - public HttpRequest CreateHttpRequest(HttpMethod httpMethod, string? requestUri, string? body) -#endif => CreateHttpRequest(httpMethod, requestUri, body, null, null); /// @@ -147,11 +139,7 @@ public HttpRequest CreateHttpRequest(HttpMethod httpMethod, string? requestUri, /// The optional body content. /// The content type. Defaults to . /// The . -#if NET7_0_OR_GREATER public HttpRequest CreateHttpRequest(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, string? body, string? contentType) -#else - public HttpRequest CreateHttpRequest(HttpMethod httpMethod, string? requestUri, string? body, string? contentType) -#endif => CreateHttpRequest(httpMethod, requestUri, body, contentType, null); /// @@ -161,11 +149,7 @@ public HttpRequest CreateHttpRequest(HttpMethod httpMethod, string? requestUri, /// The request uri. /// The optional modifier. /// The . -#if NET7_0_OR_GREATER public HttpRequest CreateHttpRequest(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, Action? requestModifier = null) -#else - public HttpRequest CreateHttpRequest(HttpMethod httpMethod, string? requestUri, Action? requestModifier = null) -#endif => CreateHttpRequest(httpMethod, requestUri, null, MediaTypeNames.Text.Plain, requestModifier); /// @@ -176,11 +160,7 @@ public HttpRequest CreateHttpRequest(HttpMethod httpMethod, string? requestUri, /// The optional body content. /// The optional modifier. /// The . -#if NET7_0_OR_GREATER public HttpRequest CreateHttpRequest(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, string? body, Action? requestModifier = null) -#else - public HttpRequest CreateHttpRequest(HttpMethod httpMethod, string? requestUri, string? body, Action? requestModifier = null) -#endif => CreateHttpRequest(httpMethod, requestUri, body, null, requestModifier); /// @@ -192,11 +172,7 @@ public HttpRequest CreateHttpRequest(HttpMethod httpMethod, string? requestUri, /// The content type. Defaults to . /// The optional modifier. /// The . -#if NET7_0_OR_GREATER public HttpRequest CreateHttpRequest(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri = null, string? body = null, string? contentType = null, Action? requestModifier = null) -#else - public HttpRequest CreateHttpRequest(HttpMethod httpMethod, string? requestUri = null, string? body = null, string? contentType = null, Action? requestModifier = null) -#endif { if (httpMethod == HttpMethod.Get && body != null) LoggerProvider.CreateLogger("FunctionTesterBase").LogWarning("A payload within a GET request message has no defined semantics; sending a payload body on a GET request might cause some existing implementations to reject the request (see https://www.rfc-editor.org/rfc/rfc7231)."); @@ -240,11 +216,7 @@ public HttpRequest CreateHttpRequest(HttpMethod httpMethod, string? requestUri = /// The request uri. /// The value to JSON serialize. /// The . -#if NET7_0_OR_GREATER public HttpRequest CreateJsonHttpRequest(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, object? value) -#else - public HttpRequest CreateJsonHttpRequest(HttpMethod httpMethod, string? requestUri, object? value) -#endif => CreateJsonHttpRequest(httpMethod, requestUri, value, null); /// @@ -255,11 +227,7 @@ public HttpRequest CreateJsonHttpRequest(HttpMethod httpMethod, string? requestU /// The value to JSON serialize. /// The optional modifier. /// The . -#if NET7_0_OR_GREATER public HttpRequest CreateJsonHttpRequest(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, object? value, Action? requestModifier = null) -#else - public HttpRequest CreateJsonHttpRequest(HttpMethod httpMethod, string? requestUri, object? value, Action? requestModifier = null) -#endif => CreateHttpRequest(httpMethod, requestUri, JsonSerializer.Serialize(value), MediaTypeNames.Application.Json, requestModifier: requestModifier); /// @@ -270,11 +238,7 @@ public HttpRequest CreateJsonHttpRequest(HttpMethod httpMethod, string? requestU /// The request uri. /// The embedded resource name (matches to the end of the fully qualified resource name). /// The . -#if NET7_0_OR_GREATER public HttpRequest CreateJsonHttpRequestFromResource(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, string resourceName) -#else - public HttpRequest CreateJsonHttpRequestFromResource(HttpMethod httpMethod, string? requestUri, string resourceName) -#endif => CreateJsonHttpRequestFromResource(httpMethod, requestUri, resourceName, null); /// @@ -286,11 +250,7 @@ public HttpRequest CreateJsonHttpRequestFromResource(HttpMethod httpM /// The embedded resource name (matches to the end of the fully qualified resource name). /// The optional modifier. /// The . -#if NET7_0_OR_GREATER public HttpRequest CreateJsonHttpRequestFromResource(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, string resourceName, Action? requestModifier = null) -#else - public HttpRequest CreateJsonHttpRequestFromResource(HttpMethod httpMethod, string? requestUri, string resourceName, Action? requestModifier = null) -#endif => CreateJsonHttpRequestFromResource(httpMethod, requestUri, resourceName, typeof(TAssembly).Assembly, requestModifier); /// @@ -301,11 +261,7 @@ public HttpRequest CreateJsonHttpRequestFromResource(HttpMethod httpM /// The embedded resource name (matches to the end of the fully qualified resource name). /// The that contains the embedded resource; defaults to . /// The . -#if NET7_0_OR_GREATER public HttpRequest CreateJsonHttpRequestFromResource(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, string resourceName, Assembly assembly) -#else - public HttpRequest CreateJsonHttpRequestFromResource(HttpMethod httpMethod, string? requestUri, string resourceName, Assembly assembly) -#endif => CreateJsonHttpRequestFromResource(httpMethod, requestUri, resourceName, assembly, null); /// @@ -317,11 +273,7 @@ public HttpRequest CreateJsonHttpRequestFromResource(HttpMethod httpMethod, stri /// The that contains the embedded resource; defaults to . /// The optional modifier. /// The . -#if NET7_0_OR_GREATER public HttpRequest CreateJsonHttpRequestFromResource(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, string resourceName, Assembly assembly, Action? requestModifier = null) -#else - public HttpRequest CreateJsonHttpRequestFromResource(HttpMethod httpMethod, string? requestUri, string resourceName, Assembly assembly, Action? requestModifier = null) -#endif => CreateHttpRequest(httpMethod, requestUri, Resource.GetJson(resourceName, assembly ?? Assembly.GetCallingAssembly()), MediaTypeNames.Application.Json, requestModifier: requestModifier); #endregion diff --git a/src/UnitTestEx/AspNetCore/HttpTester.cs b/src/UnitTestEx/AspNetCore/HttpTester.cs index eaace4c..c51257b 100644 --- a/src/UnitTestEx/AspNetCore/HttpTester.cs +++ b/src/UnitTestEx/AspNetCore/HttpTester.cs @@ -24,11 +24,7 @@ public class HttpTester(TesterBaseCore owner, IHttpClientSource httpClientSource /// The string that represents the request . /// The optional modifier. /// An . -#if NET7_0_OR_GREATER public HttpResponseMessageAssertor Run(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, Action? requestModifier = null) -#else - public HttpResponseMessageAssertor Run(HttpMethod httpMethod, string? requestUri, Action? requestModifier = null) -#endif => RunAsync(httpMethod, requestUri, requestModifier).GetAwaiter().GetResult(); /// @@ -40,11 +36,7 @@ public HttpResponseMessageAssertor Run(HttpMethod httpMethod, string? requestUri /// The content type. Defaults to . /// The optional modifier. /// An . -#if NET7_0_OR_GREATER public HttpResponseMessageAssertor Run(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, string? body, string? contentType = MediaTypeNames.Text.Plain, Action? requestModifier = null) -#else - public HttpResponseMessageAssertor Run(HttpMethod httpMethod, string? requestUri, string? body, string? contentType = MediaTypeNames.Text.Plain, Action? requestModifier = null) -#endif => RunAsync(httpMethod, requestUri, body, contentType, requestModifier).GetAwaiter().GetResult(); /// @@ -55,11 +47,7 @@ public HttpResponseMessageAssertor Run(HttpMethod httpMethod, string? requestUri /// The request body value. /// The optional modifier. /// An . -#if NET7_0_OR_GREATER public HttpResponseMessageAssertor Run(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, T? value, Action? requestModifier = null) -#else - public HttpResponseMessageAssertor Run(HttpMethod httpMethod, string? requestUri, T? value, Action? requestModifier = null) -#endif => SendAsync(httpMethod, requestUri, value, requestModifier).GetAwaiter().GetResult(); /// @@ -69,11 +57,7 @@ public HttpResponseMessageAssertor Run(HttpMethod httpMethod, string? request /// The string that represents the request . /// The optional modifier. /// An . -#if NET7_0_OR_GREATER public Task RunAsync(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, Action? requestModifier = null) -#else - public Task RunAsync(HttpMethod httpMethod, string? requestUri, Action? requestModifier = null) -#endif => SendAsync(httpMethod, requestUri, requestModifier); /// @@ -85,11 +69,7 @@ public Task RunAsync(HttpMethod httpMethod, string? /// The content type. Defaults to . /// The optional modifier. /// An . -#if NET7_0_OR_GREATER public Task RunAsync(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, string? body, string? contentType = MediaTypeNames.Text.Plain, Action? requestModifier = null) -#else - public Task RunAsync(HttpMethod httpMethod, string? requestUri, string? body, string? contentType = MediaTypeNames.Text.Plain, Action? requestModifier = null) -#endif => SendAsync(httpMethod, requestUri, body, contentType, requestModifier); /// @@ -100,11 +80,7 @@ public Task RunAsync(HttpMethod httpMethod, string? /// The request body value. /// The optional modifier. /// An . -#if NET7_0_OR_GREATER public Task RunAsync(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, T? value, Action? requestModifier = null) -#else - public Task RunAsync(HttpMethod httpMethod, string? requestUri, T? value, Action? requestModifier = null) -#endif => SendAsync(httpMethod, requestUri, value, requestModifier); } } \ No newline at end of file diff --git a/src/UnitTestEx/AspNetCore/HttpTesterBase.cs b/src/UnitTestEx/AspNetCore/HttpTesterBase.cs index fd9254e..a142b21 100644 --- a/src/UnitTestEx/AspNetCore/HttpTesterBase.cs +++ b/src/UnitTestEx/AspNetCore/HttpTesterBase.cs @@ -84,11 +84,7 @@ public HttpTesterBase(TesterBaseCore owner, IHttpClientSource httpClientSource) /// The string that represents the request . /// The optional modifier. /// The . -#if NET7_0_OR_GREATER protected async Task SendAsync(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, Action? requestModifier) -#else - protected async Task SendAsync(HttpMethod httpMethod, string? requestUri, Action? requestModifier) -#endif { try { @@ -117,11 +113,7 @@ protected async Task SendAsync(HttpMethod httpMetho /// The content type. /// The optional modifier. /// The . -#if NET7_0_OR_GREATER protected async Task SendAsync(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, string? content, string? contentType, Action? requestModifier) -#else - protected async Task SendAsync(HttpMethod httpMethod, string? requestUri, string? content, string? contentType, Action? requestModifier) -#endif { try { @@ -152,11 +144,7 @@ protected async Task SendAsync(HttpMethod httpMetho /// The value to be JSON serialized. /// The optional modifier. /// The . -#if NET7_0_OR_GREATER protected async Task SendAsync(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, object? value, Action? requestModifier) -#else - protected async Task SendAsync(HttpMethod httpMethod, string? requestUri, object? value, Action? requestModifier) -#endif { try { @@ -198,31 +186,19 @@ public class TypedHttpClient(HttpTesterBase httpTester, HttpClient client) /// /// Sends with no content. /// -#if NET7_0_OR_GREATER public async Task SendAsync(HttpMethod method, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, Action? requestModifier) -#else - public async Task SendAsync(HttpMethod method, string? requestUri, Action? requestModifier) -#endif => await SendAsync(CreateRequest(method, requestUri ?? "", null, requestModifier), default).ConfigureAwait(false); /// /// Sends with content. /// -#if NET7_0_OR_GREATER public async Task SendAsync(HttpMethod method, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, string? content, string? contentType, Action? requestModifier) -#else - public async Task SendAsync(HttpMethod method, string? requestUri, string? content, string? contentType, Action? requestModifier) -#endif => await SendAsync(CreateRequest(method, requestUri ?? "", new StringContent(content ?? string.Empty, Encoding.UTF8, contentType ?? MediaTypeNames.Text.Plain), requestModifier), default).ConfigureAwait(false); /// /// Sends with JSON value. /// -#if NET7_0_OR_GREATER public async Task SendAsync(HttpMethod method, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, object? value, Action? requestModifier) -#else - public async Task SendAsync(HttpMethod method, string? requestUri, object? value, Action? requestModifier) -#endif => await SendAsync(CreateRequest(method, requestUri ?? "", new StringContent(_httpTester.JsonSerializer.Serialize(value), Encoding.UTF8, MediaTypeNames.Application.Json), requestModifier), default).ConfigureAwait(false); /// diff --git a/src/UnitTestEx/AspNetCore/HttpTesterT.cs b/src/UnitTestEx/AspNetCore/HttpTesterT.cs index 5db7743..fcce740 100644 --- a/src/UnitTestEx/AspNetCore/HttpTesterT.cs +++ b/src/UnitTestEx/AspNetCore/HttpTesterT.cs @@ -25,11 +25,7 @@ public class HttpTester(TesterBaseCore owner, IHttpClientSource httpClie /// The string that represents the request . /// The optional modifier. /// An . -#if NET7_0_OR_GREATER public HttpResponseMessageAssertor Run(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, Action? requestModifier = null) -#else - public HttpResponseMessageAssertor Run(HttpMethod httpMethod, string? requestUri, Action? requestModifier = null) -#endif => RunAsync(httpMethod, requestUri, requestModifier).GetAwaiter().GetResult(); /// @@ -41,11 +37,7 @@ public HttpResponseMessageAssertor Run(HttpMethod httpMethod, string? re /// The content type. Defaults to . /// The optional modifier. /// An . -#if NET7_0_OR_GREATER public HttpResponseMessageAssertor Run(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, string? body, string? contentType = MediaTypeNames.Text.Plain, Action? requestModifier = null) -#else - public HttpResponseMessageAssertor Run(HttpMethod httpMethod, string? requestUri, string? body, string? contentType = MediaTypeNames.Text.Plain, Action? requestModifier = null) -#endif => RunAsync(httpMethod, requestUri, body, contentType, requestModifier).GetAwaiter().GetResult(); /// @@ -56,11 +48,7 @@ public HttpResponseMessageAssertor Run(HttpMethod httpMethod, string? re /// The request body value. /// The optional modifier. /// An . -#if NET7_0_OR_GREATER public HttpResponseMessageAssertor Run(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, T? value, Action? requestModifier = null) -#else - public HttpResponseMessageAssertor Run(HttpMethod httpMethod, string? requestUri, T? value, Action? requestModifier = null) -#endif => new(SendAsync(httpMethod, requestUri, value, requestModifier).GetAwaiter().GetResult()); /// @@ -70,11 +58,7 @@ public HttpResponseMessageAssertor Run(HttpMethod httpMethod, string? /// The string that represents the request . /// The optional modifier. /// An . -#if NET7_0_OR_GREATER public async Task> RunAsync(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, Action? requestModifier = null) -#else - public async Task> RunAsync(HttpMethod httpMethod, string? requestUri, Action? requestModifier = null) -#endif => new(await SendAsync(httpMethod, requestUri, requestModifier).ConfigureAwait(false)); /// @@ -86,11 +70,7 @@ public async Task> RunAsync(HttpMethod httpM /// The content type. Defaults to . /// The optional modifier. /// An . -#if NET7_0_OR_GREATER public async Task> RunAsync(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, string? body, string? contentType = MediaTypeNames.Text.Plain, Action? requestModifier = null) -#else - public async Task> RunAsync(HttpMethod httpMethod, string? requestUri, string? body, string? contentType = MediaTypeNames.Text.Plain, Action? requestModifier = null) -#endif => new(await SendAsync(httpMethod, requestUri, body, contentType, requestModifier)); /// @@ -101,11 +81,7 @@ public async Task> RunAsync(HttpMethod httpM /// The request body value. /// The optional modifier. /// An . -#if NET7_0_OR_GREATER public async Task> RunAsync(HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, T? value, Action? requestModifier = null) -#else - public async Task> RunAsync(HttpMethod httpMethod, string? requestUri, T? value, Action? requestModifier = null) -#endif => new(await SendAsync(httpMethod, requestUri, value, requestModifier).ConfigureAwait(false)); } } \ No newline at end of file diff --git a/src/UnitTestEx/Assertors/ActionResultAssertor.cs b/src/UnitTestEx/Assertors/ActionResultAssertor.cs index 6678cb4..6e42501 100644 --- a/src/UnitTestEx/Assertors/ActionResultAssertor.cs +++ b/src/UnitTestEx/Assertors/ActionResultAssertor.cs @@ -375,11 +375,7 @@ public ActionResultAssertor AssertJsonFromResource(string resourceName, params s /// The expected JSON. /// The JSON paths to ignore from the comparison. /// The to support fluent-style method-chaining. -#if NET7_0_OR_GREATER public ActionResultAssertor AssertJson([StringSyntax(StringSyntaxAttribute.Json)] string json, params string[] pathsToIgnore) -#else - public ActionResultAssertor AssertJson(string json, params string[] pathsToIgnore) -#endif { if (string.IsNullOrEmpty(json)) throw new ArgumentNullException(nameof(json)); diff --git a/src/UnitTestEx/Assertors/HttpResponseMessageAssertorBaseT.cs b/src/UnitTestEx/Assertors/HttpResponseMessageAssertorBaseT.cs index 61dcc95..73452f9 100644 --- a/src/UnitTestEx/Assertors/HttpResponseMessageAssertorBaseT.cs +++ b/src/UnitTestEx/Assertors/HttpResponseMessageAssertorBaseT.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using Microsoft.AspNetCore.Http; using Microsoft.Net.Http.Headers; @@ -277,11 +277,7 @@ public TSelf AssertErrors(params ApiError[] errors) /// The expected JSON. /// The JSON paths to ignore from the comparison. /// The instance to support fluent-style method-chaining. -#if NET7_0_OR_GREATER public TSelf AssertJson([StringSyntax(StringSyntaxAttribute.Json)] string json, params string[] pathsToIgnore) -#else - public TSelf AssertJson(string json, params string[] pathsToIgnore) -#endif { if (string.IsNullOrEmpty(json)) throw new ArgumentNullException(nameof(json)); diff --git a/src/UnitTestEx/Assertors/HttpResultAssertor.cs b/src/UnitTestEx/Assertors/HttpResultAssertor.cs index a7df407..fd168d3 100644 --- a/src/UnitTestEx/Assertors/HttpResultAssertor.cs +++ b/src/UnitTestEx/Assertors/HttpResultAssertor.cs @@ -1,7 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx -#if NET7_0_OR_GREATER - using Microsoft.AspNetCore.Http; using System; using System.Collections.Generic; @@ -68,5 +66,3 @@ internal static HttpResponseMessageAssertor ToHttpResponseMessageAssertor(Tester } } } - -#endif \ No newline at end of file diff --git a/src/UnitTestEx/Assertors/ValueAssertor.cs b/src/UnitTestEx/Assertors/ValueAssertor.cs index 8df8262..bb38e95 100644 --- a/src/UnitTestEx/Assertors/ValueAssertor.cs +++ b/src/UnitTestEx/Assertors/ValueAssertor.cs @@ -54,11 +54,7 @@ public ValueAssertor AssertValue(TValue? expectedValue, params string[] /// The JSON . /// The JSON paths to ignore from the comparison. /// The to support fluent-style method-chaining. -#if NET7_0_OR_GREATER public ValueAssertor AssertJson([StringSyntax(StringSyntaxAttribute.Json)] string json, params string[] pathsToIgnore) -#else - public ValueAssertor AssertJson(string json, params string[] pathsToIgnore) -#endif { AssertSuccess(); var cr = Owner.CreateJsonComparer().CompareValue(json, JsonSerializer.Serialize(Value), pathsToIgnore); @@ -115,17 +111,11 @@ public HttpResponseMessageAssertor ToHttpResponseMessageAssertor(HttpRequest? ht if (Value is IActionResult ar) return ActionResultAssertor.ToHttpResponseMessageAssertor(Owner, ar, LogMessages, httpRequest); -#if NET7_0_OR_GREATER if (Value is IResult ir) return HttpResultAssertor.ToHttpResponseMessageAssertor(Owner, ir, LogMessages, httpRequest); -#endif } -#if NET7_0_OR_GREATER throw new InvalidOperationException($"Result Type '{typeof(TValue).Name}' must be either a '{nameof(HttpResponseMessage)}', '{nameof(IResult)}' or '{nameof(IActionResult)}', and the value must not be null."); -#else - throw new InvalidOperationException($"Result Type '{typeof(TValue).Name}' must be either a '{nameof(HttpResponseMessage)}' or '{nameof(IActionResult)}', and the value must not be null."); -#endif } } } \ No newline at end of file diff --git a/src/UnitTestEx/Expectations/ExpectationsExtensions.cs b/src/UnitTestEx/Expectations/ExpectationsExtensions.cs index e5c01f5..df86634 100644 --- a/src/UnitTestEx/Expectations/ExpectationsExtensions.cs +++ b/src/UnitTestEx/Expectations/ExpectationsExtensions.cs @@ -223,11 +223,7 @@ public static TSelf ExpectValue(this IValueExpectationsThe JSON . /// The JSON paths to ignore. /// The instance to support fluent-style method-chaining. -#if NET7_0_OR_GREATER public static TSelf ExpectJson(this IValueExpectations expectations, [StringSyntax(StringSyntaxAttribute.Json)] string json, params string[] pathsToIgnore) where TSelf : IValueExpectations -#else - public static TSelf ExpectJson(this IValueExpectations expectations, string json, params string[] pathsToIgnore) where TSelf : IValueExpectations -#endif { expectations.ExpectationsArranger.GetOrAdd(() => new ValueExpectations(expectations.ExpectationsArranger.Owner, (TSelf)expectations)).SetExpectJson(t => json); expectations.ExpectationsArranger.PathsToIgnore.AddRange(pathsToIgnore); @@ -247,6 +243,6 @@ public static TSelf ExpectJson(this IValueExpectations(this IValueExpectations expectations, string resourceName, params string[] pathsToIgnore) where TSelf : IValueExpectations => ExpectJson(expectations, Resource.GetJson(resourceName, Assembly.GetCallingAssembly()), pathsToIgnore); -#endregion + #endregion } } \ No newline at end of file diff --git a/src/UnitTestEx/Generic/GenericTesterCore.cs b/src/UnitTestEx/Generic/GenericTesterCore.cs index 15dde90..f1cbe95 100644 --- a/src/UnitTestEx/Generic/GenericTesterCore.cs +++ b/src/UnitTestEx/Generic/GenericTesterCore.cs @@ -59,7 +59,6 @@ private IHost GetHost() var ep = new EntryPoint(Activator.CreateInstance()); -#if NET8_0_OR_GREATER var settings = new HostApplicationBuilderSettings { EnvironmentName = TestSetUp.Environment, @@ -108,41 +107,6 @@ private IHost GetHost() _host = builder.Build(); OnHostStartUp(); return _host; -#else - _host ??= Host.CreateDefaultBuilder() - .UseEnvironment(TestSetUp.Environment) - .ConfigureLogging((lb) => { lb.SetMinimumLevel(SetUp.MinimumLogLevel); lb.ClearProviders(); lb.AddProvider(LoggerProvider); }) - .ConfigureHostConfiguration(cb => - { - cb.SetBasePath(Environment.CurrentDirectory); - ep.ConfigureHostConfiguration(cb); - cb.AddJsonFile("appsettings.json", optional: true) - .AddJsonFile($"appsettings.{TestSetUp.Environment.ToLowerInvariant()}.json", optional: true); - }) - .ConfigureAppConfiguration((hbc, cb) => - { - ep.ConfigureAppConfiguration(hbc, cb); - cb.AddJsonFile("appsettings.unittest.json", optional: true) - .AddEnvironmentVariables(); - - if (AdditionalConfiguration != null) - cb.AddInMemoryCollection(AdditionalConfiguration); - }) - .ConfigureServices(sc => - { - ep.ConfigureServices(sc); - sc.ReplaceScoped(_ => SharedState); - - foreach (var tec in TestSetUp.Extensions) - tec.ConfigureServices(this, sc); - - SetUp.ConfigureServices?.Invoke(sc); - AddConfiguredServices(sc); - }).Build(); - - OnHostStartUp(); - return _host; -#endif } } diff --git a/src/UnitTestEx/Hosting/EntryPoint.cs b/src/UnitTestEx/Hosting/EntryPoint.cs index 1e128f8..271f2eb 100644 --- a/src/UnitTestEx/Hosting/EntryPoint.cs +++ b/src/UnitTestEx/Hosting/EntryPoint.cs @@ -18,9 +18,7 @@ public class EntryPoint private readonly MethodInfo? _mi1; private readonly MethodInfo? _mi2; private readonly MethodInfo? _mi3; -#if NET8_0_OR_GREATER private readonly MethodInfo? _mi4; -#endif /// /// Initializes a new instance of the class. @@ -32,9 +30,7 @@ public EntryPoint(object instance) _mi1 = instance.GetType().GetMethod(nameof(ConfigureAppConfiguration), BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public, [typeof(HostBuilderContext), typeof(IConfigurationBuilder)]); _mi2 = instance.GetType().GetMethod(nameof(ConfigureHostConfiguration), BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public, [typeof(IConfigurationBuilder)]); _mi3 = instance.GetType().GetMethod(nameof(ConfigureServices), BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public, [typeof(IServiceCollection)]); -#if NET8_0_OR_GREATER _mi4 = instance.GetType().GetMethod(nameof(ConfigureApplication), BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public, [typeof(IHostApplicationBuilder)]); -#endif } /// @@ -52,12 +48,10 @@ public EntryPoint(object instance) /// public bool HasConfigureServices => _mi3 is not null; -#if NET8_0_OR_GREATER /// /// Indicates whether the has been defined on the entry point instance. /// public bool HasConfigureApplication => _mi4 is not null; -#endif /// /// Sets up the configuration for the remainder of the build process and application. @@ -81,12 +75,10 @@ public EntryPoint(object instance) /// This is intended to be invoked by the . public void ConfigureServices(IServiceCollection services) => _mi3?.Invoke(_instance, [services]); -#if NET8_0_OR_GREATER /// /// Enables further configuration of the after it has been created/pre-configured. /// /// The public void ConfigureApplication(IHostApplicationBuilder builder) => _mi4?.Invoke(_instance, [builder]); -#endif } } \ No newline at end of file diff --git a/src/UnitTestEx/Json/JsonElementComparer.cs b/src/UnitTestEx/Json/JsonElementComparer.cs index 940feb9..084b63d 100644 --- a/src/UnitTestEx/Json/JsonElementComparer.cs +++ b/src/UnitTestEx/Json/JsonElementComparer.cs @@ -291,11 +291,7 @@ public bool Equals(JsonElement x, JsonElement y) } /// -#if NET7_0_OR_GREATER public int GetHashCode([StringSyntax(StringSyntaxAttribute.Json)] string json) -#else - public int GetHashCode(string json) -#endif { if (json == null) return 0; diff --git a/src/UnitTestEx/Logging/LoggerBase.cs b/src/UnitTestEx/Logging/LoggerBase.cs index c696ac1..ce2a054 100644 --- a/src/UnitTestEx/Logging/LoggerBase.cs +++ b/src/UnitTestEx/Logging/LoggerBase.cs @@ -25,11 +25,7 @@ public abstract class LoggerBase(string name, IExternalScopeProvider? scopeProvi public string Name { get; } = name ?? throw new ArgumentNullException(nameof(name)); /// -#if NET6_0_OR_GREATER public IDisposable BeginScope(TState state) where TState : notnull => _scopeProvider.Push(state); -#else - public IDisposable BeginScope(TState state) => _scopeProvider.Push(state); -#endif /// public bool IsEnabled(LogLevel logLevel) => true; diff --git a/src/UnitTestEx/Mocking/IHttpMockRequest.cs b/src/UnitTestEx/Mocking/IHttpMockRequest.cs index 5c42a06..a4aee14 100644 --- a/src/UnitTestEx/Mocking/IHttpMockRequest.cs +++ b/src/UnitTestEx/Mocking/IHttpMockRequest.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using Moq; using System; @@ -48,11 +48,7 @@ public interface IHttpMockRequest /// The to continue the fluent-style configuration. /// The exact JSON comparison semantics (including how is interpreted) are tier-specific - see the native WithJsonBody documentation /// on the concrete implementation being used; they are not guaranteed to be equivalent between tiers. -#if NET7_0_OR_GREATER IHttpMockRequestBody WithJsonBody([StringSyntax(StringSyntaxAttribute.Json)] string json, params string[] pathsToIgnore); -#else - IHttpMockRequestBody WithJsonBody(string json, params string[] pathsToIgnore); -#endif /// /// Indicates that the request body must match the JSON serialized representation of the specified . diff --git a/src/UnitTestEx/Mocking/MockHttpClient.cs b/src/UnitTestEx/Mocking/MockHttpClient.cs index 0c48e29..765727c 100644 --- a/src/UnitTestEx/Mocking/MockHttpClient.cs +++ b/src/UnitTestEx/Mocking/MockHttpClient.cs @@ -239,11 +239,7 @@ public void WithoutMocking(params Type[] excludeTypes) /// The . Defaults to . /// The string that represents the request . /// The . -#if NET7_0_OR_GREATER public MockHttpClientRequest Request(HttpMethod? method = null, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri = null) -#else - public MockHttpClientRequest Request(HttpMethod? method = null, string? requestUri = null) -#endif { if (_noMocking) throw new InvalidOperationException($"{nameof(Request)} is not supported where {nameof(WithoutMocking)} has been specified."); diff --git a/src/UnitTestEx/Mocking/MockHttpClientRequest.cs b/src/UnitTestEx/Mocking/MockHttpClientRequest.cs index 0383397..39f1aaa 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientRequest.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientRequest.cs @@ -316,11 +316,7 @@ public MockHttpClientRequestBody WithJsonBody(T value, params string[] pathsT /// The content. /// The JSON paths to ignore from the comparison. /// The resulting to accordingly. -#if NET7_0_OR_GREATER public MockHttpClientRequestBody WithJsonBody([StringSyntax(StringSyntaxAttribute.Json)] string json, params string[] pathsToIgnore) -#else - public MockHttpClientRequestBody WithJsonBody(string json, params string[] pathsToIgnore) -#endif { Reset(); diff --git a/src/UnitTestEx/Mocking/MockHttpClientResponse.cs b/src/UnitTestEx/Mocking/MockHttpClientResponse.cs index 9425d51..759c212 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientResponse.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientResponse.cs @@ -206,11 +206,7 @@ public void With(HttpContent? content = null, HttpStatusCode? statusCode = null, /// The optional (defaults to ). /// The optional media type (defaults to ). /// The optional action to enable additional configuration of the . -#if NET7_0_OR_GREATER public void WithJson([StringSyntax(StringSyntaxAttribute.Json)] string json, HttpStatusCode? statusCode = null, string? mediaType = MediaTypeNames.Application.Json, Action? response = null) -#else - public void WithJson(string json, HttpStatusCode? statusCode = null, string? mediaType = MediaTypeNames.Application.Json, Action? response = null) -#endif { var content = new StringContent(json ?? throw new ArgumentNullException(nameof(json))); content.Headers.ContentType = MediaTypeHeaderValue.Parse(mediaType ?? MediaTypeNames.Application.Json); diff --git a/src/UnitTestEx/ObjectComparer.cs b/src/UnitTestEx/ObjectComparer.cs index 22b2a52..d63c987 100644 --- a/src/UnitTestEx/ObjectComparer.cs +++ b/src/UnitTestEx/ObjectComparer.cs @@ -66,11 +66,7 @@ public static void Assert(JsonElementComparerOptions? options, object? expected, /// The actual JSON. /// The JSON paths to ignore from the comparison. [Obsolete("The JsonAssert is being renamed to AssertJson as this name is standard with UnitTestEx.")] -#if NET7_0_OR_GREATER public static void JsonAssert([StringSyntax(StringSyntaxAttribute.Json)] string? expected, [StringSyntax(StringSyntaxAttribute.Json)] string? actual, params string[] pathsToIgnore) => AssertJson(null, expected, actual, pathsToIgnore); -#else - public static void JsonAssert(string? expected, string? actual, params string[] pathsToIgnore) => AssertJson(null, expected, actual, pathsToIgnore); -#endif /// /// Compares two JSON strings to each other. @@ -80,11 +76,7 @@ public static void Assert(JsonElementComparerOptions? options, object? expected, /// The actual JSON. /// The JSON paths to ignore from the comparison. [Obsolete("The JsonAssert is being renamed to AssertJson as this name is standard with UnitTestEx.")] -#if NET7_0_OR_GREATER public static void JsonAssert(JsonElementComparerOptions? options, [StringSyntax(StringSyntaxAttribute.Json)] string? expected, [StringSyntax(StringSyntaxAttribute.Json)] string? actual, params string[] pathsToIgnore) -#else - public static void JsonAssert(JsonElementComparerOptions? options, string? expected, string? actual, params string[] pathsToIgnore) -#endif => AssertJson(options, expected, actual, pathsToIgnore); /// @@ -93,11 +85,7 @@ public static void JsonAssert(JsonElementComparerOptions? options, string? expec /// The expected JSON. /// The actual JSON. /// The JSON paths to ignore from the comparison. -#if NET7_0_OR_GREATER public static void AssertJson([StringSyntax(StringSyntaxAttribute.Json)] string? expected, [StringSyntax(StringSyntaxAttribute.Json)] string? actual, params string[] pathsToIgnore) => AssertJson(null, expected, actual, pathsToIgnore); -#else - public static void AssertJson(string? expected, string? actual, params string[] pathsToIgnore) => AssertJson(null, expected, actual, pathsToIgnore); -#endif /// /// Compares two JSON strings to each other. @@ -106,11 +94,7 @@ public static void JsonAssert(JsonElementComparerOptions? options, string? expec /// The expected JSON. /// The actual JSON. /// The JSON paths to ignore from the comparison. -#if NET7_0_OR_GREATER public static void AssertJson(JsonElementComparerOptions? options, [StringSyntax(StringSyntaxAttribute.Json)] string? expected, [StringSyntax(StringSyntaxAttribute.Json)] string? actual, params string[] pathsToIgnore) -#else - public static void AssertJson(JsonElementComparerOptions? options, string? expected, string? actual, params string[] pathsToIgnore) -#endif { if (expected is null && actual is null) return; diff --git a/src/UnitTestEx/UnitTestEx.csproj b/src/UnitTestEx/UnitTestEx.csproj index 8a39f70..6079a4a 100644 --- a/src/UnitTestEx/UnitTestEx.csproj +++ b/src/UnitTestEx/UnitTestEx.csproj @@ -1,7 +1,7 @@  - net6.0;net7.0;net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0 UnitTestEx UnitTestEx UnitTestEx Test Extensions. @@ -15,18 +15,6 @@ - - - - - - - - - - - - diff --git a/tests/UnitTestEx.IsolatedFunction/Program.cs b/tests/UnitTestEx.IsolatedFunction/Program.cs index d5c76d4..4894379 100644 --- a/tests/UnitTestEx.IsolatedFunction/Program.cs +++ b/tests/UnitTestEx.IsolatedFunction/Program.cs @@ -1,10 +1,8 @@ +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using UnitTestEx.IsolatedFunction; -var startup = new Startup(); -var host = new HostBuilder() - .ConfigureFunctionsWebApplication() - .ConfigureServices((hbc, sc) => startup.ConfigureServices(sc)) - .Build(); - -host.Run(); +var builder = FunctionsApplication.CreateBuilder(args); +builder.ConfigureFunctionsWebApplication(); +builder.Services.AddHttpClient("XXX", client => client.BaseAddress = new Uri("https://somesys")); +builder.Build().Run(); diff --git a/tests/UnitTestEx.IsolatedFunction/Startup.cs b/tests/UnitTestEx.IsolatedFunction/Startup.cs deleted file mode 100644 index 7325c62..0000000 --- a/tests/UnitTestEx.IsolatedFunction/Startup.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace UnitTestEx.IsolatedFunction -{ - public class Startup - { - public void ConfigureServices(IServiceCollection services) - { - services.AddHttpClient("XXX", hc => hc.BaseAddress = new System.Uri("https://somesys")); - } - } -} \ No newline at end of file diff --git a/tests/UnitTestEx.IsolatedFunction/UnitTestEx.IsolatedFunction.csproj b/tests/UnitTestEx.IsolatedFunction/UnitTestEx.IsolatedFunction.csproj index 9891091..e3a0e2d 100644 --- a/tests/UnitTestEx.IsolatedFunction/UnitTestEx.IsolatedFunction.csproj +++ b/tests/UnitTestEx.IsolatedFunction/UnitTestEx.IsolatedFunction.csproj @@ -1,22 +1,20 @@  - net6.0 + net8.0;net9.0;net10.0 v4 Exe enable enable - - - - - + + + + + - - + - diff --git a/tests/UnitTestEx.MSTest.Test/UnitTestEx.MSTest.Test.csproj b/tests/UnitTestEx.MSTest.Test/UnitTestEx.MSTest.Test.csproj index 365611a..09ec4e0 100644 --- a/tests/UnitTestEx.MSTest.Test/UnitTestEx.MSTest.Test.csproj +++ b/tests/UnitTestEx.MSTest.Test/UnitTestEx.MSTest.Test.csproj @@ -36,7 +36,6 @@ - diff --git a/tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs b/tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs index 684d962..f6f0f93 100644 --- a/tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs +++ b/tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs @@ -1,21 +1,78 @@ -using NUnit.Framework; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NUnit.Framework; +using System; using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; using UnitTestEx.Expectations; using UnitTestEx.IsolatedFunction; namespace UnitTestEx.NUnit.Test { + /// + /// The function application uses plain top-level statements in its Program.cs; no test-specific structure is required. + /// [TestFixture] public class IsolatedFunctionTest { [Test] public void HttpTrigger() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .ExpectLogContains("C# HTTP trigger function processed a request.") .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, null))) .AssertSuccess(); } + + [Test] + public void Program_ServicesUsed_WorkerNotStarted() + { + using var test = FunctionTester.Create() + .ConfigureServices(sc => sc.AddHostedService()); + + using var client = test.Services.GetRequiredService().CreateClient("XXX"); + Assert.That(client.BaseAddress, Is.EqualTo(new Uri("https://somesys"))); + } + + [Test] + public void Program_TestOverrides() + { + using var test = FunctionTester.Create(additionalConfiguration: [new("SpecialKey", "VerySpecial")]) + .ReplaceSingleton(new TestService("test override")) + .ConfigureServices(sc => sc.AddHttpClient("XXX", c => c.BaseAddress = new Uri("https://test-override"))); + + Assert.Multiple(() => + { + Assert.That(test.Configuration["SpecialKey"], Is.EqualTo("VerySpecial")); + Assert.That(test.Services.GetRequiredService().EnvironmentName, Is.EqualTo(UnitTestEx.TestSetUp.Environment)); + Assert.That(test.Services.GetRequiredService().Value, Is.EqualTo("test override")); + using var client = test.Services.GetRequiredService().CreateClient("XXX"); + Assert.That(client.BaseAddress, Is.EqualTo(new Uri("https://test-override"))); + }); + } + + [Test] + public async Task Program_ConcurrentHostsAreIsolated() + { + static string Build(string value) + { + using var test = FunctionTester.Create().ReplaceSingleton(new TestService(value)); + return test.Services.GetRequiredService().Value; + } + + var results = await Task.WhenAll(Task.Run(() => Build("first")), Task.Run(() => Build("second"))); + Assert.That(results, Is.EqualTo(new[] { "first", "second" })); + } + + public sealed record TestService(string Value); + + public sealed class MustNotStartService : IHostedService + { + public Task StartAsync(CancellationToken cancellationToken) => throw new InvalidOperationException("The test must not start hosted services."); + + public Task StopAsync(CancellationToken cancellationToken) => throw new InvalidOperationException("The test must not stop an unstarted host."); + } } -} \ No newline at end of file +} diff --git a/tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj b/tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj index 19722fb..7ff832a 100644 --- a/tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj +++ b/tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj @@ -43,7 +43,6 @@ - diff --git a/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj b/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj index 4c0696c..2d4fff3 100644 --- a/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj +++ b/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj @@ -40,7 +40,6 @@ - From 6b602e2e79cb2526a529f0a01a09181871f79306 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 28 Oct 2026 15:04:04 -0700 Subject: [PATCH 02/18] v6: isolated-worker-only Azure Functions testing (Phase 1) - Remove in-process/WebJobs support, legacy FunctionsStartup fallback and WebJobs Service Bus assertors - Add generic TriggerTester (instance and static methods) and FunctionOutputAssertor for multi-output functions - Support HttpRequest/IActionResult/IResult and HttpRequestData/HttpResponseData with HttpResponseMessage assertion - Migrate in-process test fixture to UnitTestEx.IsolatedFunction; update NUnit/xUnit/MSTest tests - Update README for isolated-worker model Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 47 +++- UnitTestEx.sln | 27 -- .../Azure/Functions/FunctionOutputAssertor.cs | 77 +++++ .../Azure/Functions/FunctionTesterBase.cs | 102 +------ .../Azure/Functions/FunctionTesterT.cs | 2 +- .../Functions/HttpResponseDataAssertor.cs | 41 +++ .../Azure/Functions/HttpTriggerTester.cs | 246 +++++++++++++--- .../ServiceBusMessageActionStatus.cs | 2 - .../Functions/ServiceBusTriggerTester.cs | 71 +---- .../Azure/Functions/TestFunctionContext.cs | 96 +++++++ .../Azure/Functions/TestHttpRequestData.cs | 67 +++++ .../Azure/Functions/TestHttpResponseData.cs | 52 ++++ .../Azure/Functions/TriggerTester.cs | 150 ++++++++++ ...WebJobsServiceBusMessageActionsAssertor.cs | 218 --------------- ...ServiceBusSessionMessageActionsAssertor.cs | 250 ----------------- .../WorkerServiceBusMessageActionsAssertor.cs | 12 +- .../ExtensionMethods.cs | 123 +++++++- .../FunctionTester.cs | 2 +- .../UnitTestEx.Azure.Functions.csproj | 4 - src/UnitTestEx/Hosting/HostTesterBase.cs | 37 ++- src/UnitTestEx/UnitTestEx.csproj | 4 + tests/UnitTestEx.Function/.gitignore | 264 ------------------ tests/UnitTestEx.Function/PersonFunction.cs | 96 ------- tests/UnitTestEx.Function/ProductFunction.cs | 56 ---- .../Properties/serviceDependencies.json | 11 - .../Properties/serviceDependencies.local.json | 11 - .../UnitTestEx.Function/ServiceBusFunction.cs | 90 ------ tests/UnitTestEx.Function/Startup.cs | 21 -- .../UnitTestEx.Function.csproj | 23 -- tests/UnitTestEx.Function/host.json | 11 - .../HttpVariantFunctions.cs | 93 ++++++ .../PersonFunction.cs | 77 +++++ .../ProductFunction.cs | 45 +++ tests/UnitTestEx.IsolatedFunction/Program.cs | 2 + .../ServiceBusFunction.cs | 79 ++++-- .../MockHttpClientTest.cs | 1 + .../PersonFunctionTest.cs | 40 +-- .../ProductFunctionTest.cs | 18 +- .../ServiceBusFunctionTest.cs | 30 +- .../UnitTestEx.MSTest.Test.csproj | 3 +- .../IsolatedFunctionTest.cs | 154 ++++++++++ .../MockHttpClientTest.cs | 1 + .../Other/OneOffTestSetUpTest.cs | 2 +- .../PersonFunctionTest.cs | 52 ++-- .../ProductFunctionTest.cs | 27 +- .../ServiceBusFunctionTest.cs | 40 +-- .../UnitTestEx.NUnit.Test.csproj | 2 +- .../MockHttpClientTest.cs | 1 + .../PersonFunctionTest.cs | 44 +-- .../ProductFunctionTest.cs | 18 +- .../ServiceBusFunctionTest.cs | 28 +- .../UnitTestEx.Xunit.Test.csproj | 3 +- 52 files changed, 1489 insertions(+), 1484 deletions(-) create mode 100644 src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionOutputAssertor.cs create mode 100644 src/UnitTestEx.Azure.Functions/Azure/Functions/HttpResponseDataAssertor.cs create mode 100644 src/UnitTestEx.Azure.Functions/Azure/Functions/TestFunctionContext.cs create mode 100644 src/UnitTestEx.Azure.Functions/Azure/Functions/TestHttpRequestData.cs create mode 100644 src/UnitTestEx.Azure.Functions/Azure/Functions/TestHttpResponseData.cs create mode 100644 src/UnitTestEx.Azure.Functions/Azure/Functions/TriggerTester.cs delete mode 100644 src/UnitTestEx.Azure.Functions/Azure/Functions/WebJobsServiceBusMessageActionsAssertor.cs delete mode 100644 src/UnitTestEx.Azure.Functions/Azure/Functions/WebJobsServiceBusSessionMessageActionsAssertor.cs delete mode 100644 tests/UnitTestEx.Function/.gitignore delete mode 100644 tests/UnitTestEx.Function/PersonFunction.cs delete mode 100644 tests/UnitTestEx.Function/ProductFunction.cs delete mode 100644 tests/UnitTestEx.Function/Properties/serviceDependencies.json delete mode 100644 tests/UnitTestEx.Function/Properties/serviceDependencies.local.json delete mode 100644 tests/UnitTestEx.Function/ServiceBusFunction.cs delete mode 100644 tests/UnitTestEx.Function/Startup.cs delete mode 100644 tests/UnitTestEx.Function/UnitTestEx.Function.csproj delete mode 100644 tests/UnitTestEx.Function/host.json create mode 100644 tests/UnitTestEx.IsolatedFunction/HttpVariantFunctions.cs create mode 100644 tests/UnitTestEx.IsolatedFunction/PersonFunction.cs create mode 100644 tests/UnitTestEx.IsolatedFunction/ProductFunction.cs diff --git a/README.md b/README.md index c5291d4..490cce0 100644 --- a/README.md +++ b/README.md @@ -50,54 +50,73 @@ test.ReplaceHttpClientFactory(mcf) ## HTTP-triggered Azure Function -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. +`FunctionTester` supports the Azure Functions v4 isolated worker model on .NET 8, 9 and 10, including the modern `FunctionsApplication.CreateBuilder` / `IHostApplicationBuilder` startup model. It executes the application's normal entry point to build its host, applies test configuration and service overrides, then invokes the function method directly. The worker is not started, so this is not a Functions runtime or binding-emulator test. -The following is an [example](./tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs). +The following is an [example](./tests/UnitTestEx.NUnit.Test/ProductFunctionTest.cs). ``` csharp -using var test = FunctionTester.Create(); +using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .HttpTrigger() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person/abc", null), "abc", test.Logger)) + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "abc")) .AssertOK() - .Assert(new { id = "Abc", description = "A blue carrot" }); + .AssertValue(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. +HTTP functions can use ASP.NET Core `HttpRequest` with `IActionResult`/`IResult`, or worker-native `HttpRequestData` with `HttpResponseData`. Both synchronous and asynchronous result methods are supported. HTTP responses can be asserted through `HttpResponseMessageAssertor`; worker-native response bodies, headers, status codes and test cookies are adapted to `HttpResponseMessage`. + +Multi-output functions can assert their HTTP result and non-HTTP outputs separately: + +```csharp +test.HttpTrigger() + .Run(f => f.Run(test.CreateHttpRequestData(HttpMethod.Get, "route"))) + .Output(x => x.Message).AssertValue("expected message"); -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. +test.HttpTrigger() + .Run(f => f.Run(test.CreateHttpRequestData(HttpMethod.Get, "route"))) + .ToHttpResponseMessageAssertor() + .AssertOK(); +``` + +By default, the HTTP tester validates the request method and route against the worker `HttpTriggerAttribute`. `WithNoMethodCheck`, `WithNoRouteCheck` and `WithRouteCheck` configure those checks.
## 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 Service Bus tester supports isolated-worker `ServiceBusTriggerAttribute` functions and worker `ServiceBusMessageActions`: 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(); +using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .ServiceBusTrigger() - .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = "Smith" }), test.Logger)) + .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = "Smith" }))) .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. +For triggers without a dedicated helper, `Trigger()` provides generic sync/async invocation, log capture, expectations and value assertions. It supports instance and static methods and requires a worker trigger attribute on the invoked method: + +```csharp +test.Trigger() + .Run(f => f.Run5(test.CreateServiceBusMessageFromValue(new Person("Jane", "Doe")))) + .AssertSuccess(); +```
## 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. +To test a function type directly without validating a trigger attribute, use the general-purpose `Type` tester. The following is an [example](./tests/UnitTestEx.NUnit.Test/ServiceBusFunctionTest.cs). ``` csharp -using var test = FunctionTester.Create(); +using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .Type() - .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = "Smith" }), test.Logger)) + .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = "Smith" }))) .AssertSuccess(); ``` diff --git a/UnitTestEx.sln b/UnitTestEx.sln index 2f21bac..e2982f3 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}" @@ -114,18 +112,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 @@ -198,18 +184,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 @@ -291,7 +265,6 @@ 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} diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionOutputAssertor.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionOutputAssertor.cs new file mode 100644 index 0000000..91259d5 --- /dev/null +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionOutputAssertor.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker.Http; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Linq.Expressions; +using System.Net.Http; +using System.Reflection; +using UnitTestEx.Abstractions; +using UnitTestEx.Assertors; + +namespace UnitTestEx.Azure.Functions +{ + /// + /// Provides assertions for a function's multi-output return value. + /// + public sealed class FunctionOutputAssertor(TesterBase owner, TOutput result, IEnumerable? logs, Exception? exception) + : AssertorBase>(owner, logs, exception) + { + /// + /// Gets the complete function output. + /// + public TOutput Result { get; } = result; + + /// + /// Gets a non-HTTP output property as a value assertor. + /// + public ValueAssertor Output(Expression> selector) + { + AssertSuccess(); + ArgumentNullException.ThrowIfNull(selector); + if (Result is null) + throw new InvalidOperationException("The function returned a null multi-output result."); + + return new ValueAssertor(Owner, selector.Compile()(Result), LogMessages, null); + } + + /// + /// Executes or converts the property marked with the worker HttpResult attribute for HTTP assertions. + /// + public HttpResponseMessageAssertor ToHttpResponseMessageAssertor(HttpRequest? httpRequest = null) + { + AssertSuccess(); + if (Result is null) + throw new InvalidOperationException("The function returned a null multi-output result."); + + var properties = Result.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(p => p.GetCustomAttributesData().Any(a => a.AttributeType.Name == "HttpResultAttribute")) + .ToArray(); + if (properties.Length != 1) + throw new InvalidOperationException($"Expected exactly one public output property marked with the worker HttpResult attribute, found {properties.Length}."); + + var value = properties[0].GetValue(Result) ?? throw new InvalidOperationException("The function's HttpResult output is null."); + if (value is HttpResponseData responseData) + { + var stopwatch = Stopwatch.StartNew(); + var response = responseData.ToHttpResponseMessage(); + stopwatch.Stop(); + Owner.LogHttpResponseMessage(response, stopwatch); + return new HttpResponseMessageAssertor(Owner, LogMessages, response); + } + + if (value is IActionResult actionResult) + return ActionResultAssertor.ToHttpResponseMessageAssertor(Owner, actionResult, LogMessages, httpRequest); + + if (value is IResult result) + return HttpResultAssertor.ToHttpResponseMessageAssertor(Owner, result, LogMessages, httpRequest); + + if (value is HttpResponseMessage responseMessage) + return new HttpResponseMessageAssertor(Owner, LogMessages, responseMessage); + + throw new InvalidOperationException($"The function's HttpResult output type '{value.GetType().FullName}' is not supported."); + } + } +} diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs index 38db321..fae8140 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs @@ -1,16 +1,11 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx -using Microsoft.AspNetCore.Http; -using Microsoft.Azure.Functions.Extensions.DependencyInjection; -using Microsoft.Azure.WebJobs; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Moq; using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Text.Json; using UnitTestEx.Abstractions; @@ -21,7 +16,7 @@ namespace UnitTestEx.Azure.Functions /// /// Provides the basic Azure Function unit-testing capabilities. /// - /// The or other entry point . + /// A type from the isolated-worker application assembly. /// The to support inheriting fluent-style method-chaining. public abstract class FunctionTesterBase : TesterBase, IDisposable where TEntryPoint : class where TSelf : FunctionTesterBase { @@ -34,16 +29,6 @@ public abstract class FunctionTesterBase : TesterBase private IHost? _host; private bool _disposed; - private class Fhb(IServiceCollection services) : IFunctionsHostBuilder - { - public IServiceCollection Services { get; } = services; - } - - private class Fcb(IConfigurationBuilder configurationBuilder) : IFunctionsConfigurationBuilder - { - public IConfigurationBuilder ConfigurationBuilder { get; } = configurationBuilder; - } - /// /// Initializes a new instance of the class. /// @@ -59,26 +44,6 @@ public FunctionTesterBase(TestFrameworkImplementor implementor, bool? includeUni AdditionalConfiguration = additionalConfiguration; } - /// - /// Mock the interface. - /// - private static IFunctionsConfigurationBuilder MockIFunctionsConfigurationBuilder(IConfigurationBuilder configurationBuilder) - { - var mock = new Mock(); - mock.Setup(x => x.ConfigurationBuilder).Returns(configurationBuilder); - return mock.Object; - } - - /// - /// Mock the interface. - /// - private static IFunctionsHostBuilder MockIFunctionsHostBuilder(IServiceCollection services) - { - var mock = new Mock(); - mock.Setup(x => x.Services).Returns(services); - return mock.Object; - } - /// /// Get the local.settings.json values and store in a temporary file. /// @@ -135,9 +100,7 @@ private IHost GetHost() if (_host != null) return _host; - _host = typeof(TEntryPoint).IsSubclassOf(typeof(FunctionsStartup)) || !FunctionHostFactory.CanBuildHost(typeof(TEntryPoint).Assembly) - ? BuildStartupHost() - : BuildApplicationHost(); + _host = BuildApplicationHost(); OnHostStartUp(); @@ -194,60 +157,6 @@ private void ConfigureTestServices(IServiceCollection sc) AddConfiguredServices(sc); } - /// - /// Builds the using the legacy (or convention) approach. - /// - private IHost BuildStartupHost() - { - { - var ep = Activator.CreateInstance(); - var ep2 = ep as FunctionsStartup; - var ep3 = new EntryPoint(ep); - - return new HostBuilder() - .UseEnvironment(UnitTestEx.TestSetUp.Environment) - .ConfigureLogging((lb) => { lb.SetMinimumLevel(SetUp.MinimumLogLevel); lb.ClearProviders(); lb.AddProvider(LoggerProvider); }) - .ConfigureHostConfiguration(cb => - { - cb.SetBasePath(Environment.CurrentDirectory) - .AddInMemoryCollection([new("AzureWebJobsConfigurationSection", "AzureFunctionsJobHost")]) - .AddJsonFile(GetLocalSettingsJson(), optional: true) - .AddJsonFile("appsettings.json", optional: true) - .AddJsonFile($"appsettings.{UnitTestEx.TestSetUp.Environment.ToLowerInvariant()}.json", optional: true); - - ep3?.ConfigureHostConfiguration(cb); - }) - .ConfigureAppConfiguration((hbc, cb) => - { - ep2?.ConfigureAppConfiguration(MockIFunctionsConfigurationBuilder(cb)); - ep3?.ConfigureAppConfiguration(hbc, cb); - - if (!_includeUserSecrets.HasValue && TestSetUp.FunctionTesterIncludeUserSecrets || _includeUserSecrets.HasValue && _includeUserSecrets.Value) - cb.AddUserSecrets(); - - cb.AddEnvironmentVariables(); - - if (!_includeUnitTestConfiguration.HasValue && TestSetUp.FunctionTesterIncludeUnitTestConfiguration || _includeUnitTestConfiguration.HasValue && _includeUnitTestConfiguration.Value) - cb.AddJsonFile("appsettings.unittest.json", optional: true); - - if (AdditionalConfiguration != null) - cb.AddInMemoryCollection(AdditionalConfiguration); - }) - .ConfigureServices(sc => - { - ep2?.Configure(MockIFunctionsHostBuilder(sc)); - ep3?.ConfigureServices(sc); - sc.ReplaceScoped(_ => SharedState); - - foreach (var tec in UnitTestEx.TestSetUp.Extensions) - tec.ConfigureServices(this, sc); - - SetUp.ConfigureServices?.Invoke(sc); - AddConfiguredServices(sc); - }).Build(); - } - } - /// protected override void OnResetHost() { @@ -298,6 +207,13 @@ protected override void OnResetHost() /// The . public ServiceBusTriggerTester ServiceBusTrigger() where TFunction : class => new(this, HostExecutionWrapper(() => GetHost().Services.CreateScope())); + /// + /// Specifies an isolated-worker function with any trigger that does not have a dedicated tester. + /// + /// The function type. + /// A generic trigger tester. + public TriggerTester Trigger() where TFunction : class => new(this, HostExecutionWrapper(() => GetHost().Services.CreateScope())); + /// /// Releases all resources. /// diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterT.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterT.cs index c00d50a..54a5bd9 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterT.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterT.cs @@ -8,7 +8,7 @@ namespace UnitTestEx.Azure.Functions /// /// Provides the NUnit implementation. /// - /// Any within the Azure Functions (isolated worker) application assembly; or the legacy in-process FunctionsStartup . + /// 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. diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpResponseDataAssertor.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpResponseDataAssertor.cs new file mode 100644 index 0000000..b7cb60a --- /dev/null +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpResponseDataAssertor.cs @@ -0,0 +1,41 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Microsoft.Azure.Functions.Worker.Http; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net.Http; +using UnitTestEx.Abstractions; +using UnitTestEx.Assertors; + +namespace UnitTestEx.Azure.Functions +{ + /// + /// Represents the (isolated worker) test assert helper; see to perform the HTTP response assertions. + /// + /// The owning . + /// The . + /// The logs captured during execution. + /// The (if any). + public class HttpResponseDataAssertor(TesterBase owner, HttpResponseData result, IEnumerable? logs, Exception? exception) : AssertorBase(owner, logs, exception) + { + /// + /// Gets the . + /// + public HttpResponseData Result { get; } = result; + + /// + /// Converts the to an (see ). + /// + /// The corresponding . + public HttpResponseMessageAssertor ToHttpResponseMessageAssertor() + { + AssertSuccess(); + var sw = Stopwatch.StartNew(); + var hr = Result.ToHttpResponseMessage(); + sw.Stop(); + Owner.LogHttpResponseMessage(hr, sw); + return new HttpResponseMessageAssertor(Owner, LogMessages, hr); + } + } +} diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs index 323b644..cda710b 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs @@ -1,9 +1,10 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Generic; @@ -24,14 +25,14 @@ namespace UnitTestEx.Azure.Functions { /// - /// Provides Azure Function or unit-testing capabilities. + /// Provides Azure Functions isolated-worker HTTP trigger unit-testing capabilities. /// /// The Azure Function . /// To aid with the testing the following checks are automatically performed during execution with an thrown when: /// - /// The or does not contain the . + /// The does not contain the . /// This check can be further configured using the and methods prior to the execution. - /// The or does not match the and combination. + /// The does not match the and combination. /// This check can be further configured using the and methods prior to the execution. /// /// The above checks are generally neccessary to assist in ensuring that the function is being invoked correctly given the parameters have to be explicitly passed in separately. @@ -60,7 +61,7 @@ public HttpTriggerTester(TesterBase owner, IServiceScope serviceScope) : base(ow public ExpectationsArranger> ExpectationsArranger { get; } /// - /// Indicates that no check is performed to ensure that the or contain the . + /// Indicates that no check is performed to ensure that the contain the . /// /// Defaults to configuration values where configured; otherwise, . public HttpTriggerTester WithNoMethodCheck() @@ -70,7 +71,7 @@ public HttpTriggerTester WithNoMethodCheck() } /// - /// Indicates that a check is performed to ensure that the or contain the . + /// Indicates that a check is performed to ensure that the contain the . /// /// Defaults to configuration values where configured; otherwise, this is the default. public HttpTriggerTester WithMethodCheck() @@ -98,63 +99,199 @@ public HttpTriggerTester WithRouteCheck(RouteCheckOption option, Stri return this; } + private static readonly Type[] _httpTriggerAttributeTypes = [typeof(Microsoft.Azure.Functions.Worker.HttpTriggerAttribute)]; + /// - /// Runs the HTTP Triggered (see or ) function using an within the . + /// Runs the HTTP-triggered function returning an . /// /// The function operation invocation expression. /// An . public ActionResultAssertor Run(Expression>> expression) => RunAsync(expression).GetAwaiter().GetResult(); /// - /// Runs the HTTP Triggered (see or ) function using an within the . + /// Runs the HTTP-triggered function returning an . /// /// The function operation invocation expression. /// An . public async Task RunAsync(Expression>> expression) { - (IActionResult result, Exception? ex, double ms) = await RunAsync(expression, [typeof(Microsoft.Azure.WebJobs.HttpTriggerAttribute), typeof(Microsoft.Azure.Functions.Worker.HttpTriggerAttribute)], (p, a, v) => - { - var requestVal = v; - var httpRequest = v as HttpRequest; - LogRequest(httpRequest, requestVal); + var (result, ex, logs) = await CompleteAsync(RunAsync(expression, _httpTriggerAttributeTypes, ValidateHttpRequest)).ConfigureAwait(false); + return new ActionResultAssertor(Owner, result, logs, ex); + } - if (httpRequest is not null) - { - var httpTriggerAttribute = a as Microsoft.Azure.WebJobs.HttpTriggerAttribute; - if (httpTriggerAttribute is not null) - { - if (_methodCheck && !httpTriggerAttribute.Methods.Contains(httpRequest.Method, StringComparer.OrdinalIgnoreCase)) - throw new InvalidOperationException($"The function {nameof(Microsoft.Azure.WebJobs.HttpTriggerAttribute)} supports {nameof(Microsoft.Azure.WebJobs.HttpTriggerAttribute.Methods)} of {string.Join(" or ", httpTriggerAttribute.Methods.Select(x => $"'{x.ToUpperInvariant()}'"))}; however, invoked using '{httpRequest.Method.ToUpperInvariant()}' which is not valid. Use '{nameof(WithNoMethodCheck)}' to change this behavior."); + /// + /// Runs the HTTP Triggered (see ) synchronous function returning an . + /// + /// The function operation invocation expression. + /// An . + public ActionResultAssertor Run(Expression> expression) => RunAsync(expression).GetAwaiter().GetResult(); - CheckRoute(httpRequest, httpTriggerAttribute.Route, p); - } + /// + /// Runs the HTTP Triggered (see ) synchronous function returning an . + /// + /// The function operation invocation expression. + /// An . + public async Task RunAsync(Expression> expression) + { + var (result, ex, logs) = await CompleteAsync(RunSyncAsync(expression, _httpTriggerAttributeTypes, ValidateHttpRequest)).ConfigureAwait(false); + return new ActionResultAssertor(Owner, result, logs, ex); + } - var httpTriggerAttribute2 = a as Microsoft.Azure.Functions.Worker.HttpTriggerAttribute; - if (httpTriggerAttribute2 is not null) - { - if (_methodCheck && httpTriggerAttribute2.Methods is not null && !httpTriggerAttribute2.Methods!.Contains(httpRequest.Method, StringComparer.OrdinalIgnoreCase)) - throw new InvalidOperationException($"The function {nameof(Microsoft.Azure.Functions.Worker.HttpTriggerAttribute)} supports {nameof(Microsoft.Azure.Functions.Worker.HttpTriggerAttribute.Methods)} of {string.Join(" or ", httpTriggerAttribute2.Methods.Select(x => $"'{x.ToUpperInvariant()}'"))}; however, invoked using '{httpRequest.Method.ToUpperInvariant()}' which is not valid. Use '{nameof(WithNoMethodCheck)}' to change this behavior."); + /// + /// Runs the HTTP Triggered (see ) function returning an . + /// + /// The function operation invocation expression. + /// An . + public HttpResultAssertor Run(Expression>> expression) => RunAsync(expression).GetAwaiter().GetResult(); - CheckRoute(httpRequest, httpTriggerAttribute2.Route, p); - } - } - }).ConfigureAwait(false); + /// + /// Runs the HTTP Triggered (see ) function returning an . + /// + /// The function operation invocation expression. + /// An . + public async Task RunAsync(Expression>> expression) + { + var (result, ex, logs) = await CompleteAsync(RunAsync(expression, _httpTriggerAttributeTypes, ValidateHttpRequest)).ConfigureAwait(false); + return new HttpResultAssertor(Owner, result, logs, ex); + } + + /// + /// Runs the HTTP Triggered (see ) synchronous function returning an . + /// + /// The function operation invocation expression. + /// An . + public HttpResultAssertor Run(Expression> expression) => RunAsync(expression).GetAwaiter().GetResult(); + + /// + /// Runs the HTTP Triggered (see ) synchronous function returning an . + /// + /// The function operation invocation expression. + /// An . + public async Task RunAsync(Expression> expression) + { + var (result, ex, logs) = await CompleteAsync(RunSyncAsync(expression, _httpTriggerAttributeTypes, ValidateHttpRequest)).ConfigureAwait(false); + return new HttpResultAssertor(Owner, result, logs, ex); + } + + /// + /// Runs the HTTP Triggered (see ) function returning an . + /// + /// The function operation invocation expression. + /// An . + public HttpResponseDataAssertor Run(Expression>> expression) => RunAsync(expression).GetAwaiter().GetResult(); + + /// + /// Runs the HTTP Triggered (see ) function returning an . + /// + /// The function operation invocation expression. + /// An . + public async Task RunAsync(Expression>> expression) + { + var (result, ex, logs) = await CompleteAsync(RunAsync(expression, _httpTriggerAttributeTypes, ValidateHttpRequest)).ConfigureAwait(false); + return new HttpResponseDataAssertor(Owner, result, logs, ex); + } + + /// + /// Runs the HTTP Triggered (see ) synchronous function returning an . + /// + /// The function operation invocation expression. + /// An . + public HttpResponseDataAssertor Run(Expression> expression) => RunAsync(expression).GetAwaiter().GetResult(); + + /// + /// Runs the HTTP Triggered (see ) synchronous function returning an . + /// + /// The function operation invocation expression. + /// An . + public async Task RunAsync(Expression> expression) + { + var (result, ex, logs) = await CompleteAsync(RunSyncAsync(expression, _httpTriggerAttributeTypes, ValidateHttpRequest)).ConfigureAwait(false); + return new HttpResponseDataAssertor(Owner, result, logs, ex); + } + + /// + /// Runs an HTTP-triggered function returning an isolated-worker multi-output value. + /// + public FunctionOutputAssertor Run(Expression>> expression) => RunAsync(expression).GetAwaiter().GetResult(); + + /// + /// Runs an HTTP-triggered function returning an isolated-worker multi-output value. + /// + public async Task> RunAsync(Expression>> expression) + { + var (result, ex, logs) = await CompleteAsync(RunAsync(expression, _httpTriggerAttributeTypes, ValidateHttpRequest)).ConfigureAwait(false); + return new FunctionOutputAssertor(Owner, result, logs, ex); + } + + /// + /// Runs an HTTP-triggered synchronous function returning an isolated-worker multi-output value. + /// + public FunctionOutputAssertor Run(Expression> expression) => RunAsync(expression).GetAwaiter().GetResult(); + + /// + /// Runs an HTTP-triggered synchronous function returning an isolated-worker multi-output value. + /// + public async Task> RunAsync(Expression> expression) + { + var (result, ex, logs) = await CompleteAsync(RunSyncAsync(expression, _httpTriggerAttributeTypes, ValidateHttpRequest)).ConfigureAwait(false); + return new FunctionOutputAssertor(Owner, result, logs, ex); + } + + /// + /// Completes the run: logs the response and asserts the expectations. + /// + private async Task<(TResult Result, Exception? Exception, IEnumerable Logs)> CompleteAsync(Task<(TResult Result, Exception? Exception, double ElapsedMilliseconds)> run) + { + var (result, ex, ms) = await run.ConfigureAwait(false); await Task.Delay(UnitTestEx.TestSetUp.TaskDelayMilliseconds).ConfigureAwait(false); var logs = Owner.SharedState.GetLoggerMessages(); LogResponse(result, ex, ms, logs); await ExpectationsArranger.AssertAsync(logs, ex).ConfigureAwait(false); + return (result, ex, logs); + } - return new ActionResultAssertor(Owner, result, logs, ex); + /// + /// Validates (and logs) the or prior to execution. + /// + private void ValidateHttpRequest((string? Name, object? Value)[] p, Attribute? a, object? v) + { + string? method = null; + Uri? url = null; + + if (v is HttpRequest httpRequest) + { + LogRequest(httpRequest, v); + method = httpRequest.Method; + url = new Uri(httpRequest.GetDisplayUrl()); + } + else if (v is HttpRequestData httpRequestData) + { + LogRequest(httpRequestData, v); + method = httpRequestData.Method; + url = httpRequestData.Url; + } + else + LogRequest((HttpRequest?)null, v); + + if (method is null || url is null) + return; + + if (a is Microsoft.Azure.Functions.Worker.HttpTriggerAttribute httpTriggerAttribute2) + { + if (_methodCheck && httpTriggerAttribute2.Methods is not null && !httpTriggerAttribute2.Methods!.Contains(method, StringComparer.OrdinalIgnoreCase)) + throw new InvalidOperationException($"The function {nameof(Microsoft.Azure.Functions.Worker.HttpTriggerAttribute)} supports {nameof(Microsoft.Azure.Functions.Worker.HttpTriggerAttribute.Methods)} of {string.Join(" or ", httpTriggerAttribute2.Methods.Select(x => $"'{x.ToUpperInvariant()}'"))}; however, invoked using '{method.ToUpperInvariant()}' which is not valid. Use '{nameof(WithNoMethodCheck)}' to change this behavior."); + + CheckRoute(url, httpTriggerAttribute2.Route, p); + } } - private void CheckRoute(HttpRequest request, string? route, (string? Name, object? Value)[] @params) + private void CheckRoute(Uri reqUri, string? route, (string? Name, object? Value)[] @params) { if (_routeCheckOption == RouteCheckOption.None) return; - var reqUri = new Uri(request.GetDisplayUrl()); var rouUri = new Uri(new Uri($"{reqUri.Scheme}://{reqUri.Host}"), FormatReplacement(route ?? string.Empty, @params)); switch (_routeCheckOption) @@ -298,6 +435,29 @@ private static string FormatValue(object value) return value.ToString() ?? string.Empty; } + /// + /// Log the request to the output. + /// + private void LogRequest(HttpRequestData req, object? reqVal) + { + if (req is TestHttpRequestData thrd) + { + LogRequest(thrd.HttpRequest, reqVal); + return; + } + + Implementor.WriteLine(""); + Implementor.WriteLine("FUNCTION HTTP-TRIGGER TESTER..."); + Implementor.WriteLine(""); + Implementor.WriteLine("REQUEST >"); + Implementor.WriteLine($"Request: {req.Method} {req.Url}"); + Implementor.WriteLine($"Headers: {(!req.Headers.Any() ? "none" : "")}"); + foreach (var hdr in req.Headers) + { + Implementor.WriteLine($" {hdr.Key}: {string.Join(", ", hdr.Value)}"); + } + } + /// /// Log the request to the output. /// @@ -380,7 +540,7 @@ private void LogRequest(HttpRequest? req, object? reqVal) /// /// Log the response to the output. /// - private void LogResponse(IActionResult res, Exception? ex, double ms, IEnumerable? logs) + private void LogResponse(object? res, Exception? ex, double ms, IEnumerable? logs) { Implementor.WriteLine(""); Implementor.WriteLine("LOGGING >"); @@ -402,7 +562,21 @@ private void LogResponse(IActionResult res, Exception? ex, double ms, IEnumerabl Implementor.WriteLine($"Exception: {ex.Message} [{ex.GetType().Name}]"); Implementor.WriteLine(ex.ToString()); } - else + else if (res is IResult ir) + { + Implementor.WriteLine($"IResult: {ir.GetType().Name}"); + if (ir is IStatusCodeHttpResult schr && schr.StatusCode != null) + Implementor.WriteLine($"HttpStatusCode: {(HttpStatusCode)schr.StatusCode} ({schr.StatusCode})"); + + Implementor.WriteLine("Content: use 'ToHttpResponseMessageAssertor' to execute and log the response content."); + } + else if (res is HttpResponseData hrd) + { + Implementor.WriteLine($"HttpResponseData: {hrd.GetType().Name}"); + Implementor.WriteLine($"HttpStatusCode: {hrd.StatusCode} ({(int)hrd.StatusCode})"); + Implementor.WriteLine("Content: use 'ToHttpResponseMessageAssertor' to convert and log the response content."); + } + else if (res is IActionResult) { Implementor.WriteLine($"IActionResult: {res.GetType().Name}"); if (res is IStatusCodeActionResult scar && scar.StatusCode != null) diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusMessageActionStatus.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusMessageActionStatus.cs index 4eb1d7b..da3b4f0 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusMessageActionStatus.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusMessageActionStatus.cs @@ -1,7 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx -using Microsoft.Azure.WebJobs.ServiceBus; - namespace UnitTestEx.Azure.Functions { /// diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusTriggerTester.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusTriggerTester.cs index 00ab1aa..d9a9473 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusTriggerTester.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusTriggerTester.cs @@ -8,7 +8,6 @@ using System.Linq; using System.Linq.Expressions; using System.Net.Mime; -using System.Threading; using System.Threading.Tasks; using UnitTestEx.Abstractions; using UnitTestEx.Assertors; @@ -19,13 +18,11 @@ namespace UnitTestEx.Azure.Functions { /// - /// Provides Azure Function or unit-testing and integration emulation testing capabilities. + /// Provides Azure Functions isolated-worker Service Bus trigger unit-testing and integration emulation testing capabilities. /// /// The Azure Function . public class ServiceBusTriggerTester : HostTesterBase, IExpectations> where TFunction : class { - private static readonly Semaphore _semaphore = new(1, 1); - /// /// Initializes a new class. /// @@ -39,29 +36,27 @@ public class ServiceBusTriggerTester : HostTesterBase, IEx public ExpectationsArranger> ExpectationsArranger { get; } /// - /// Runs the Service Bus Triggered ( or ) function expected as a parameter within the . + /// Runs the isolated-worker Service Bus-triggered function expected as a parameter within the . /// /// The function operation invocation expression. - /// Indicates whether to validate the or properties to ensure correct configuration. + /// Indicates whether to validate the worker trigger properties to ensure correct configuration. /// A . public VoidAssertor Run(Expression> expression, bool validateTriggerProperties = false) => RunAsync(expression, validateTriggerProperties).GetAwaiter().GetResult(); /// - /// Runs the Service Bus Triggered ( or ) function expected as a parameter within the . + /// Runs the isolated-worker Service Bus-triggered function expected as a parameter within the . /// /// The function operation invocation expression. - /// Indicates whether to validate the or properties to ensure correct configuration. + /// Indicates whether to validate the worker trigger properties to ensure correct configuration. /// A . public async Task RunAsync(Expression> expression, bool validateTriggerProperties = false) { object? sbv = null; - WebJobsServiceBusMessageActionsAssertor? sba = null; - WebJobsServiceBusSessionMessageActionsAssertor? ssba = null; WorkerServiceBusMessageActionsAssertor? wsba = null; - (Exception? ex, double ms) = await RunAsync(expression, [typeof(Microsoft.Azure.WebJobs.ServiceBusTriggerAttribute), typeof(Microsoft.Azure.Functions.Worker.ServiceBusTriggerAttribute)], (p, a, v) => + (Exception? ex, double ms) = await RunAsync(expression, [typeof(Microsoft.Azure.Functions.Worker.ServiceBusTriggerAttribute)], (p, a, v) => { if (validateTriggerProperties && a == null) - throw new InvalidOperationException($"The function method must have a parameter using the {nameof(Microsoft.Azure.WebJobs.ServiceBusTriggerAttribute)}."); + throw new InvalidOperationException($"The function method must have a parameter using the {nameof(Microsoft.Azure.Functions.Worker.ServiceBusTriggerAttribute)}."); Implementor.WriteLine(""); Implementor.WriteLine("FUNCTION SERVICE BUS TRIGGER TESTER..."); @@ -71,10 +66,6 @@ public async Task RunAsync(Expression> expre { if (pi.Value is ServiceBusReceivedMessage sbrm) sbv = sbrm; - else if (pi.Value is WebJobsServiceBusMessageActionsAssertor psba) - sba = psba; - else if (pi.Value is WebJobsServiceBusSessionMessageActionsAssertor pssba) - ssba = pssba; else if (pi.Value is WorkerServiceBusMessageActionsAssertor pwsba) wsba = pwsba; } @@ -82,19 +73,13 @@ public async Task RunAsync(Expression> expre if (validateTriggerProperties && a is not null) { var config = Services.GetRequiredService(); - var sbta = a as Microsoft.Azure.WebJobs.ServiceBusTriggerAttribute; - if (sbta is not null) - VerifyServiceBusTriggerProperties(config, sbta); - - var sbta2 = a as Microsoft.Azure.Functions.Worker.ServiceBusTriggerAttribute; - if (sbta2 is not null) - VerifyServiceBusTriggerProperties2(config, sbta2); + VerifyServiceBusTriggerProperties(config, (Microsoft.Azure.Functions.Worker.ServiceBusTriggerAttribute)a); } }).ConfigureAwait(false); await Task.Delay(UnitTestEx.TestSetUp.TaskDelayMilliseconds).ConfigureAwait(false); var logs = Owner.SharedState.GetLoggerMessages(); - LogOutput(ex, ms, sbv, sba, ssba, wsba, logs); + LogOutput(ex, ms, sbv, wsba, logs); await ExpectationsArranger.AssertAsync(logs, ex).ConfigureAwait(false); @@ -104,39 +89,7 @@ public async Task RunAsync(Expression> expre /// /// Verifies the service bus trigger properties. /// - internal static (string ConnectionString, string? QueueName, string? TopicName, string? SubscriptionName) VerifyServiceBusTriggerProperties(IConfiguration config, Microsoft.Azure.WebJobs.ServiceBusTriggerAttribute sbta) - { - // Get the connection string. - var csn = sbta.Connection ?? "ServiceBus"; - var cs = config.GetValue(csn); - if (cs == null) - { - csn = $"AzureWebJobs{csn}"; - cs = config.GetValue(csn); - } - - if (string.IsNullOrEmpty(cs)) - throw new InvalidOperationException("Service Bus Connection String configuration setting either does not exist or does not have a value."); - - // Get the queue name. - string? qn = null; - string? tn = null; - string? sn = null; - if (sbta.QueueName != null) - qn = GetValueFromConfig(config, "Queue Name", sbta.QueueName); - else - { - tn = GetValueFromConfig(config, "Topic Name", sbta.TopicName); - sn = GetValueFromConfig(config, "Subscription Name", sbta.SubscriptionName); - } - - return (cs, qn, tn, sn); - } - - /// - /// Verifies the service bus trigger properties. - /// - internal static (string ConnectionString, string? QueueName, string? TopicName, string? SubscriptionName) VerifyServiceBusTriggerProperties2(IConfiguration config, Microsoft.Azure.Functions.Worker.ServiceBusTriggerAttribute sbta) + internal static (string ConnectionString, string? QueueName, string? TopicName, string? SubscriptionName) VerifyServiceBusTriggerProperties(IConfiguration config, Microsoft.Azure.Functions.Worker.ServiceBusTriggerAttribute sbta) { // Get the connection string. var csn = sbta.Connection ?? "ServiceBus"; @@ -191,7 +144,7 @@ internal static string GetValueFromConfig(IConfiguration config, string type, st /// /// Log the output. /// - private void LogOutput(Exception? ex, double ms, object? value, WebJobsServiceBusMessageActionsAssertor? sba, WebJobsServiceBusSessionMessageActionsAssertor? ssba, WorkerServiceBusMessageActionsAssertor? wsba, IEnumerable? logs) + private void LogOutput(Exception? ex, double ms, object? value, WorkerServiceBusMessageActionsAssertor? wsba, IEnumerable? logs) { Implementor.WriteLine(""); Implementor.WriteLine("LOGGING >"); @@ -257,8 +210,6 @@ private void LogOutput(Exception? ex, double ms, object? value, WebJobsServiceBu Implementor.WriteLine(ex.ToString()); } - sba?.LogResult(); - ssba?.LogResult(); wsba?.LogResult(); //Implementor.WriteLine(""); diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/TestFunctionContext.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/TestFunctionContext.cs new file mode 100644 index 0000000..061a0e7 --- /dev/null +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/TestFunctionContext.cs @@ -0,0 +1,96 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Microsoft.Azure.Functions.Worker; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading; + +namespace UnitTestEx.Azure.Functions +{ + /// + /// Provides a unit-testing (isolated worker) implementation. + /// + /// The . + /// The optional . + /// The , , and are not available (null) unless explicitly set. + public class TestFunctionContext(IServiceProvider instanceServices, string? functionId = null) : FunctionContext + { + /// + public override string InvocationId { get; } = Guid.NewGuid().ToString(); + + /// + public override string FunctionId { get; } = functionId ?? "unittestex"; + + /// + /// Gets or sets the . + /// + public TraceContext? TestTraceContext { get; set; } + + /// + public override TraceContext TraceContext => TestTraceContext!; + + /// + /// Gets or sets the . + /// + public BindingContext? TestBindingContext { get; set; } + + /// + public override BindingContext BindingContext => TestBindingContext!; + + /// + /// Gets or sets the . + /// + public RetryContext? TestRetryContext { get; set; } + + /// + public override RetryContext RetryContext => TestRetryContext!; + + /// + public override IServiceProvider InstanceServices { get; set; } = instanceServices ?? throw new ArgumentNullException(nameof(instanceServices)); + + /// + /// Gets or sets the . + /// + public FunctionDefinition? TestFunctionDefinition { get; set; } + + /// + public override FunctionDefinition FunctionDefinition => TestFunctionDefinition!; + + /// + public override IDictionary Items { get; set; } = new Dictionary(); + + /// + public override IInvocationFeatures Features { get; } = new TestInvocationFeatures(); + + /// + /// Gets or sets the . + /// + public CancellationToken TestCancellationToken { get; set; } + + /// + public override CancellationToken CancellationToken => TestCancellationToken; + + /// + /// Provides a simple implementation. + /// + private sealed class TestInvocationFeatures : IInvocationFeatures + { + private readonly Dictionary _features = []; + + public T? Get() => _features.TryGetValue(typeof(T), out var f) ? (T)f : default; + + public void Set(T instance) + { + if (instance is null) + _features.Remove(typeof(T)); + else + _features[typeof(T)] = instance; + } + + public IEnumerator> GetEnumerator() => _features.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } +} diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/TestHttpRequestData.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/TestHttpRequestData.cs new file mode 100644 index 0000000..509a8a1 --- /dev/null +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/TestHttpRequestData.cs @@ -0,0 +1,67 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Claims; + +namespace UnitTestEx.Azure.Functions +{ + /// + /// Provides a unit-testing (isolated worker) implementation that wraps an underlying . + /// + /// The is also added to the (key 'HttpRequestContext') to mirror the ASP.NET Core integration; i.e. enables FunctionContext.GetHttpContext(). + public class TestHttpRequestData : HttpRequestData + { + internal const string HttpContextKey = "HttpRequestContext"; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying . + /// The . + public TestHttpRequestData(HttpRequest httpRequest, FunctionContext functionContext) : base(functionContext) + { + HttpRequest = httpRequest ?? throw new ArgumentNullException(nameof(httpRequest)); + functionContext.Items[HttpContextKey] = httpRequest.HttpContext; + + Headers = []; + foreach (var h in httpRequest.Headers) + Headers.TryAddWithoutValidation(h.Key, h.Value.Where(v => v is not null).Select(v => v!)); + + Cookies = [.. httpRequest.Cookies.Select(c => new HttpCookie(c.Key, c.Value))]; + Url = new Uri(httpRequest.GetEncodedUrl()); + } + + /// + /// Gets the underlying . + /// + public HttpRequest HttpRequest { get; } + + /// + public override Stream Body => HttpRequest.Body; + + /// + public override HttpHeadersCollection Headers { get; } + + /// + public override IReadOnlyCollection Cookies { get; } + + /// + public override Uri Url { get; } + + /// + public override IEnumerable Identities => HttpRequest.HttpContext.User?.Identities ?? []; + + /// + public override string Method => HttpRequest.Method; + + /// + public override HttpResponseData CreateResponse() => new TestHttpResponseData(FunctionContext); + } +} diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/TestHttpResponseData.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/TestHttpResponseData.cs new file mode 100644 index 0000000..dc6deef --- /dev/null +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/TestHttpResponseData.cs @@ -0,0 +1,52 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; + +namespace UnitTestEx.Azure.Functions +{ + /// + /// Provides a unit-testing (isolated worker) implementation. + /// + /// The . + public class TestHttpResponseData(FunctionContext functionContext) : HttpResponseData(functionContext) + { + /// + public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK; + + /// + public override HttpHeadersCollection Headers { get; set; } = []; + + /// + public override Stream Body { get; set; } = new MemoryStream(); + + /// + public override HttpCookies Cookies { get; } = new TestHttpCookies(); + } + + /// + /// Provides a unit-testing (isolated worker) implementation. + /// + public class TestHttpCookies : HttpCookies + { + private readonly List _cookies = []; + + /// + /// Gets the appended items. + /// + public IReadOnlyList Items => _cookies; + + /// + public override void Append(string name, string value) => _cookies.Add(new HttpCookie(name, value)); + + /// + public override void Append(IHttpCookie cookie) => _cookies.Add(cookie ?? throw new ArgumentNullException(nameof(cookie))); + + /// + public override IHttpCookie CreateNew() => new HttpCookie(string.Empty, string.Empty); + } +} diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/TriggerTester.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/TriggerTester.cs new file mode 100644 index 0000000..0dc70d2 --- /dev/null +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/TriggerTester.cs @@ -0,0 +1,150 @@ +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using UnitTestEx.Abstractions; +using UnitTestEx.Assertors; +using UnitTestEx.Expectations; +using UnitTestEx.Hosting; + +namespace UnitTestEx.Azure.Functions +{ + /// + /// Provides a generic isolated-worker trigger tester for triggers without a dedicated tester. + /// + /// The function type. + public sealed class TriggerTester : HostTesterBase>, IExpectations> where TFunction : class + { + /// + /// Initializes a new trigger tester. + /// + public TriggerTester(TesterBase owner, IServiceScope serviceScope) : base(owner, serviceScope.ServiceProvider) + { + ExpectationsArranger = new ExpectationsArranger>(owner, this); + } + + /// + /// Gets the expectations arranger. + /// + public ExpectationsArranger> ExpectationsArranger { get; } + + /// + /// Runs a synchronous trigger function that returns no value. + /// + public VoidAssertor Run(Expression> expression) => RunAsync(expression).GetAwaiter().GetResult(); + + /// + /// Runs a synchronous trigger function that returns no value. + /// + public async Task RunAsync(Expression> expression) + { + ValidateTrigger(expression); + var (_, ex, elapsed) = await RunSyncAsync(expression, null, null).ConfigureAwait(false); + return await CompleteVoidAsync(ex, elapsed).ConfigureAwait(false); + } + + /// + /// Runs an asynchronous trigger function that returns no value. + /// + public VoidAssertor Run(Expression> expression) => RunAsync(expression).GetAwaiter().GetResult(); + + /// + /// Runs an asynchronous trigger function that returns no value. + /// + public async Task RunAsync(Expression> expression) + { + ValidateTrigger(expression); + var (ex, elapsed) = await RunAsync(expression, null, null).ConfigureAwait(false); + return await CompleteVoidAsync(ex, elapsed).ConfigureAwait(false); + } + + /// + /// Runs a synchronous trigger function returning a value. + /// + public ValueAssertor Run(Expression> expression) => RunAsync(expression).GetAwaiter().GetResult(); + + /// + /// Runs a synchronous trigger function returning a value. + /// + public async Task> RunAsync(Expression> expression) + { + ValidateTrigger(expression); + var (result, ex, elapsed) = await RunSyncAsync(expression, null, null).ConfigureAwait(false); + return await CompleteValueAsync(result, ex, elapsed).ConfigureAwait(false); + } + + /// + /// Runs an asynchronous trigger function returning a value. + /// + public ValueAssertor Run(Expression>> expression) => RunAsync(expression).GetAwaiter().GetResult(); + + /// + /// Runs an asynchronous trigger function returning a value. + /// + public async Task> RunAsync(Expression>> expression) + { + ValidateTrigger(expression); + var (result, ex, elapsed) = await RunAsync(expression, null, null).ConfigureAwait(false); + return await CompleteValueAsync(result, ex, elapsed).ConfigureAwait(false); + } + + private async Task CompleteVoidAsync(Exception? exception, double elapsed) + { + await Task.Delay(UnitTestEx.TestSetUp.TaskDelayMilliseconds).ConfigureAwait(false); + var logs = Owner.SharedState.GetLoggerMessages(); + LogResult(null, exception, elapsed, logs); + await ExpectationsArranger.AssertAsync(logs, exception).ConfigureAwait(false); + return new VoidAssertor(Owner, logs, exception); + } + + private async Task> CompleteValueAsync(TValue result, Exception? exception, double elapsed) + { + await Task.Delay(UnitTestEx.TestSetUp.TaskDelayMilliseconds).ConfigureAwait(false); + var logs = Owner.SharedState.GetLoggerMessages(); + LogResult(result, exception, elapsed, logs); + await ExpectationsArranger.AssertValueAsync(logs, result, exception).ConfigureAwait(false); + return new ValueAssertor(Owner, result, logs, exception); + } + + private void ValidateTrigger(LambdaExpression expression) + { + var method = MethodCallExpressionValidate(expression).Method; + if (!method.GetParameters().SelectMany(p => p.GetCustomAttributesData()).Any(a => IsWorkerTriggerAttribute(a.AttributeType))) + throw new InvalidOperationException($"Function method '{method.Name}' does not have an isolated-worker trigger attribute."); + } + + private static bool IsWorkerTriggerAttribute(Type type) + => type.Name.EndsWith("TriggerAttribute", StringComparison.Ordinal) + && type.Namespace?.StartsWith("Microsoft.Azure.Functions.Worker", StringComparison.Ordinal) == true; + + private void LogResult(object? result, Exception? exception, double elapsed, IEnumerable logs) + { + Implementor.WriteLine(""); + Implementor.WriteLine("FUNCTION TRIGGER TESTER..."); + Implementor.WriteLine($"Function: {typeof(TFunction).Name}"); + Implementor.WriteLine($"Elapsed (ms): {elapsed}"); + Implementor.WriteLine(""); + Implementor.WriteLine("LOGGING >"); + if (logs.Any()) + { + foreach (var message in logs) + Implementor.WriteLine(message); + } + else + Implementor.WriteLine("None."); + + if (exception is not null) + { + Implementor.WriteLine($"Exception: {exception.Message} [{exception.GetType().Name}]"); + Implementor.WriteLine(exception.ToString()); + } + else if (result is not null) + { + Implementor.WriteLine($"Result: {result.GetType().Name}"); + Implementor.WriteLine(Owner.JsonSerializer.Serialize(result)); + } + } + } +} diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/WebJobsServiceBusMessageActionsAssertor.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/WebJobsServiceBusMessageActionsAssertor.cs deleted file mode 100644 index 5c7864b..0000000 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/WebJobsServiceBusMessageActionsAssertor.cs +++ /dev/null @@ -1,218 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx - -using Azure.Messaging.ServiceBus; -using Microsoft.Azure.WebJobs.ServiceBus; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using UnitTestEx.Abstractions; - -namespace UnitTestEx.Azure.Functions -{ - /// - /// Provides a test mock and assert verification. - /// - /// The . - public class WebJobsServiceBusMessageActionsAssertor(TestFrameworkImplementor implementor) : ServiceBusMessageActions - { - private readonly TestFrameworkImplementor _implementor = implementor ?? throw new ArgumentNullException(nameof(implementor)); - - /// - /// Gets the . - /// - public ServiceBusMessageActionStatus Status { get; private set; } = ServiceBusMessageActionStatus.None; - - /// - /// Gets the reason where specified. - /// - public string? DeadLetterReason { get; private set; } - - /// - /// Gets the error description where specified. - /// - public string? DeadLetterErrorDescription { get; private set; } - - /// - /// Gets the properties of the message modified where specified. - /// - public IDictionary? PropertiesModified { get; private set; } - - /// - /// Gets the count. - /// - public int RenewCount { get; private set; } - - /// - public override Task AbandonMessageAsync(ServiceBusReceivedMessage message, IDictionary? propertiesToModify = default, CancellationToken cancellationToken = default) - { - VerifyAndSetActionStatus(ServiceBusMessageActionStatus.Abandon); - Status = ServiceBusMessageActionStatus.Abandon; - PropertiesModified = propertiesToModify; - return Task.CompletedTask; - } - - /// - public override Task CompleteMessageAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken = default) - { - VerifyAndSetActionStatus(ServiceBusMessageActionStatus.Complete); - return Task.CompletedTask; - } - - /// - public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, IDictionary? propertiesToModify = default, CancellationToken cancellationToken = default) - { - VerifyAndSetActionStatus(ServiceBusMessageActionStatus.DeadLetter); - PropertiesModified = propertiesToModify; - return Task.CompletedTask; - } - - /// - public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, string deadLetterReason, string? deadLetterErrorDescription = default, CancellationToken cancellationToken = default) - { - VerifyAndSetActionStatus(ServiceBusMessageActionStatus.DeadLetter); - DeadLetterReason = deadLetterReason; - DeadLetterErrorDescription = deadLetterErrorDescription; - return Task.CompletedTask; - } - - /// - public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, Dictionary propertiesToModify, string deadLetterReason, string deadLetterErrorDescription = default!, CancellationToken cancellationToken = default) - { - VerifyAndSetActionStatus(ServiceBusMessageActionStatus.DeadLetter); - DeadLetterReason = deadLetterReason; - DeadLetterErrorDescription = deadLetterErrorDescription; - PropertiesModified = propertiesToModify; - return Task.CompletedTask; - } - - /// - public override Task DeferMessageAsync(ServiceBusReceivedMessage message, IDictionary? propertiesToModify = null, CancellationToken cancellationToken = default) - { - VerifyAndSetActionStatus(ServiceBusMessageActionStatus.Defer); - PropertiesModified = propertiesToModify; - return Task.CompletedTask; - } - - /// - public override Task RenewMessageLockAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken = default) - { - VerifyAndSetActionStatus(ServiceBusMessageActionStatus.Renew); - RenewCount++; - return Task.CompletedTask; - } - - /// - /// Verifies action is a valid operation. - /// - private void VerifyAndSetActionStatus(ServiceBusMessageActionStatus status) - { - if (Status != ServiceBusMessageActionStatus.None) - throw new InvalidOperationException($"The {status}MessageAsync cannot be invoked after a previous {Status}."); - - if (status != ServiceBusMessageActionStatus.Renew) - Status = status; - } - - /// - /// Logs the result of the . - /// - internal void LogResult() - { - _implementor.WriteLine(""); - _implementor.WriteLine("MESSAGE ACTIONS >"); - _implementor.WriteLine($"Action: {Status}"); - - if (RenewCount > 0) - _implementor.WriteLine($"Renew count: {RenewCount}"); - - if (Status == ServiceBusMessageActionStatus.DeadLetter) - { - _implementor.WriteLine($"Reason: {DeadLetterReason ?? ""}"); - _implementor.WriteLine($"Description: {DeadLetterErrorDescription ?? ""}"); - } - - _implementor.WriteLine($"Properties modified{(PropertiesModified is null ? ": None." : " >")}"); - if (PropertiesModified != null) - { - foreach (var pm in PropertiesModified) - { - _implementor.WriteLine($" {pm.Key}: {pm.Value ?? ""}"); - } - } - } - - /// - /// Assert the status. - /// - private WebJobsServiceBusMessageActionsAssertor AssertStatus(ServiceBusMessageActionStatus status) - { - if (!Status.Equals(status)) - _implementor.AssertAreEqual(status, Status, "ServiceBusMessageActions status is not equal."); - - return this; - } - - /// - /// Asserts that the was invoked. - /// - /// The to support fluent-style method-chaining. - public WebJobsServiceBusMessageActionsAssertor AssertAbandon() => AssertStatus(ServiceBusMessageActionStatus.Abandon); - - /// - /// Asserts that the was invoked. - /// - /// The to support fluent-style method-chaining. - public WebJobsServiceBusMessageActionsAssertor AssertComplete() => AssertStatus(ServiceBusMessageActionStatus.Complete); - - /// - /// Asserts that the was invoked. - /// - /// The to support fluent-style method-chaining. - public WebJobsServiceBusMessageActionsAssertor AssertDefer() => AssertStatus(ServiceBusMessageActionStatus.Defer); - - /// - /// Asserts that the one of the methods was invoked. - /// - /// Asserts that the resulting contains the specified content. - /// Asserts that the resulting contains the specified content. - /// The to support fluent-style method-chaining. - public WebJobsServiceBusMessageActionsAssertor AssertDeadLetter(string? reasonContains = default, string? errorDescriptionContains = default) - { - AssertStatus(ServiceBusMessageActionStatus.DeadLetter); - if (!string.IsNullOrEmpty(reasonContains)) - { - if (string.IsNullOrEmpty(DeadLetterReason) || !DeadLetterReason.Contains(reasonContains, StringComparison.InvariantCultureIgnoreCase)) - _implementor.AssertFail($"Expected the {nameof(DeadLetterReason)} to contain: {reasonContains}"); - } - - if (!string.IsNullOrEmpty(errorDescriptionContains)) - { - if (string.IsNullOrEmpty(DeadLetterErrorDescription) || !DeadLetterErrorDescription.Contains(errorDescriptionContains, StringComparison.InvariantCultureIgnoreCase)) - _implementor.AssertFail($"Expected the {nameof(DeadLetterErrorDescription)} to contain: {errorDescriptionContains}"); - } - - return this; - } - - /// - /// Asserts that the was invoked at least once or specified. - /// - /// The to support fluent-style method-chaining. - public WebJobsServiceBusMessageActionsAssertor AssertRenew(int? count = default) - { - if (count.HasValue) - _implementor.AssertAreEqual(count.Value, RenewCount, $"{nameof(RenewCount)} is not equal."); - else if (RenewCount == 0) - _implementor.AssertFail($"Expected the {nameof(RenewCount)} to be greater than zero."); - - return this; - } - - /// - /// Asserts that no methods (with the exception of ) were invoked. - /// - /// The to support fluent-style method-chaining. - public WebJobsServiceBusMessageActionsAssertor AssertNone() => AssertStatus(ServiceBusMessageActionStatus.None); - } -} \ No newline at end of file diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/WebJobsServiceBusSessionMessageActionsAssertor.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/WebJobsServiceBusSessionMessageActionsAssertor.cs deleted file mode 100644 index 38d1c08..0000000 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/WebJobsServiceBusSessionMessageActionsAssertor.cs +++ /dev/null @@ -1,250 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx - -using Azure.Messaging.ServiceBus; -using Microsoft.Azure.WebJobs.ServiceBus; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using UnitTestEx.Abstractions; - -namespace UnitTestEx.Azure.Functions -{ - /// - /// Provides a test mock and assert verification. - /// - /// The . - /// The sessions locked until ; defaults to plus five minutes. - /// The session state ; defaults to . - public class WebJobsServiceBusSessionMessageActionsAssertor(TestFrameworkImplementor implementor, DateTimeOffset? sessionLockedUntil = default, BinaryData? sessionState = default) : ServiceBusSessionMessageActions - { - private readonly TestFrameworkImplementor _implementor = implementor ?? throw new ArgumentNullException(nameof(implementor)); - private DateTimeOffset _sessionLockedUntil = sessionLockedUntil ?? DateTimeOffset.UtcNow.AddMinutes(5); - private BinaryData _sessionState = sessionState ?? BinaryData.Empty; - - /// - /// Gets the . - /// - public ServiceBusMessageActionStatus Status { get; private set; } = ServiceBusMessageActionStatus.None; - - /// - /// Gets the reason where specified. - /// - public string? DeadLetterReason { get; private set; } - - /// - /// Gets the error description where specified. - /// - public string? DeadLetterErrorDescription { get; private set; } - - /// - /// Gets the properties of the message modified where specified. - /// - public IDictionary? PropertiesModified { get; private set; } - - /// - /// Gets the count. - /// - public int RenewCount { get; private set; } - - /// - /// Indicates whether the was invoked. - /// - public bool SessionReleased { get; private set; } - - /// - public override Task AbandonMessageAsync(ServiceBusReceivedMessage message, IDictionary? propertiesToModify = default, CancellationToken cancellationToken = default) - { - VerifyAndSetActionStatus(ServiceBusMessageActionStatus.Abandon); - Status = ServiceBusMessageActionStatus.Abandon; - PropertiesModified = propertiesToModify; - return Task.CompletedTask; - } - - /// - public override Task CompleteMessageAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken = default) - { - VerifyAndSetActionStatus(ServiceBusMessageActionStatus.Complete); - return Task.CompletedTask; - } - - /// - public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, IDictionary? propertiesToModify = default, CancellationToken cancellationToken = default) - { - VerifyAndSetActionStatus(ServiceBusMessageActionStatus.DeadLetter); - PropertiesModified = propertiesToModify; - return Task.CompletedTask; - } - - /// - public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, string deadLetterReason, string? deadLetterErrorDescription = default, CancellationToken cancellationToken = default) - { - VerifyAndSetActionStatus(ServiceBusMessageActionStatus.DeadLetter); - DeadLetterReason = deadLetterReason; - DeadLetterErrorDescription = deadLetterErrorDescription; - return Task.CompletedTask; - } - - /// - public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, Dictionary propertiesToModify, string deadLetterReason, string deadLetterErrorDescription = default!, CancellationToken cancellationToken = default) - { - VerifyAndSetActionStatus(ServiceBusMessageActionStatus.DeadLetter); - DeadLetterReason = deadLetterReason; - DeadLetterErrorDescription = deadLetterErrorDescription; - PropertiesModified = propertiesToModify; - return Task.CompletedTask; - } - - /// - public override Task DeferMessageAsync(ServiceBusReceivedMessage message, IDictionary? propertiesToModify = null, CancellationToken cancellationToken = default) - { - VerifyAndSetActionStatus(ServiceBusMessageActionStatus.Defer); - PropertiesModified = propertiesToModify; - return Task.CompletedTask; - } - - /// - public override Task RenewMessageLockAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken = default) - { - VerifyAndSetActionStatus(ServiceBusMessageActionStatus.Renew); - RenewCount++; - return Task.CompletedTask; - } - - /// - public override Task GetSessionStateAsync(CancellationToken cancellationToken = default) => Task.FromResult(_sessionState); - - /// - public override Task SetSessionStateAsync(BinaryData sessionState, CancellationToken cancellationToken = default) - { - _sessionState = sessionState; - return Task.CompletedTask; - } - - /// - public override Task RenewSessionLockAsync(CancellationToken cancellationToken = default) - { - _sessionLockedUntil = DateTimeOffset.UtcNow.AddMinutes(5); - return Task.CompletedTask; - } - - /// - public override DateTimeOffset SessionLockedUntil => _sessionLockedUntil; - - /// - public override void ReleaseSession() => SessionReleased = true; - - /// - /// Verifies action is a valid operation. - /// - private void VerifyAndSetActionStatus(ServiceBusMessageActionStatus status) - { - if (Status != ServiceBusMessageActionStatus.None) - throw new InvalidOperationException($"The {status}MessageAsync cannot be invoked after a previous {Status}."); - - if (status != ServiceBusMessageActionStatus.Renew) - Status = status; - } - - /// - /// Logs the result of the . - /// - internal void LogResult() - { - _implementor.WriteLine(""); - _implementor.WriteLine("SESSION MESSAGE ACTIONS >"); - _implementor.WriteLine($"Action: {Status}"); - - if (RenewCount > 0) - _implementor.WriteLine($"Renew count: {RenewCount}"); - - if (Status == ServiceBusMessageActionStatus.DeadLetter) - { - _implementor.WriteLine($"Reason: {DeadLetterReason ?? ""}"); - _implementor.WriteLine($"Description: {DeadLetterErrorDescription ?? ""}"); - } - - _implementor.WriteLine($"Properties modified: {(PropertiesModified is null ? "None." : "")}"); - if (PropertiesModified != null) - { - foreach (var pm in PropertiesModified) - { - _implementor.WriteLine($"{pm.Key}: {pm.Value ?? ""}"); - } - } - } - - /// - /// Assert the status. - /// - private WebJobsServiceBusSessionMessageActionsAssertor AssertStatus(ServiceBusMessageActionStatus status) - { - if (!Status.Equals(status)) - _implementor.AssertAreEqual(status, Status, "ServiceBusMessageActions status is not equal."); - - return this; - } - - /// - /// Asserts that the was invoked. - /// - /// The to support fluent-style method-chaining. - public WebJobsServiceBusSessionMessageActionsAssertor AssertAbandon() => AssertStatus(ServiceBusMessageActionStatus.Abandon); - - /// - /// Asserts that the was invoked. - /// - /// The to support fluent-style method-chaining. - public WebJobsServiceBusSessionMessageActionsAssertor AssertComplete() => AssertStatus(ServiceBusMessageActionStatus.Complete); - - /// - /// Asserts that the was invoked. - /// - /// The to support fluent-style method-chaining. - public WebJobsServiceBusSessionMessageActionsAssertor AssertDefer() => AssertStatus(ServiceBusMessageActionStatus.Defer); - - /// - /// Asserts that the one of the methods was invoked. - /// - /// Asserts that the resulting contains the specified content. - /// Asserts that the resulting contains the specified content. - /// The to support fluent-style method-chaining. - public WebJobsServiceBusSessionMessageActionsAssertor AssertDeadLetter(string? reasonContains = default, string? errorDescriptionContains = default) - { - AssertStatus(ServiceBusMessageActionStatus.DeadLetter); - if (!string.IsNullOrEmpty(reasonContains)) - { - if (string.IsNullOrEmpty(DeadLetterReason) || !DeadLetterReason.Contains(reasonContains, StringComparison.InvariantCultureIgnoreCase)) - _implementor.AssertFail($"Expected the {nameof(DeadLetterReason)} to contain: {reasonContains}"); - } - - if (!string.IsNullOrEmpty(errorDescriptionContains)) - { - if (string.IsNullOrEmpty(DeadLetterErrorDescription) || !DeadLetterErrorDescription.Contains(errorDescriptionContains, StringComparison.InvariantCultureIgnoreCase)) - _implementor.AssertFail($"Expected the {nameof(DeadLetterErrorDescription)} to contain: {errorDescriptionContains}"); - } - - return this; - } - - /// - /// Asserts that the was invoked at least once or specified. - /// - /// The to support fluent-style method-chaining. - public WebJobsServiceBusSessionMessageActionsAssertor AssertRenew(int? count = default) - { - if (count.HasValue) - _implementor.AssertAreEqual(count.Value, RenewCount, $"{nameof(RenewCount)} is not equal."); - else if (RenewCount == 0) - _implementor.AssertFail($"Expected the {nameof(RenewCount)} to be greater than zero."); - - return this; - } - - /// - /// Asserts that no methods (with the exception of ) were invoked. - /// - /// The to support fluent-style method-chaining. - public WebJobsServiceBusSessionMessageActionsAssertor AssertNone() => AssertStatus(ServiceBusMessageActionStatus.None); - } -} \ No newline at end of file diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/WorkerServiceBusMessageActionsAssertor.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/WorkerServiceBusMessageActionsAssertor.cs index edfc25f..9a6d073 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/WorkerServiceBusMessageActionsAssertor.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/WorkerServiceBusMessageActionsAssertor.cs @@ -85,7 +85,7 @@ private void VerifyAndSetActionStatus(ServiceBusMessageActionStatus status) } /// - /// Logs the result of the . + /// Logs the result of the worker Service Bus message actions. /// internal void LogResult() { @@ -123,19 +123,19 @@ private WorkerServiceBusMessageActionsAssertor AssertStatus(ServiceBusMessageAct /// /// Asserts that the was invoked. /// - /// The to support fluent-style method-chaining. + /// The to support fluent-style method-chaining. public WorkerServiceBusMessageActionsAssertor AssertAbandon() => AssertStatus(ServiceBusMessageActionStatus.Abandon); /// /// Asserts that the was invoked. /// - /// The to support fluent-style method-chaining. + /// The to support fluent-style method-chaining. public WorkerServiceBusMessageActionsAssertor AssertComplete() => AssertStatus(ServiceBusMessageActionStatus.Complete); /// /// Asserts that the was invoked. /// - /// The to support fluent-style method-chaining. + /// The to support fluent-style method-chaining. public WorkerServiceBusMessageActionsAssertor AssertDefer() => AssertStatus(ServiceBusMessageActionStatus.Defer); /// @@ -143,7 +143,7 @@ private WorkerServiceBusMessageActionsAssertor AssertStatus(ServiceBusMessageAct /// /// Asserts that the resulting contains the specified content. /// Asserts that the resulting contains the specified content. - /// The to support fluent-style method-chaining. + /// The to support fluent-style method-chaining. public WorkerServiceBusMessageActionsAssertor AssertDeadLetter(string? reasonContains = default, string? errorDescriptionContains = default) { AssertStatus(ServiceBusMessageActionStatus.DeadLetter); @@ -165,7 +165,7 @@ public WorkerServiceBusMessageActionsAssertor AssertDeadLetter(string? reasonCon /// /// Asserts that no methods were invoked. /// - /// The to support fluent-style method-chaining. + /// The to support fluent-style method-chaining. public WorkerServiceBusMessageActionsAssertor AssertNone() => AssertStatus(ServiceBusMessageActionStatus.None); } } \ No newline at end of file diff --git a/src/UnitTestEx.Azure.Functions/ExtensionMethods.cs b/src/UnitTestEx.Azure.Functions/ExtensionMethods.cs index 68ebf7d..bd5da6e 100644 --- a/src/UnitTestEx.Azure.Functions/ExtensionMethods.cs +++ b/src/UnitTestEx.Azure.Functions/ExtensionMethods.cs @@ -1,8 +1,13 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using Microsoft.AspNetCore.Http; -using Microsoft.Azure.WebJobs.ServiceBus; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Net.Http.Headers; using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Net.Http; using UnitTestEx.Abstractions; using UnitTestEx.Azure.Functions; @@ -18,30 +23,120 @@ public static class ExtensionMethods internal const string HttpRouteComparisonName = "HttpTriggerTester_" + nameof(StringComparison); /// - /// Creates a as the instance to enable test mock and assert verification. + /// Creates a as the instance to enable test mock and assert verification. /// + /// The . /// The . - /// The . - public static WebJobsServiceBusMessageActionsAssertor CreateWebJobsServiceBusMessageActions(this TesterBase tester) => new(tester.Implementor); + public static WorkerServiceBusMessageActionsAssertor CreateWorkerServiceBusMessageActions(this TesterBase tester) => new(tester.Implementor); /// - /// Creates a as the instance to enable test mock and assert verification. + /// Creates a new (isolated worker) with optional ; see for further details. /// /// The . - /// The sessions locked until ; defaults to plus five minutes. - /// The session state ; defaults to . - /// The . - public static WebJobsServiceBusSessionMessageActionsAssertor CreateWebJobsServiceBusSessionMessageActions(this TesterBase tester, DateTimeOffset? sessionLockedUntil = default, BinaryData? sessionState = default) => new(tester.Implementor, sessionLockedUntil, sessionState); + /// The . + /// The request uri. + /// The optional body content. + /// The content type. Defaults to . + /// The optional underlying modifier. + /// The . + public static HttpRequestData CreateHttpRequestData(this TesterBase tester, HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri = null, string? body = null, string? contentType = null, Action? requestModifier = null) + => tester.CreateHttpRequest(httpMethod, requestUri, body, contentType, requestModifier).ToHttpRequestData(); /// - /// Creates a as the instance to enable test mock and assert verification. + /// Creates a new (isolated worker) with the JSON serialized as content type of . /// - /// The . /// The . - public static WorkerServiceBusMessageActionsAssertor CreateWorkerServiceBusMessageActions(this TesterBase tester) => new(tester.Implementor); + /// The . + /// The request uri. + /// The value to JSON serialize. + /// The optional underlying modifier. + /// The . + public static HttpRequestData CreateJsonHttpRequestData(this TesterBase tester, HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, object? value, Action? requestModifier = null) + => tester.CreateJsonHttpRequest(httpMethod, requestUri, value, requestModifier).ToHttpRequestData(); + + /// + /// Converts (wraps) the as an (isolated worker); see . + /// + /// The . + /// The optional ; defaults to a using the . + /// The . + public static HttpRequestData ToHttpRequestData(this HttpRequest httpRequest, FunctionContext? functionContext = null) + { + ArgumentNullException.ThrowIfNull(httpRequest); + return new TestHttpRequestData(httpRequest, functionContext ?? new TestFunctionContext(httpRequest.HttpContext.RequestServices ?? throw new ArgumentException($"The {nameof(HttpContext)}.{nameof(HttpContext.RequestServices)} must not be null.", nameof(httpRequest)))); + } + + /// + /// Converts the (isolated worker) to an equivalent (status code, headers, cookies and body content) to enable assertions. + /// + /// The . + /// The . + /// The bytes are copied as-is (not re-serialized); where seekable the body is read from the start and its position is restored, otherwise it is read from its current position. The body stream is not disposed. + /// The are only available where using the ; these are converted to corresponding 'Set-Cookie' headers. + public static HttpResponseMessage ToHttpResponseMessage(this HttpResponseData response) + { + ArgumentNullException.ThrowIfNull(response); + + var hr = new HttpResponseMessage(response.StatusCode); + + byte[] bytes = []; + var body = response.Body; + if (body is not null && body.CanRead) + { + long? position = body.CanSeek ? body.Position : null; + if (position.HasValue) + body.Position = 0; + + using var ms = new MemoryStream(); + body.CopyTo(ms); + bytes = ms.ToArray(); + + if (position.HasValue) + body.Position = position.Value; + } + + hr.Content = new ByteArrayContent(bytes); + + if (response.Headers is not null) + { + foreach (var h in response.Headers) + { + if (!hr.Headers.TryAddWithoutValidation(h.Key, h.Value)) + hr.Content.Headers.TryAddWithoutValidation(h.Key, h.Value); + } + } + + if (response.Cookies is TestHttpCookies cookies) + { + foreach (var c in cookies.Items) + hr.Headers.TryAddWithoutValidation(HeaderNames.SetCookie, ToSetCookieHeaderValue(c).ToString()); + } + + return hr; + } + + /// + /// Converts the to a . + /// + private static SetCookieHeaderValue ToSetCookieHeaderValue(IHttpCookie cookie) => new(cookie.Name, cookie.Value) + { + Domain = cookie.Domain, + Path = cookie.Path, + Expires = cookie.Expires, + MaxAge = cookie.MaxAge.HasValue ? TimeSpan.FromSeconds(cookie.MaxAge.Value) : null, + Secure = cookie.Secure ?? false, + HttpOnly = cookie.HttpOnly ?? false, + SameSite = cookie.SameSite switch + { + SameSite.Lax => Microsoft.Net.Http.Headers.SameSiteMode.Lax, + SameSite.Strict => Microsoft.Net.Http.Headers.SameSiteMode.Strict, + SameSite.ExplicitNone => Microsoft.Net.Http.Headers.SameSiteMode.None, + _ => Microsoft.Net.Http.Headers.SameSiteMode.Unspecified + } + }; /// - /// Sets the default that no check is performed to ensure that the or contains the for the . + /// Sets the default that no check is performed to ensure that the contains the for the . /// /// The . public static TestSetUp WithNoMethodCheck(this TestSetUp setup) @@ -51,7 +146,7 @@ public static TestSetUp WithNoMethodCheck(this TestSetUp setup) } /// - /// Sets the default that a check is performed to ensure that the or contains the for the . + /// Sets the default that a check is performed to ensure that the contains the for the . /// /// The . public static TestSetUp WithMethodCheck(this TestSetUp setup) diff --git a/src/UnitTestEx.Azure.Functions/FunctionTester.cs b/src/UnitTestEx.Azure.Functions/FunctionTester.cs index 6e92ae0..55ea6b4 100644 --- a/src/UnitTestEx.Azure.Functions/FunctionTester.cs +++ b/src/UnitTestEx.Azure.Functions/FunctionTester.cs @@ -14,7 +14,7 @@ public static class FunctionTester /// /// Creates a new instance of the class. /// - /// Any within the Azure Functions (isolated worker) application assembly (e.g. a function class); the application's own entry point (Program.cs) is executed as-is to build the host. For the legacy in-process model this is the FunctionsStartup . + /// Any within the Azure Functions isolated-worker application assembly (e.g. a function class); the application's own entry point (Program.cs) is executed as-is to build the host. /// Indicates whether to include 'appsettings.unittest.json' configuration file. /// Indicates whether to include user secrets. /// Additional configuration values to add/override. diff --git a/src/UnitTestEx.Azure.Functions/UnitTestEx.Azure.Functions.csproj b/src/UnitTestEx.Azure.Functions/UnitTestEx.Azure.Functions.csproj index 5848b9c..f9848ea 100644 --- a/src/UnitTestEx.Azure.Functions/UnitTestEx.Azure.Functions.csproj +++ b/src/UnitTestEx.Azure.Functions/UnitTestEx.Azure.Functions.csproj @@ -10,13 +10,9 @@ - - - - diff --git a/src/UnitTestEx/Hosting/HostTesterBase.cs b/src/UnitTestEx/Hosting/HostTesterBase.cs index 0e7e0bb..a88e103 100644 --- a/src/UnitTestEx/Hosting/HostTesterBase.cs +++ b/src/UnitTestEx/Hosting/HostTesterBase.cs @@ -93,7 +93,7 @@ public class HostTesterBase(TesterBase owner, IServiceProvider service onBeforeRun?.Invoke(@params, paramAttribute, paramValue); - var h = CreateHost(null); + var h = mce.Method.IsStatic ? null : CreateHost(null); var sw = Stopwatch.StartNew(); try @@ -123,7 +123,34 @@ public class HostTesterBase(TesterBase owner, IServiceProvider service /// The optional parameter array to find. /// Action to verify the method parameters prior to method invocation. /// The resulting value, resulting exception if any, and elapsed milliseconds. - protected async Task<(TValue Result, Exception? Exception, double ElapsedMilliseconds)> RunAsync(Expression>> expression, Type[]? paramAttributeTypes, OnBeforeRun? onBeforeRun) + protected Task<(TValue Result, Exception? Exception, double ElapsedMilliseconds)> RunAsync(Expression>> expression, Type[]? paramAttributeTypes, OnBeforeRun? onBeforeRun) + => RunCoreAsync(expression, h => expression.Compile().Invoke(h), paramAttributeTypes, onBeforeRun); + + /// + /// Orchestrates the execution of a synchronous method as described by the returning a result of . + /// + /// The result value . + /// The method execution expression. + /// The optional parameter array to find. + /// Action to verify the method parameters prior to method invocation. + /// The resulting value, resulting exception if any, and elapsed milliseconds. + internal Task<(TValue Result, Exception? Exception, double ElapsedMilliseconds)> RunSyncAsync(Expression> expression, Type[]? paramAttributeTypes, Action<(string? Name, object? Value)[], Attribute?, object?>? onBeforeRun) + => RunCoreAsync(expression, h => Task.FromResult(expression.Compile().Invoke(h)), paramAttributeTypes, onBeforeRun is null ? null : (parameters, attribute, value) => onBeforeRun(parameters, attribute, value)); + + /// + /// Executes a synchronous method that does not return a value. + /// + internal Task<(bool Result, Exception? Exception, double ElapsedMilliseconds)> RunSyncAsync(Expression> expression, Type[]? paramAttributeTypes, Action<(string? Name, object? Value)[], Attribute?, object?>? onBeforeRun) + => RunCoreAsync(expression, h => + { + expression.Compile().Invoke(h); + return Task.FromResult(true); + }, paramAttributeTypes, onBeforeRun is null ? null : (parameters, attribute, value) => onBeforeRun(parameters, attribute, value)); + + /// + /// Orchestrates the execution of a method as described by the using the returning a result of . + /// + private async Task<(TValue Result, Exception? Exception, double ElapsedMilliseconds)> RunCoreAsync(LambdaExpression expression, Func> invoker, Type[]? paramAttributeTypes, OnBeforeRun? onBeforeRun) { TestSetUp.LogAutoSetUpOutputs(Implementor); @@ -154,12 +181,12 @@ public class HostTesterBase(TesterBase owner, IServiceProvider service onBeforeRun?.Invoke(@params, paramAttribute, paramValue); - var h = CreateHost(null); + var h = mce.Method.IsStatic ? null : CreateHost(null); var sw = Stopwatch.StartNew(); try { - var mr = await expression.Compile().Invoke(h).ConfigureAwait(false); + var mr = await invoker(h!).ConfigureAwait(false); sw.Stop(); return (mr, null, sw.Elapsed.TotalMilliseconds); } @@ -200,7 +227,7 @@ internal static MethodCallExpression MethodCallExpressionValidate([NotNull] Expr if (lex.Body is not MethodCallExpression mce) throw new ArgumentException($"Expression must be of Type '{nameof(MethodCallExpression)}'.", nameof(expression)); - if (mce.Object == null || mce.Object.NodeType != ExpressionType.Parameter) + if (mce.Object is not null && mce.Object.NodeType != ExpressionType.Parameter) throw new InvalidOperationException($"UnitTestEx methods that enable an expression must not include method-chaining '{mce}'; i.e. must be an invocation of a single method with zero or more arguments only." + Environment.NewLine + $"This is because UnitTestEx is reflecting the underlying type and arguments to validate, log, and in some instances refactor the execution using this information. If this is not the desired " + "behavior then consider using one of the other methods that does not use expressions to execute the test."); diff --git a/src/UnitTestEx/UnitTestEx.csproj b/src/UnitTestEx/UnitTestEx.csproj index 6079a4a..fb8e9e6 100644 --- a/src/UnitTestEx/UnitTestEx.csproj +++ b/src/UnitTestEx/UnitTestEx.csproj @@ -9,6 +9,10 @@ unittestex api function unit test unittest + + + + diff --git a/tests/UnitTestEx.Function/.gitignore b/tests/UnitTestEx.Function/.gitignore deleted file mode 100644 index ff5b00c..0000000 --- a/tests/UnitTestEx.Function/.gitignore +++ /dev/null @@ -1,264 +0,0 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. - -# Azure Functions localsettings file -local.settings.json - -# User-specific files -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ - -# Visual Studio 2015 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUNIT -*.VisualState.xml -TestResult.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# DNX -project.lock.json -project.fragment.lock.json -artifacts/ - -*_i.c -*_p.c -*_i.h -*.ilk -*.meta -*.obj -*.pch -*.pdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# JustCode is a .NET coding add-in -.JustCode - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# TODO: Comment the next line if you want to checkin your web deploy settings -# but database connection strings (with potential passwords) will be unencrypted -#*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# The packages folder can be ignored because of Package Restore -**/packages/* -# except build/, which is used as an MSBuild target. -!**/packages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/packages/repositories.config -# NuGet v3's project.json files produces more ignoreable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -node_modules/ -orleans.codegen.cs - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm - -# SQL Server files -*.mdf -*.ldf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# JetBrains Rider -.idea/ -*.sln.iml - -# CodeRush -.cr/ - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc \ No newline at end of file diff --git a/tests/UnitTestEx.Function/PersonFunction.cs b/tests/UnitTestEx.Function/PersonFunction.cs deleted file mode 100644 index f45005e..0000000 --- a/tests/UnitTestEx.Function/PersonFunction.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System.IO; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Azure.WebJobs; -using Microsoft.Azure.WebJobs.Extensions.Http; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Configuration; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using System.Net.Mime; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Web.Http; - -namespace UnitTestEx.Function -{ - public class PersonFunction - { - [FunctionName("PersonFunction")] - public async Task Run( - [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req, ILogger log) - { - log.LogInformation("C# HTTP trigger function processed a request."); - - string name = req.Query["name"]; - - string requestBody = await new StreamReader(req.Body).ReadToEndAsync(); - var data = string.IsNullOrEmpty(requestBody) ? null : JsonSerializer.Deserialize(requestBody); - name ??= data?.Name; - - if (name == "Brian") - { - var msd = new ModelStateDictionary(); - msd.AddModelError("name", "Name cannot be Brian."); - return new BadRequestObjectResult(msd); - } - - if (name == "Rachel") - { - var obj = new { FirstName = "Rachel", LastName = "Smith" }; - return new JsonResult(obj); - } - - if (name == "Damien") - return new BadRequestErrorMessageResult("Name cannot be Damien."); - - if (name == "Bruce") - return new ContentResult { Content = "Name cannot be Bruce.", StatusCode = 400 }; - - string responseMessage = string.IsNullOrEmpty(name) - ? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response." - : $"Hello, {name}. This HTTP triggered function executed successfully."; - - return new OkObjectResult(responseMessage); - } - - [FunctionName("PersonFunctionObj")] - public async Task RunWithValue([HttpTrigger(AuthorizationLevel.Function, "post", Route = "people/{name}")] Person person, ILogger log) - { - await Task.CompletedTask.ConfigureAwait(false); - log.LogInformation("C# HTTP trigger function processed a request."); - return new OkObjectResult(new { first = person.FirstName, last = person.LastName }); - } - - [FunctionName("PersonFunctionContent")] - public async Task RunWithContent([HttpTrigger(AuthorizationLevel.Function, "post", Route = "people/content/{name}")] Person person, ILogger log) - { - await Task.CompletedTask.ConfigureAwait(false); - log.LogInformation("C# HTTP trigger function processed a request."); - return new ContentResult { Content = JsonSerializer.Serialize(new { first = person.FirstName, last = person.LastName }), ContentType = MediaTypeNames.Application.Json, StatusCode = 200 }; - } - - [FunctionName("PersonFunctionQuery")] - public async Task RunWithQuery([HttpTrigger(AuthorizationLevel.Function, "get", Route = "api/people?name={name}")] HttpRequest request, string name, ILogger log) - { - await Task.CompletedTask.ConfigureAwait(false); - log.LogInformation("C# HTTP trigger function processed a request."); - return new OkObjectResult(new { name }); - } - } - - public class Namer - { - [JsonPropertyName("name")] - public string Name { get; set; } - } - - public class Person - { - [JsonPropertyName("firstName")] - public string FirstName { get; set; } - - [JsonPropertyName("lastName")] - public string LastName { get; set; } - } -} \ No newline at end of file diff --git a/tests/UnitTestEx.Function/ProductFunction.cs b/tests/UnitTestEx.Function/ProductFunction.cs deleted file mode 100644 index c60be29..0000000 --- a/tests/UnitTestEx.Function/ProductFunction.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Azure.WebJobs; -using Microsoft.Azure.WebJobs.Extensions.Http; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Linq; -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; - -namespace UnitTestEx.Function -{ - public class ProductFunction - { - private readonly HttpClient _httpClient; - private readonly ILogger _logger; - - public ProductFunction(IHttpClientFactory clientFactory, ILogger logger) - { - _httpClient = clientFactory.CreateClient("XXX"); - _logger = logger; - } - - [FunctionName("ProductFunction")] - public async Task Run([HttpTrigger(AuthorizationLevel.Function, "get", Route = "product/{id}")] HttpRequest _, string id, ILogger log) - => await Logic(id, log).ConfigureAwait(false); - - private async Task Logic(string id, ILogger log) - { - log.LogInformation("C# HTTP trigger function processed a request."); - - if (id == "exception") - throw new InvalidOperationException("An unexpected exception occured."); - - var result = await _httpClient.GetAsync($"products/{id}").ConfigureAwait(false); - if (result.StatusCode == HttpStatusCode.NotFound) - return new NotFoundResult(); - - result.EnsureSuccessStatusCode(); - - var str = await result.Content.ReadAsStringAsync().ConfigureAwait(false); - JObject val = JObject.Parse(str); - - return new JsonResult(new { id = val["id"].Value(), description = val["description"].Value() }); - } - - [FunctionName("TimerTriggered")] - public Task DailyRun([TimerTrigger("0 0 0 */1 * *", RunOnStartup = true)] TimerInfo _, ILogger otherLogger) - { - _logger.LogInformation("C# Timer trigger function executed (DI)."); - otherLogger.LogInformation("C# Timer trigger function executed (method)."); - return Task.CompletedTask; - } - } -} diff --git a/tests/UnitTestEx.Function/Properties/serviceDependencies.json b/tests/UnitTestEx.Function/Properties/serviceDependencies.json deleted file mode 100644 index df4dcc9..0000000 --- a/tests/UnitTestEx.Function/Properties/serviceDependencies.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "dependencies": { - "appInsights1": { - "type": "appInsights" - }, - "storage1": { - "type": "storage", - "connectionId": "AzureWebJobsStorage" - } - } -} \ No newline at end of file diff --git a/tests/UnitTestEx.Function/Properties/serviceDependencies.local.json b/tests/UnitTestEx.Function/Properties/serviceDependencies.local.json deleted file mode 100644 index b804a28..0000000 --- a/tests/UnitTestEx.Function/Properties/serviceDependencies.local.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "dependencies": { - "appInsights1": { - "type": "appInsights.sdk" - }, - "storage1": { - "type": "storage.emulator", - "connectionId": "AzureWebJobsStorage" - } - } -} \ No newline at end of file diff --git a/tests/UnitTestEx.Function/ServiceBusFunction.cs b/tests/UnitTestEx.Function/ServiceBusFunction.cs deleted file mode 100644 index c730614..0000000 --- a/tests/UnitTestEx.Function/ServiceBusFunction.cs +++ /dev/null @@ -1,90 +0,0 @@ -using Azure.Messaging.ServiceBus; -using Microsoft.Azure.WebJobs; -using Microsoft.Azure.WebJobs.ServiceBus; -using Microsoft.Extensions.Logging; -using System; -using System.Net.Http; -using System.Net.Http.Formatting; -using System.Net.Http.Json; -using System.Text.Json; -using System.Threading.Tasks; - -namespace UnitTestEx.Function -{ - public class ServiceBusFunction - { - private readonly HttpClient _httpClient; - - public ServiceBusFunction(IHttpClientFactory clientFactory) - { - _httpClient = clientFactory.CreateClient("XXX"); - } - - [FunctionName("ServiceBusFunction")] - public async Task Run([ServiceBusTrigger("unittestex", Connection = "ServiceBusConnectionString")] Person p, ILogger log) - { - log.LogInformation($"C# ServiceBus queue trigger function processed message: {p.FirstName} {p.LastName}"); - - if (p.FirstName == null) - throw new InvalidOperationException("First name is required."); - - var resp = await _httpClient.PostAsJsonAsync($"person", p, new JsonSerializerOptions(JsonSerializerDefaults.Web)).ConfigureAwait(false); - - resp.EnsureSuccessStatusCode(); - } - - [FunctionName("ServiceBusFunction2")] - public async Task Run2([ServiceBusTrigger("unittestex", Connection = "ServiceBusConnectionString")] ServiceBusReceivedMessage message, ILogger log) - { - var p = message.Body.ToObjectFromJson(); - log.LogInformation($"C# ServiceBus queue trigger function processed message: {p.FirstName} {p.LastName}"); - - if (p.FirstName == null) - throw new InvalidOperationException("First name is required."); - - var resp = await _httpClient.PostAsJsonAsync($"person", p, new JsonSerializerOptions(JsonSerializerDefaults.Web)).ConfigureAwait(false); - - resp.EnsureSuccessStatusCode(); - } - - [FunctionName("ServiceBusFunction3")] - public async Task Run3([ServiceBusTrigger("unittestex", Connection = "ServiceBusConnectionString", AutoCompleteMessages = false)] ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, ILogger log) - { - var p = message.Body.ToObjectFromJson(); - log.LogInformation($"C# ServiceBus queue trigger function processed message: {p.FirstName} {p.LastName}"); - - if (p.FirstName == null) - { - await messageActions.DeadLetterMessageAsync(message, "Validation error.", "First name is required.").ConfigureAwait(false); - log.LogError("First name is required."); - return; - } - - if (p.FirstName == "zerodivision") - throw new DivideByZeroException("Divide by zero is not a thing."); - - var resp = await _httpClient.PostAsJsonAsync($"person", p, new JsonSerializerOptions(JsonSerializerDefaults.Web)).ConfigureAwait(false); - - resp.EnsureSuccessStatusCode(); - - await messageActions.CompleteMessageAsync(message).ConfigureAwait(false); - } - - [FunctionName("ServiceBusFunction4")] - public static Task Run4([ServiceBusTrigger("%Run4QueueName%", Connection = "ServiceBusConnectionString")] Person p, string subject, ILogger log) - { - log.LogInformation($"C# ServiceBus queue trigger function processed {subject} message: {p.FirstName} {p.LastName}"); - - if (p.FirstName == null) - throw new InvalidOperationException("First name is required."); - - return Task.CompletedTask; - } - - [FunctionName("ServiceBusSessionFunction5")] - public static Task Run5([ServiceBusTrigger("unittestexsess", Connection = "ServiceBusConnectionString", IsSessionsEnabled = true)] ServiceBusReceivedMessage _) - { - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/tests/UnitTestEx.Function/Startup.cs b/tests/UnitTestEx.Function/Startup.cs deleted file mode 100644 index 5518ac1..0000000 --- a/tests/UnitTestEx.Function/Startup.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.Azure.Functions.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection; - -[assembly: FunctionsStartup(typeof(UnitTestEx.Function.Startup))] - -namespace UnitTestEx.Function -{ - public class Startup : FunctionsStartup - { - public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder) - { - //if (builder.ConfigurationBuilder.Build()["SpecialKey"] != "VerySpecialValue") - // throw new InvalidOperationException("The people do not feel very special!"); - } - - public override void Configure(IFunctionsHostBuilder builder) - { - builder.Services.AddHttpClient("XXX", hc => hc.BaseAddress = new System.Uri("https://somesys")); - } - } -} \ No newline at end of file diff --git a/tests/UnitTestEx.Function/UnitTestEx.Function.csproj b/tests/UnitTestEx.Function/UnitTestEx.Function.csproj deleted file mode 100644 index 3ab6480..0000000 --- a/tests/UnitTestEx.Function/UnitTestEx.Function.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - net6.0 - v4 - fff1f13f-b465-40c0-bf17-1f705f473c9e - preview - - - - - - - - - - PreserveNewest - - - Always - Never - - - \ No newline at end of file diff --git a/tests/UnitTestEx.Function/host.json b/tests/UnitTestEx.Function/host.json deleted file mode 100644 index beb2e40..0000000 --- a/tests/UnitTestEx.Function/host.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "2.0", - "logging": { - "applicationInsights": { - "samplingSettings": { - "isEnabled": true, - "excludedTypes": "Request" - } - } - } -} \ No newline at end of file diff --git a/tests/UnitTestEx.IsolatedFunction/HttpVariantFunctions.cs b/tests/UnitTestEx.IsolatedFunction/HttpVariantFunctions.cs new file mode 100644 index 0000000..bc8b6dd --- /dev/null +++ b/tests/UnitTestEx.IsolatedFunction/HttpVariantFunctions.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using System.Net; +using System.Text.Json.Serialization; + +namespace UnitTestEx.IsolatedFunction +{ + public sealed class Person + { + public Person() { } + + public Person(string? firstName, string? lastName) + { + FirstName = firstName; + LastName = lastName; + } + + [JsonPropertyName("firstName")] + public string? FirstName { get; set; } + + [JsonPropertyName("lastName")] + public string? LastName { get; set; } + } + + public class HttpVariantFunctions(ILogger logger) + { + private readonly ILogger _logger = logger; + + [Function("MultiOutput")] + public MultiOutputResult MultiOutput([HttpTrigger(AuthorizationLevel.Function, "get", Route = "multi-output")] HttpRequestData req) + { + var response = req.CreateResponse(HttpStatusCode.OK); + response.WriteString("multi-output response"); + return new MultiOutputResult { HttpResponse = response, Message = "multi-output message" }; + } + + [Function("DataJson")] + public async Task DataJson([HttpTrigger(AuthorizationLevel.Function, "post", Route = "data/json")] HttpRequestData req) + { + var person = await req.ReadFromJsonAsync(); + _logger.LogInformation("Data person {FirstName}.", person?.FirstName); + + var res = req.CreateResponse(); + res.Headers.Add("X-Test", "abc"); + res.Cookies.Append(new HttpCookie("session", "xyz") { Path = "/", HttpOnly = true }); + await res.WriteAsJsonAsync(new Person(person?.LastName, person?.FirstName)); + return res; + } + + [Function("DataText")] + public HttpResponseData DataText([HttpTrigger(AuthorizationLevel.Function, "get", Route = "data/text/{name}")] HttpRequestData req, string name) + { + var res = req.CreateResponse(HttpStatusCode.Accepted); + res.Headers.Add("Content-Type", "text/plain; charset=utf-8"); + res.WriteString($"Hello {name}"); + return res; + } + + [Function("DataHttpContext")] + public HttpResponseData DataHttpContext([HttpTrigger(AuthorizationLevel.Function, "get", Route = "data/context")] HttpRequestData req) + { + var ctx = req.FunctionContext.GetHttpContext(); + var res = req.CreateResponse(ctx is null ? HttpStatusCode.InternalServerError : HttpStatusCode.OK); + res.WriteString(ctx?.Request.Path.Value ?? "none"); + return res; + } + + [Function("Result")] + public async Task Result([HttpTrigger(AuthorizationLevel.Function, "get", Route = "result")] HttpRequest req) + { + await Task.CompletedTask; + return Results.Ok(new Person("Jane", "Doe")); + } + + [Function("ResultSync")] + public IResult ResultSync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "result/sync")] HttpRequest req) => Results.NotFound(); + + [Function("ActionSync")] + public IActionResult ActionSync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "action/sync")] HttpRequest req) => new OkObjectResult(new Person("John", "Smith")); + } + + public sealed class MultiOutputResult + { + [HttpResult] + public HttpResponseData? HttpResponse { get; set; } + + [ServiceBusOutput("myqueue", Connection = "ServiceBusConnection")] + public string? Message { get; set; } + } +} diff --git a/tests/UnitTestEx.IsolatedFunction/PersonFunction.cs b/tests/UnitTestEx.IsolatedFunction/PersonFunction.cs new file mode 100644 index 0000000..801d16f --- /dev/null +++ b/tests/UnitTestEx.IsolatedFunction/PersonFunction.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace UnitTestEx.IsolatedFunction; + +public sealed class PersonFunction(ILogger logger) +{ + [Function("PersonFunction")] + public async Task Run([HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequest req) + { + logger.LogInformation("C# HTTP trigger function processed a request."); + var name = req.Query["name"].FirstOrDefault(); + using var reader = new StreamReader(req.Body); + var body = await reader.ReadToEndAsync().ConfigureAwait(false); + var data = string.IsNullOrEmpty(body) ? null : JsonSerializer.Deserialize(body); + name ??= data?.Name; + + if (name == "Brian") + { + var modelState = new Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary(); + modelState.AddModelError("name", "Name cannot be Brian."); + return new BadRequestObjectResult(modelState); + } + + if (name == "Rachel") + return new JsonResult(new { FirstName = "Rachel", LastName = "Smith" }); + + if (name == "Damien") + return new BadRequestObjectResult("Name cannot be Damien."); + + if (name == "Bruce") + return new ContentResult { Content = "Name cannot be Bruce.", StatusCode = StatusCodes.Status400BadRequest }; + + var response = string.IsNullOrEmpty(name) + ? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response." + : $"Hello, {name}. This HTTP triggered function executed successfully."; + return new OkObjectResult(response); + } + + [Function("PersonFunctionObj")] + public async Task RunWithValue([HttpTrigger(AuthorizationLevel.Function, "post", Route = "people/{name}")] HttpRequest req, string name) + { + logger.LogInformation("C# HTTP trigger function processed a request."); + var person = await JsonSerializer.DeserializeAsync(req.Body).ConfigureAwait(false); + return new OkObjectResult(new { first = person?.FirstName, last = person?.LastName }); + } + + [Function("PersonFunctionContent")] + public async Task RunWithContent([HttpTrigger(AuthorizationLevel.Function, "post", Route = "people/content/{name}")] HttpRequest req, string name) + { + logger.LogInformation("C# HTTP trigger function processed a request."); + var person = await JsonSerializer.DeserializeAsync(req.Body).ConfigureAwait(false); + return new ContentResult + { + Content = JsonSerializer.Serialize(new { first = person?.FirstName, last = person?.LastName }), + ContentType = "application/json", + StatusCode = StatusCodes.Status200OK + }; + } + + [Function("PersonFunctionQuery")] + public IActionResult RunWithQuery([HttpTrigger(AuthorizationLevel.Function, "get", Route = "api/people/{name}")] HttpRequest request, string name) + { + logger.LogInformation("C# HTTP trigger function processed a request."); + return new OkObjectResult(new { name }); + } +} + +public sealed class Namer +{ + [JsonPropertyName("name")] + public string? Name { get; set; } +} diff --git a/tests/UnitTestEx.IsolatedFunction/ProductFunction.cs b/tests/UnitTestEx.IsolatedFunction/ProductFunction.cs new file mode 100644 index 0000000..3e78bc7 --- /dev/null +++ b/tests/UnitTestEx.IsolatedFunction/ProductFunction.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using System.Net; + +namespace UnitTestEx.IsolatedFunction; + +public sealed class ProductFunction +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public ProductFunction(IHttpClientFactory clientFactory, ILogger logger) + { + _httpClient = clientFactory.CreateClient("XXX"); + _logger = logger; + } + + [Function("ProductFunction")] + public async Task Run([HttpTrigger(AuthorizationLevel.Function, "get", Route = "product/{id}")] HttpRequest request, string id) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + if (id == "exception") + throw new InvalidOperationException("An unexpected exception occured."); + + using var response = await _httpClient.GetAsync($"products/{id}").ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotFound) + return new NotFoundResult(); + + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var value = JObject.Parse(json); + return new JsonResult(new { id = value["id"]?.Value(), description = value["description"]?.Value() }); + } + + [Function("TimerTriggered")] + public Task DailyRun([TimerTrigger("0 0 0 */1 * *", RunOnStartup = true)] string timer) + { + _logger.LogInformation("C# Timer trigger function executed (DI)."); + _logger.LogInformation("C# Timer trigger function executed (method)."); + return Task.CompletedTask; + } +} diff --git a/tests/UnitTestEx.IsolatedFunction/Program.cs b/tests/UnitTestEx.IsolatedFunction/Program.cs index 4894379..4ff7df7 100644 --- a/tests/UnitTestEx.IsolatedFunction/Program.cs +++ b/tests/UnitTestEx.IsolatedFunction/Program.cs @@ -1,8 +1,10 @@ using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; var builder = FunctionsApplication.CreateBuilder(args); builder.ConfigureFunctionsWebApplication(); builder.Services.AddHttpClient("XXX", client => client.BaseAddress = new Uri("https://somesys")); +builder.Configuration.AddInMemoryCollection([new KeyValuePair("SpecialKey", "VerySpecialValue")]); builder.Build().Run(); diff --git a/tests/UnitTestEx.IsolatedFunction/ServiceBusFunction.cs b/tests/UnitTestEx.IsolatedFunction/ServiceBusFunction.cs index cca5c1d..fea281e 100644 --- a/tests/UnitTestEx.IsolatedFunction/ServiceBusFunction.cs +++ b/tests/UnitTestEx.IsolatedFunction/ServiceBusFunction.cs @@ -1,28 +1,75 @@ using Azure.Messaging.ServiceBus; using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; +using System.Net.Http.Json; +using System.Text.Json; -namespace UnitTestEx.IsolatedFunction +namespace UnitTestEx.IsolatedFunction; + +public sealed class ServiceBusFunction { - public class ServiceBusFunction + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public ServiceBusFunction(IHttpClientFactory clientFactory, ILogger logger) { - private readonly HttpClient _httpClient; + _httpClient = clientFactory.CreateClient("XXX"); + _logger = logger; + } - public ServiceBusFunction(IHttpClientFactory clientFactory) - { - _httpClient = clientFactory.CreateClient("XXX"); - } + [Function("ServiceBusFunction")] + public async Task Run([ServiceBusTrigger("unittestex", Connection = "ServiceBusConnectionString")] Person person) + { + _logger.LogInformation("C# ServiceBus queue trigger function processed message: {FirstName} {LastName}", person.FirstName, person.LastName); + if (person.FirstName is null) + throw new InvalidOperationException("First name is required."); - [Function("ServiceBusFunction")] - public async Task Run([ServiceBusTrigger("myqueue", Connection = "ServiceBusConnection")] ServiceBusReceivedMessage message) - { - var id = message.Body.ToObjectFromJson(); - await DoStuff(id).ConfigureAwait(false); - } + using var response = await _httpClient.PostAsJsonAsync("person", person, new JsonSerializerOptions(JsonSerializerDefaults.Web)).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + [Function("ServiceBusFunction2")] + public async Task Run2([ServiceBusTrigger("unittestex", Connection = "ServiceBusConnectionString")] ServiceBusReceivedMessage message) + { + var person = message.Body.ToObjectFromJson(); + _logger.LogInformation("C# ServiceBus queue trigger function processed message: {FirstName} {LastName}", person?.FirstName, person?.LastName); + if (person is null || person.FirstName is null) + throw new InvalidOperationException("First name is required."); - private async Task DoStuff(int id) + using var response = await _httpClient.PostAsJsonAsync("person", person, new JsonSerializerOptions(JsonSerializerDefaults.Web)).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + [Function("ServiceBusFunction3")] + public async Task Run3([ServiceBusTrigger("unittestex", Connection = "ServiceBusConnectionString", AutoCompleteMessages = false)] ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions) + { + var person = message.Body.ToObjectFromJson(); + _logger.LogInformation("C# ServiceBus queue trigger function processed message: {FirstName} {LastName}", person?.FirstName, person?.LastName); + if (person is null || person.FirstName is null) { - var hr = await _httpClient.GetAsync($"products/{id}").ConfigureAwait(false); - hr.EnsureSuccessStatusCode(); + await messageActions.DeadLetterMessageAsync(message, null, "Validation error.", "First name is required.").ConfigureAwait(false); + _logger.LogError("First name is required."); + return; } + + if (person.FirstName == "zerodivision") + throw new DivideByZeroException("Divide by zero is not a thing."); + + using var response = await _httpClient.PostAsJsonAsync("person", person, new JsonSerializerOptions(JsonSerializerDefaults.Web)).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + await messageActions.CompleteMessageAsync(message).ConfigureAwait(false); } + + [Function("ServiceBusFunction4")] + public static Task Run4([ServiceBusTrigger("%Run4QueueName%", Connection = "ServiceBusConnectionString")] Person person) + { + if (person.FirstName is null) + throw new InvalidOperationException("First name is required."); + + return Task.CompletedTask; + } + + [Function("ServiceBusSessionFunction5")] + public Task Run5([ServiceBusTrigger("unittestexsess", Connection = "ServiceBusConnectionString", IsSessionsEnabled = true)] ServiceBusReceivedMessage _) + => Task.CompletedTask; } \ No newline at end of file diff --git a/tests/UnitTestEx.MSTest.Test/MockHttpClientTest.cs b/tests/UnitTestEx.MSTest.Test/MockHttpClientTest.cs index aae0c02..c53d6d1 100644 --- a/tests/UnitTestEx.MSTest.Test/MockHttpClientTest.cs +++ b/tests/UnitTestEx.MSTest.Test/MockHttpClientTest.cs @@ -4,6 +4,7 @@ using System; using System.Net; using System.Net.Http; +using System.Net.Http.Json; using System.Net.Mime; using System.Text; using System.Threading.Tasks; diff --git a/tests/UnitTestEx.MSTest.Test/PersonFunctionTest.cs b/tests/UnitTestEx.MSTest.Test/PersonFunctionTest.cs index dbd422b..34d2f74 100644 --- a/tests/UnitTestEx.MSTest.Test/PersonFunctionTest.cs +++ b/tests/UnitTestEx.MSTest.Test/PersonFunctionTest.cs @@ -1,7 +1,7 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Net.Http; using System.Threading.Tasks; -using UnitTestEx.Function; +using UnitTestEx.IsolatedFunction; namespace UnitTestEx.MSTest.Test { @@ -11,10 +11,10 @@ public class PersonFunctionTest [TestMethod] public async Task NoData() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); (await test.HttpTrigger() .WithNoRouteCheck() - .RunAsync(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person"), test.Logger))) + .RunAsync(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person")))) .AssertOK() .AssertValue("This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."); } @@ -22,10 +22,10 @@ public async Task NoData() [TestMethod] public void QueryString() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person?name=Trevor"), test.Logger)) + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person?name=Trevor"))) .AssertOK() .AssertValue("Hello, Trevor. This HTTP triggered function executed successfully."); } @@ -33,10 +33,10 @@ public void QueryString() [TestMethod] public void WithBody() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Jane" }), test.Logger)) + .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Jane" }))) .AssertOK() .AssertValue("Hello, Jane. This HTTP triggered function executed successfully."); } @@ -44,10 +44,10 @@ public void WithBody() [TestMethod] public void BadRequest1() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Post, "person", new { name = "Brian" }), test.Logger)) + .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Post, "person", new { name = "Brian" }))) .AssertBadRequest() .AssertErrors("Name cannot be Brian."); } @@ -55,10 +55,10 @@ public void BadRequest1() [TestMethod] public void BadRequest2() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Post, "person", new { name = "Brian" }), test.Logger)) + .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Post, "person", new { name = "Brian" }))) .AssertBadRequest() .AssertErrors(new ApiError("name", "Name cannot be Brian.")); } @@ -66,10 +66,10 @@ public void BadRequest2() [TestMethod] public void ValidJson() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Rachel" }), test.Logger)) + .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Rachel" }))) .AssertOK() .AssertValue(new { FirstName = "Rachel", LastName = "Smith" }); } @@ -77,10 +77,10 @@ public void ValidJson() [TestMethod] public void ValidJsonResource() { - using var test = FunctionTester.Create().UseJsonSerializer(new Json.JsonSerializer()); + using var test = FunctionTester.Create().UseJsonSerializer(new Json.JsonSerializer()); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Rachel" }), test.Logger)) + .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Rachel" }))) .AssertOK() .AssertValueFromJsonResource("FunctionTest-ValidJsonResource.json"); } @@ -88,9 +88,9 @@ public void ValidJsonResource() [TestMethod] public void ValueVsHttpRequestObject() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() - .Run(f => f.RunWithValue(new Person { FirstName = "Rachel", LastName = "Smith" }, test.Logger)) + .Run(f => f.RunWithValue(test.CreateJsonHttpRequest(HttpMethod.Post, "people/rachel", new Person { FirstName = "Rachel", LastName = "Smith" }), "rachel")) .AssertOK() .AssertValue(new { first = "Rachel", last = "Smith" }); } @@ -98,9 +98,9 @@ public void ValueVsHttpRequestObject() [TestMethod] public void ValueVsHttpRequestContent() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() - .Run(f => f.RunWithContent(new Person { FirstName = "Rachel", LastName = "Smith" }, test.Logger)) + .Run(f => f.RunWithContent(test.CreateJsonHttpRequest(HttpMethod.Post, "people/content/rachel", new Person { FirstName = "Rachel", LastName = "Smith" }), "rachel")) .AssertOK() .AssertValue(new { first = "Rachel", last = "Smith" }); } diff --git a/tests/UnitTestEx.MSTest.Test/ProductFunctionTest.cs b/tests/UnitTestEx.MSTest.Test/ProductFunctionTest.cs index 5f6db81..9aba232 100644 --- a/tests/UnitTestEx.MSTest.Test/ProductFunctionTest.cs +++ b/tests/UnitTestEx.MSTest.Test/ProductFunctionTest.cs @@ -2,7 +2,7 @@ using System; using System.Net; using System.Net.Http; -using UnitTestEx.Function; +using UnitTestEx.IsolatedFunction; namespace UnitTestEx.MSTest.Test { @@ -16,10 +16,10 @@ public void Notfound() mcf.CreateClient("XXX", new Uri("https://d365test")) .Request(HttpMethod.Get, "products/xyz").Respond.With(HttpStatusCode.NotFound); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .HttpTrigger() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/xyz"), "xyz", test.Logger)) + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/xyz"), "xyz")) .AssertNotFound(); } @@ -30,10 +30,10 @@ public void Success() mcf.CreateClient("XXX", new Uri("https://d365test")) .Request(HttpMethod.Get, "products/abc").Respond.WithJson(new { id = "Abc", description = "A blue carrot" }); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .HttpTrigger() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "abc", test.Logger)) + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "abc")) .AssertOK() .AssertValue(new { id = "Abc", description = "A blue carrot" }); } @@ -45,10 +45,10 @@ public void Success2() mcf.CreateClient("XXX", new Uri("https://d365test")) .Request(HttpMethod.Get, "products/abc").Respond.WithJson(new { id = "Abc", description = "A blue carrot" }); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .Type() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "abc", test.Logger)) + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "abc")) .ToActionResultAssertor() .AssertOK() .AssertValue(new { id = "Abc", description = "A blue carrot" }); @@ -59,10 +59,10 @@ public void Exception() { var mcf = MockHttpClientFactory.Create(); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .HttpTrigger() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/exception"), "exception", test.Logger)) + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/exception"), "exception")) .AssertException("An unexpected exception occured."); } } diff --git a/tests/UnitTestEx.MSTest.Test/ServiceBusFunctionTest.cs b/tests/UnitTestEx.MSTest.Test/ServiceBusFunctionTest.cs index a5cd54f..02350e3 100644 --- a/tests/UnitTestEx.MSTest.Test/ServiceBusFunctionTest.cs +++ b/tests/UnitTestEx.MSTest.Test/ServiceBusFunctionTest.cs @@ -5,7 +5,7 @@ using System.Net; using System.Net.Http; using System.Text.Json; -using UnitTestEx.Function; +using UnitTestEx.IsolatedFunction; using UnitTestEx.Json; namespace UnitTestEx.MSTest.Test @@ -20,10 +20,10 @@ public void Object_Success() mcf.CreateClient("XXX", new Uri("https://somesys")) .Request(HttpMethod.Post, "person").WithJsonBody(new { firstName = "Bob", lastName = "Smith" }).Respond.With(HttpStatusCode.OK); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .ServiceBusTrigger() - .Run(f => f.Run(new Person { FirstName = "Bob", LastName = "Smith" }, test.Logger)) + .Run(f => f.Run(new Person { FirstName = "Bob", LastName = "Smith" })) .AssertSuccess(); mcf.VerifyAll(); @@ -36,10 +36,10 @@ public void Object_HttpClientError() mcf.CreateClient("XXX", new Uri("https://somesys")) .Request(HttpMethod.Post, "person").WithJsonBody(new { firstName = "Bob", lastName = (string)null }).Respond.With(HttpStatusCode.InternalServerError); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); var r = test.ReplaceHttpClientFactory(mcf) .Type() - .Run(f => f.Run(new Person { FirstName = "Bob", LastName = (string)null }, test.Logger)) + .Run(f => f.Run(new Person { FirstName = "Bob", LastName = (string)null })) .AssertException("Response status code does not indicate success: 500 (Internal Server Error)."); mcf.VerifyAll(); @@ -50,10 +50,10 @@ public void Object_ThrowsException() { var mcf = MockHttpClientFactory.Create(); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .ServiceBusTrigger() - .Run(f => f.Run(new Person { FirstName = null, LastName = "Smith" }, test.Logger)) + .Run(f => f.Run(new Person { FirstName = null, LastName = "Smith" })) .AssertException("First name is required."); mcf.VerifyAll(); @@ -66,10 +66,10 @@ public void ServiceBusMessage_Success() mcf.CreateClient("XXX", new Uri("https://somesys")) .Request(HttpMethod.Post, "person").WithJsonBody(new { firstName = "Bob", lastName = "Smith" }).Respond.With(HttpStatusCode.OK); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .ServiceBusTrigger() - .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = "Smith" }), test.Logger)) + .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = "Smith" }))) .AssertSuccess(); mcf.VerifyAll(); @@ -82,10 +82,10 @@ public void ServiceBusMessage_HttpClientError() mcf.CreateClient("XXX", new Uri("https://somesys")) .Request(HttpMethod.Post, "person").WithJsonBody(new { firstName = "Bob", lastName = (string)null }).Respond.With(HttpStatusCode.InternalServerError); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .ServiceBusTrigger() - .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = (string)null }), test.Logger)) + .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = (string)null }))) .AssertException("Response status code does not indicate success: 500 (Internal Server Error)."); mcf.VerifyAll(); @@ -96,10 +96,10 @@ public void ServiceBusMessage_ThrowsException() { var mcf = MockHttpClientFactory.Create(); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .ServiceBusTrigger() - .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = null, LastName = "Smith" }), test.Logger)) + .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = null, LastName = "Smith" }))) .AssertException("First name is required."); mcf.VerifyAll(); @@ -111,7 +111,7 @@ public void ServiceProvider() var mcf = MockHttpClientFactory.Create(); mcf.CreateClient("XXX", new Uri("https://somesys")).Request(HttpMethod.Get, "test").Respond.With("test output"); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); var hc = test.ReplaceHttpClientFactory(mcf) .Services.GetService().CreateClient("XXX"); @@ -123,7 +123,7 @@ public void ServiceProvider() [TestMethod] public void CreateServiceBusMessage() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); var sbrm = test.CreateServiceBusMessage(new ServiceBusMessage() { Subject = "xxx" }); Assert.IsNotNull(sbrm); Assert.IsNotNull(sbrm.Subject); diff --git a/tests/UnitTestEx.MSTest.Test/UnitTestEx.MSTest.Test.csproj b/tests/UnitTestEx.MSTest.Test/UnitTestEx.MSTest.Test.csproj index 09ec4e0..d37682a 100644 --- a/tests/UnitTestEx.MSTest.Test/UnitTestEx.MSTest.Test.csproj +++ b/tests/UnitTestEx.MSTest.Test/UnitTestEx.MSTest.Test.csproj @@ -25,6 +25,7 @@ + @@ -39,7 +40,7 @@ - + diff --git a/tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs b/tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs index f6f0f93..7258e54 100644 --- a/tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs +++ b/tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs @@ -5,6 +5,8 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker.Http; +using UnitTestEx.Azure.Functions; using UnitTestEx.Expectations; using UnitTestEx.IsolatedFunction; @@ -66,6 +68,158 @@ static string Build(string value) Assert.That(results, Is.EqualTo(new[] { "first", "second" })); } + [Test] + public void HttpTrigger_RequestData_Json() + { + using var test = FunctionTester.Create(); + test.HttpTrigger() + .ExpectLogContains("Data person Jane.") + .Run(f => f.DataJson(test.CreateJsonHttpRequestData(HttpMethod.Post, "data/json", new Person("Jane", "Doe"), null))) + .AssertSuccess() + .ToHttpResponseMessageAssertor() + .AssertOK() + .AssertContentType("application/json") + .AssertNamedHeader("X-Test", "abc") + .AssertNamedHeader("Set-Cookie", "session=xyz; path=/; httponly") + .AssertValue(new Person("Doe", "Jane")); + } + + [Test] + public void HttpTrigger_RequestData_SyncText_RouteParameter() + { + using var test = FunctionTester.Create(); + test.HttpTrigger() + .Run(f => f.DataText(test.CreateHttpRequestData(HttpMethod.Get, "data/text/bob", null, null, null), "bob")) + .ToHttpResponseMessageAssertor() + .AssertAccepted() + .AssertContentType("text/plain") + .AssertContent("Hello bob"); + } + + [Test] + public void HttpTrigger_RequestData_MethodCheck() + { + using var test = FunctionTester.Create(); + var ex = Assert.Throws(() => test.HttpTrigger() + .Run(f => f.DataText(test.CreateHttpRequestData(HttpMethod.Post, "data/text/bob", null, null, null), "bob"))); + Assert.That(ex!.Message, Does.Contain("invoked using 'POST'")); + } + + [Test] + public void HttpTrigger_RequestData_RouteCheck() + { + using var test = FunctionTester.Create(); + Assert.Throws(() => test.HttpTrigger() + .Run(f => f.DataText(test.CreateHttpRequestData(HttpMethod.Get, "data/text/alice", null, null, null), "bob"))); + } + + [Test] + public void HttpTrigger_RequestData_GetHttpContext() + { + using var test = FunctionTester.Create(); + test.HttpTrigger() + .Run(f => f.DataHttpContext(test.CreateHttpRequestData(HttpMethod.Get, "data/context", null, null, null))) + .ToHttpResponseMessageAssertor() + .AssertOK() + .AssertContent("/data/context"); + } + + [Test] + public void HttpTrigger_MultiOutput() + { + using var test = FunctionTester.Create(); + test.HttpTrigger() + .Run(f => f.MultiOutput(test.CreateHttpRequestData(HttpMethod.Get, "multi-output"))) + .Output(x => x.Message) + .AssertValue("multi-output message"); + + test.HttpTrigger() + .Run(f => f.MultiOutput(test.CreateHttpRequestData(HttpMethod.Get, "multi-output"))) + .ToHttpResponseMessageAssertor() + .AssertOK() + .AssertContent("multi-output response"); + } + + [Test] + public void GenericTrigger_ServiceBus() + { + using var test = FunctionTester.Create(); + test.Trigger() + .Run(f => f.Run5(test.CreateServiceBusMessageFromValue(new Person("Jane", "Doe")))) + .AssertSuccess(); + + test.Trigger() + .Run(f => ServiceBusFunction.Run4(new Person("Jane", "Doe"))) + .AssertSuccess(); + } + + [Test] + public async Task HttpTrigger_Result_Async() + { + using var test = FunctionTester.Create(); + (await test.HttpTrigger() + .RunAsync(f => f.Result(test.CreateHttpRequest(HttpMethod.Get, "result")))) + .AssertSuccess() + .ToHttpResponseMessageAssertor() + .AssertOK() + .AssertValue(new Person("Jane", "Doe")); + } + + [Test] + public void HttpTrigger_Result_Sync() + { + using var test = FunctionTester.Create(); + test.HttpTrigger() + .Run(f => f.ResultSync(test.CreateHttpRequest(HttpMethod.Get, "result/sync"))) + .ToHttpResponseMessageAssertor() + .AssertNotFound(); + } + + [Test] + public void HttpTrigger_ActionResult_Sync() + { + using var test = FunctionTester.Create(); + test.HttpTrigger() + .Run(f => f.ActionSync(test.CreateHttpRequest(HttpMethod.Get, "action/sync"))) + .AssertOK() + .AssertValue(new Person("John", "Smith")) + .ToHttpResponseMessageAssertor() + .AssertOK() + .AssertValue(new Person("John", "Smith")); + } + + [Test] + public void HttpTrigger_ActionResult_ToHttpResponseMessage() + { + using var test = FunctionTester.Create(); + test.HttpTrigger() + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, null))) + .ToHttpResponseMessageAssertor() + .AssertOK() + .AssertContent("Welcome to Azure Functions!"); + } + + [Test] + public async Task HttpResponseData_ToHttpResponseMessage_PreservesBodyPosition() + { + using var test = FunctionTester.Create(); + var res = new TestHttpResponseData(new TestFunctionContext(test.Services)) { StatusCode = System.Net.HttpStatusCode.Created }; + res.Headers.Add("Content-Type", "text/plain"); + res.Headers.Add("X-Id", "42"); + await res.WriteStringAsync("abc"); + var pos = res.Body.Position; + + using var hrm = res.ToHttpResponseMessage(); + Assert.Multiple(async () => + { + Assert.That(hrm.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.Created)); + Assert.That(hrm.Content.Headers.ContentType!.MediaType, Is.EqualTo("text/plain")); + Assert.That(hrm.Headers.GetValues("X-Id"), Is.EqualTo(new[] { "42" })); + Assert.That(await hrm.Content.ReadAsStringAsync(), Is.EqualTo("abc")); + Assert.That(res.Body.Position, Is.EqualTo(pos)); + }); + } + public sealed record TestService(string Value); public sealed class MustNotStartService : IHostedService diff --git a/tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs b/tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs index f0c7206..f34f81d 100644 --- a/tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs +++ b/tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Json; using System.Net.Mime; using System.Text; using System.Threading.Tasks; diff --git a/tests/UnitTestEx.NUnit.Test/Other/OneOffTestSetUpTest.cs b/tests/UnitTestEx.NUnit.Test/Other/OneOffTestSetUpTest.cs index ab59d1d..64b90d3 100644 --- a/tests/UnitTestEx.NUnit.Test/Other/OneOffTestSetUpTest.cs +++ b/tests/UnitTestEx.NUnit.Test/Other/OneOffTestSetUpTest.cs @@ -29,7 +29,7 @@ public void TesterExtensions_ApiTester() [Test] public void TesterExtensions_FunctionTester() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); var bs = test.Services.GetService(); Assert.That(bs, Is.Not.Null); } diff --git a/tests/UnitTestEx.NUnit.Test/PersonFunctionTest.cs b/tests/UnitTestEx.NUnit.Test/PersonFunctionTest.cs index 0ba1e72..c2c94b7 100644 --- a/tests/UnitTestEx.NUnit.Test/PersonFunctionTest.cs +++ b/tests/UnitTestEx.NUnit.Test/PersonFunctionTest.cs @@ -1,7 +1,7 @@ -using NUnit.Framework; +using NUnit.Framework; using System.Net.Http; using System.Threading.Tasks; -using UnitTestEx.Function; +using UnitTestEx.IsolatedFunction; namespace UnitTestEx.NUnit.Test { @@ -11,10 +11,10 @@ public class PersonFunctionTest [Test] public async Task NoData() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); (await test.HttpTrigger() .WithNoRouteCheck() - .RunAsync(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person"), test.Logger))) + .RunAsync(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person")))) .AssertOK() .AssertValue("This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."); } @@ -22,10 +22,10 @@ public async Task NoData() [Test] public void QueryString() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person?name=Trevor"), test.Logger)) + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person?name=Trevor"))) .AssertOK() .AssertValue("Hello, Trevor. This HTTP triggered function executed successfully."); } @@ -33,10 +33,10 @@ public void QueryString() [Test] public void WithBody() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Jane" }), test.Logger)) + .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Jane" }))) .AssertOK() .AssertValue("Hello, Jane. This HTTP triggered function executed successfully."); } @@ -44,11 +44,11 @@ public void WithBody() [Test] public void BadRequest1() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoMethodCheck() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Delete, "person", new { name = "Brian" }), test.Logger)) + .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Delete, "person", new { name = "Brian" }))) .AssertBadRequest() .AssertErrors("Name cannot be Brian."); } @@ -56,10 +56,10 @@ public void BadRequest1() [Test] public void BadRequest2() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Post, "person", new { name = "Brian" }), test.Logger)) + .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Post, "person", new { name = "Brian" }))) .AssertBadRequest() .AssertErrors(new ApiError("name", "Name cannot be Brian.")); } @@ -67,10 +67,10 @@ public void BadRequest2() [Test] public void ValidJson() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Rachel" }), test.Logger)) + .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Rachel" }))) .AssertOK() .AssertValue(new { FirstName = "Rachel", LastName = "Smith" }); } @@ -78,10 +78,10 @@ public void ValidJson() [Test] public void ValidJsonResource() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Rachel" }), test.Logger)) + .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Rachel" }))) .AssertOK() .AssertValueFromJsonResource("FunctionTest-ValidJsonResource.json"); } @@ -89,9 +89,9 @@ public void ValidJsonResource() [Test] public void ValueVsHttpRequestObject() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() - .Run(f => f.RunWithValue(new Person { FirstName = "Rachel", LastName = "Smith" }, test.Logger)) + .Run(f => f.RunWithValue(test.CreateJsonHttpRequest(HttpMethod.Post, "people/rachel", new Person { FirstName = "Rachel", LastName = "Smith" }), "rachel")) .AssertOK() .AssertValue(new { first = "Rachel", last = "Smith" }); } @@ -99,9 +99,9 @@ public void ValueVsHttpRequestObject() [Test] public void ValueVsHttpRequestContent() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() - .Run(f => f.RunWithContent(new Person { FirstName = "Rachel", LastName = "Smith" }, test.Logger)) + .Run(f => f.RunWithContent(test.CreateJsonHttpRequest(HttpMethod.Post, "people/content/rachel", new Person { FirstName = "Rachel", LastName = "Smith" }), "rachel")) .AssertOK() .AssertValue(new { first = "Rachel", last = "Smith" }); } @@ -109,10 +109,10 @@ public void ValueVsHttpRequestContent() [Test] public void WithPathAndQueryCheck() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() - .WithRouteCheck(Azure.Functions.RouteCheckOption.Query) - .Run(f => f.RunWithQuery(test.CreateHttpRequest(HttpMethod.Get, "https://blah/api/persons?name=Damien"), "Damien", test.Logger)) + .WithRouteCheck(Azure.Functions.RouteCheckOption.Path) + .Run(f => f.RunWithQuery(test.CreateHttpRequest(HttpMethod.Get, "https://blah/api/people/Damien?name=Damien"), "Damien")) .AssertOK() .AssertValue(new { name = "Damien" }); } @@ -120,10 +120,10 @@ public void WithPathAndQueryCheck() [Test] public void WithPathAndQueryStartsWithCheck() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() - .WithRouteCheck(Azure.Functions.RouteCheckOption.QueryStartsWith) - .Run(f => f.RunWithQuery(test.CreateHttpRequest(HttpMethod.Get, "https://blah/api/persons?name=Damien&$order=name"), "Damien", test.Logger)) + .WithRouteCheck(Azure.Functions.RouteCheckOption.PathAndQueryStartsWith) + .Run(f => f.RunWithQuery(test.CreateHttpRequest(HttpMethod.Get, "https://blah/api/people/Damien?name=Damien&$order=name"), "Damien")) .AssertOK() .AssertValue(new { name = "Damien" }); } diff --git a/tests/UnitTestEx.NUnit.Test/ProductFunctionTest.cs b/tests/UnitTestEx.NUnit.Test/ProductFunctionTest.cs index aff8b1b..d74b160 100644 --- a/tests/UnitTestEx.NUnit.Test/ProductFunctionTest.cs +++ b/tests/UnitTestEx.NUnit.Test/ProductFunctionTest.cs @@ -1,13 +1,10 @@ -using Microsoft.Azure.WebJobs; -using Microsoft.Azure.WebJobs.Extensions.Timers; -using Moq; -using NUnit.Framework; +using NUnit.Framework; using System; using System.Linq; using System.Net; using System.Net.Http; using UnitTestEx.Expectations; -using UnitTestEx.Function; +using UnitTestEx.IsolatedFunction; namespace UnitTestEx.NUnit.Test { @@ -21,10 +18,10 @@ public void Notfound() mcf.CreateClient("XXX", new Uri("https://d365test")) .Request(HttpMethod.Get, "products/xyz").Respond.With(HttpStatusCode.NotFound); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .HttpTrigger() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/xyz"), "xyz", test.Logger)) + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/xyz"), "xyz")) .AssertNotFound(); } @@ -35,11 +32,11 @@ public void Success() mcf.CreateClient("XXX", new Uri("https://d365test")) .Request(HttpMethod.Get, "products/abc").Respond.WithJson(new { id = "Abc", description = "A blue carrot" }); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); var assertor =test.ReplaceHttpClientFactory(mcf) .HttpTrigger() .ExpectLogContains("C# HTTP trigger function processed a request.") - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "abc", test.Logger)) + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "abc")) .AssertOK() .AssertValue(new { id = "Abc", description = "A blue carrot" }); @@ -53,10 +50,10 @@ public void Success2() mcf.CreateClient("XXX", new Uri("https://d365test")) .Request(HttpMethod.Get, "products/abc").Respond.WithJson(new { id = "Abc", description = "A blue carrot" }); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .Type() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person/abc"), "abc", test.Logger)) + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person/abc"), "abc")) .ToActionResultAssertor() .AssertOK() .AssertValue(new { id = "Abc", description = "A blue carrot" }); @@ -67,21 +64,21 @@ public void Exception() { var mcf = MockHttpClientFactory.Create(); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .HttpTrigger() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/exception"), "exception", test.Logger)) + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/exception"), "exception")) .AssertException("An unexpected exception occured."); } [Test] public void TimerTriggered() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.Type() .ExpectLogContains("(DI)") .ExpectLogContains("(method)") - .Run(f => f.DailyRun(new TimerInfo(new DailySchedule("2:00:00"), It.IsAny(), false), test.Logger)) + .Run(f => f.DailyRun(default!)) .AssertSuccess(); } } diff --git a/tests/UnitTestEx.NUnit.Test/ServiceBusFunctionTest.cs b/tests/UnitTestEx.NUnit.Test/ServiceBusFunctionTest.cs index e84337f..056856c 100644 --- a/tests/UnitTestEx.NUnit.Test/ServiceBusFunctionTest.cs +++ b/tests/UnitTestEx.NUnit.Test/ServiceBusFunctionTest.cs @@ -6,7 +6,7 @@ using System.Net; using System.Net.Http; using System.Text.Json; -using UnitTestEx.Function; +using UnitTestEx.IsolatedFunction; using UnitTestEx.Json; namespace UnitTestEx.NUnit.Test @@ -21,10 +21,10 @@ public void Object_Success() mcf.CreateClient("XXX", new Uri("https://somesys")) .Request(HttpMethod.Post, "person").WithJsonBody(new { firstName = "Bob", lastName = "Smith" }).Respond.With(HttpStatusCode.OK); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .ServiceBusTrigger() - .Run(f => f.Run(new Person { FirstName = "Bob", LastName = "Smith" }, test.Logger)) + .Run(f => f.Run(new Person { FirstName = "Bob", LastName = "Smith" })) .AssertSuccess(); mcf.VerifyAll(); @@ -37,10 +37,10 @@ public void Object_HttpClientError() mcf.CreateClient("XXX", new Uri("https://somesys")) .Request(HttpMethod.Post, "person").WithJsonBody(new { firstName = "Bob", lastName = (string)null }).Respond.With(HttpStatusCode.InternalServerError); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .Type() - .Run(f => f.Run(new Person { FirstName = "Bob", LastName = (string)null }, test.Logger)) + .Run(f => f.Run(new Person { FirstName = "Bob", LastName = (string)null })) .AssertException("Response status code does not indicate success: 500 (Internal Server Error)."); mcf.VerifyAll(); @@ -51,10 +51,10 @@ public void Object_ThrowsException() { var mcf = MockHttpClientFactory.Create(); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .ServiceBusTrigger() - .Run(f => f.Run(new Person { FirstName = null, LastName = "Smith" }, test.Logger)) + .Run(f => f.Run(new Person { FirstName = null, LastName = "Smith" })) .AssertException("First name is required."); mcf.VerifyAll(); @@ -67,10 +67,10 @@ public void ServiceBusMessage_Success() mcf.CreateClient("XXX", new Uri("https://somesys")) .Request(HttpMethod.Post, "person").WithJsonBody(new { firstName = "Bob", lastName = "Smith" }).Respond.With(HttpStatusCode.OK); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .ServiceBusTrigger() - .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = "Smith" }), test.Logger)) + .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = "Smith" }))) .AssertSuccess(); mcf.VerifyAll(); @@ -83,10 +83,10 @@ public void ServiceBusMessage_HttpClientError() mcf.CreateClient("XXX", new Uri("https://somesys")) .Request(HttpMethod.Post, "person").WithJsonBody(new { firstName = "Bob", lastName = (string)null }).Respond.With(HttpStatusCode.InternalServerError); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); var r = test.ReplaceHttpClientFactory(mcf) .ServiceBusTrigger() - .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = (string)null }), test.Logger)) + .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = (string)null }))) .AssertException("Response status code does not indicate success: 500 (Internal Server Error)."); mcf.VerifyAll(); @@ -97,10 +97,10 @@ public void ServiceBusMessage_ThrowsException() { var mcf = MockHttpClientFactory.Create(); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .ServiceBusTrigger() - .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = null, LastName = "Smith" }), test.Logger)) + .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = null, LastName = "Smith" }))) .AssertException("First name is required."); mcf.VerifyAll(); @@ -109,12 +109,12 @@ public void ServiceBusMessage_ThrowsException() [Test] public void ServiceBusMessage_AssertActions() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); var msg = test.CreateServiceBusMessageFromValue(new Person { FirstName = null, LastName = "Smith" }); - var act = test.CreateWebJobsServiceBusMessageActions(); + var act = test.CreateWorkerServiceBusMessageActions(); test.ServiceBusTrigger() - .Run(f => f.Run3(msg, act, test.Logger)) + .Run(f => f.Run3(msg, act)) .AssertSuccess(); act.AssertDeadLetter("Validation error", "First name is required"); @@ -126,7 +126,7 @@ public void ServiceProvider() var mcf = MockHttpClientFactory.Create(); mcf.CreateClient("XXX", new Uri("https://somesys")).Request(HttpMethod.Get, "test").Respond.With("test output"); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); var hc = test.ReplaceHttpClientFactory(mcf) .Services.GetService().CreateClient("XXX"); @@ -138,7 +138,7 @@ public void ServiceProvider() [Test] public void Configuration() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); var cv = test.Configuration.GetValue("SpecialKey"); Assert.That(cv, Is.EqualTo("VerySpecialValue")); } @@ -147,7 +147,7 @@ public void Configuration() public void Configuration_Overrride() { // Demonstrates how to override the configuration settings for a test. - using var test = FunctionTester.Create(additionalConfiguration: [new("SpecialKey", "NotSoSpecial")]); + using var test = FunctionTester.Create(additionalConfiguration: [new("SpecialKey", "NotSoSpecial")]); var cv = test.Configuration.GetValue("SpecialKey"); Assert.That(cv, Is.EqualTo("NotSoSpecial")); } @@ -156,7 +156,7 @@ public void Configuration_Overrride() public void Configuration_Overrride_Use() { // Demonstrates how to override the configuration settings for a test. - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.UseAdditionalConfiguration([new("SpecialKey", "NotSoSpecial")]); var cv = test.Configuration.GetValue("SpecialKey"); Assert.That(cv, Is.EqualTo("NotSoSpecial")); diff --git a/tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj b/tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj index 7ff832a..0aa6d8c 100644 --- a/tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj +++ b/tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj @@ -28,6 +28,7 @@ + @@ -46,7 +47,6 @@ - diff --git a/tests/UnitTestEx.Xunit.Test/MockHttpClientTest.cs b/tests/UnitTestEx.Xunit.Test/MockHttpClientTest.cs index 710055d..607a88b 100644 --- a/tests/UnitTestEx.Xunit.Test/MockHttpClientTest.cs +++ b/tests/UnitTestEx.Xunit.Test/MockHttpClientTest.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Net; using System.Net.Http; +using System.Net.Http.Json; using System.Net.Mime; using System.Text; using System.Threading.Tasks; diff --git a/tests/UnitTestEx.Xunit.Test/PersonFunctionTest.cs b/tests/UnitTestEx.Xunit.Test/PersonFunctionTest.cs index 92eb39e..7a872da 100644 --- a/tests/UnitTestEx.Xunit.Test/PersonFunctionTest.cs +++ b/tests/UnitTestEx.Xunit.Test/PersonFunctionTest.cs @@ -1,6 +1,6 @@ -using System.Net.Http; +using System.Net.Http; using System.Threading.Tasks; -using UnitTestEx.Function; +using UnitTestEx.IsolatedFunction; using Xunit; using Xunit.Abstractions; @@ -13,10 +13,10 @@ public PersonFunctionTest(ITestOutputHelper output) : base(output) { } [Fact] public async Task NoData() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); (await test.HttpTrigger() .WithNoRouteCheck() - .RunAsync(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person"), test.Logger))) + .RunAsync(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person")))) .AssertOK() .AssertContent("This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."); } @@ -24,10 +24,10 @@ public async Task NoData() [Fact] public void QueryString() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person?name=Trevor"), test.Logger)) + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person?name=Trevor"))) .AssertOK() .AssertContent("Hello, Trevor. This HTTP triggered function executed successfully."); } @@ -35,10 +35,10 @@ public void QueryString() [Fact] public void WithBody() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Jane" }), test.Logger)) + .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Jane" }))) .AssertOK() .AssertContent("Hello, Jane. This HTTP triggered function executed successfully."); } @@ -46,10 +46,10 @@ public void WithBody() [Fact] public void BadRequest1() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Post, "person", new { name = "Brian" }), test.Logger)) + .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Post, "person", new { name = "Brian" }))) .AssertBadRequest() .AssertErrors("Name cannot be Brian."); } @@ -57,10 +57,10 @@ public void BadRequest1() [Fact] public void BadRequest2() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Post, "person", new { name = "Brian" }), test.Logger)) + .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Post, "person", new { name = "Brian" }))) .AssertBadRequest() .AssertErrors(new ApiError("name", "Name cannot be Brian.")); } @@ -68,10 +68,10 @@ public void BadRequest2() [Fact] public void BadRequest4() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Post, "person", new { name = "Bruce" }), test.Logger)) + .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Post, "person", new { name = "Bruce" }))) .AssertBadRequest() .AssertErrors("Name cannot be Bruce."); } @@ -79,10 +79,10 @@ public void BadRequest4() [Fact] public void ValidJson() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Rachel" }), test.Logger)) + .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Rachel" }))) .AssertOK() .AssertValue(new { FirstName = "Rachel", LastName = "Smith" }); } @@ -90,10 +90,10 @@ public void ValidJson() [Fact] public void ValidJsonResource() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() .WithNoRouteCheck() - .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Rachel" }), test.Logger)) + .Run(f => f.Run(test.CreateJsonHttpRequest(HttpMethod.Get, "person", new { name = "Rachel" }))) .AssertOK() .AssertValueFromJsonResource("FunctionTest-ValidJsonResource.json"); } @@ -101,9 +101,9 @@ public void ValidJsonResource() [Fact] public void ValueVsHttpRequestObject() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() - .Run(f => f.RunWithValue(new Person { FirstName = "Rachel", LastName = "Smith" }, test.Logger)) + .Run(f => f.RunWithValue(test.CreateJsonHttpRequest(HttpMethod.Post, "people/rachel", new Person { FirstName = "Rachel", LastName = "Smith" }), "rachel")) .AssertOK() .AssertValue(new { first = "Rachel", last = "Smith" }); } @@ -111,9 +111,9 @@ public void ValueVsHttpRequestObject() [Fact] public void ValueVsHttpRequestContent() { - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.HttpTrigger() - .Run(f => f.RunWithContent(new Person { FirstName = "Rachel", LastName = "Smith" }, test.Logger)) + .Run(f => f.RunWithContent(test.CreateJsonHttpRequest(HttpMethod.Post, "people/content/rachel", new Person { FirstName = "Rachel", LastName = "Smith" }), "rachel")) .AssertOK() .AssertValue(new { first = "Rachel", last = "Smith" }); } diff --git a/tests/UnitTestEx.Xunit.Test/ProductFunctionTest.cs b/tests/UnitTestEx.Xunit.Test/ProductFunctionTest.cs index 0cffded..c317b57 100644 --- a/tests/UnitTestEx.Xunit.Test/ProductFunctionTest.cs +++ b/tests/UnitTestEx.Xunit.Test/ProductFunctionTest.cs @@ -1,7 +1,7 @@ using System; using System.Net; using System.Net.Http; -using UnitTestEx.Function; +using UnitTestEx.IsolatedFunction; using UnitTestEx.Xunit; using Xunit; using Xunit.Abstractions; @@ -19,10 +19,10 @@ public void Notfound() mcf.CreateClient("XXX", new Uri("https://d365test")) .Request(HttpMethod.Get, "products/xyz").Respond.With(HttpStatusCode.NotFound); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .HttpTrigger() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/xyz"), "xyz", test.Logger)) + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/xyz"), "xyz")) .AssertNotFound(); } @@ -33,10 +33,10 @@ public void Success() mcf.CreateClient("XXX", new Uri("https://d365test")) .Request(HttpMethod.Get, "products/abc").Respond.WithJson(new { id = "Abc", description = "A blue carrot" }); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .HttpTrigger() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "abc", test.Logger)) + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "abc")) .AssertOK() .AssertValue(new { id = "Abc", description = "A blue carrot" }); } @@ -48,10 +48,10 @@ public void Success2() mcf.CreateClient("XXX", new Uri("https://d365test")) .Request(HttpMethod.Get, "products/abc").Respond.WithJson(new { id = "Abc", description = "A blue carrot" }); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .Type() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "abc", test.Logger)) + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "abc")) .ToActionResultAssertor() .AssertOK() .AssertValue(new { id = "Abc", description = "A blue carrot" }); @@ -62,10 +62,10 @@ public void Exception() { var mcf = MockHttpClientFactory.Create(); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .HttpTrigger() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/exception"), "exception", test.Logger)) + .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "product/exception"), "exception")) .AssertException("An unexpected exception occured."); } } diff --git a/tests/UnitTestEx.Xunit.Test/ServiceBusFunctionTest.cs b/tests/UnitTestEx.Xunit.Test/ServiceBusFunctionTest.cs index 6a93bb8..1ce5fa5 100644 --- a/tests/UnitTestEx.Xunit.Test/ServiceBusFunctionTest.cs +++ b/tests/UnitTestEx.Xunit.Test/ServiceBusFunctionTest.cs @@ -4,7 +4,7 @@ using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; -using UnitTestEx.Function; +using UnitTestEx.IsolatedFunction; using UnitTestEx.Json; using Xunit; using Xunit.Abstractions; @@ -22,10 +22,10 @@ public void Object_Success() mcf.CreateClient("XXX", new Uri("https://somesys")) .Request(HttpMethod.Post, "person").WithJsonBody(new { firstName = "Bob", lastName = "Smith" }).Respond.With(HttpStatusCode.OK); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .ServiceBusTrigger() - .Run(f => f.Run(new Person { FirstName = "Bob", LastName = "Smith" }, test.Logger)) + .Run(f => f.Run(new Person { FirstName = "Bob", LastName = "Smith" })) .AssertSuccess(); mcf.VerifyAll(); @@ -38,10 +38,10 @@ public void Object_HttpClientError() mcf.CreateClient("XXX", new Uri("https://somesys")) .Request(HttpMethod.Post, "person").WithJsonBody(new { firstName = "Bob", lastName = (string)null }).Respond.With(HttpStatusCode.InternalServerError); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); var r = test.ReplaceHttpClientFactory(mcf) .Type() - .Run(f => f.Run(new Person { FirstName = "Bob", LastName = (string)null }, test.Logger)) + .Run(f => f.Run(new Person { FirstName = "Bob", LastName = (string)null })) .AssertException("Response status code does not indicate success: 500 (Internal Server Error)."); mcf.VerifyAll(); @@ -52,10 +52,10 @@ public void Object_ThrowsException() { var mcf = MockHttpClientFactory.Create(); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .ServiceBusTrigger() - .Run(f => f.Run(new Person { FirstName = null, LastName = "Smith" }, test.Logger)) + .Run(f => f.Run(new Person { FirstName = null, LastName = "Smith" })) .AssertException("First name is required."); mcf.VerifyAll(); @@ -68,10 +68,10 @@ public void ServiceBusMessage_Success() mcf.CreateClient("XXX", new Uri("https://somesys")) .Request(HttpMethod.Post, "person").WithJsonBody(new { firstName = "Bob", lastName = "Smith" }).Respond.With(HttpStatusCode.OK); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .ServiceBusTrigger() - .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = "Smith" }), test.Logger)) + .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = "Smith" }))) .AssertSuccess(); mcf.VerifyAll(); @@ -84,10 +84,10 @@ public void ServiceBusMessage_HttpClientError() mcf.CreateClient("XXX", new Uri("https://somesys")) .Request(HttpMethod.Post, "person").WithJsonBody(new { firstName = "Bob", lastName = (string)null }).Respond.With(HttpStatusCode.InternalServerError); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .ServiceBusTrigger() - .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = (string)null }), test.Logger)) + .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = (string)null }))) .AssertException("Response status code does not indicate success: 500 (Internal Server Error)."); mcf.VerifyAll(); @@ -98,10 +98,10 @@ public void ServiceBusMessage_ThrowsException() { var mcf = MockHttpClientFactory.Create(); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); test.ReplaceHttpClientFactory(mcf) .ServiceBusTrigger() - .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = null, LastName = "Smith" }), test.Logger)) + .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = null, LastName = "Smith" }))) .AssertException("First name is required."); mcf.VerifyAll(); @@ -113,7 +113,7 @@ public async Task ServiceProvider() var mcf = MockHttpClientFactory.Create(); mcf.CreateClient("XXX", new Uri("https://somesys")).Request(HttpMethod.Get, "test").Respond.With("test output"); - using var test = FunctionTester.Create(); + using var test = FunctionTester.Create(); var hc = test.ReplaceHttpClientFactory(mcf) .Services.GetService().CreateClient("XXX"); diff --git a/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj b/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj index 2d4fff3..52501bc 100644 --- a/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj +++ b/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj @@ -25,6 +25,7 @@ + @@ -43,7 +44,7 @@ - + From 0d1be138d7b5993cd744875ab73bf10f5041c60f Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 28 Oct 2026 17:51:41 -0700 Subject: [PATCH 03/18] v6: add middleware and Event Grid support Add worker middleware testing, Event Grid helpers, HTTP no-body matching, and expand documentation and coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 17 +- README.md | 561 +++--------------- docs/api-testing.md | 94 +++ docs/aspire.md | 198 +++++++ docs/assertions-and-expectations.md | 81 +++ docs/azure-functions.md | 221 +++++++ .../dependency-injection-and-configuration.md | 70 +++ docs/generic-testing.md | 72 +++ docs/http-client-mocking.md | 227 +++++++ docs/migration-v6.md | 62 ++ docs/test-setup-and-extensibility.md | 66 +++ .../Aspire/HttpMock/AspireHttpMockRequest.cs | 20 + .../Azure/Functions/FunctionPipeline.cs | 407 +++++++++++++ .../Azure/Functions/FunctionTesterBase.cs | 20 +- .../Azure/Functions/HttpTriggerTester.cs | 3 + .../Functions/ServiceBusTriggerTester.cs | 5 +- .../Azure/Functions/TestFunctionContext.cs | 7 + .../Azure/Functions/TriggerTester.cs | 3 + .../EventGridExtensionMethods.cs | 71 +++ .../UnitTestEx.Azure.Functions.csproj | 1 + .../strong-name-key.snk | Bin 596 -> 0 bytes src/UnitTestEx/Hosting/HostTesterBase.cs | 13 +- .../Mocking/HttpMockResourceConfig.cs | 10 +- src/UnitTestEx/Mocking/IHttpMockRequest.cs | 7 + .../Mocking/MockHttpClientRequest.cs | 30 +- src/UnitTestEx/Schema/mock.unittestex.json | 3 +- .../AspireTesterTest.cs | 27 + .../AspireTesterTest-mock.unittestex.yaml | 5 + tests/UnitTestEx.IsolatedFunction/Program.cs | 1 + .../TestMiddleware.cs | 67 +++ .../Other/JsonElementComparerTest.cs | 14 +- .../EventGridExtensionMethodsTest.cs | 88 +++ .../IsolatedFunctionTest.cs | 93 +++ .../Other/DependencyInjectionTest.cs | 60 ++ .../Other/DocumentationLinksTest.cs | 70 +++ .../HttpMockInterfaceTest.cs | 26 + ...HttpMockInterfaceTest-mock.unittestex.yaml | 5 + 37 files changed, 2212 insertions(+), 513 deletions(-) create mode 100644 docs/api-testing.md create mode 100644 docs/aspire.md create mode 100644 docs/assertions-and-expectations.md create mode 100644 docs/azure-functions.md create mode 100644 docs/dependency-injection-and-configuration.md create mode 100644 docs/generic-testing.md create mode 100644 docs/http-client-mocking.md create mode 100644 docs/migration-v6.md create mode 100644 docs/test-setup-and-extensibility.md create mode 100644 src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionPipeline.cs create mode 100644 src/UnitTestEx.Azure.Functions/EventGridExtensionMethods.cs delete mode 100644 src/UnitTestEx.Azure.ServiceBus/strong-name-key.snk create mode 100644 tests/UnitTestEx.IsolatedFunction/TestMiddleware.cs create mode 100644 tests/UnitTestEx.NUnit.Test/EventGridExtensionMethodsTest.cs create mode 100644 tests/UnitTestEx.NUnit.Test/Other/DependencyInjectionTest.cs create mode 100644 tests/UnitTestEx.NUnit.Test/Other/DocumentationLinksTest.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ed11020..24920a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,26 @@ 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:* `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. +- *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: ^` for that. On Aspire, `GET`/`HEAD` (or an unspecified method) apply no body constraint, as WireMock.Net does not read those bodies by default. +- *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/README.md b/README.md index 490cce0..0058594 100644 --- a/README.md +++ b/README.md @@ -6,162 +6,38 @@ ## 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). - -``` csharp -using var test = ApiTester.Create(); -test.ReplaceHttpClientFactory(mcf) - .Controller() - .Run(c => c.Get("abc")) - .AssertOK() - .Assert(new { id = "Abc", description = "A blue carrot" }); -``` +The framework package (`UnitTestEx.NUnit`, `UnitTestEx.MSTest` or `UnitTestEx.Xunit`) wires output and assertions into your test framework.
-## HTTP-triggered Azure Function +## Quick start -`FunctionTester` supports the Azure Functions v4 isolated worker model on .NET 8, 9 and 10, including the modern `FunctionsApplication.CreateBuilder` / `IHostApplicationBuilder` startup model. It executes the application's normal entry point to build its host, applies test configuration and service overrides, then invokes the function method directly. The worker is not started, so this is not a Functions runtime or binding-emulator test. - -The following is an [example](./tests/UnitTestEx.NUnit.Test/ProductFunctionTest.cs). - -``` 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" }); -``` - -HTTP functions can use ASP.NET Core `HttpRequest` with `IActionResult`/`IResult`, or worker-native `HttpRequestData` with `HttpResponseData`. Both synchronous and asynchronous result methods are supported. HTTP responses can be asserted through `HttpResponseMessageAssertor`; worker-native response bodies, headers, status codes and test cookies are adapted to `HttpResponseMessage`. - -Multi-output functions can assert their HTTP result and non-HTTP outputs separately: - -```csharp -test.HttpTrigger() - .Run(f => f.Run(test.CreateHttpRequestData(HttpMethod.Get, "route"))) - .Output(x => x.Message).AssertValue("expected message"); - -test.HttpTrigger() - .Run(f => f.Run(test.CreateHttpRequestData(HttpMethod.Get, "route"))) - .ToHttpResponseMessageAssertor() - .AssertOK(); -``` - -By default, the HTTP tester validates the request method and route against the worker `HttpTriggerAttribute`. `WithNoMethodCheck`, `WithNoRouteCheck` and `WithRouteCheck` configure those checks. - -
- -## Service Bus-trigger Azure Function - -The Service Bus tester supports isolated-worker `ServiceBusTriggerAttribute` functions and worker `ServiceBusMessageActions`: - -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" }))) - .AssertSuccess(); -``` - -For triggers without a dedicated helper, `Trigger()` provides generic sync/async invocation, log capture, expectations and value assertions. It supports instance and static methods and requires a worker trigger attribute on the invoked method: - -```csharp -test.Trigger() - .Run(f => f.Run5(test.CreateServiceBusMessageFromValue(new Person("Jane", "Doe")))) - .AssertSuccess(); -``` - -
- -## Generic Azure Function Type - -To test a function type directly without validating a trigger attribute, use the general-purpose `Type` tester. - -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" }))) - .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(); @@ -173,387 +49,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 +## Capabilities -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. +Each row links to the docs and to a test that shows it working. -The following is an [example](./tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs). - -``` 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) +Any other trigger (Timer, Blob, Queue, ...) | Function | [`TimerTriggered`](./tests/UnitTestEx.NUnit.Test/ProductFunctionTest.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 - -_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. +## Documentation -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/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..60510b5 --- /dev/null +++ b/docs/aspire.md @@ -0,0 +1,198 @@ +# 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" }); +``` + +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`). + +_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) +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) + +[← 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..cc97340 --- /dev/null +++ b/docs/assertions-and-expectations.md @@ -0,0 +1,81 @@ +# 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. + +
+ +## 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. + +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..fe2b287 --- /dev/null +++ b/docs/azure-functions.md @@ -0,0 +1,221 @@ +# 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) + +
+ +## 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). + +
+ +## 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. + +
+ +## 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). + +### 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 the event. They do not emulate Event Grid delivery, retries or filtering. To verify real end-to-end delivery, use [Aspire testing](./aspire.md). + +Example: [`EventGridEvent_FromValue`](../tests/UnitTestEx.NUnit.Test/EventGridExtensionMethodsTest.cs), [`CloudEvent_FromResource`](../tests/UnitTestEx.NUnit.Test/EventGridExtensionMethodsTest.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. + +``` 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: [`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 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. + +[← 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..cd37080 --- /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, `GET`/`HEAD` (or an unspecified method) apply no body constraint, as WireMock.Net does not read those bodies by default. +`WithAnyBody()` | Any body, including none. +`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 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, so `WithNoBody()` applies no body constraint for those methods (or where the method is unspecified). + +
+ +
+ +## 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/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs b/src/UnitTestEx.Aspire/Aspire/HttpMock/AspireHttpMockRequest.cs index 1f20cab..da7907c 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; @@ -70,6 +71,22 @@ public AspireHttpMockRequest Times(Times times) /// The to continue the fluent-style configuration. public AspireHttpMockRequestBody WithAnyBody() => 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), where any body rule would + /// therefore never match; as such, no body constraint is applied for these methods, or where the method is unspecified. + public AspireHttpMockRequestBody WithNoBody() + { + Rule.Body = Rule.Methods is null || Rule.Methods.Any(m => string.Equals(m, HttpMethod.Get.Method, StringComparison.OrdinalIgnoreCase) || string.Equals(m, HttpMethod.Head.Method, StringComparison.OrdinalIgnoreCase)) + ? null + : new BodyModel { Matcher = new MatcherModel { Name = "NotNullOrEmptyMatcher", RejectOnMatch = true } }; + + return new(this); + } + /// /// Indicates that the request body must match the specified exactly. /// @@ -239,6 +256,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.Azure.Functions/Azure/Functions/FunctionPipeline.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionPipeline.cs new file mode 100644 index 0000000..25e86dc --- /dev/null +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionPipeline.cs @@ -0,0 +1,407 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Azure.Functions.Worker.Invocation; +using Microsoft.Azure.Functions.Worker.Middleware; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; +using UnitTestEx.Abstractions; + +namespace UnitTestEx.Azure.Functions +{ + /// + /// Provides the internal function tester options. + /// + internal interface IFunctionTesterOptions + { + /// + /// Indicates whether the function is executed within the application's isolated worker middleware pipeline. + /// + bool UseWorkerMiddleware { get; } + } + + /// + /// Executes a function method within the application's own isolated worker middleware pipeline (as registered via UseMiddleware, UseWhen, etc.). + /// + /// The pipeline is the application's ; the terminal FunctionExecutionMiddleware is redirected (via the feature) to the test's method invocation. + /// Internal worker contracts that have no public equivalent (IFunctionBindingsFeature and the ASP.NET Core integration IHttpCoordinator) are implemented using a ; i.e. no private state is accessed. + internal static class FunctionPipeline + { + internal const string HttpContextItemKey = "UnitTestEx.HttpContext"; + private const string HttpCoordinatorTypeName = "Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.IHttpCoordinator"; + private const string BindingsFeatureTypeName = "Microsoft.Azure.Functions.Worker.Context.Features.IFunctionBindingsFeature"; + private const string OutputBindingsInfoProviderTypeName = "Microsoft.Azure.Functions.Worker.OutputBindings.IOutputBindingsInfoProvider"; + + /// + /// Replaces the ASP.NET Core integration IHttpCoordinator (where registered) as there is no HTTP proxy request to coordinate with; the test is provided directly. + /// + public static void ConfigureServices(IServiceCollection services) + { + var descriptors = services.Where(x => x.ServiceType.FullName == HttpCoordinatorTypeName).ToArray(); + if (descriptors.Length == 0) + return; + + foreach (var sd in descriptors) + services.Remove(sd); + + var type = descriptors[0].ServiceType; + services.AddSingleton(type, _ => DispatchProxy.Create(type, typeof(HttpCoordinatorProxy))); + } + + /// + /// Invokes the function method within the worker middleware pipeline. + /// + public static async Task InvokeAsync(TesterBase owner, IServiceProvider services, MethodInfo method, object?[] arguments, Func> invoke) + { + var pipeline = owner is IFunctionTesterOptions { UseWorkerMiddleware: false } ? null : services.GetService(); + if (pipeline is null) + return await invoke().ConfigureAwait(false); + + var context = arguments.OfType().FirstOrDefault() + ?? arguments.OfType().Select(x => x.FunctionContext).OfType().FirstOrDefault() + ?? new TestFunctionContext(services); + + context.InstanceServices = services; + context.TestFunctionDefinition ??= TestFunctionDefinition.Create(method); + + var bindings = CreateBindingsFeature(services, context); + var executor = new TestFunctionExecutor(invoke, TestFunctionDefinition.HasReturnValue(method), bindings); + context.Features.Set(executor); + + var requestData = arguments.OfType().FirstOrDefault(); + if (requestData is not null) + context.Features.Set(new HttpRequestDataFeature(requestData)); + + var httpContext = arguments.OfType().FirstOrDefault()?.HttpContext ?? (requestData as TestHttpRequestData)?.HttpRequest.HttpContext; + object? priorHttpContext = null; + if (httpContext is not null) + { + httpContext.RequestServices = services; + if (httpContext.Response.Body == Stream.Null) + httpContext.Response.Body = new MemoryStream(); + + // The ASP.NET Core integration middleware adds (not sets) the HttpContext item; remove to avoid a duplicate key, restoring after where not re-added. + context.Items[HttpContextItemKey] = httpContext; + if (context.Items.Remove(TestHttpRequestData.HttpContextKey, out priorHttpContext) is false) + priorHttpContext = null; + } + + try + { + await pipeline(context).ConfigureAwait(false); + } + finally + { + context.Items.Remove(HttpContextItemKey); + if (priorHttpContext is not null) + context.Items.TryAdd(TestHttpRequestData.HttpContextKey, priorHttpContext); + } + + if (bindings.InvocationResult is TValue value) + return value; + + // The ASP.NET Core integration clears an HttpResponseData invocation result once written to the HttpContext response. + if (bindings.LastHttpResponseData is TValue hrd) + return hrd; + + if (executor.Completed) + return executor.Result!; + + if (httpContext is not null && typeof(TValue).IsAssignableFrom(typeof(HttpResponseReplayResult))) + return (TValue)(object)HttpResponseReplayResult.Create(httpContext.Response); + + if (executor.Exception is not null) + return default!; + + throw new InvalidOperationException($"Function method '{method.DeclaringType?.Name}.{method.Name}' was not executed; the worker middleware pipeline short-circuited the invocation without setting a compatible invocation result."); + } + + /// + /// Creates the IFunctionBindingsFeature and adds to the . + /// + private static FunctionBindingsProxy CreateBindingsFeature(IServiceProvider services, TestFunctionContext context) + { + var coreAssembly = typeof(FunctionExecutionDelegate).Assembly; + var featureType = coreAssembly.GetType(BindingsFeatureTypeName, throwOnError: true)!; + var proxy = (FunctionBindingsProxy)DispatchProxy.Create(featureType, typeof(FunctionBindingsProxy)); + + var providerType = coreAssembly.GetType(OutputBindingsInfoProviderTypeName, throwOnError: true)!; + var provider = services.GetService(providerType); + proxy.OutputBindingsInfo = provider is null ? null : providerType.GetMethod("GetBindingsInfo")!.Invoke(provider, [context.FunctionDefinition]); + + context.SetFeature(featureType, proxy); + return proxy; + } + + /// + /// Provides the that invokes the test method. + /// + private sealed class TestFunctionExecutor(Func> invoke, bool hasReturnValue, FunctionBindingsProxy bindings) : IFunctionExecutor + { + public bool Completed { get; private set; } + + public TValue? Result { get; private set; } + + public Exception? Exception { get; private set; } + + public async ValueTask ExecuteAsync(FunctionContext context) + { + try + { + Result = await invoke().ConfigureAwait(false); + Completed = true; + if (hasReturnValue) + bindings.InvocationResult = Result; + } + catch (TargetInvocationException tie) when (tie.InnerException is not null) + { + Exception = tie.InnerException; + ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); + } + catch (Exception ex) + { + Exception = ex; + throw; + } + } + } + + /// + /// Provides the for the test . + /// + private sealed class HttpRequestDataFeature(HttpRequestData requestData) : IHttpRequestDataFeature + { + public ValueTask GetHttpRequestDataAsync(FunctionContext context) => ValueTask.FromResult(requestData); + } + } + + /// + /// Provides the IFunctionBindingsFeature (internal worker contract) implementation. + /// + internal class FunctionBindingsProxy : DispatchProxy + { + private static readonly IReadOnlyDictionary _empty = new ReadOnlyDictionary(new Dictionary()); + + private object? _invocationResult; + + public object? InvocationResult + { + get => _invocationResult; + set + { + _invocationResult = value; + if (value is HttpResponseData hrd) + LastHttpResponseData = hrd; + } + } + + public HttpResponseData? LastHttpResponseData { get; private set; } + + public object? OutputBindingsInfo { get; set; } + + public IDictionary OutputBindingData { get; } = new Dictionary(); + + /// + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) => targetMethod?.Name switch + { + "get_InvocationResult" => InvocationResult, + "set_InvocationResult" => InvocationResult = args![0], + "get_OutputBindingData" => OutputBindingData, + "get_OutputBindingsInfo" => OutputBindingsInfo, + "get_TriggerMetadata" or "get_InputData" => _empty, + _ => throw new NotSupportedException($"UnitTestEx does not support the worker '{targetMethod?.DeclaringType?.Name}.{targetMethod?.Name}' member.") + }; + } + + /// + /// Provides the ASP.NET Core integration IHttpCoordinator (internal worker contract) implementation; returns the test directly. + /// + internal class HttpCoordinatorProxy : DispatchProxy + { + /// + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + switch (targetMethod?.Name) + { + case "SetFunctionContextAsync": + var context = (FunctionContext)args![1]!; + var httpContext = context.Items.TryGetValue(FunctionPipeline.HttpContextItemKey, out var hc) && hc is HttpContext h ? h : new DefaultHttpContext { RequestServices = context.InstanceServices }; + if (httpContext.Response.Body == Stream.Null) + httpContext.Response.Body = new MemoryStream(); + + return Task.FromResult(httpContext); + + case "CompleteFunctionInvocation": + return null; + + default: + throw new NotSupportedException($"UnitTestEx does not support the worker '{targetMethod?.DeclaringType?.Name}.{targetMethod?.Name}' member."); + } + } + } + + /// + /// Provides a inferred from the function method and its binding attributes. + /// + internal sealed class TestFunctionDefinition : FunctionDefinition + { + public override ImmutableArray Parameters { get; } = []; + + public override string PathToAssembly { get; } = string.Empty; + + public override string EntryPoint { get; } = string.Empty; + + public override string Id { get; } = string.Empty; + + public override string Name { get; } = string.Empty; + + public override IImmutableDictionary InputBindings { get; } = ImmutableDictionary.Empty; + + public override IImmutableDictionary OutputBindings { get; } = ImmutableDictionary.Empty; + + private TestFunctionDefinition(string name, string entryPoint, string pathToAssembly, ImmutableArray parameters, IImmutableDictionary inputs, IImmutableDictionary outputs) + { + Name = name; + Id = name; + EntryPoint = entryPoint; + PathToAssembly = pathToAssembly; + Parameters = parameters; + InputBindings = inputs; + OutputBindings = outputs; + } + + /// + /// Creates the from the . + /// + public static FunctionDefinition Create(MethodInfo method) + { + var inputs = ImmutableDictionary.CreateBuilder(); + foreach (var p in method.GetParameters()) + { + var ba = p.GetCustomAttributes().FirstOrDefault(a => a is InputBindingAttribute or TriggerBindingAttribute); + if (ba is not null && p.Name is not null) + inputs[p.Name] = new TestBindingMetadata(p.Name, GetBindingType(ba.GetType()), BindingDirection.In); + } + + var outputs = ImmutableDictionary.CreateBuilder(); + var returnType = GetReturnType(method); + var returnBinding = method.ReturnParameter.GetCustomAttributes().Concat(method.GetCustomAttributes()).FirstOrDefault(a => a is OutputBindingAttribute); + if (returnBinding is not null) + outputs["$return"] = new TestBindingMetadata("$return", GetBindingType(returnBinding.GetType()), BindingDirection.Out); + else if (returnType is not null && IsHttpResponseType(returnType)) + outputs["$return"] = new TestBindingMetadata("$return", "http", BindingDirection.Out); + else if (returnType is not null) + { + foreach (var pi in returnType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var pa = pi.GetCustomAttributes().FirstOrDefault(a => a is OutputBindingAttribute || a.GetType().Name == "HttpResultAttribute"); + if (pa is not null) + outputs[pi.Name] = new TestBindingMetadata(pi.Name, GetBindingType(pa.GetType()), BindingDirection.Out); + else if (typeof(HttpResponseData).IsAssignableFrom(pi.PropertyType)) + outputs[pi.Name] = new TestBindingMetadata(pi.Name, "http", BindingDirection.Out); + } + } + + var name = method.GetCustomAttribute()?.Name ?? method.Name; + var parameters = method.GetParameters().Select(p => new FunctionParameter(p.Name ?? string.Empty, p.ParameterType)).ToImmutableArray(); + return new TestFunctionDefinition(name, $"{method.DeclaringType?.FullName}.{method.Name}", method.DeclaringType?.Assembly.Location ?? string.Empty, parameters, inputs.ToImmutable(), outputs.ToImmutable()); + } + + /// + /// Indicates whether the returns a value (i.e. not void or ). + /// + public static bool HasReturnValue(MethodInfo method) => GetReturnType(method) is not null; + + private static Type? GetReturnType(MethodInfo method) + { + var type = method.ReturnType; + if (type == typeof(void) || type == typeof(Task) || type == typeof(ValueTask)) + return null; + + if (type.IsGenericType && (type.GetGenericTypeDefinition() == typeof(Task<>) || type.GetGenericTypeDefinition() == typeof(ValueTask<>))) + return type.GetGenericArguments()[0]; + + return type; + } + + private static bool IsHttpResponseType(Type type) => typeof(HttpResponseData).IsAssignableFrom(type) || typeof(IActionResult).IsAssignableFrom(type) || typeof(IResult).IsAssignableFrom(type); + + /// + /// Gets the binding type (as would be generated within the function metadata) from the attribute type name; e.g. 'HttpTriggerAttribute' is 'httpTrigger', and 'BlobInputAttribute' is 'blob'. + /// + private static string GetBindingType(Type attributeType) + { + var name = attributeType.Name; + if (name.EndsWith("Attribute", StringComparison.Ordinal)) + name = name[..^"Attribute".Length]; + + if (name == "HttpResult") + return "http"; + + if (name.EndsWith("Input", StringComparison.Ordinal)) + name = name[..^"Input".Length]; + else if (name.EndsWith("Output", StringComparison.Ordinal)) + name = name[..^"Output".Length]; + + return name.Length == 0 ? name : char.ToLowerInvariant(name[0]) + name[1..]; + } + + private sealed class TestBindingMetadata(string name, string type, BindingDirection direction) : BindingMetadata + { + public override string Name { get; } = name; + + public override string Type { get; } = type; + + public override BindingDirection Direction { get; } = direction; + } + } + + /// + /// Provides an and that replays a captured ; i.e. where worker middleware writes the response directly without invoking the function. + /// + internal sealed class HttpResponseReplayResult : IActionResult, IResult + { + private int _statusCode; + private readonly List> _headers = []; + private byte[] _body = []; + + /// + /// Creates the result from the . + /// + public static HttpResponseReplayResult Create(HttpResponse response) + { + var r = new HttpResponseReplayResult { _statusCode = response.StatusCode }; + r._headers.AddRange(response.Headers); + if (response.Body is MemoryStream ms) + r._body = ms.ToArray(); + + return r; + } + + /// + public Task ExecuteResultAsync(ActionContext context) => ExecuteAsync(context.HttpContext); + + /// + public async Task ExecuteAsync(HttpContext httpContext) + { + httpContext.Response.StatusCode = _statusCode; + foreach (var h in _headers) + httpContext.Response.Headers[h.Key] = h.Value; + + if (_body.Length > 0) + await httpContext.Response.Body.WriteAsync(_body).ConfigureAwait(false); + } + } +} diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs index fae8140..d5cc995 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs @@ -18,7 +18,7 @@ namespace UnitTestEx.Azure.Functions ///
/// A type from the isolated-worker application assembly. /// The to support inheriting fluent-style method-chaining. - public abstract class FunctionTesterBase : TesterBase, IDisposable where TEntryPoint : class where TSelf : FunctionTesterBase + public abstract class FunctionTesterBase : TesterBase, IFunctionTesterOptions, IDisposable where TEntryPoint : class where TSelf : FunctionTesterBase { private static readonly object _lock = new(); private static readonly JsonSerializerOptions _localSettingsJsonSerializerOptions = new() { ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }; @@ -28,6 +28,7 @@ public abstract class FunctionTesterBase : TesterBase private readonly bool? _includeUserSecrets; private IHost? _host; private bool _disposed; + private bool _useWorkerMiddleware = true; /// /// Initializes a new instance of the class. @@ -155,6 +156,23 @@ private void ConfigureTestServices(IServiceCollection sc) SetUp.ConfigureServices?.Invoke(sc); AddConfiguredServices(sc); + FunctionPipeline.ConfigureServices(sc); + } + + /// + /// Indicates whether the function is executed within the application's isolated worker middleware pipeline; defaults to true. + /// + bool IFunctionTesterOptions.UseWorkerMiddleware => _useWorkerMiddleware; + + /// + /// Indicates whether the function is to be executed within the application's isolated worker middleware pipeline (as registered via UseMiddleware, UseWhen, etc.); defaults to true. + /// + /// true to execute within the worker middleware pipeline; otherwise, false to invoke the function method directly. + /// The to support fluent-style method-chaining. + public TSelf UseWorkerMiddleware(bool useWorkerMiddleware = true) + { + _useWorkerMiddleware = useWorkerMiddleware; + return (TSelf)this; } /// diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs index cda710b..fad06e2 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs @@ -59,6 +59,9 @@ public HttpTriggerTester(TesterBase owner, IServiceScope serviceScope) : base(ow /// Gets the . /// public ExpectationsArranger> ExpectationsArranger { get; } + + /// + internal override Task InvokeAsync(System.Reflection.MethodInfo method, object?[] arguments, Func> invoke) => FunctionPipeline.InvokeAsync(Owner, Services, method, arguments, invoke); /// /// Indicates that no check is performed to ensure that the contain the . diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusTriggerTester.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusTriggerTester.cs index d9a9473..eeb05b3 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusTriggerTester.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusTriggerTester.cs @@ -1,4 +1,4 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using Azure.Messaging.ServiceBus; using Microsoft.Extensions.Configuration; @@ -34,6 +34,9 @@ public class ServiceBusTriggerTester : HostTesterBase, IEx /// Gets the . /// public ExpectationsArranger> ExpectationsArranger { get; } + + /// + internal override Task InvokeAsync(System.Reflection.MethodInfo method, object?[] arguments, Func> invoke) => FunctionPipeline.InvokeAsync(Owner, Services, method, arguments, invoke); /// /// Runs the isolated-worker Service Bus-triggered function expected as a parameter within the . diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/TestFunctionContext.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/TestFunctionContext.cs index 061a0e7..c3d5f14 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/TestFunctionContext.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/TestFunctionContext.cs @@ -71,6 +71,11 @@ public class TestFunctionContext(IServiceProvider instanceServices, string? func /// public override CancellationToken CancellationToken => TestCancellationToken; + /// + /// Sets a feature using a runtime ; enables internal worker feature contracts to be set. + /// + internal void SetFeature(Type type, object instance) => ((TestInvocationFeatures)Features).Set(type, instance); + /// /// Provides a simple implementation. /// @@ -88,6 +93,8 @@ public void Set(T instance) _features[typeof(T)] = instance; } + public void Set(Type type, object instance) => _features[type] = instance; + public IEnumerator> GetEnumerator() => _features.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/TriggerTester.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/TriggerTester.cs index 0dc70d2..481dec4 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/TriggerTester.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/TriggerTester.cs @@ -29,6 +29,9 @@ public TriggerTester(TesterBase owner, IServiceScope serviceScope) : base(owner, /// Gets the expectations arranger. /// public ExpectationsArranger> ExpectationsArranger { get; } + + /// + internal override Task InvokeAsync(System.Reflection.MethodInfo method, object?[] arguments, Func> invoke) => FunctionPipeline.InvokeAsync(Owner, Services, method, arguments, invoke); /// /// Runs a synchronous trigger function that returns no value. diff --git a/src/UnitTestEx.Azure.Functions/EventGridExtensionMethods.cs b/src/UnitTestEx.Azure.Functions/EventGridExtensionMethods.cs new file mode 100644 index 0000000..f93790e --- /dev/null +++ b/src/UnitTestEx.Azure.Functions/EventGridExtensionMethods.cs @@ -0,0 +1,71 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx + +using Azure.Messaging; +using Azure.Messaging.EventGrid; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using UnitTestEx.Abstractions; + +namespace UnitTestEx +{ + /// + /// Provides Event Grid event test helpers. + /// + public static class EventGridExtensionMethods + { + /// + /// Creates an whose data contains the serialized as JSON. + /// + public static EventGridEvent CreateEventGridEventFromValue(this TesterBase tester, T value, string subject, string eventType, string dataVersion = "1.0") + => CreateEventGridEventFromJson(tester, tester.JsonSerializer.Serialize(value), subject, eventType, dataVersion); + + /// + /// Creates an from JSON. + /// + public static EventGridEvent CreateEventGridEventFromJson(this TesterBase tester, [StringSyntax(StringSyntaxAttribute.Json)] string json, string subject, string eventType, string dataVersion = "1.0") + { + ArgumentNullException.ThrowIfNull(json); + return new EventGridEvent(subject, eventType, dataVersion, BinaryData.FromString(json)); + } + + /// + /// Creates an from a JSON embedded resource. + /// + public static EventGridEvent CreateEventGridEventFromResource(this TesterBase tester, string resourceName, string subject, string eventType, string dataVersion = "1.0") + => CreateEventGridEventFromResource(tester, resourceName, subject, eventType, dataVersion, typeof(TAssembly).Assembly); + + /// + /// Creates an from a JSON embedded resource. + /// + public static EventGridEvent CreateEventGridEventFromResource(this TesterBase tester, string resourceName, string subject, string eventType, string dataVersion = "1.0", Assembly? assembly = null) + => CreateEventGridEventFromJson(tester, Resource.GetJson(resourceName, assembly ?? Assembly.GetCallingAssembly()), subject, eventType, dataVersion); + + /// + /// Creates a whose data contains the serialized as JSON. + /// + public static CloudEvent CreateCloudEventFromValue(this TesterBase tester, T value, string source, string type) + => CreateCloudEventFromJson(tester, tester.JsonSerializer.Serialize(value), source, type); + + /// + /// Creates a from JSON. + /// + public static CloudEvent CreateCloudEventFromJson(this TesterBase tester, [StringSyntax(StringSyntaxAttribute.Json)] string json, string source, string type) + { + ArgumentNullException.ThrowIfNull(json); + return new CloudEvent(source, type, BinaryData.FromString(json), "application/json", CloudEventDataFormat.Json); + } + + /// + /// Creates a from a JSON embedded resource. + /// + public static CloudEvent CreateCloudEventFromResource(this TesterBase tester, string resourceName, string source, string type) + => CreateCloudEventFromResource(tester, resourceName, source, type, typeof(TAssembly).Assembly); + + /// + /// Creates a from a JSON embedded resource. + /// + public static CloudEvent CreateCloudEventFromResource(this TesterBase tester, string resourceName, string source, string type, Assembly? assembly = null) + => CreateCloudEventFromJson(tester, Resource.GetJson(resourceName, assembly ?? Assembly.GetCallingAssembly()), source, type); + } +} diff --git a/src/UnitTestEx.Azure.Functions/UnitTestEx.Azure.Functions.csproj b/src/UnitTestEx.Azure.Functions/UnitTestEx.Azure.Functions.csproj index f9848ea..1bae4a5 100644 --- a/src/UnitTestEx.Azure.Functions/UnitTestEx.Azure.Functions.csproj +++ b/src/UnitTestEx.Azure.Functions/UnitTestEx.Azure.Functions.csproj @@ -10,6 +10,7 @@ + diff --git a/src/UnitTestEx.Azure.ServiceBus/strong-name-key.snk b/src/UnitTestEx.Azure.ServiceBus/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/UnitTestEx/Hosting/HostTesterBase.cs b/src/UnitTestEx/Hosting/HostTesterBase.cs index a88e103..7793eba 100644 --- a/src/UnitTestEx/Hosting/HostTesterBase.cs +++ b/src/UnitTestEx/Hosting/HostTesterBase.cs @@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Linq.Expressions; +using System.Reflection; using System.Threading.Tasks; using UnitTestEx.Abstractions; using UnitTestEx.Json; @@ -99,7 +100,7 @@ public class HostTesterBase(TesterBase owner, IServiceProvider service try { var p = @params.Select(x => x.Value).ToArray(); - await ((Task)mce.Method.Invoke(h, p)!).ConfigureAwait(false); + await InvokeAsync(mce.Method, p, async () => { await ((Task)mce.Method.Invoke(h, p)!).ConfigureAwait(false); return true; }).ConfigureAwait(false); sw.Stop(); return (null, sw.Elapsed.TotalMilliseconds); } @@ -186,7 +187,7 @@ public class HostTesterBase(TesterBase owner, IServiceProvider service try { - var mr = await invoker(h!).ConfigureAwait(false); + var mr = await InvokeAsync(mce.Method, @params.Select(x => x.Value).ToArray(), () => invoker(h!)).ConfigureAwait(false); sw.Stop(); return (mr, null, sw.Elapsed.TotalMilliseconds); } @@ -202,6 +203,14 @@ public class HostTesterBase(TesterBase owner, IServiceProvider service } } + /// + /// Wraps the actual method invocation; enables a derived tester to execute within a host-specific pipeline (e.g. Azure Functions worker middleware). + /// + /// The method being invoked. + /// The method arguments. + /// The function that performs the actual method invocation. + internal virtual Task InvokeAsync(MethodInfo method, object?[] arguments, Func> invoke) => invoke(); + /// /// Represents the on before run delegate. /// diff --git a/src/UnitTestEx/Mocking/HttpMockResourceConfig.cs b/src/UnitTestEx/Mocking/HttpMockResourceConfig.cs index 223efff..01832ed 100644 --- a/src/UnitTestEx/Mocking/HttpMockResourceConfig.cs +++ b/src/UnitTestEx/Mocking/HttpMockResourceConfig.cs @@ -19,10 +19,8 @@ namespace UnitTestEx.Mocking /// Uses the same YAML/JSON schema as Tier 1's native, synchronous (see src/UnitTestEx/Schema/mock.unittestex.json), /// but is implemented purely against the shared /// abstractions - /// so the same embedded resource works identically against Tier 1's and Tier 2/3's AspireHttpMockClient, without either tier reimplementing the parsing. - /// One intentional behavioral difference versus Tier 1's native method: where a request entry omits body entirely, Tier 1's native method matches only a request with no body - /// (content must be absent); this shared implementation instead matches any body (equivalent to body: ^), via - the shared interface has no - /// means to express "body must be absent", and this is both consistent with 's own true-wildcard semantics and almost always what's actually wanted - /// (e.g. a bodyless GET matches either way). + /// Where a request entry omits body entirely, the request must have no body (absent or zero-length; see ), consistent with Tier 1's native method; + /// use body: ^ to match any body. /// Response header values are applied via repeated / calls, one per /// configured value; Tier 1 accumulates multiple values per header name, whereas Aspire's WireMock.Net-backed response currently retains only the last value written for a given header name. internal static class HttpMockResourceConfig @@ -101,8 +99,10 @@ public async Task AddAsync(IHttpMockClient client, CancellationToken cancellatio ? request.WithJsonBody(Body, Ignore ?? []) : request.WithBody(Body, media); } + else if (Body == "^") + body = request.WithAnyBody(); else - body = request.WithAnyBody(); // Also covers the omitted-body case; see remarks on this class for the resulting (intentional) behavioral difference from Tier 1's native method. + body = request.WithNoBody(); if (Response is not null && Sequence is not null) throw new InvalidOperationException($"A mocked request can not contain both a {nameof(Response)} and a {nameof(Sequence)} as they are mutually exclusive."); diff --git a/src/UnitTestEx/Mocking/IHttpMockRequest.cs b/src/UnitTestEx/Mocking/IHttpMockRequest.cs index a4aee14..68de6ca 100644 --- a/src/UnitTestEx/Mocking/IHttpMockRequest.cs +++ b/src/UnitTestEx/Mocking/IHttpMockRequest.cs @@ -32,6 +32,13 @@ public interface IHttpMockRequest /// The to continue the fluent-style configuration. IHttpMockRequestBody WithAnyBody(); + /// + /// 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 both tiers deliberately treat them identically. + IHttpMockRequestBody WithNoBody(); + /// /// Indicates that the request body must match the specified exactly, with the specified . /// diff --git a/src/UnitTestEx/Mocking/MockHttpClientRequest.cs b/src/UnitTestEx/Mocking/MockHttpClientRequest.cs index 39f1aaa..57470d7 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientRequest.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientRequest.cs @@ -183,7 +183,7 @@ private bool RequestPredicate(HttpRequestMessage request) return true; if (_mediaType == null) - return request.Content == null; + return IsEmptyContent(request.Content); if (request?.Content == null) return false; @@ -215,6 +215,20 @@ private bool RequestPredicate(HttpRequestMessage request) return body == _content?.ToString(); } + /// + /// Indicates whether the is absent or zero-length; these are indistinguishable on the wire (both send Content-Length: 0), so are treated identically to be consistent with Aspire. + /// + private static bool IsEmptyContent(HttpContent? content) + { + if (content is null) + return true; + + if (content.Headers.ContentLength is long length) + return length == 0; + + return content.ReadAsByteArrayAsync().GetAwaiter().GetResult().Length == 0; + } + /// public override string ToString() { @@ -267,6 +281,17 @@ public MockHttpClientRequestBody WithAnyBody() return new MockHttpClientRequestBody(Rule); } + /// + /// Indicates that the request must have no body; i.e. the content is absent or zero-length. + /// + /// The resulting to accordingly. + /// This is also the default where no body is configured (i.e. is used directly). + public MockHttpClientRequestBody WithNoBody() + { + Reset(); + return new MockHttpClientRequestBody(Rule); + } + /// /// Provides the expected request body as (). /// @@ -422,6 +447,9 @@ public void Verify() /// IHttpMockRequestBody IHttpMockRequest.WithAnyBody() => WithAnyBody(); + /// + IHttpMockRequestBody IHttpMockRequest.WithNoBody() => WithNoBody(); + /// IHttpMockRequestBody IHttpMockRequest.WithBody(string body, string mediaType) => WithBody(body, mediaType); diff --git a/src/UnitTestEx/Schema/mock.unittestex.json b/src/UnitTestEx/Schema/mock.unittestex.json index e213302..2508637 100644 --- a/src/UnitTestEx/Schema/mock.unittestex.json +++ b/src/UnitTestEx/Schema/mock.unittestex.json @@ -17,7 +17,8 @@ }, "body": { "type": "string", - "title": "The HTTP Request Body (content)." + "title": "The HTTP Request Body (content).", + "description": "Where omitted the request must have no body (absent or zero-length); use '^' to match any body." }, "media": { "type": "string", diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs index 1992164..28583d6 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs +++ b/tests/UnitTestEx.Aspire.Xunit.Test/AspireTesterTest.cs @@ -647,6 +647,33 @@ public async Task HttpMock_WithRequestsFromResourceAsync_LoadsStubsFromYaml_Iden tester.Http("mockhost").Run(HttpMethod.Post, "/resource-config/echo", new { id = "Abc", stamp = Guid.NewGuid().ToString() }) .AssertAccepted() .AssertValue(new { id = "Abc", description = "matched" }); + + // An omitted 'body' means no body (absent or zero-length), consistent with Tier 1; a request with a body is unmatched (WireMock.Net returns 404). + using var hc = ((IHttpClientSource)tester).CreateHttpClient("mockhost"); + Assert.Equal(HttpStatusCode.NoContent, (await hc.PostAsync("/resource-config/no-body", null)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await hc.PostAsync("/resource-config/no-body", new StringContent("x"))).StatusCode); + } + + [Fact] + public async Task HttpMock_WithNoBody_MatchesAbsentOrEmptyBodyOnly() + { + await using var tester = AspireTester.Create(); + + await tester.WaitForResourceAsync("mockhost"); + + IHttpMockClient client = tester.HttpMock("mockhost"); + var stub = await client.Request(HttpMethod.Post, "/interface/no-body") + .Times(Times.Exactly(3)) + .WithNoBody() + .Respond.WithAsync(HttpStatusCode.NoContent); + + using var hc = ((IHttpClientSource)tester).CreateHttpClient("mockhost"); + Assert.Equal(HttpStatusCode.NoContent, (await hc.PostAsync("/interface/no-body", null)).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await hc.PostAsync("/interface/no-body", new StringContent(""))).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await hc.PostAsync("/interface/no-body", new ByteArrayContent([]))).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await hc.PostAsync("/interface/no-body", new StringContent("x"))).StatusCode); + + await stub.VerifyAsync(); } [Fact] diff --git a/tests/UnitTestEx.Aspire.Xunit.Test/Resources/AspireTesterTest-mock.unittestex.yaml b/tests/UnitTestEx.Aspire.Xunit.Test/Resources/AspireTesterTest-mock.unittestex.yaml index 6c2fe2f..58278c6 100644 --- a/tests/UnitTestEx.Aspire.Xunit.Test/Resources/AspireTesterTest-mock.unittestex.yaml +++ b/tests/UnitTestEx.Aspire.Xunit.Test/Resources/AspireTesterTest-mock.unittestex.yaml @@ -14,3 +14,8 @@ status: 202 body: | {"id":"Abc","description":"matched"} + +- method: post + uri: /resource-config/no-body + response: + status: 204 diff --git a/tests/UnitTestEx.IsolatedFunction/Program.cs b/tests/UnitTestEx.IsolatedFunction/Program.cs index 4ff7df7..a489972 100644 --- a/tests/UnitTestEx.IsolatedFunction/Program.cs +++ b/tests/UnitTestEx.IsolatedFunction/Program.cs @@ -5,6 +5,7 @@ var builder = FunctionsApplication.CreateBuilder(args); builder.ConfigureFunctionsWebApplication(); +builder.UseMiddleware(); builder.Services.AddHttpClient("XXX", client => client.BaseAddress = new Uri("https://somesys")); builder.Configuration.AddInMemoryCollection([new KeyValuePair("SpecialKey", "VerySpecialValue")]); builder.Build().Run(); diff --git a/tests/UnitTestEx.IsolatedFunction/TestMiddleware.cs b/tests/UnitTestEx.IsolatedFunction/TestMiddleware.cs new file mode 100644 index 0000000..d0868cd --- /dev/null +++ b/tests/UnitTestEx.IsolatedFunction/TestMiddleware.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Azure.Functions.Worker.Middleware; +using Microsoft.Extensions.Logging; +using System.Net; + +namespace UnitTestEx.IsolatedFunction +{ + /// + /// Test middleware: always logs; behaviour is otherwise opt-in via the X-Middleware request header (deny or handle). + /// + public sealed class TestMiddleware : IFunctionsWorkerMiddleware + { + public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next) + { + var logger = context.GetLogger(); + logger.LogInformation("Middleware before {FunctionName}.", context.FunctionDefinition.Name); + + var req = await context.GetHttpRequestDataAsync(); + var mode = req is not null && req.Headers.TryGetValues("X-Middleware", out var values) ? values.FirstOrDefault() : null; + + if (mode == "deny") + { + await SetResponseAsync(context, req!, HttpStatusCode.Unauthorized, "denied by middleware"); + return; + } + + try + { + await next(context); + } + catch (Exception ex) when (mode == "handle") + { + logger.LogWarning("Middleware handled {Message}.", ex.InnerException?.Message ?? ex.Message); + await SetResponseAsync(context, req!, HttpStatusCode.InternalServerError, "handled by middleware"); + return; + } + + logger.LogInformation("Middleware after {FunctionName}.", context.FunctionDefinition.Name); + } + + private static async Task SetResponseAsync(FunctionContext context, HttpRequestData req, HttpStatusCode statusCode, string body) + { + var httpContext = context.GetHttpContext(); + if (httpContext is not null && context.FunctionDefinition.Parameters.Any(p => p.Type == typeof(HttpRequest))) + { + httpContext.Response.StatusCode = (int)statusCode; + await httpContext.Response.WriteAsync(body); + return; + } + + var res = req.CreateResponse(statusCode); + await res.WriteStringAsync(body); + context.GetInvocationResult().Value = res; + } + } + + public class MiddlewareFunctions + { + [Function("ThrowsAction")] + public Microsoft.AspNetCore.Mvc.IActionResult ThrowsAction([HttpTrigger(AuthorizationLevel.Function, "get", Route = "throws/action")] HttpRequest req) => throw new InvalidOperationException("action boom"); + + [Function("ThrowsData")] + public HttpResponseData ThrowsData([HttpTrigger(AuthorizationLevel.Function, "get", Route = "throws/data")] HttpRequestData req) => throw new InvalidOperationException("data boom"); + } +} diff --git a/tests/UnitTestEx.MSTest.Test/Other/JsonElementComparerTest.cs b/tests/UnitTestEx.MSTest.Test/Other/JsonElementComparerTest.cs index 38b86d8..c35c63f 100644 --- a/tests/UnitTestEx.MSTest.Test/Other/JsonElementComparerTest.cs +++ b/tests/UnitTestEx.MSTest.Test/Other/JsonElementComparerTest.cs @@ -21,19 +21,19 @@ public void Compare_Object_DiffOrderAndDiffNumberFormat() [TestMethod] public void Compare_Object_DiffValuesAndTypes() { - Assert.AreEqual(@"Path '$.name': Value is not equal: ""gary"" != ""brian"". -Path '$.age': Value is not equal: 40.0 != 41.0. -Path '$.cool': Kind is not equal: Null != False. -Path '$.salary': Kind is not equal: Number != Null.", + Assert.AreEqual("Path '$.name': Value is not equal: \"gary\" != \"brian\".\n" + + "Path '$.age': Value is not equal: 40.0 != 41.0.\n" + + "Path '$.cool': Kind is not equal: Null != False.\n" + + "Path '$.salary': Kind is not equal: Number != Null.", new JsonElementComparer().Compare("{\"name\":\"gary\",\"age\":40.0,\"cool\":null,\"salary\":42000}", "{\"name\":\"brian\",\"age\":41.0,\"cool\":false,\"salary\":null}").ToString()); } [TestMethod] public void Compare_Object_PropertyNameMismatch() { - Assert.AreEqual(@"Path '$.name': Does not exist in right JSON. -Path '$.salary': Does not exist in right JSON. -Path '$.Name': Does not exist in left JSON.", + Assert.AreEqual("Path '$.name': Does not exist in right JSON.\n" + + "Path '$.salary': Does not exist in right JSON.\n" + + "Path '$.Name': Does not exist in left JSON.", new JsonElementComparer().Compare("{\"name\":\"gary\",\"age\":40.0,\"cool\":false,\"salary\":null}", "{\"Name\":\"gary\",\"age\":40.0,\"cool\":false}").ToString()); } diff --git a/tests/UnitTestEx.NUnit.Test/EventGridExtensionMethodsTest.cs b/tests/UnitTestEx.NUnit.Test/EventGridExtensionMethodsTest.cs new file mode 100644 index 0000000..a7b2894 --- /dev/null +++ b/tests/UnitTestEx.NUnit.Test/EventGridExtensionMethodsTest.cs @@ -0,0 +1,88 @@ +using Azure.Messaging; +using Azure.Messaging.EventGrid; +using NUnit.Framework; +using UnitTestEx.Azure.Functions; +using UnitTestEx.IsolatedFunction; + +namespace UnitTestEx.NUnit.Test +{ + [TestFixture] + public class EventGridExtensionMethodsTest + { + [Test] + public void EventGridEvent_FromValue() + { + using var test = FunctionTester.Create(); + var value = new Person("Jane", "Doe"); + + var evt = test.CreateEventGridEventFromValue(value, "/people/1", "People.Created"); + + Assert.Multiple(() => + { + Assert.That(evt.Subject, Is.EqualTo("/people/1")); + Assert.That(evt.EventType, Is.EqualTo("People.Created")); + Assert.That(evt.DataVersion, Is.EqualTo("1.0")); + Assert.That(evt.Data.ToString(), Is.EqualTo(test.JsonSerializer.Serialize(value))); + }); + } + + [Test] + public void EventGridEvent_FromJson() + { + using var test = FunctionTester.Create(); + const string json = """{"name":"Jane"}"""; + + var evt = test.CreateEventGridEventFromJson(json, "/people/1", "People.Created"); + + Assert.That(evt.Data.ToString(), Is.EqualTo(json)); + } + + [Test] + public void EventGridEvent_FromResource() + { + using var test = FunctionTester.Create(); + + var evt = test.CreateEventGridEventFromResource("FunctionTest-ValidJsonResource.json", "/people/1", "People.Created"); + + Assert.That(evt.Data.ToString(), Is.EqualTo(Resource.GetJson("FunctionTest-ValidJsonResource.json", typeof(EventGridExtensionMethodsTest).Assembly))); + } + + [Test] + public void CloudEvent_FromValue() + { + using var test = FunctionTester.Create(); + var value = new Person("Jane", "Doe"); + + var evt = test.CreateCloudEventFromValue(value, "/people", "People.Created"); + + Assert.Multiple(() => + { + Assert.That(evt.Source, Is.EqualTo("/people")); + Assert.That(evt.Type, Is.EqualTo("People.Created")); + Assert.That(evt.DataContentType, Is.EqualTo("application/json")); + Assert.That(evt.Data?.ToString(), Is.EqualTo(test.JsonSerializer.Serialize(value))); + }); + } + + [Test] + public void CloudEvent_FromJson() + { + using var test = FunctionTester.Create(); + const string json = """{"name":"Jane"}"""; + + var evt = test.CreateCloudEventFromJson(json, "/people", "People.Created"); + + Assert.That(evt.Data?.ToString(), Is.EqualTo(json)); + } + + [Test] + public void CloudEvent_FromResource() + { + using var test = FunctionTester.Create(); + + var evt = test.CreateCloudEventFromResource("FunctionTest-ValidJsonResource.json", "/people", "People.Created"); + + Assert.That(evt.Data?.ToString(), Is.EqualTo(Resource.GetJson("FunctionTest-ValidJsonResource.json", typeof(EventGridExtensionMethodsTest).Assembly))); + } + } +} diff --git a/tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs b/tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs index 7258e54..9d1f598 100644 --- a/tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs +++ b/tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs @@ -220,6 +220,99 @@ public async Task HttpResponseData_ToHttpResponseMessage_PreservesBodyPosition() }); } + [Test] + public void Middleware_Logs_Captured_Http() + { + using var test = FunctionTester.Create(); + test.HttpTrigger() + .ExpectLogContains("Middleware before DataText.") + .ExpectLogContains("Middleware after DataText.") + .Run(f => f.DataText(test.CreateHttpRequestData(HttpMethod.Get, "data/text/bob"), "bob")) + .ToHttpResponseMessageAssertor() + .AssertAccepted(); + } + + [Test] + public void Middleware_Logs_Captured_NonHttp() + { + using var test = FunctionTester.Create(); + test.Trigger() + .ExpectLogContains("Middleware before") + .ExpectLogContains("Middleware after") + .Run(f => f.Run5(test.CreateServiceBusMessageFromValue(new Person("Jane", "Doe")))) + .AssertSuccess(); + } + + [Test] + public void Middleware_ShortCircuit_HttpResponseData() + { + using var test = FunctionTester.Create(); + var req = test.CreateHttpRequestData(HttpMethod.Get, "data/text/bob", requestModifier: r => r.Headers["X-Middleware"] = "deny"); + test.HttpTrigger() + .ExpectLogContains("Middleware before DataText.") + .Run(f => f.DataText(req, "bob")) + .ToHttpResponseMessageAssertor() + .AssertUnauthorized() + .AssertContent("denied by middleware"); + } + + [Test] + public void Middleware_ShortCircuit_ActionResult() + { + using var test = FunctionTester.Create(); + var req = test.CreateHttpRequest(HttpMethod.Get, "action/sync", r => r.Headers["X-Middleware"] = "deny"); + test.HttpTrigger() + .Run(f => f.ActionSync(req)) + .ToHttpResponseMessageAssertor() + .AssertUnauthorized() + .AssertContent("denied by middleware"); + } + + [Test] + public void Middleware_HandlesException_ActionResult() + { + using var test = FunctionTester.Create(); + var req = test.CreateHttpRequest(HttpMethod.Get, "throws/action", r => r.Headers["X-Middleware"] = "handle"); + test.HttpTrigger() + .ExpectLogContains("Middleware handled action boom.") + .Run(f => f.ThrowsAction(req)) + .ToHttpResponseMessageAssertor() + .AssertInternalServerError() + .AssertContent("handled by middleware"); + } + + [Test] + public void Middleware_HandlesException_HttpResponseData() + { + using var test = FunctionTester.Create(); + var req = test.CreateHttpRequestData(HttpMethod.Get, "throws/data", requestModifier: r => r.Headers["X-Middleware"] = "handle"); + test.HttpTrigger() + .ExpectLogContains("Middleware handled data boom.") + .Run(f => f.ThrowsData(req)) + .ToHttpResponseMessageAssertor() + .AssertInternalServerError() + .AssertContent("handled by middleware"); + } + + [Test] + public void Middleware_UnhandledException_Propagates() + { + using var test = FunctionTester.Create(); + test.HttpTrigger() + .Run(f => f.ThrowsAction(test.CreateHttpRequest(HttpMethod.Get, "throws/action"))) + .AssertException("action boom"); + } + + [Test] + public void Middleware_Bypassed() + { + using var test = FunctionTester.Create().UseWorkerMiddleware(false); + var req = test.CreateHttpRequest(HttpMethod.Get, "action/sync", r => r.Headers["X-Middleware"] = "deny"); + test.HttpTrigger() + .Run(f => f.ActionSync(req)) + .AssertOK(); + } + public sealed record TestService(string Value); public sealed class MustNotStartService : IHostedService diff --git a/tests/UnitTestEx.NUnit.Test/Other/DependencyInjectionTest.cs b/tests/UnitTestEx.NUnit.Test/Other/DependencyInjectionTest.cs new file mode 100644 index 0000000..207563f --- /dev/null +++ b/tests/UnitTestEx.NUnit.Test/Other/DependencyInjectionTest.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.DependencyInjection; +using Moq; +using NUnit.Framework; +using UnitTestEx.Generic; + +namespace UnitTestEx.NUnit.Test.Other +{ + [TestFixture] + public class DependencyInjectionTest + { + public interface IGreeter { string Greet(string name); } + + public class Greeter : IGreeter { public string Greet(string name) => $"Hello {name}"; } + + public class StubGreeter : IGreeter { public string Greet(string name) => $"Stub {name}"; } + + private static GenericTesterBase> CreateTester() => GenericTester.Create().ConfigureServices(sc => sc.AddScoped()); + + [Test] + public void ReplaceSingleton_OverridesRegistration() + { + using var test = CreateTester().ReplaceSingleton(new StubGreeter()); + test.Run(g => g.Greet("Bob")) + .AssertSuccess() + .AssertValue("Stub Bob"); + } + + [Test] + public void ReplaceScoped_OverridesRegistration() + { + // Scoped services must be resolved within a scope; Run resolves from the root provider so use ScopedType. + using var test = CreateTester().ReplaceScoped(); + test.ScopedType(st => st + .Run(g => g.Greet("Bob")) + .AssertValue("Stub Bob")); + } + + [Test] + public void MockSingleton_UsesMoqObject() + { + var mock = new Mock(); + mock.Setup(x => x.Greet("Bob")).Returns("Mocked Bob"); + + using var test = CreateTester().MockSingleton(mock); + test.Run(g => g.Greet("Bob")) + .AssertValue("Mocked Bob"); + + mock.Verify(x => x.Greet("Bob"), Times.Once); + } + + [Test] + public void ScopedType_RunsWithinServiceScope() + { + using var test = CreateTester(); + test.ScopedType(st => st + .Run(g => g.Greet("Bob")) + .AssertValue("Hello Bob")); + } + } +} diff --git a/tests/UnitTestEx.NUnit.Test/Other/DocumentationLinksTest.cs b/tests/UnitTestEx.NUnit.Test/Other/DocumentationLinksTest.cs new file mode 100644 index 0000000..22424e5 --- /dev/null +++ b/tests/UnitTestEx.NUnit.Test/Other/DocumentationLinksTest.cs @@ -0,0 +1,70 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +namespace UnitTestEx.NUnit.Test.Other +{ + /// + /// Guards the README and docs against stale relative links; where the link text is a backticked identifier pointing at a .cs file, the identifier must exist within that file. + /// + [TestFixture] + public partial class DocumentationLinksTest + { + [GeneratedRegex(@"\[(?[^\]]*)\]\((?[^)\s]+)\)")] + private static partial Regex LinkRegex(); + + [Test] + public void RelativeLinks_Resolve() + { + var root = FindRepoRoot(); + var files = new List { Path.Combine(root, "README.md") }; + var docs = Path.Combine(root, "docs"); + if (Directory.Exists(docs)) + files.AddRange(Directory.GetFiles(docs, "*.md", SearchOption.AllDirectories)); + + var errors = new List(); + foreach (var file in files) + { + var content = File.ReadAllText(file); + foreach (Match m in LinkRegex().Matches(content)) + { + var target = m.Groups["target"].Value; + if (target.StartsWith('#') || target.Contains("://") || target.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase)) + continue; + + var path = target.Split('#')[0]; + var full = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(file)!, path.Replace('/', Path.DirectorySeparatorChar))); + var rel = Path.GetRelativePath(root, file); + + if (!File.Exists(full) && !Directory.Exists(full)) + { + errors.Add($"{rel}: '{target}' does not exist."); + continue; + } + + var text = m.Groups["text"].Value; + if (full.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) && text.Length > 2 && text.StartsWith('`') && text.EndsWith('`')) + { + var identifier = text.Trim('`'); + if (Regex.IsMatch(identifier, @"^\w+$") && !File.ReadAllText(full).Contains(identifier, StringComparison.Ordinal)) + errors.Add($"{rel}: '{identifier}' not found in '{target}'."); + } + } + } + + Assert.That(errors, Is.Empty, string.Join(Environment.NewLine, errors)); + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "UnitTestEx.sln"))) + dir = dir.Parent; + + return dir?.FullName ?? throw new InvalidOperationException("Unable to locate the repository root (UnitTestEx.sln)."); + } + } +} diff --git a/tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs b/tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs index 16a7628..185c5d4 100644 --- a/tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs +++ b/tests/UnitTestEx.Xunit.Test/HttpMockInterfaceTest.cs @@ -44,6 +44,26 @@ public async Task Interface_Request_WithBody_TextDim_MatchesPlainTextBody() await stub.VerifyAsync(); } + [Fact] + public async Task Interface_Request_WithNoBody_MatchesAbsentOrEmptyBodyOnly() + { + var mcf = MockHttpClientFactory.Create(); + IHttpMockClient client = mcf.CreateClient("XXX", new System.Uri("https://d365test")); + + // Moq evaluates the most recent matching setup first, so this any-body fallback only handles requests that the no-body stub rejects. + var fallback = await client.Request(HttpMethod.Post, "no-body").Times(Times.Once()).WithAnyBody().Respond.WithAsync(HttpStatusCode.BadRequest); + var stub = await client.Request(HttpMethod.Post, "no-body").Times(Times.Exactly(3)).WithNoBody().Respond.WithAsync(HttpStatusCode.NoContent); + + var hc = mcf.GetHttpClient("XXX"); + Assert.Equal(HttpStatusCode.NoContent, (await hc.PostAsync("no-body", null)).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await hc.PostAsync("no-body", new StringContent(""))).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await hc.PostAsync("no-body", new ByteArrayContent([]))).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await hc.PostAsync("no-body", new StringContent("x"))).StatusCode); + + await stub.VerifyAsync(); + await fallback.VerifyAsync(); + } + [Fact] public async Task Interface_Request_WithJsonResourceBody_Dim_MatchesResourceContent() { @@ -126,10 +146,16 @@ public async Task Interface_Client_WithRequestsFromResourceAsync_Dim_LoadsStubsF var mcf = MockHttpClientFactory.Create(); IHttpMockClient client = mcf.CreateClient("XXX", new System.Uri("https://d365test")); + // Moq evaluates the most recent matching setup first, so this any-body fallback only handles requests that the YAML's omitted-body (no body) stub rejects. + await client.Request(HttpMethod.Post, "no-body").WithAnyBody().Respond.WithAsync(HttpStatusCode.BadRequest); + await client.WithRequestsFromResourceAsync("HttpMockInterfaceTest-mock.unittestex.yaml"); // DIM: shared YAML/JSON resource loader. var hc = mcf.GetHttpClient("XXX"); + Assert.Equal(HttpStatusCode.NoContent, (await hc.PostAsync("no-body", null)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await hc.PostAsync("no-body", new StringContent("x"))).StatusCode); + var res1 = await hc.PostAsJsonAsync("products/xyz", new { product = "xyz", quantity = 1, stamp = "does-not-matter" }); Assert.Equal(HttpStatusCode.Accepted, res1.StatusCode); Assert.Equal("{\"product\":\"xyz\",\"quantity\":1}", (await res1.Content.ReadAsStringAsync()).Trim()); diff --git a/tests/UnitTestEx.Xunit.Test/Resources/HttpMockInterfaceTest-mock.unittestex.yaml b/tests/UnitTestEx.Xunit.Test/Resources/HttpMockInterfaceTest-mock.unittestex.yaml index c32d28b..e6bdded 100644 --- a/tests/UnitTestEx.Xunit.Test/Resources/HttpMockInterfaceTest-mock.unittestex.yaml +++ b/tests/UnitTestEx.Xunit.Test/Resources/HttpMockInterfaceTest-mock.unittestex.yaml @@ -28,3 +28,8 @@ - status: 200 body: | {"seq":2} + +- method: post + uri: no-body + response: + status: 204 From 181e144e881895dca1a0b647cf007ae18646c12b Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Thu, 29 Oct 2026 09:46:26 -0700 Subject: [PATCH 04/18] Improve Azure Functions and Aspire testing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 3 + README.md | 2 +- docs/aspire.md | 65 ++++++++++ docs/azure-functions.md | 20 ++- .../Aspire/AspireTesterBase.cs | 117 ++++++++++++++++++ .../Aspire/FunctionsWebhookClient.cs | 110 ++++++++++++++++ .../UnitTestEx.Aspire.csproj | 1 + src/UnitTestEx.Aspire/WithAspireTester.cs | 64 ++++++++++ .../Azure/Functions/FunctionHostFactory.cs | 4 + .../Azure/Functions/FunctionPipeline.cs | 22 ++++ .../Azure/Functions/HttpTriggerTester.cs | 6 + .../Azure/Functions/TriggerTester.cs | 30 +++++ .../EventGridExtensionMethods.cs | 37 ++++++ src/UnitTestEx/Abstractions/TesterBaseCore.cs | 59 +++++++++ src/UnitTestEx/Resource.cs | 3 + .../AppHost.cs | 26 ++++ ...UnitTestEx.Aspire.Functions.AppHost.csproj | 22 ++++ .../AspireTesterTest.cs | 112 ++++++++++------- .../Functions/AzuriteBlobTriggerTest.cs | 103 +++++++++++++++ .../UnitTestEx.Aspire.NUnit.Test.csproj | 3 + .../StorageTriggerFunctions.cs | 22 ++++ .../TestMiddleware.cs | 7 ++ .../UnitTestEx.IsolatedFunction.csproj | 2 + .../EventGridExtensionMethodsTest.cs | 23 ++++ .../Other/DependencyInjectionTest.cs | 4 + .../Other/DocumentationLinksTest.cs | 7 ++ .../Other/EventuallyAsyncTest.cs | 68 ++++++++++ .../Other/ExpectationsTest.cs | 4 + .../StorageTriggerTest.cs | 41 ++++++ 29 files changed, 939 insertions(+), 48 deletions(-) create mode 100644 src/UnitTestEx.Aspire/Aspire/FunctionsWebhookClient.cs create mode 100644 src/UnitTestEx.Aspire/WithAspireTester.cs create mode 100644 tests/UnitTestEx.Aspire.Functions.AppHost/AppHost.cs create mode 100644 tests/UnitTestEx.Aspire.Functions.AppHost/UnitTestEx.Aspire.Functions.AppHost.csproj create mode 100644 tests/UnitTestEx.Aspire.NUnit.Test/Functions/AzuriteBlobTriggerTest.cs create mode 100644 tests/UnitTestEx.IsolatedFunction/StorageTriggerFunctions.cs create mode 100644 tests/UnitTestEx.NUnit.Test/Other/EventuallyAsyncTest.cs create mode 100644 tests/UnitTestEx.NUnit.Test/StorageTriggerTest.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dba2709..eb0c370 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,9 @@ jobs: 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 diff --git a/README.md b/README.md index 0058594..7121988 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Multi-output bindings | Function | [`HttpTrigger_MultiOutput`](./tests/UnitTestE 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) -Any other trigger (Timer, Blob, Queue, ...) | Function | [`TimerTriggered`](./tests/UnitTestEx.NUnit.Test/ProductFunctionTest.cs), [`GenericTrigger_ServiceBus`](./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) diff --git a/docs/aspire.md b/docs/aspire.md index 60510b5..6fc7730 100644 --- a/docs/aspire.md +++ b/docs/aspire.md @@ -23,6 +23,26 @@ tester.Http("shopping") .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. + 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 @@ -172,6 +192,50 @@ tester.AssertLogContains("Order 123 processed successfully."); // Only sees acti 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 + +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(); +``` + +`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)); +``` + +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. Run it with `dotnet test tests/UnitTestEx.Aspire.NUnit.Test/UnitTestEx.Aspire.NUnit.Test.csproj --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. + _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. @@ -194,5 +258,6 @@ Stubbing external dependencies (WireMock) | [`HttpMock_StubsExternalMockHostDepe 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) [← Back to README](../README.md) diff --git a/docs/azure-functions.md b/docs/azure-functions.md index fe2b287..51de896 100644 --- a/docs/azure-functions.md +++ b/docs/azure-functions.md @@ -153,7 +153,7 @@ test.Trigger() .AssertSuccess(); ``` -These helpers only create the event. They do not emulate Event Grid delivery, retries or filtering. To verify real end-to-end delivery, use [Aspire testing](./aspire.md). +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). @@ -163,6 +163,22 @@ Example: [`EventGridEvent_FromValue`](../tests/UnitTestEx.NUnit.Test/EventGridEx 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. @@ -174,7 +190,7 @@ test.Type() .AssertSuccess(); ``` -Example: [`GenericTrigger_ServiceBus`](../tests/UnitTestEx.NUnit.Test/IsolatedFunctionTest.cs), [`TimerTriggered`](../tests/UnitTestEx.NUnit.Test/ProductFunctionTest.cs). +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).
diff --git a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs index b2c05c8..e216ea7 100644 --- a/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs +++ b/src/UnitTestEx.Aspire/Aspire/AspireTesterBase.cs @@ -3,6 +3,7 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Testing; +using Azure.Messaging.EventGrid; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -14,6 +15,7 @@ using System.Linq; using System.Net.Http; using System.Text; +using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -300,6 +302,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 +545,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 +740,9 @@ public void FlushPending() } } + /// + /// Flushes the pending log entry to the buffer; the caller must hold _lock. + /// private void FlushPendingNoLock() { if (_pendingTimestamp is null) diff --git a/src/UnitTestEx.Aspire/Aspire/FunctionsWebhookClient.cs b/src/UnitTestEx.Aspire/Aspire/FunctionsWebhookClient.cs new file mode 100644 index 0000000..d21588b --- /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 = "UnitTestEx", + subject = eventGridEvent.Subject, + eventType = eventGridEvent.EventType, + eventTime = eventGridEvent.EventTime, + dataVersion = eventGridEvent.DataVersion, + metadataVersion = "1", + data = data.RootElement.Clone() + }; + } + } +} diff --git a/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj b/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj index da3d40c..286c8fe 100644 --- a/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj +++ b/src/UnitTestEx.Aspire/UnitTestEx.Aspire.csproj @@ -10,6 +10,7 @@ +