From 72a4ab5967dbaf5baac26af91e0f2563af706f35 Mon Sep 17 00:00:00 2001 From: "Eric Sibly [chullybun]" Date: Thu, 7 Mar 2024 06:33:51 -0800 Subject: [PATCH] v4.2.0 (#70) * WithConfigurations and WithoutMocking * Additional internal tweaks. * Finalize. * Correct spelling. * Fix grammar. * Fix test to run as github action --- CHANGELOG.md | 5 + Common.targets | 2 +- README.md | 41 ++- .../UnitTestEx.MSTest.csproj | 2 +- src/UnitTestEx.NUnit/UnitTestEx.NUnit.csproj | 2 +- src/UnitTestEx.Xunit/UnitTestEx.Xunit.csproj | 2 +- src/UnitTestEx/AspNetCore/HttpTesterBase.cs | 3 +- src/UnitTestEx/ExtensionMethods.cs | 30 +-- src/UnitTestEx/Json/JsonSerializer.cs | 30 ++- src/UnitTestEx/Logging/LoggerBase.cs | 3 +- src/UnitTestEx/Mocking/MockHttpClient.cs | 250 ++++++++++++++++-- .../Mocking/MockHttpClientFactory.cs | 44 ++- .../Mocking/MockHttpClientHandler.cs | 5 +- .../Mocking/MockHttpClientRequest.cs | 9 +- .../Mocking/MockHttpClientRequestBody.cs | 2 +- .../Mocking/MockHttpClientResponse.cs | 6 +- .../Mocking/MockHttpClientResponseSequence.cs | 2 +- src/UnitTestEx/UnitTestEx.csproj | 12 +- tests/UnitTestEx.Api/Startup.cs | 19 +- .../UnitTestEx.Function.csproj | 2 +- .../MockHttpClientTest.cs | 17 ++ .../ProductControllerTest.cs | 64 ++++- .../UnitTestEx.MSTest.Test.csproj | 2 +- .../ProductControllerTest.cs | 1 - .../UnitTestEx.NUnit.Test.csproj | 2 +- .../UnitTestEx.Xunit.Test.csproj | 4 +- 26 files changed, 475 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d3ff73..90bcd07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Represents the **NuGet** versions. +## v4.2.0 +- *Enhancement:* Any configuration specified as part of 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. See [`README`](./README.md#http-client-configurations) for more details on capabilities introduced; highlights are: + - New `MockHttpClient.WithConfigurations` method indicates that the `HttpMessageHandler` and `HttpClient` configurations are to be used. + - New `MockHttpClient.WithoutMocking` method 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 will allow the mixing of real and mocked HTTP requests within the same test. + ## v4.1.2 - *Fixed*: The `AssertLocationHeader` has been corrected to also support the specification of the `Uri` as a string. Additionally, contains support has been added with `AssertLocationHeaderContains`. diff --git a/Common.targets b/Common.targets index 1c494df..4ad9f7e 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 4.1.2 + 4.2.0 preview Avanade Avanade diff --git a/README.md b/README.md index d1baf35..512bca1 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,46 @@ test.ReplaceHttpClientFactory(mcf) .Assert(new { id = "Abc", description = "A blue carrot" }); ``` -
+
+ +### 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 diff --git a/src/UnitTestEx.MSTest/UnitTestEx.MSTest.csproj b/src/UnitTestEx.MSTest/UnitTestEx.MSTest.csproj index 07b8661..f6a0233 100644 --- a/src/UnitTestEx.MSTest/UnitTestEx.MSTest.csproj +++ b/src/UnitTestEx.MSTest/UnitTestEx.MSTest.csproj @@ -15,7 +15,7 @@
- + diff --git a/src/UnitTestEx.NUnit/UnitTestEx.NUnit.csproj b/src/UnitTestEx.NUnit/UnitTestEx.NUnit.csproj index 325c122..5b39a19 100644 --- a/src/UnitTestEx.NUnit/UnitTestEx.NUnit.csproj +++ b/src/UnitTestEx.NUnit/UnitTestEx.NUnit.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/UnitTestEx.Xunit/UnitTestEx.Xunit.csproj b/src/UnitTestEx.Xunit/UnitTestEx.Xunit.csproj index 6690ef6..23a30d4 100644 --- a/src/UnitTestEx.Xunit/UnitTestEx.Xunit.csproj +++ b/src/UnitTestEx.Xunit/UnitTestEx.Xunit.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/UnitTestEx/AspNetCore/HttpTesterBase.cs b/src/UnitTestEx/AspNetCore/HttpTesterBase.cs index 5806104..dcbc161 100644 --- a/src/UnitTestEx/AspNetCore/HttpTesterBase.cs +++ b/src/UnitTestEx/AspNetCore/HttpTesterBase.cs @@ -14,6 +14,7 @@ using UnitTestEx.Abstractions; using UnitTestEx.Assertors; using UnitTestEx.Json; +using UnitTestEx.Mocking; namespace UnitTestEx.AspNetCore { @@ -204,7 +205,7 @@ public async Task SendAsync(HttpMethod method, string? requ private static HttpRequestMessage CreateRequest(HttpMethod method, string requestUri, HttpContent? content, Action? requestModifier) { var uri = new Uri(requestUri, UriKind.RelativeOrAbsolute); - var ub = new UriBuilder(uri.IsAbsoluteUri ? uri : new Uri(new Uri("https://unittestex"), requestUri)); + var ub = new UriBuilder(uri.IsAbsoluteUri ? uri : new Uri(MockHttpClient.DefaultBaseAddress, requestUri)); var request = new HttpRequestMessage(method, ub.Uri); if (content != null) diff --git a/src/UnitTestEx/ExtensionMethods.cs b/src/UnitTestEx/ExtensionMethods.cs index 31aeeb8..30d0bbe 100644 --- a/src/UnitTestEx/ExtensionMethods.cs +++ b/src/UnitTestEx/ExtensionMethods.cs @@ -31,11 +31,8 @@ public static class ExtensionMethods /// The to support fluent-style method-chaining. public static IServiceCollection ReplaceSingleton(this IServiceCollection services, Func implementationFactory) where TService : class { - if (services == null) - throw new ArgumentNullException(nameof(services)); - - if (implementationFactory == null) - throw new ArgumentNullException(nameof(implementationFactory)); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(implementationFactory); services.Remove(); return services.AddSingleton(implementationFactory); @@ -59,8 +56,7 @@ public static IServiceCollection ReplaceSingleton(this IServiceCollect /// The to support fluent-style method-chaining. public static IServiceCollection ReplaceSingleton(this IServiceCollection services) where TService : class where TImplementation : class, TService { - if (services == null) - throw new ArgumentNullException(nameof(services)); + ArgumentNullException.ThrowIfNull(services); services.Remove(); return services.AddSingleton(); @@ -79,11 +75,8 @@ public static IServiceCollection ReplaceSingleton(thi /// The to support fluent-style method-chaining. public static IServiceCollection ReplaceScoped(this IServiceCollection services, Func implementationFactory) where TService : class { - if (services == null) - throw new ArgumentNullException(nameof(services)); - - if (implementationFactory == null) - throw new ArgumentNullException(nameof(implementationFactory)); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(implementationFactory); services.Remove(); return services.AddScoped(implementationFactory); @@ -107,8 +100,7 @@ public static IServiceCollection ReplaceScoped(this IServiceCollection /// The to support fluent-style method-chaining. public static IServiceCollection ReplaceScoped(this IServiceCollection services) where TService : class where TImplementation : class, TService { - if (services == null) - throw new ArgumentNullException(nameof(services)); + ArgumentNullException.ThrowIfNull(services); services.Remove(); return services.AddScoped(); @@ -127,11 +119,8 @@ public static IServiceCollection ReplaceScoped(this I /// The to support fluent-style method-chaining. public static IServiceCollection ReplaceTransient(this IServiceCollection services, Func implementationFactory) where TService : class { - if (services == null) - throw new ArgumentNullException(nameof(services)); - - if (implementationFactory == null) - throw new ArgumentNullException(nameof(implementationFactory)); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(implementationFactory); services.Remove(); return services.AddTransient(implementationFactory); @@ -155,8 +144,7 @@ public static IServiceCollection ReplaceTransient(this IServiceCollect /// The to support fluent-style method-chaining. public static IServiceCollection ReplaceTransient(this IServiceCollection services) where TService : class where TImplementation : class, TService { - if (services == null) - throw new ArgumentNullException(nameof(services)); + ArgumentNullException.ThrowIfNull(services); services.Remove(); return services.AddTransient(); diff --git a/src/UnitTestEx/Json/JsonSerializer.cs b/src/UnitTestEx/Json/JsonSerializer.cs index 9054d52..1f86717 100644 --- a/src/UnitTestEx/Json/JsonSerializer.cs +++ b/src/UnitTestEx/Json/JsonSerializer.cs @@ -8,9 +8,10 @@ namespace UnitTestEx.Json /// /// Provides the encapsulated implementation. /// - /// The . Defaults to . - public class JsonSerializer(Stj.JsonSerializerOptions? options = null) : IJsonSerializer + public class JsonSerializer : IJsonSerializer { + private static IJsonSerializer? _default; + /// /// Gets or sets the default . /// @@ -23,7 +24,21 @@ public class JsonSerializer(Stj.JsonSerializerOptions? options = null) : IJsonSe /// /// Gets or sets the default . /// - public static IJsonSerializer Default { get; set; } = new JsonSerializer(); + public static IJsonSerializer Default + { + get => _default ??= new JsonSerializer(); + set => _default = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// Initializes a new instance of the class. + /// + /// The . Defaults to . + public JsonSerializer(Stj.JsonSerializerOptions? options = null) + { + Options = options ?? DefaultOptions; + IndentedOptions = new Stj.JsonSerializerOptions(Options) { WriteIndented = true }; + } /// object IJsonSerializer.Options => Options; @@ -31,7 +46,12 @@ public class JsonSerializer(Stj.JsonSerializerOptions? options = null) : IJsonSe /// /// Gets the . /// - public Stj.JsonSerializerOptions Options { get; } = options ?? DefaultOptions; + public Stj.JsonSerializerOptions Options { get; } + + /// + /// Gets or sets the with = true. + /// + public Stj.JsonSerializerOptions? IndentedOptions { get; } /// public object? Deserialize(string json) => Stj.JsonSerializer.Deserialize(json, Options); @@ -43,6 +63,6 @@ public class JsonSerializer(Stj.JsonSerializerOptions? options = null) : IJsonSe public T? Deserialize(string json) => Stj.JsonSerializer.Deserialize(json, Options)!; /// - public string Serialize(T value, JsonWriteFormat? format = null) => Stj.JsonSerializer.Serialize(value, format == null ? Options : new Stj.JsonSerializerOptions(Options) { WriteIndented = format.Value == JsonWriteFormat.Indented }); + public string Serialize(T value, JsonWriteFormat? format = null) => Stj.JsonSerializer.Serialize(value, format == null || format.Value == JsonWriteFormat.None ? Options : IndentedOptions); } } \ No newline at end of file diff --git a/src/UnitTestEx/Logging/LoggerBase.cs b/src/UnitTestEx/Logging/LoggerBase.cs index f067f26..cfb8cd2 100644 --- a/src/UnitTestEx/Logging/LoggerBase.cs +++ b/src/UnitTestEx/Logging/LoggerBase.cs @@ -40,8 +40,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except if (!IsEnabled(logLevel)) return; - if (formatter == null) - throw new ArgumentNullException(nameof(formatter)); + ArgumentNullException.ThrowIfNull(formatter); var sb = new StringBuilder(); sb.Append($"{DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fffff", DateTimeFormatInfo.InvariantInfo)} {GetLogLevel(logLevel)}: {formatter(state, exception)} [{Name}]"); diff --git a/src/UnitTestEx/Mocking/MockHttpClient.cs b/src/UnitTestEx/Mocking/MockHttpClient.cs index 8045eb2..7b88977 100644 --- a/src/UnitTestEx/Mocking/MockHttpClient.cs +++ b/src/UnitTestEx/Mocking/MockHttpClient.cs @@ -1,8 +1,12 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Options; using Moq; using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; namespace UnitTestEx.Mocking @@ -10,23 +14,35 @@ namespace UnitTestEx.Mocking /// /// Provides the (more specifically ) mocking. /// - public class MockHttpClient + public sealed class MockHttpClient : IDisposable { + /// + /// Gets the default being 'https://unittest'. + /// + public static Uri DefaultBaseAddress { get; } = new Uri("https://unittest"); + + private readonly Uri? _baseAddress; private readonly List _requests = []; + private readonly object _lock = new(); + private HttpClient? _httpClient; + private bool _noMocking; + private bool _useHttpMessageHandlers; + private Type[] _excludeTypes = []; + private bool _useHttpClientConfigurations; /// /// Initializes a new instance of the class. /// /// The . /// The logical name of the client. - /// The base Uniform Resource Identifier (URI) of the Internet resource used when sending requests; defaults to 'https://unittest' where not specified. + /// The base Uniform Resource Identifier (URI) of the Internet resource used when sending requests; defaults to where not specified. internal MockHttpClient(MockHttpClientFactory factory, string name, Uri? baseAddress) { Factory = factory; - HttpClient = new HttpClient(new MockHttpClientHandler(factory, MessageHandler.Object)) { BaseAddress = baseAddress ?? new Uri("https://unittest") }; - IsBaseAddressSpecified = baseAddress is not null; Name = name ?? throw new ArgumentNullException(nameof(name)); - Factory.HttpClientFactory.Setup(x => x.CreateClient(It.Is(x => x == name))).Returns(() => HttpClient); + _baseAddress = baseAddress; + IsBaseAddressSpecified = baseAddress is not null; + Factory.HttpClientFactory.Setup(x => x.CreateClient(It.Is(x => x == name))).Returns(GetHttpClient); } /// @@ -42,45 +58,241 @@ internal MockHttpClient(MockHttpClientFactory factory, string name, Uri? baseAdd /// /// Gets the . /// - public Mock MessageHandler { get; } = new Mock(); + /// This is not used when is used. + internal Mock MessageHandler { get; } = new Mock(); /// - /// Verifies that all verifiable expectations have been met; being all requests have been invoked. + /// Indicates whether the is explicitly specified (overridden) for the test; otherwise, will use as configured or default to . /// - /// This is a wrapper for 'MessageHandler.Verify()' which can be invoked directly to leverage additional capabilities (overloads). Additionally, the is invoked for each - /// underlying to perform the corresponding verification.Note: no verify will occur where using sequences; this appears to be a - /// limitation of MOQ. - public void Verify() + internal bool IsBaseAddressSpecified { get; set; } + + /// + /// Gets the mocked . + /// + /// This will cache the and reuse; the can be used to clear and dispose. + public HttpClient GetHttpClient() { - MessageHandler.Verify(); + if (_httpClient is not null) + return _httpClient; - foreach (var r in _requests) + lock (_lock) { - r.Verify(); + return _httpClient ??= CreateHttpClient(); } } /// - /// Indicates whether the is specified or defaulted. + /// Create the . /// - internal bool IsBaseAddressSpecified { get; set; } + private HttpClient CreateHttpClient() + { + // Get the factory options where applicable. + HttpClientFactoryOptions? options = null; + if (Factory.Services is not null) + { + var om = Factory.Services.GetRequiredService>(); + options = om?.Get(Name); + } + else if (_noMocking) + { + // Where no mocking and no service provider then use the default HttpClient; that is all we can do. + return new HttpClient(new HttpClientHandler(), true) { BaseAddress = _baseAddress ?? DefaultBaseAddress }; + } + + // Build the http handler. + HttpClient httpClient; + if (_useHttpMessageHandlers && options is not null) + { + var builder = new MockHttpMessageHandlerBuilder(Name, Factory.Services!, _noMocking ? new HttpClientHandler() : new MockHttpClientHandler(Factory, MessageHandler.Object), _excludeTypes); + + for (int i = 0; i < options.HttpMessageHandlerBuilderActions.Count; i++) + { + options.HttpMessageHandlerBuilderActions[i](builder); + } + + httpClient = new HttpClient(builder.Build(), true); + } + else + httpClient = new HttpClient(new MockHttpClientHandler(Factory, MessageHandler.Object), true); + + // Configure the client where applicable. + if (_useHttpClientConfigurations && options is not null) + { + for (int i = 0; i < options.HttpClientActions.Count; i++) + { + options.HttpClientActions[i](httpClient); + } + } + + // Where a base address is specified, use it (override configuration). + if (IsBaseAddressSpecified) + httpClient.BaseAddress = _baseAddress; + + // Where no base address is specified or configured then default. + if (!_noMocking) + httpClient.BaseAddress ??= DefaultBaseAddress; + + return httpClient; + } /// - /// Gets the mocked . + /// Specifies that the and configurations are to be used. + /// + /// The to support fluent-style method-chaining. + /// This is a combination of both and . + public MockHttpClient WithConfigurations(params Type[] excludeTypes) => WithHttpClientConfigurations().WithHttpMessageHandlers(excludeTypes); + + /// + /// Specifies that the and configurations are not to be used. + /// + /// The to support fluent-style method-chaining. + /// The is a combination of both and . + public MockHttpClient WithoutConfigurations() => WithoutHttpClientConfigurations().WithoutHttpMessageHandlers(); + + /// + /// Specifies that the configurations for the are to be used. + /// + /// The types to be excluded. + /// The to support fluent-style method-chaining. + /// By default the configurations are not invoked. + public MockHttpClient WithHttpMessageHandlers(params Type[] excludeTypes) + { + if (_noMocking) + throw new InvalidOperationException($"{nameof(WithHttpMessageHandlers)} is not supported where {nameof(WithoutMocking)} has been specified."); + + _useHttpMessageHandlers = true; + _excludeTypes = excludeTypes; + return this; + } + + /// + /// Specifies that the configurations for the are not to be used. + /// + /// The to support fluent-style method-chaining. + public MockHttpClient WithoutHttpMessageHandlers() + { + if (_noMocking) + throw new InvalidOperationException($"{nameof(WithoutHttpMessageHandlers)} is not supported where {nameof(WithoutMocking)} has been specified."); + + _useHttpMessageHandlers = false; + _excludeTypes = []; + return this; + } + + /// + /// Specifies that the configurations for the aer to be used. + /// + /// The to support fluent-style method-chaining. + /// By default the configurations are not invoked. + public MockHttpClient WithHttpClientConfigurations() + { + if (_noMocking) + throw new InvalidOperationException($"{nameof(WithHttpClientConfigurations)} is not supported where {nameof(WithoutMocking)} has been specified."); + + _useHttpClientConfigurations = true; + return this; + } + + /// + /// Specifies that the configurations for the are not to be used. + /// + /// The to support fluent-style method-chaining. + public MockHttpClient WithoutHttpClientConfigurations() + { + if (_noMocking) + throw new InvalidOperationException($"{nameof(WithoutHttpClientConfigurations)} is not supported where {nameof(WithoutMocking)} has been specified."); + + _useHttpClientConfigurations = false; + return this; + } + + /// + /// Specifies that the resulting from the is to be instantiated with no mocking; i.e. will result in an actual/real HTTP request. /// - internal HttpClient HttpClient { get; set; } + /// The types to be excluded. + /// Once set this is immutable. + /// As this results in no mocking, no specific usage tracking is then performed and as such the associated will never assert anything but success. + /// Note: although this may imply that the native and related implementation is being leveraged, this is not the case. This is still using the + /// to enable the behavior leveraging the internal implementation. Best efforts have been made to achieve native-like functionality; however, some edge cases may not have been + /// accounted for. + public void WithoutMocking(params Type[] excludeTypes) + { + if (_requests.Count > 0) + throw new InvalidOperationException($"{nameof(WithoutMocking)} is not supported where a {nameof(Request)} has already been specified."); + + _noMocking = true; + _excludeTypes = excludeTypes; + _useHttpMessageHandlers = true; + _useHttpClientConfigurations = true; + } /// - /// Creates a new for the . + /// Creates a new for the . /// /// The . /// The string that represents the request . /// The . public MockHttpClientRequest Request(HttpMethod method, string requestUri) { + if (_noMocking) + throw new InvalidOperationException($"{nameof(Request)} is not supported where {nameof(WithoutMocking)} has been specified."); + var r = new MockHttpClientRequest(this, method, requestUri); _requests.Add(r); return r; } + + /// + /// Verifies that all verifiable expectations have been met; being all requests have been invoked. + /// + /// This is a wrapper for 'MessageHandler.Verify()' which can be invoked directly to leverage additional capabilities (overloads). Additionally, the is invoked for each + /// underlying to perform the corresponding verification.Note: no verify will occur where using sequences; this appears to be a + /// limitation of MOQ. + public void Verify() + { + MessageHandler.Verify(); + + foreach (var r in _requests) + { + r.Verify(); + } + } + + /// + /// Disposes and removes the cached . + /// + public void Reset() + { + lock (_lock) + { + _httpClient?.Dispose(); + _httpClient = null; + } + } + + /// + public void Dispose() => Reset(); + + /// + /// Provides an internal/mocked + /// + private class MockHttpMessageHandlerBuilder(string? name, IServiceProvider services, HttpMessageHandler primaryHandler, Type[] excludeTypes) : HttpMessageHandlerBuilder + { + private readonly Type[] _excludeTypes = excludeTypes; + + /// + public override IList AdditionalHandlers { get; } = new List(); + + public override IServiceProvider Services { get; } = services; + + /// + public override string? Name { get; set; } = name; + + /// + public override HttpMessageHandler PrimaryHandler { get; set; } = primaryHandler; + + /// + public override HttpMessageHandler Build() => CreateHandlerPipeline(PrimaryHandler, AdditionalHandlers.Where(x => !_excludeTypes.Contains(x.GetType()))); + } } } \ No newline at end of file diff --git a/src/UnitTestEx/Mocking/MockHttpClientFactory.cs b/src/UnitTestEx/Mocking/MockHttpClientFactory.cs index edf511d..dc66b62 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientFactory.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientFactory.cs @@ -14,7 +14,7 @@ namespace UnitTestEx.Mocking /// /// Provides the mocking. /// - public class MockHttpClientFactory(TestFrameworkImplementor implementor) + public sealed class MockHttpClientFactory(TestFrameworkImplementor implementor) : IDisposable { private readonly Dictionary _mockClients = []; @@ -68,6 +68,26 @@ public MockHttpClientFactory UseJsonComparerOptions(JsonElementComparerOptions o return this; } + /// + /// Gets the optional . + /// + /// This is set automatically when the is performed. + public IServiceProvider? Services { get; private set; } + + /// + /// Sets the optional ; once set this cannot be changed. + /// + /// The . + /// The current instance to support fluent-style method-chaining. + public MockHttpClientFactory UseServiceProvider(IServiceProvider? serviceProvider) + { + if (Services is not null) + throw new InvalidOperationException($"{nameof(Services)} has already been assigned; once set it cannot be changed."); + + Services = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + return this; + } + /// /// Creates the with the specified logical . /// @@ -92,7 +112,7 @@ public MockHttpClient CreateClient(string name, Uri baseAddress) /// Creates the with the specified logical . /// /// The logical name of the client. - /// The base address of Uniform Resource Identifier (URI) of the Internet resource used when sending requests; defaults to 'https://unittest' where not specified. + /// The base address of Uniform Resource Identifier (URI) of the Internet resource used when sending requests; defaults to where not specified. /// The . /// Only a single client can be created per logical name. public MockHttpClient CreateClient(string name, string? baseAddress = null) @@ -127,7 +147,7 @@ public MockHttpClient CreateDefaultClient(Uri baseAddress) /// /// Creates the default (unnamed) . /// - /// The base address of Uniform Resource Identifier (URI) of the Internet resource used when sending requests; defaults to 'https://unittest' where not specified. + /// The base address of Uniform Resource Identifier (URI) of the Internet resource used when sending requests; defaults to where not specified. /// The . /// Only a single default client can be created. public MockHttpClient CreateDefaultClient(string? baseAddress = null) @@ -147,23 +167,24 @@ public MockHttpClient CreateDefaultClient(string? baseAddress = null) /// The to support fluent-style method-chaining. public IServiceCollection Replace(IServiceCollection sc) => sc.ReplaceSingleton(sp => { + UseServiceProvider(sp); Logger = sp.GetRequiredService>(); - Logger.LogInformation($"UnitTestEx > Replacing '{nameof(HttpClientFactory)}' service provider (DI) instance with '{nameof(MockHttpClientFactory)}'."); + Logger.LogDebug($"UnitTestEx > Replacing '{nameof(HttpClientFactory)}' service provider (DI) instance with '{nameof(MockHttpClientFactory)}'."); return HttpClientFactory.Object; }); /// - /// Gets the logically named mocked . + /// Gets the logically named mocked . /// /// The logical name of the client. /// The where it exists; otherwise; null. - public HttpClient? GetHttpClient(string name) => _mockClients.GetValueOrDefault(name ?? throw new ArgumentNullException(nameof(name)))?.HttpClient; + public HttpClient? GetHttpClient(string name) => _mockClients.GetValueOrDefault(name ?? throw new ArgumentNullException(nameof(name)))?.GetHttpClient(); /// /// Gets the default (unnamed) mocked . /// /// The default where it exists; otherwise; null. - public HttpClient? GetHttpClient() => _mockClients.GetValueOrDefault(string.Empty)?.HttpClient; + public HttpClient? GetHttpClient() => _mockClients.GetValueOrDefault(string.Empty)?.GetHttpClient(); /// /// Verifies that all verifiable expectations have been met for all instances; being all requests have been invoked. @@ -177,5 +198,14 @@ public void VerifyAll() mc.Value.Verify(); } } + + /// + public void Dispose() + { + foreach (var mc in _mockClients) + { + mc.Value.Dispose(); + } + } } } \ No newline at end of file diff --git a/src/UnitTestEx/Mocking/MockHttpClientHandler.cs b/src/UnitTestEx/Mocking/MockHttpClientHandler.cs index 6f57f9d..ccf6aae 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientHandler.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientHandler.cs @@ -25,10 +25,11 @@ public class MockHttpClientHandler : DelegatingHandler protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var logger = _factory.Logger ?? _factory.Implementor.CreateLoggerProvider().CreateLogger(nameof(MockHttpClientFactory)); - logger.LogInformation($"UnitTestEx > Sending HTTP request {request.Method} {request.RequestUri} {LogContent(request.Content)}"); + logger.LogDebug($"UnitTestEx > Sending HTTP request {request.Method} {request.RequestUri} {LogContent(request.Content)}"); var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false) ?? throw new MockHttpClientException($"No corresponding MockHttpClient response found for HTTP request {request.Method} {request.RequestUri} {LogContent(request.Content)}"); - logger.LogInformation($"UnitTestEx > Received HTTP response {response.StatusCode} ({(int)response.StatusCode}) {LogContent(response.Content)}"); + + logger.LogDebug($"UnitTestEx > Received HTTP response {response.StatusCode} ({(int)response.StatusCode}) {LogContent(response.Content)}"); return response; } diff --git a/src/UnitTestEx/Mocking/MockHttpClientRequest.cs b/src/UnitTestEx/Mocking/MockHttpClientRequest.cs index b4215e0..046d929 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientRequest.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientRequest.cs @@ -8,7 +8,6 @@ using System.Net.Http; using System.Net.Mime; using System.Reflection; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using UnitTestEx.Abstractions; @@ -19,7 +18,7 @@ namespace UnitTestEx.Mocking /// /// Provides the configuration for mocking. /// - public class MockHttpClientRequest + public sealed class MockHttpClientRequest { private readonly MockHttpClient _client; private readonly HttpMethod _method; @@ -210,7 +209,11 @@ private bool RequestPredicate(HttpRequestMessage request) } /// - public override string ToString() => $"<{_client.Name}> {_method} {(_client.HttpClient.BaseAddress == null ? _requestUri : new Uri(_client.HttpClient.BaseAddress, _requestUri))} {ContentToString()} {(_mediaType == null ? string.Empty : $"({_mediaType})")}"; + public override string ToString() + { + var hc = _client.GetHttpClient(); + return $"<{_client.Name}> {_method} {(hc.BaseAddress == null ? _requestUri : new Uri(hc.BaseAddress!, _requestUri))} {ContentToString()} {(_mediaType == null ? string.Empty : $"({_mediaType})")}"; + } /// /// Convert the content to a string. diff --git a/src/UnitTestEx/Mocking/MockHttpClientRequestBody.cs b/src/UnitTestEx/Mocking/MockHttpClientRequestBody.cs index 4c71ef4..f261b71 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientRequestBody.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientRequestBody.cs @@ -5,7 +5,7 @@ namespace UnitTestEx.Mocking /// /// Represents the result of adding a body to the and to accordingly. /// - public class MockHttpClientRequestBody + public sealed class MockHttpClientRequestBody { private readonly MockHttpClientRequestRule _rule; diff --git a/src/UnitTestEx/Mocking/MockHttpClientResponse.cs b/src/UnitTestEx/Mocking/MockHttpClientResponse.cs index a3989ce..c7150e9 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientResponse.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientResponse.cs @@ -1,7 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using System; -using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -16,7 +15,7 @@ namespace UnitTestEx.Mocking /// /// Provides the configuration for mocking. /// - public class MockHttpClientResponse + public sealed class MockHttpClientResponse { private readonly MockHttpClientRequest _clientRequest; private readonly MockHttpClientRequestRule? _rule; @@ -196,8 +195,7 @@ public void WithSequence(Action sequence) if (_rule == null) throw new InvalidOperationException("A WithSequence can not be issued within the context of a parent WithSequence."); - if (sequence == null) - throw new ArgumentNullException(nameof(sequence)); + ArgumentNullException.ThrowIfNull(sequence); _rule.Responses ??= []; diff --git a/src/UnitTestEx/Mocking/MockHttpClientResponseSequence.cs b/src/UnitTestEx/Mocking/MockHttpClientResponseSequence.cs index d8e1cfa..5529c77 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientResponseSequence.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientResponseSequence.cs @@ -5,7 +5,7 @@ namespace UnitTestEx.Mocking /// /// Mocks the within a sequence. /// - public class MockHttpClientResponseSequence + public sealed class MockHttpClientResponseSequence { private readonly MockHttpClientRequest _clientRequest; private readonly MockHttpClientRequestRule _rule; diff --git a/src/UnitTestEx/UnitTestEx.csproj b/src/UnitTestEx/UnitTestEx.csproj index c11ce2f..6448d15 100644 --- a/src/UnitTestEx/UnitTestEx.csproj +++ b/src/UnitTestEx/UnitTestEx.csproj @@ -17,28 +17,28 @@ - + + - - + - + - + - + diff --git a/tests/UnitTestEx.Api/Startup.cs b/tests/UnitTestEx.Api/Startup.cs index 6105b3a..2bab7a5 100644 --- a/tests/UnitTestEx.Api/Startup.cs +++ b/tests/UnitTestEx.Api/Startup.cs @@ -3,6 +3,10 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; namespace UnitTestEx.Api { @@ -19,7 +23,9 @@ public Startup(IConfiguration configuration) public void ConfigureServices(IServiceCollection services) { services.AddControllers().AddNewtonsoftJson(); - services.AddHttpClient("XXX", hc => hc.BaseAddress = new System.Uri("https://somesys")); + services.AddHttpClient("XXX", hc => hc.BaseAddress = new System.Uri("https://somesys")) + .AddHttpMessageHandler(_ => new MessageProcessingHandler()) + .ConfigureHttpClient(hc => hc.DefaultRequestVersion = new Version(1, 2)); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -41,5 +47,16 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) endpoints.MapControllers(); }); } + + public class MessageProcessingHandler : DelegatingHandler + { + public static bool WasExecuted { get; set;} + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + WasExecuted = true; + return base.SendAsync(request, cancellationToken); + } + } } } \ No newline at end of file diff --git a/tests/UnitTestEx.Function/UnitTestEx.Function.csproj b/tests/UnitTestEx.Function/UnitTestEx.Function.csproj index a832d62..dd2c9c9 100644 --- a/tests/UnitTestEx.Function/UnitTestEx.Function.csproj +++ b/tests/UnitTestEx.Function/UnitTestEx.Function.csproj @@ -19,4 +19,4 @@ Never - + \ No newline at end of file diff --git a/tests/UnitTestEx.MSTest.Test/MockHttpClientTest.cs b/tests/UnitTestEx.MSTest.Test/MockHttpClientTest.cs index 2c399cc..6299abd 100644 --- a/tests/UnitTestEx.MSTest.Test/MockHttpClientTest.cs +++ b/tests/UnitTestEx.MSTest.Test/MockHttpClientTest.cs @@ -453,5 +453,22 @@ public async Task Timeout_CancellationToken_WithSequence() var ex = Assert.ThrowsException(() => mcf.VerifyAll()); Assert.AreEqual("There were 3 response(s) configured for the Sequence and only 2 response(s) invoked. Request: GET https://d365test/ 'No content' ", ex.Message); } + + [TestMethod] + public async Task WithoutMocking() + { + // https://www.bing.com/search?q=unittestex + var mcf = MockHttpClientFactory.Create(); + mcf.CreateDefaultClient(new Uri("https://www.bing.com/")).WithoutMocking(); + + var hc = mcf.GetHttpClient(); + var res = await hc.GetAsync("search?q=unittestex").ConfigureAwait(false); + + Assert.IsNotNull(res); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + + var content = await res.Content.ReadAsStringAsync(); + Assert.IsTrue(content.Contains("https://github.com/Avanade/unittestex", StringComparison.OrdinalIgnoreCase)); + } } } \ No newline at end of file diff --git a/tests/UnitTestEx.MSTest.Test/ProductControllerTest.cs b/tests/UnitTestEx.MSTest.Test/ProductControllerTest.cs index f04d95a..3c12944 100644 --- a/tests/UnitTestEx.MSTest.Test/ProductControllerTest.cs +++ b/tests/UnitTestEx.MSTest.Test/ProductControllerTest.cs @@ -12,17 +12,39 @@ namespace UnitTestEx.MSTest.Test public class ProductControllerTest { [TestMethod] - public void Notfound() + public void Notfound_WithoutConfigurations() { var mcf = MockHttpClientFactory.Create(); - mcf.CreateClient("XXX", new Uri("https://somesys/")) + mcf.CreateClient("XXX", new Uri("https://somesys")) .Request(HttpMethod.Get, "products/xyz").Respond.With(HttpStatusCode.NotFound); + Startup.MessageProcessingHandler.WasExecuted = false; + using var test = ApiTester.Create(); test.ReplaceHttpClientFactory(mcf) .Controller() .Run(c => c.Get("xyz")) .AssertNotFound(); + + Assert.IsFalse(Startup.MessageProcessingHandler.WasExecuted); + } + + [TestMethod] + public void Notfound_WithConfigurations() + { + var mcf = MockHttpClientFactory.Create(); + mcf.CreateClient("XXX").WithConfigurations() + .Request(HttpMethod.Get, "products/xyz").Respond.With(HttpStatusCode.NotFound); + + Startup.MessageProcessingHandler.WasExecuted = false; + + using var test = ApiTester.Create(); + test.ReplaceHttpClientFactory(mcf) + .Controller() + .Run(c => c.Get("xyz")) + .AssertNotFound(); + + Assert.IsTrue(Startup.MessageProcessingHandler.WasExecuted); } [TestMethod] @@ -54,5 +76,43 @@ public void ServiceProvider() Assert.IsNotNull(r); Assert.AreEqual("test output", r.Content.ReadAsStringAsync().Result); } + + [TestMethod] + public void MockHttpClientFactory_NoMocking() + { + var mcf = MockHttpClientFactory.Create(); + mcf.CreateClient("XXX").WithoutMocking(); + + Startup.MessageProcessingHandler.WasExecuted = false; + + using var test = ApiTester.Create(); + var hc = test.ReplaceHttpClientFactory(mcf) + .Services.GetService().CreateClient("XXX"); + + var ex = Assert.ThrowsException(() => hc.GetAsync("test").Result); + + Assert.IsTrue(Startup.MessageProcessingHandler.WasExecuted); + + mcf.VerifyAll(); + } + + [TestMethod] + public void MockHttpClientFactory_NoMocking_Exclude() + { + using var mcf = MockHttpClientFactory.Create(); + mcf.CreateClient("XXX").WithoutMocking(typeof(Startup.MessageProcessingHandler)); + + Startup.MessageProcessingHandler.WasExecuted = false; + + using var test = ApiTester.Create(); + var hc = test.ReplaceHttpClientFactory(mcf) + .Services.GetService().CreateClient("XXX"); + + var ex = Assert.ThrowsException(() => hc.GetAsync("test").Result); + + Assert.IsFalse(Startup.MessageProcessingHandler.WasExecuted); + + mcf.VerifyAll(); + } } } \ No newline at end of file diff --git a/tests/UnitTestEx.MSTest.Test/UnitTestEx.MSTest.Test.csproj b/tests/UnitTestEx.MSTest.Test/UnitTestEx.MSTest.Test.csproj index 8536dbf..e762d4b 100644 --- a/tests/UnitTestEx.MSTest.Test/UnitTestEx.MSTest.Test.csproj +++ b/tests/UnitTestEx.MSTest.Test/UnitTestEx.MSTest.Test.csproj @@ -26,7 +26,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs b/tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs index 18fce03..93d9abc 100644 --- a/tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs +++ b/tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs @@ -55,7 +55,6 @@ public void Success() using var test = ApiTester.Create(); test.ReplaceHttpClientFactory(mcf) .Controller() - .ExpectLogContains("Received HTTP response OK") .Run(c => c.Get("abc")) .AssertOK() .AssertValue(new { id = "Abc", description = "A blue carrot" }); diff --git a/tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj b/tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj index b4001e0..7cd9ce8 100644 --- a/tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj +++ b/tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj @@ -24,7 +24,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj b/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj index 719ff71..1393165 100644 --- a/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj +++ b/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj @@ -25,8 +25,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all