From bab38d5be811c451956d6759f6adf0b8ab02ead4 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 12 Aug 2025 16:16:32 -0700 Subject: [PATCH 1/7] v5.6.0 - *Enhancement:* The `RunAsync` methods updated to support `ValueTask` as well as `Task` for the `TypeTester` and `GenericTester` (.NET 9+ only). - *Enhancement:* Added `HttpResultAssertor` for ASP.NET Minimal APIs `Results` (e.g. `Results.Ok()`, `Results.NotFound()`, etc.) to enable assertions via the `ToHttpResponseMessageAssertor`. - *Enhancement:* `TesterBase`, `GenericTester` and `TypeTester` updated to support keyed services. - *Enhancement:* `GenericTester` and `TypeTester` updated to support the test run execution within a DI scope (using `UseRunAsScoped`). - *Fixed:* The `ExpectationsArranger` updated to `Clear` versus `Reset` after an assertion run to ensure no cross-test contamination. --- CHANGELOG.md | 7 + Common.targets | 2 +- UnitTestEx.sln | 1 + .../Azure/Functions/FunctionTesterBase.cs | 5 +- .../Azure/Functions/HttpTriggerTester.cs | 6 +- .../Functions/ServiceBusTriggerTester.cs | 6 +- .../Abstractions/TestFrameworkImplementor.cs | 2 +- .../Abstractions/TestSharedState.cs | 2 +- src/UnitTestEx/Abstractions/TesterBase.cs | 7 +- src/UnitTestEx/Abstractions/TesterBaseT.cs | 98 +++- src/UnitTestEx/AspNetCore/ApiTesterBase.cs | 25 +- src/UnitTestEx/AspNetCore/HttpTesterBase.cs | 2 + src/UnitTestEx/AspNetCore/HttpTesterBaseT2.cs | 14 +- .../Assertors/ActionResultAssertor.cs | 2 +- .../Assertors/HttpResponseMessageAssertor.cs | 2 +- .../Assertors/HttpResultAssertor.cs | 69 +++ src/UnitTestEx/Assertors/ValueAssertor.cs | 10 +- .../Expectations/ErrorExpectations.cs | 3 + .../Expectations/ExceptionExpectations.cs | 3 + .../Expectations/ExpectationsArranger.cs | 23 +- .../Expectations/ExpectationsBase.cs | 5 + .../HttpResponseMessageExpectations.cs | 3 + .../Expectations/LoggerExpectations.cs | 3 + .../Expectations/ValueExpectations.cs | 3 + src/UnitTestEx/ExtensionMethods.cs | 171 ++++++- src/UnitTestEx/Generic/GenericTesterBase.cs | 423 +++++++++++++++++- src/UnitTestEx/Generic/GenericTesterBaseT.cs | 4 - src/UnitTestEx/Hosting/HostTesterBase.cs | 8 +- src/UnitTestEx/Hosting/TypeTester.cs | 207 ++++++++- .../Json/JsonElementComparerOptions.cs | 5 + .../Mocking/MockHttpClientRequest.cs | 2 +- src/UnitTestEx/ObjectComparer.cs | 7 +- src/UnitTestEx/TestSetUp.cs | 5 + .../Controllers/ProductController.cs | 6 +- tests/UnitTestEx.Api/UnitTestEx.Api.csproj | 6 +- .../Other/GenericTest.cs | 15 +- .../UnitTestEx.MSTest.Test.csproj | 2 +- .../Other/ExpectationsTest.cs | 27 +- .../Other/GenericTest.cs | 21 +- .../UnitTestEx.NUnit.Test.csproj | 2 +- .../Other/GenericTest.cs | 8 +- .../UnitTestEx.Xunit.Test.csproj | 3 +- 42 files changed, 1106 insertions(+), 119 deletions(-) create mode 100644 src/UnitTestEx/Assertors/HttpResultAssertor.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f88c60b..7115b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ Represents the **NuGet** versions. +## v5.6.0 +- *Enhancement:* The `RunAsync` methods updated to support `ValueTask` as well as `Task` for the `TypeTester` and `GenericTester` (.NET 9+ only). +- *Enhancement:* Added `HttpResultAssertor` for ASP.NET Minimal APIs `Results` (e.g. `Results.Ok()`, `Results.NotFound()`, etc.) to enable assertions via the `ToHttpResponseMessageAssertor`. +- *Enhancement:* `TesterBase`, `GenericTester` and `TypeTester` updated to support keyed services. +- *Enhancement:* `GenericTester` and `TypeTester` updated to support the test run execution within a DI scope (using `UseRunAsScoped`). +- *Fixed:* The `ExpectationsArranger` updated to `Clear` versus `Reset` after an assertion run to ensure no cross-test contamination. + ## v5.5.0 - *Enhancement:* The `GenericTester` where using `.NET8.0` and above will leverage the new `IHostApplicationBuilder` versus existing `IHostBuilder` (see Microsoft [documentation](https://learn.microsoft.com/en-us/dotnet/core/extensions/generic-host) and [recommendation](https://github.com/dotnet/runtime/discussions/81090#discussioncomment-4784551)). Additionally, if a `TEntryPoint` is specified with a method signature of `public void ConfigureApplication(IHostApplicationBuilder builder)` then this will be automatically invoked during host instantiation. This is a non-breaking change as largely internal. diff --git a/Common.targets b/Common.targets index f89c20a..e2f6174 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 5.5.0 + 5.6.0 preview Avanade Avanade diff --git a/UnitTestEx.sln b/UnitTestEx.sln index 005ec76..945a484 100644 --- a/UnitTestEx.sln +++ b/UnitTestEx.sln @@ -23,6 +23,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ProjectSection(SolutionItems) = preProject .gitignore = .gitignore CHANGELOG.md = CHANGELOG.md + .github\workflows\ci.yml = .github\workflows\ci.yml CODE_OF_CONDUCT.md = CODE_OF_CONDUCT.md Common.targets = Common.targets CONTRIBUTING.md = CONTRIBUTING.md diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs index 002d984..4849f88 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs @@ -224,11 +224,12 @@ protected override void ResetHost() public HttpTriggerTester HttpTrigger() where TFunction : class => new(this, HostExecutionWrapper(() => GetHost().Services.CreateScope())); /// - /// Specifies the of that is to be tested. + /// Enables a specified (of ) to be tested. /// /// The to be tested. + /// The optional keyed service key. /// The . - public TypeTester Type() where T : class => new(this, HostExecutionWrapper(() => GetHost().Services.CreateScope())); + public TypeTester Type(object? serviceKey = null) where T : class => new(this, HostExecutionWrapper(() => GetHost().Services.CreateScope()), serviceKey); /// /// Specifies the Function that utilizes the that is to be tested. diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs index 2a0035e..fbcdabf 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs @@ -448,9 +448,9 @@ private void LogResponse(IActionResult res, Exception? ex, double ms, IEnumerabl } } - Implementor.WriteLine(""); - Implementor.WriteLine(new string('=', 80)); - Implementor.WriteLine(""); + //Implementor.WriteLine(""); + //Implementor.WriteLine(new string('=', 80)); + //Implementor.WriteLine(""); } } } \ No newline at end of file diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusTriggerTester.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusTriggerTester.cs index ae4cdca..6b43a9d 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusTriggerTester.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusTriggerTester.cs @@ -261,9 +261,9 @@ private void LogOutput(Exception? ex, double ms, object? value, WebJobsServiceBu ssba?.LogResult(); wsba?.LogResult(); - Implementor.WriteLine(""); - Implementor.WriteLine(new string('=', 80)); - Implementor.WriteLine(""); + //Implementor.WriteLine(""); + //Implementor.WriteLine(new string('=', 80)); + //Implementor.WriteLine(""); } } } \ No newline at end of file diff --git a/src/UnitTestEx/Abstractions/TestFrameworkImplementor.cs b/src/UnitTestEx/Abstractions/TestFrameworkImplementor.cs index 632ceb9..a11fa1d 100644 --- a/src/UnitTestEx/Abstractions/TestFrameworkImplementor.cs +++ b/src/UnitTestEx/Abstractions/TestFrameworkImplementor.cs @@ -44,7 +44,7 @@ public static void SetGlobalCreateFactory(Func createF public static void SetLocalCreateFactory(Func createFactory) { if (_localCreateFactory.Value is not null) - throw new InvalidOperationException($"The local {nameof(TestFrameworkImplementor)} factory has already been set."); + return; _localCreateFactory.Value = createFactory ?? throw new ArgumentNullException(nameof(createFactory)); } diff --git a/src/UnitTestEx/Abstractions/TestSharedState.cs b/src/UnitTestEx/Abstractions/TestSharedState.cs index a5d5be7..59252de 100644 --- a/src/UnitTestEx/Abstractions/TestSharedState.cs +++ b/src/UnitTestEx/Abstractions/TestSharedState.cs @@ -85,7 +85,7 @@ private string GetRequestId() } /// - /// Gets the state extension data that can be used for addition state information (where applicable). + /// Gets the state extension data that can be used for additional state information (where applicable). /// public ConcurrentDictionary StateData { get; } = new ConcurrentDictionary(); diff --git a/src/UnitTestEx/Abstractions/TesterBase.cs b/src/UnitTestEx/Abstractions/TesterBase.cs index dd1bc27..24af1cb 100644 --- a/src/UnitTestEx/Abstractions/TesterBase.cs +++ b/src/UnitTestEx/Abstractions/TesterBase.cs @@ -67,7 +67,6 @@ public TesterBase(TestFrameworkImplementor implementor) SetUp = TestSetUp.Default.Clone(); JsonSerializer = SetUp.JsonSerializer; JsonComparerOptions = SetUp.JsonComparerOptions; - TestSetUp.LogAutoSetUpOutputs(Implementor); } /// @@ -253,7 +252,7 @@ internal void LogHttpResponseMessage(HttpResponseMessage res, Stopwatch? sw) object? jo = null; var content = res.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - if (!string.IsNullOrEmpty(content) && JsonMediaTypeNames.Contains(res.Content?.Headers?.ContentType?.MediaType)) + if (!string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(res.Content?.Headers?.ContentType?.MediaType) && JsonMediaTypeNames.Contains(res.Content.Headers.ContentType.MediaType)) { try { @@ -270,10 +269,6 @@ internal void LogHttpResponseMessage(HttpResponseMessage res, Stopwatch? sw) } else Implementor.WriteLine($"{txt} {(string.IsNullOrEmpty(content) ? "none" : content)}"); - - Implementor.WriteLine(""); - Implementor.WriteLine(new string('=', 80)); - Implementor.WriteLine(""); } #region CreateHttpRequest diff --git a/src/UnitTestEx/Abstractions/TesterBaseT.cs b/src/UnitTestEx/Abstractions/TesterBaseT.cs index d75edfa..f2422dc 100644 --- a/src/UnitTestEx/Abstractions/TesterBaseT.cs +++ b/src/UnitTestEx/Abstractions/TesterBaseT.cs @@ -208,6 +208,45 @@ public TSelf UseAdditionalConfiguration(IEnumerableThe to support fluent-style method-chaining. public TSelf ReplaceSingleton(bool autoResetHost = true) where TService : class where TImplementation : class, TService => ConfigureServices(sc => sc.ReplaceSingleton(), autoResetHost); + /// + /// Replaces (where existing), or adds, a singleton service . + /// + /// The service . + /// The instance value. + /// The service key. + /// Indicates whether to automatically (passing false) when configuring the service. + /// The to support fluent-style method-chaining. + public TSelf ReplaceKeyedSingleton(TService instance, object? serviceKey, bool autoResetHost = true) where TService : class => ConfigureServices(sc => sc.ReplaceKeyedSingleton(serviceKey, _ => instance), autoResetHost); + + /// + /// Replaces (where existing), or adds, a singleton service using an . + /// + /// The service . + /// The implementation factory. + /// The service key. + /// Indicates whether to automatically (passing false) when configuring the service. + /// The to support fluent-style method-chaining. + public TSelf ReplaceKeyedSingleton(object? serviceKey, Func implementationFactory, bool autoResetHost = true) where TService : class => ConfigureServices(sc => sc.ReplaceKeyedSingleton(serviceKey, implementationFactory), autoResetHost); + + /// + /// Replaces (where existing), or adds, a singleton service. + /// + /// The service . + /// The service key. + /// Indicates whether to automatically (passing false) when configuring the service. + /// The to support fluent-style method-chaining. + public TSelf ReplaceKeyedSingleton(object? serviceKey, bool autoResetHost = true) where TService : class => ConfigureServices(sc => sc.ReplaceKeyedSingleton(serviceKey), autoResetHost); + + /// + /// Replaces (where existing), or adds, a singleton service. + /// + /// The service . + /// The implementation . + /// The service key. + /// Indicates whether to automatically (passing false) when configuring the service. + /// The to support fluent-style method-chaining. + public TSelf ReplaceKeyedSingleton(object? serviceKey, bool autoResetHost = true) where TService : class where TImplementation : class, TService => ConfigureServices(sc => sc.ReplaceKeyedSingleton(serviceKey), autoResetHost); + /// /// Replaces (where existing), or adds, a scoped service using an . /// @@ -234,6 +273,35 @@ public TSelf UseAdditionalConfiguration(IEnumerableThe to support fluent-style method-chaining. public TSelf ReplaceScoped(bool autoResetHost = true) where TService : class where TImplementation : class, TService => ConfigureServices(sc => sc.ReplaceScoped(), autoResetHost); + /// + /// Replaces (where existing), or adds, a scoped service using an . + /// + /// The service . + /// The service key. + /// The implementation factory. + /// Indicates whether to automatically (passing false) when configuring the service. + /// The to support fluent-style method-chaining. + public TSelf ReplaceKeyedScoped(object? serviceKey, Func implementationFactory, bool autoResetHost = true) where TService : class => ConfigureServices(sc => sc.ReplaceKeyedScoped(serviceKey, implementationFactory), autoResetHost); + + /// + /// Replaces (where existing), or adds, a scoped service. + /// + /// The service . + /// The service key. + /// Indicates whether to automatically (passing false) when configuring the service. + /// The to support fluent-style method-chaining. + public TSelf ReplaceKeyedScoped(object? serviceKey, bool autoResetHost = true) where TService : class => ConfigureServices(sc => sc.ReplaceKeyedScoped(serviceKey), autoResetHost); + + /// + /// Replaces (where existing), or adds, a scoped service. + /// + /// The service . + /// The implementation . + /// The service key. + /// Indicates whether to automatically (passing false) when configuring the service. + /// The to support fluent-style method-chaining. + public TSelf ReplaceKeyedScoped(object? serviceKey, bool autoResetHost = true) where TService : class where TImplementation : class, TService => ConfigureServices(sc => sc.ReplaceKeyedScoped(serviceKey), autoResetHost); + /// /// Replaces (where existing), or adds, a transient service using an . /// @@ -260,6 +328,35 @@ public TSelf UseAdditionalConfiguration(IEnumerableThe to support fluent-style method-chaining. public TSelf ReplaceTransient(bool autoResetHost = true) where TService : class where TImplementation : class, TService => ConfigureServices(sc => sc.ReplaceTransient(), autoResetHost); + /// + /// Replaces (where existing), or adds, a transient service using an . + /// + /// The service . + /// The service key. + /// The implementation factory. + /// Indicates whether to automatically (passing false) when configuring the service. + /// The to support fluent-style method-chaining. + public TSelf ReplaceKeyedTransient(object? serviceKey, Func implementationFactory, bool autoResetHost = true) where TService : class => ConfigureServices(sc => sc.ReplaceKeyedTransient(serviceKey, implementationFactory), autoResetHost); + + /// + /// Replaces (where existing), or adds, a transient service. + /// + /// The service . + /// The service key. + /// Indicates whether to automatically (passing false) when configuring the service. + /// The to support fluent-style method-chaining. + public TSelf ReplaceKeyedTransient(object? serviceKey, bool autoResetHost = true) where TService : class => ConfigureServices(sc => sc.ReplaceKeyedTransient(serviceKey), autoResetHost); + + /// + /// Replaces (where existing), or adds, a transient service. + /// + /// The service . + /// The implementation . + /// The service key. + /// Indicates whether to automatically (passing false) when configuring the service. + /// The to support fluent-style method-chaining. + public TSelf ReplaceKeyedTransient(object? serviceKey, bool autoResetHost = true) where TService : class where TImplementation : class, TService => ConfigureServices(sc => sc.ReplaceKeyedTransient(serviceKey), autoResetHost); + /// /// Wraps the host execution to perform required start-up style activities; specifically resetting the . /// @@ -268,7 +365,6 @@ public TSelf UseAdditionalConfiguration(IEnumerableThe . protected T HostExecutionWrapper(Func result) { - TestSetUp.LogAutoSetUpOutputs(Implementor); SharedState.Reset(); return result(); } diff --git a/src/UnitTestEx/AspNetCore/ApiTesterBase.cs b/src/UnitTestEx/AspNetCore/ApiTesterBase.cs index 9b6317f..c416ee3 100644 --- a/src/UnitTestEx/AspNetCore/ApiTesterBase.cs +++ b/src/UnitTestEx/AspNetCore/ApiTesterBase.cs @@ -19,7 +19,7 @@ namespace UnitTestEx.AspNetCore /// /// Provides the basic API unit-testing capabilities. /// - /// The API startup . + /// The API startup . /// The to support inheriting fluent-style method-chaining. public abstract class ApiTesterBase : TesterBase, IDisposable where TEntryPoint : class where TSelf : ApiTesterBase { @@ -121,7 +121,7 @@ protected override void ResetHost() /// /// Gets the for the specified from the underlying . /// - /// The to infer the category name. + /// The to infer the category name. /// The . public ILogger GetLogger() => Services.GetRequiredService>(); @@ -134,7 +134,7 @@ protected override void ResetHost() /// /// Specify the API Controller to test. /// - /// The API Controller . + /// The API Controller . /// The . public ControllerTester Controller() where TController : ControllerBase => new(this, GetTestServer()); @@ -145,18 +145,27 @@ protected override void ResetHost() public HttpTester Http() => new(this, GetTestServer()); /// - /// Enables a test to be sent to the underlying with an expected response value . + /// Enables a test to be sent to the underlying with an expected response value . /// - /// The response value . + /// The response value . /// The . public HttpTester Http() => new(this, GetTestServer()); /// - /// Specifies the of that is to be tested. + /// Enables a specified (of ) to be tested. /// - /// The to be tested. + /// The to be tested. + /// The optional keyed service key. /// The . - public TypeTester Type() where T : class => new(this, HostExecutionWrapper(Services.CreateScope)); + public TypeTester Type(object? serviceKey = null) where T : class => new(this, HostExecutionWrapper(Services.CreateScope), serviceKey); + + /// + /// Enables a specified (of ) to be tested. + /// + /// The to be tested. + /// The factory to create the instance. + /// The . + public TypeTester Type(Func serviceFactory) where T : class => new(this, HostExecutionWrapper(Services.CreateScope), serviceFactory); /// /// Gets the underlying . diff --git a/src/UnitTestEx/AspNetCore/HttpTesterBase.cs b/src/UnitTestEx/AspNetCore/HttpTesterBase.cs index 87ddff2..863a0dc 100644 --- a/src/UnitTestEx/AspNetCore/HttpTesterBase.cs +++ b/src/UnitTestEx/AspNetCore/HttpTesterBase.cs @@ -164,6 +164,8 @@ public class HttpDelegatingHandler(HttpTesterBase httpTester, HttpMessageHandler /// protected async override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + TestSetUp.LogAutoSetUpOutputs(_httpTester.Owner.Implementor); + if (_httpTester.Owner.SetUp.OnBeforeHttpRequestMessageSendAsync != null) await _httpTester.Owner.SetUp.OnBeforeHttpRequestMessageSendAsync(request, _httpTester.UserName, cancellationToken); diff --git a/src/UnitTestEx/AspNetCore/HttpTesterBaseT2.cs b/src/UnitTestEx/AspNetCore/HttpTesterBaseT2.cs index 5b81545..8c1bba9 100644 --- a/src/UnitTestEx/AspNetCore/HttpTesterBaseT2.cs +++ b/src/UnitTestEx/AspNetCore/HttpTesterBaseT2.cs @@ -57,6 +57,18 @@ public TSelf WithUser(object? userIdentifier) } /// - protected override Task AssertExpectationsAsync(HttpResponseMessage res) => ExpectationsArranger.AssertAsync(ExpectationsArranger.CreateArgs(LastLogs).AddExtra(res)); + protected async override Task AssertExpectationsAsync(HttpResponseMessage res) + { + TValue value = default!; + try + { + var json = await res.Content.ReadAsStringAsync(); + if (!string.IsNullOrEmpty(json)) + value = JsonSerializer.Deserialize(json)!; + } + catch { } + + await ExpectationsArranger.AssertAsync(ExpectationsArranger.CreateValueArgs(LastLogs, value).AddExtra(res)); + } } } \ No newline at end of file diff --git a/src/UnitTestEx/Assertors/ActionResultAssertor.cs b/src/UnitTestEx/Assertors/ActionResultAssertor.cs index 004d3a7..a2f408d 100644 --- a/src/UnitTestEx/Assertors/ActionResultAssertor.cs +++ b/src/UnitTestEx/Assertors/ActionResultAssertor.cs @@ -333,7 +333,7 @@ internal ActionResultAssertor AssertContentResult(TValue expectedValue, AssertResultType(); var cr = (ContentResult)Result; - if (expectedValue != null && cr.Content != null && TesterBase.JsonMediaTypeNames.Contains(cr.ContentType)) + if (expectedValue != null && cr.Content != null && !string.IsNullOrEmpty(cr.ContentType) && TesterBase.JsonMediaTypeNames.Contains(cr.ContentType)) return AssertValue(expectedValue, JsonSerializer.Deserialize(cr.Content)!, pathsToIgnore); else return AssertValue(expectedValue, cr.Content!, pathsToIgnore); diff --git a/src/UnitTestEx/Assertors/HttpResponseMessageAssertor.cs b/src/UnitTestEx/Assertors/HttpResponseMessageAssertor.cs index 0315816..c2e6748 100644 --- a/src/UnitTestEx/Assertors/HttpResponseMessageAssertor.cs +++ b/src/UnitTestEx/Assertors/HttpResponseMessageAssertor.cs @@ -80,7 +80,7 @@ public HttpResponseMessageAssertor AssertValue(TValue? expectedValue, pa return this; } - if (TesterBase.JsonMediaTypeNames.Contains(Response.Content.Headers?.ContentType?.MediaType)) + if (!string.IsNullOrEmpty(Response.Content.Headers?.ContentType?.MediaType) && TesterBase.JsonMediaTypeNames.Contains(Response.Content.Headers.ContentType.MediaType!)) { var json = Response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); if (expectedValue == null) diff --git a/src/UnitTestEx/Assertors/HttpResultAssertor.cs b/src/UnitTestEx/Assertors/HttpResultAssertor.cs new file mode 100644 index 0000000..fed3b84 --- /dev/null +++ b/src/UnitTestEx/Assertors/HttpResultAssertor.cs @@ -0,0 +1,69 @@ +// 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.Diagnostics; +using System.IO; +using System.Net.Http; +using UnitTestEx.Abstractions; + +namespace UnitTestEx.Assertors +{ + /// + /// Represents the test assert helper; specifically the . + /// + /// The owning . + /// The . + /// The (if any). + public class HttpResultAssertor(TesterBase owner, IResult result, Exception? exception) : AssertorBase(owner, exception) + { + /// + /// Gets the . + /// + public IResult Result { get; } = result; + + /// + /// Converts the to an . + /// + /// The optional requesting with ; otherwise, will default. + /// The corresponding . + public HttpResponseMessageAssertor ToHttpResponseMessageAssertor(HttpRequest? httpRequest = null) => ToHttpResponseMessageAssertor(Owner, Result, httpRequest); + + /// + /// Converts the to an . + /// + /// The owning . + /// The to convert. + /// The optional requesting ; otherwise, will default. + /// The corresponding . + internal static HttpResponseMessageAssertor ToHttpResponseMessageAssertor(TesterBase owner, IResult result, HttpRequest? httpRequest) + { + var sw = Stopwatch.StartNew(); + using var ms = new MemoryStream(); + var context = httpRequest?.HttpContext ?? new DefaultHttpContext { RequestServices = owner.Services }; + context.Response.Body = ms; + + result.ExecuteAsync(context).GetAwaiter().GetResult(); + + var hr = new HttpResponseMessage((System.Net.HttpStatusCode)context.Response.StatusCode); + foreach (var h in context.Response.Headers) + hr.Headers.TryAddWithoutValidation(h.Key, [.. h.Value]); + + ms.Position = 0; + hr.Content = new ByteArrayContent(ms.ToArray()); + + hr.Content.Headers.ContentLength = context.Response.ContentLength; + if (context.Response.ContentType is not null && System.Net.Http.Headers.MediaTypeHeaderValue.TryParse(context.Response.ContentType, out var ct)) + hr.Content.Headers.ContentType = ct; + + sw.Stop(); + owner.LogHttpResponseMessage(hr, sw); + + return new HttpResponseMessageAssertor(owner, hr); + } + } +} + +#endif \ No newline at end of file diff --git a/src/UnitTestEx/Assertors/ValueAssertor.cs b/src/UnitTestEx/Assertors/ValueAssertor.cs index a83753e..2183bd8 100644 --- a/src/UnitTestEx/Assertors/ValueAssertor.cs +++ b/src/UnitTestEx/Assertors/ValueAssertor.cs @@ -111,11 +111,19 @@ public HttpResponseMessageAssertor ToHttpResponseMessageAssertor(HttpRequest? ht if (Value is HttpResponseMessage hrm) return new HttpResponseMessageAssertor(Owner, hrm); - if (Value is ActionResult ar) + if (Value is IActionResult ar) return ActionResultAssertor.ToHttpResponseMessageAssertor(Owner, ar, httpRequest); +#if NET7_0_OR_GREATER + if (Value is IResult ir) + return HttpResultAssertor.ToHttpResponseMessageAssertor(Owner, ir, 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/ErrorExpectations.cs b/src/UnitTestEx/Expectations/ErrorExpectations.cs index ed5ad49..261ed10 100644 --- a/src/UnitTestEx/Expectations/ErrorExpectations.cs +++ b/src/UnitTestEx/Expectations/ErrorExpectations.cs @@ -16,6 +16,9 @@ namespace UnitTestEx.Expectations /// The initiating tester. public class ErrorExpectations(TesterBase owner, TTester tester) : ExpectationsBase(owner, tester) { + /// + public override string Title => "Error expectations"; + /// /// Gets or sets the expected error (contains) message (as distinct from the ). /// diff --git a/src/UnitTestEx/Expectations/ExceptionExpectations.cs b/src/UnitTestEx/Expectations/ExceptionExpectations.cs index 1d84032..580814c 100644 --- a/src/UnitTestEx/Expectations/ExceptionExpectations.cs +++ b/src/UnitTestEx/Expectations/ExceptionExpectations.cs @@ -14,6 +14,9 @@ namespace UnitTestEx.Expectations /// The initiating tester. public class ExceptionExpectations(TesterBase owner, TTester tester) : ExpectationsBase(owner, tester) { + /// + public override string Title => "Exception expectations"; + /// public override int Order => int.MinValue; diff --git a/src/UnitTestEx/Expectations/ExpectationsArranger.cs b/src/UnitTestEx/Expectations/ExpectationsArranger.cs index 93627e6..2acc016 100644 --- a/src/UnitTestEx/Expectations/ExpectationsArranger.cs +++ b/src/UnitTestEx/Expectations/ExpectationsArranger.cs @@ -70,14 +70,14 @@ public bool TryGet(out TExpectation? expectation) where TExpectati } /// - /// Performs the expectations assertion(s) with the specified and . + /// Performs the expectations assertion(s) with the specified and , and then does a (regardless of outcome). /// /// The logs captured. /// The . public Task AssertAsync(IEnumerable? logs, Exception? exception = null) => AssertAsync(CreateArgs(logs, exception)); /// - /// Performs the expectations assertion(s) with the specified , and . + /// Performs the expectations assertion(s) with the specified , and , and then does a (regardless of outcome). /// /// The logs captured. /// The resulting value. @@ -85,18 +85,25 @@ public bool TryGet(out TExpectation? expectation) where TExpectati public Task AssertValueAsync(IEnumerable? logs, object? value, Exception? exception = null) => AssertAsync(CreateValueArgs(logs, value, exception)); /// - /// Performs the expectations assertion(s) for the specified and then does a (regardless of outcome). + /// Performs the expectations assertion(s) for the specified and then does a (regardless of outcome). /// public async Task AssertAsync(AssertArgs args) { try { + if (_expectations.Values.Count > 0) + { + Owner.Implementor.WriteLine(""); + Owner.Implementor.WriteLine("EXPECTATIONS >"); + } + foreach (var assert in _expectations.Values.OrderBy(x => x.Order)) { + Owner.Implementor.WriteLine($"> {assert.Title}."); await assert.AssertAsync(args).ConfigureAwait(false); } } - finally { Reset(); } + finally { Clear(); } } /// @@ -111,9 +118,13 @@ public void Reset() } /// - /// Clears (removes) any existing expectations. + /// and clears (removes) any existing expectations. /// - public void Clear() => _expectations.Clear(); + public void Clear() + { + Reset(); + _expectations.Clear(); + } /// /// Creates a new . diff --git a/src/UnitTestEx/Expectations/ExpectationsBase.cs b/src/UnitTestEx/Expectations/ExpectationsBase.cs index 186de19..5b9c95e 100644 --- a/src/UnitTestEx/Expectations/ExpectationsBase.cs +++ b/src/UnitTestEx/Expectations/ExpectationsBase.cs @@ -20,6 +20,11 @@ public abstract class ExpectationsBase(TesterBase owner) /// public TesterBase Owner { get; } = owner ?? throw new ArgumentNullException(nameof(owner)); + /// + /// Gets or sets the title used in the assertion output. + /// + public abstract string Title { get; } + /// /// Gets or sets the order in which the expectation is asserted. /// diff --git a/src/UnitTestEx/Expectations/HttpResponseMessageExpectations.cs b/src/UnitTestEx/Expectations/HttpResponseMessageExpectations.cs index 5884e4f..a5acf58 100644 --- a/src/UnitTestEx/Expectations/HttpResponseMessageExpectations.cs +++ b/src/UnitTestEx/Expectations/HttpResponseMessageExpectations.cs @@ -17,6 +17,9 @@ public class HttpResponseMessageExpectations(TesterBase owner, TTester { private HttpStatusCode? _httpStatusCode; + /// + public override string Title => "HTTP Response Message expectations"; + /// /// Expects that the is equal to the . /// diff --git a/src/UnitTestEx/Expectations/LoggerExpectations.cs b/src/UnitTestEx/Expectations/LoggerExpectations.cs index 1604ced..7610fd4 100644 --- a/src/UnitTestEx/Expectations/LoggerExpectations.cs +++ b/src/UnitTestEx/Expectations/LoggerExpectations.cs @@ -18,6 +18,9 @@ public class LoggerExpectations(TesterBase owner, TTester tester) : Exp { private readonly List _expectTexts = []; + /// + public override string Title => "Logger expectations"; + /// /// Expects that the will have logged a message that contains the specified . /// diff --git a/src/UnitTestEx/Expectations/ValueExpectations.cs b/src/UnitTestEx/Expectations/ValueExpectations.cs index 2f47466..e018e31 100644 --- a/src/UnitTestEx/Expectations/ValueExpectations.cs +++ b/src/UnitTestEx/Expectations/ValueExpectations.cs @@ -20,6 +20,9 @@ public class ValueExpectations(TesterBase owner, TTester tester) : Expe private Func? _json; private bool _expectNull; + /// + public override string Title => "Value expectations"; + /// /// Expects that the result JSON compares to the expected . /// diff --git a/src/UnitTestEx/ExtensionMethods.cs b/src/UnitTestEx/ExtensionMethods.cs index 30d0bbe..51d2eb6 100644 --- a/src/UnitTestEx/ExtensionMethods.cs +++ b/src/UnitTestEx/ExtensionMethods.cs @@ -62,6 +62,61 @@ public static IServiceCollection ReplaceSingleton(thi return services.AddSingleton(); } + /* Keyed */ + + /// + /// Replaces (where existing), or adds, a keyed singleton service . + /// + /// The service . + /// The . + /// The service key. + /// The instance value. + /// The to support fluent-style method-chaining. + public static IServiceCollection ReplaceKeyedSingleton(this IServiceCollection services, object? serviceKey, TService instance) where TService : class => ReplaceKeyedSingleton(services, serviceKey, _ => instance); + + /// + /// Replaces (where existing), or adds, a keyed singleton service using an . + /// + /// The service . + /// The . + /// The service key. + /// The implementation factory. + /// The to support fluent-style method-chaining. + public static IServiceCollection ReplaceKeyedSingleton(this IServiceCollection services, object? serviceKey, Func implementationFactory) where TService : class + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(implementationFactory); + + services.RemoveKeyed(serviceKey); + return services.AddKeyedSingleton(serviceKey, implementationFactory); + } + + /// + /// Replaces (where existing), or adds, a keyed singleton service. + /// + /// The service . + /// The . + /// The service key. + /// The to support fluent-style method-chaining. + public static IServiceCollection ReplaceKeyedSingleton(this IServiceCollection services, object? serviceKey) where TService : class + => ReplaceKeyedSingleton(services, serviceKey); + + /// + /// Replaces (where existing), or adds, a keyed singleton service. + /// + /// The service . + /// The implementation . + /// The . + /// The service key. + /// The to support fluent-style method-chaining. + public static IServiceCollection ReplaceKeyedSingleton(this IServiceCollection services, object? serviceKey) where TService : class where TImplementation : class, TService + { + ArgumentNullException.ThrowIfNull(services); + + services.RemoveKeyed(serviceKey); + return services.AddKeyedSingleton(serviceKey); + } + #endregion #region Scoped @@ -106,6 +161,51 @@ public static IServiceCollection ReplaceScoped(this I return services.AddScoped(); } + /* Keyed */ + + /// + /// Replaces (where existing), or adds, a keyed scoped service using an . + /// + /// The service . + /// The . + /// The service key. + /// The implementation factory. + /// The to support fluent-style method-chaining. + public static IServiceCollection ReplaceKeyedScoped(this IServiceCollection services, object? serviceKey, Func implementationFactory) where TService : class + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(implementationFactory); + + services.RemoveKeyed(serviceKey); + return services.AddKeyedScoped(serviceKey, implementationFactory); + } + + /// + /// Replaces (where existing), or adds, a keyed scoped service. + /// + /// The service . + /// The . + /// The service key. + /// The to support fluent-style method-chaining. + public static IServiceCollection ReplaceKeyedScoped(this IServiceCollection services, object? serviceKey) where TService : class + => ReplaceKeyedScoped(services, serviceKey); + + /// + /// Replaces (where existing), or adds, a keyed scoped service. + /// + /// The service . + /// The implementation . + /// The . + /// The service key. + /// The to support fluent-style method-chaining. + public static IServiceCollection ReplaceKeyedScoped(this IServiceCollection services, object? serviceKey) where TService : class where TImplementation : class, TService + { + ArgumentNullException.ThrowIfNull(services); + + services.RemoveKeyed(serviceKey); + return services.AddKeyedScoped(serviceKey); + } + #endregion #region Transient @@ -150,6 +250,51 @@ public static IServiceCollection ReplaceTransient(thi return services.AddTransient(); } + /* Keyed */ + + /// + /// Replaces (where existing), or adds, a keyed transient service using an . + /// + /// The service . + /// The . + /// The service key. + /// The implementation factory. + /// The to support fluent-style method-chaining. + public static IServiceCollection ReplaceKeyedTransient(this IServiceCollection services, object? serviceKey, Func implementationFactory) where TService : class + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(implementationFactory); + + services.RemoveKeyed(serviceKey); + return services.AddKeyedTransient(serviceKey, implementationFactory); + } + + /// + /// Replaces (where existing), or adds, a keyed transient service. + /// + /// The service . + /// The . + /// The service key. + /// The to support fluent-style method-chaining. + public static IServiceCollection ReplaceKeyedTransient(this IServiceCollection services, object? serviceKey) where TService : class + => ReplaceKeyedTransient(services, serviceKey); + + /// + /// Replaces (where existing), or adds, a keyed transient service. + /// + /// The service . + /// The implementation . + /// The . + /// The service key. + /// The to support fluent-style method-chaining. + public static IServiceCollection ReplaceKeyedTransient(this IServiceCollection services, object? serviceKey) where TService : class where TImplementation : class, TService + { + ArgumentNullException.ThrowIfNull(services); + + services.RemoveKeyed(serviceKey); + return services.AddKeyedTransient(serviceKey); + } + #endregion /// @@ -157,15 +302,22 @@ public static IServiceCollection ReplaceTransient(thi /// /// The to instantiate. /// The . + /// The optional keyed service key. /// A reference to the newly created object. /// Where not specifically configured within the DI simulatution will occur by performing constructor-based injection for all required parameters. - public static T CreateInstance(this IServiceProvider serviceProvider) where T : class + public static T CreateInstance(this IServiceProvider serviceProvider, object? serviceKey = null) where T : class { // Try instantiating using service provider and use if successful. - var val = (serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider))).GetService(); + var val = serviceKey is null + ? (serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider))).GetService() + : (serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider))).GetKeyedService(serviceKey); + if (val != null) return val; + if (serviceKey is not null) + throw new InvalidOperationException($"Unable to instantiate Type '{typeof(T).Name}' with key '{serviceKey}'."); + var type = typeof(T); var ctor = type.GetConstructors().FirstOrDefault(); if (ctor == null) @@ -183,7 +335,7 @@ public static T CreateInstance(this IServiceProvider serviceProvider) where T } /// - /// Removes all items from the for the specified . + /// Removes the first occurrence from the for the specified . /// /// The service . /// The . @@ -193,5 +345,18 @@ public static bool Remove(this IServiceCollection services) where TSer var descriptor = (services ?? throw new ArgumentNullException(nameof(services))).FirstOrDefault(d => d.ServiceType == typeof(TService)); return descriptor != null && services.Remove(descriptor); } + + /// + /// Removes the first occurrence from the for the specified and . + /// + /// The service . + /// The . + /// The service key. + /// true if item was successfully removed; otherwise, false. Also returns false where item was not found. + public static bool RemoveKeyed(this IServiceCollection services, object? serviceKey) where TService : class + { + var descriptor = (services ?? throw new ArgumentNullException(nameof(services))).FirstOrDefault(d => d.ServiceType == typeof(TService) && d.IsKeyedService && d.ServiceKey == serviceKey); + return descriptor != null && services.Remove(descriptor); + } } } \ No newline at end of file diff --git a/src/UnitTestEx/Generic/GenericTesterBase.cs b/src/UnitTestEx/Generic/GenericTesterBase.cs index 934ecbb..6961a91 100644 --- a/src/UnitTestEx/Generic/GenericTesterBase.cs +++ b/src/UnitTestEx/Generic/GenericTesterBase.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using System; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using UnitTestEx.Abstractions; using UnitTestEx.Assertors; @@ -20,6 +21,35 @@ namespace UnitTestEx.Generic public abstract class GenericTesterBase(TestFrameworkImplementor implementor) : GenericTesterCore>(implementor) where TEntryPoint : class where TSelf : GenericTesterBase { + private bool _runAsScoped; + private Func? _onRunScopeFuncAsync; + + /// + /// Indicates that the underlying Run methods should be scoped (i.e. . + /// + /// The optional function to execute before the primary Run* methods when running as scoped. + /// The tester to support fluent-style method-chaining. + /// By default the Run methods are not scoped. + public TSelf UseRunAsScoped(Func? onRunAsScoped = null) + { + _runAsScoped = true; + _onRunScopeFuncAsync = onRunAsScoped; + return (TSelf)this; + } + + /// + /// Indicates that the underlying Run methods should be scoped (i.e. . + /// + /// indicates scoped; otherwise, . + /// The tester to support fluent-style method-chaining. + /// By default the Run methods are not scoped. + public TSelf UseRunAsScoped(bool runAsScoped) + { + _runAsScoped = runAsScoped; + _onRunScopeFuncAsync = null; + return (TSelf)this; + } + /// /// Executes the that performs the logic. /// @@ -36,10 +66,25 @@ public VoidAssertor Run(Action action) => RunAsync(() => /// /// The configured service to instantiate. /// The function performing the logic. + /// The optional keyed service key. /// The resulting . - public VoidAssertor Run(Action action) where TService : class => RunAsync(() => + public VoidAssertor Run(Action action, object? serviceKey = null) where TService : class => RunAsync(() => { - var service = Services.GetRequiredService(); + var service = serviceKey is null ? Services.GetRequiredService() : Services.GetRequiredKeyedService(serviceKey); + (action ?? throw new ArgumentNullException(nameof(action))).Invoke(service); + return Task.CompletedTask; + }).GetAwaiter().GetResult(); + + /// + /// Executes the that performs the logic. + /// + /// The configured service to instantiate. + /// The function performing the logic. + /// The factory to create the instance. + /// The resulting . + public VoidAssertor Run(Action action, Func serviceFactory) where TService : class => RunAsync(() => + { + var service = serviceFactory(Services); (action ?? throw new ArgumentNullException(nameof(action))).Invoke(service); return Task.CompletedTask; }).GetAwaiter().GetResult(); @@ -49,37 +94,158 @@ public VoidAssertor Run(Action action) where TService : clas /// /// The function performing the logic. /// The resulting . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif public VoidAssertor Run(Func function) => RunAsync(function).GetAwaiter().GetResult(); +#if NET9_0_OR_GREATER + /// + /// Executes the that performs the logic. + /// + /// The function performing the logic. + /// The resulting . + [OverloadResolutionPriority(2)] + public VoidAssertor Run(Func function) => RunAsync(() => function().AsTask()).GetAwaiter().GetResult(); + +#endif + /// + /// Executes the that performs the logic on the specified . + /// + /// The configured service to instantiate. + /// The function performing the logic. + /// The optional keyed service key. + /// The resulting . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public VoidAssertor Run(Func function, object? serviceKey = null) where TService : class + { + var service = serviceKey is null ? Services.GetRequiredService() : Services.GetRequiredKeyedService(serviceKey); + return RunAsync(() => function(service)).GetAwaiter().GetResult(); + } + +#if NET9_0_OR_GREATER + /// + /// Executes the that performs the logic on the specified . + /// + /// The configured service to instantiate. + /// The function performing the logic. + /// The optional keyed service key. + /// The resulting . + [OverloadResolutionPriority(2)] + public VoidAssertor Run(Func function, object? serviceKey = null) where TService : class + { + var service = serviceKey is null ? Services.GetRequiredService() : Services.GetRequiredKeyedService(serviceKey); + return RunAsync(() => function(service).AsTask()).GetAwaiter().GetResult(); + } + +#endif /// /// Executes the that performs the logic on the specified . /// /// The configured service to instantiate. /// The function performing the logic. + /// The factory to create the instance. /// The resulting . - public VoidAssertor Run(Func function) where TService : class +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public VoidAssertor Run(Func function, Func serviceFactory) where TService : class { - var service = Services.GetRequiredService(); + var service = serviceFactory(Services); return RunAsync(() => function(service)).GetAwaiter().GetResult(); } +#if NET9_0_OR_GREATER /// /// Executes the that performs the logic on the specified . /// /// The configured service to instantiate. /// The function performing the logic. + /// The factory to create the instance. /// The resulting . - public Task RunAsync(Func function) where TService : class + [OverloadResolutionPriority(2)] + public VoidAssertor Run(Func function, Func serviceFactory) where TService : class { - var service = Services.GetRequiredService(); - return RunAsync(() => function(service)); + var service = serviceFactory(Services); + return RunAsync(() => function(service).AsTask()).GetAwaiter().GetResult(); } +#endif + /// + /// Executes the that performs the logic on the specified . + /// + /// The configured service to instantiate. + /// The function performing the logic. + /// The optional keyed service key. + /// The resulting . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public async Task RunAsync(Func function, object? serviceKey = null) where TService : class + { + var service = serviceKey is null ? Services.GetRequiredService() : Services.GetRequiredKeyedService(serviceKey); + return await RunAsync(() => function(service)).ConfigureAwait(false); + } + +#if NET9_0_OR_GREATER + /// + /// Executes the that performs the logic on the specified . + /// + /// The configured service to instantiate. + /// The function performing the logic. + /// The optional keyed service key. + /// The resulting . + [OverloadResolutionPriority(2)] + public async Task RunAsync(Func function, object? serviceKey = null) where TService : class + { + var service = serviceKey is null ? Services.GetRequiredService() : Services.GetRequiredKeyedService(serviceKey); + return await RunAsync(() => function(service).AsTask()).ConfigureAwait(false); + } + +#endif + /// + /// Executes the that performs the logic on the specified . + /// + /// The configured service to instantiate. + /// The function performing the logic. + /// The factory to create the instance. + /// The resulting . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public async Task RunAsync(Func function, Func serviceFactory) where TService : class + { + var service = serviceFactory(Services); + return await RunAsync(() => function(service)).ConfigureAwait(false); + } + +#if NET9_0_OR_GREATER + /// + /// Executes the that performs the logic on the specified . + /// + /// The configured service to instantiate. + /// The function performing the logic. + /// The factory to create the instance. + /// The resulting . + [OverloadResolutionPriority(2)] + public async Task RunAsync(Func function, Func serviceFactory) where TService : class + { + var service = serviceFactory(Services); + return await RunAsync(() => function(service).AsTask()).ConfigureAwait(false); + } + +#endif + /// /// Executes the that performs the logic. /// /// The function performing the logic. /// The resulting . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif public async Task RunAsync(Func function) { ArgumentNullException.ThrowIfNull(function); @@ -90,12 +256,25 @@ public async Task RunAsync(Func function) Implementor.WriteLine("GENERIC TESTER..."); Implementor.WriteLine(""); + await OnBeforeRunAsync().ConfigureAwait(false); + if (OnBeforeRunFuncAsync is not null) + await OnBeforeRunFuncAsync().ConfigureAwait(false); + Exception? exception = null; + IServiceScope? scope = null; var sw = System.Diagnostics.Stopwatch.StartNew(); try { + scope = _runAsScoped ? Services.CreateScope() : null; + if (scope is not null) + { + await OnRunScopeAsync(scope).ConfigureAwait(false); + if (_onRunScopeFuncAsync is not null) + await _onRunScopeFuncAsync(scope).ConfigureAwait(false); + } + await function().ConfigureAwait(false); } catch (AggregateException aex) @@ -106,6 +285,10 @@ public async Task RunAsync(Func function) { exception = ex; } + finally + { + scope?.Dispose(); + } sw.Stop(); @@ -132,15 +315,22 @@ public async Task RunAsync(Func function) else Implementor.WriteLine($"Result: Success"); - Implementor.WriteLine(""); - Implementor.WriteLine(new string('=', 80)); - Implementor.WriteLine(""); - await ExpectationsArranger.AssertAsync(messages, exception).ConfigureAwait(false); return new VoidAssertor(this, exception); } +#if NET9_0_OR_GREATER + /// + /// Executes the that performs the logic. + /// + /// The function performing the logic. + /// The resulting . + [OverloadResolutionPriority(2)] + public async Task RunAsync(Func function) + => await RunAsync(() => function().AsTask()).ConfigureAwait(false); + +#endif /// /// Executes the that performs the logic. /// @@ -159,10 +349,11 @@ public ValueAssertor Run(Func function) => RunAsync(() = /// The configured service to instantiate. /// The result value . /// The function performing the logic. + /// The optional keyed service key. /// The resulting . - public ValueAssertor Run(Func function) where TService : class => RunAsync(() => + public ValueAssertor Run(Func function, object? serviceKey = null) where TService : class => RunAsync(() => { - var service = Services.GetRequiredService(); + var service = serviceKey is null ? Services.GetRequiredService() : Services.GetRequiredKeyedService(serviceKey); TValue value = (function ?? throw new ArgumentNullException(nameof(function))).Invoke(service); return Task.FromResult(value); }).GetAwaiter().GetResult(); @@ -170,43 +361,190 @@ public ValueAssertor Run(Func functi /// /// Executes the that performs the logic. /// + /// The configured service to instantiate. /// The result value . /// The function performing the logic. + /// The factory to create the instance. /// The resulting . + public ValueAssertor Run(Func function, Func serviceFactory) where TService : class => RunAsync(() => + { + var service = serviceFactory(Services); + TValue value = (function ?? throw new ArgumentNullException(nameof(function))).Invoke(service); + return Task.FromResult(value); + }).GetAwaiter().GetResult(); + + /// + /// Executes the that performs the logic. + /// + /// The result value . + /// The function performing the logic. + /// The resulting . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif public ValueAssertor Run(Func> function) => RunAsync(function).GetAwaiter().GetResult(); +#if NET9_0_OR_GREATER + /// + /// Executes the that performs the logic. + /// + /// The result value . + /// The function performing the logic. + /// The resulting . + [OverloadResolutionPriority(2)] + public ValueAssertor Run(Func> function) => RunAsync(() => function().AsTask()).GetAwaiter().GetResult(); + +#endif + /// + /// Executes the that performs the logic on the specified . + /// + /// The configured service to instantiate. + /// The result value . + /// The function performing the logic. + /// The optional keyed service key. + /// The resulting . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public ValueAssertor Run(Func> function, object? serviceKey = null) where TService : class + { + var service = serviceKey is null ? Services.GetRequiredService() : Services.GetRequiredKeyedService(serviceKey); + return RunAsync(() => function(service)).GetAwaiter().GetResult(); + } + +#if NET9_0_OR_GREATER + + /// + /// Executes the that performs the logic on the specified . + /// + /// The configured service to instantiate. + /// The result value . + /// The function performing the logic. + /// The optional keyed service key. + /// The resulting . + [OverloadResolutionPriority(2)] + public ValueAssertor Run(Func> function, object? serviceKey = null) where TService : class + { + var service = serviceKey is null ? Services.GetRequiredService() : Services.GetRequiredKeyedService(serviceKey); + return RunAsync(() => function(service).AsTask()).GetAwaiter().GetResult(); + } + +#endif /// /// Executes the that performs the logic on the specified . /// /// The configured service to instantiate. /// The result value . /// The function performing the logic. + /// The factory to create the instance. /// The resulting . - public ValueAssertor Run(Func> function) where TService : class +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public ValueAssertor Run(Func> function, Func serviceFactory) where TService : class { - var service = Services.GetRequiredService(); + var service = serviceFactory(Services); return RunAsync(() => function(service)).GetAwaiter().GetResult(); } +#if NET9_0_OR_GREATER + + /// + /// Executes the that performs the logic on the specified . + /// + /// The configured service to instantiate. + /// The result value . + /// The function performing the logic. + /// The factory to create the instance. + /// The resulting . + [OverloadResolutionPriority(2)] + public ValueAssertor Run(Func> function, Func serviceFactory) where TService : class + { + var service = serviceFactory(Services); + return RunAsync(() => function(service).AsTask()).GetAwaiter().GetResult(); + } + +#endif + + /// + /// Executes the that performs the logic on the specified . + /// + /// The configured service to instantiate. + /// The result value . + /// The function performing the logic. + /// The optional keyed service key. + /// The resulting . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public async Task> RunAsync(Func> function, object? serviceKey = null) where TService : class + { + var service = serviceKey is null ? Services.GetRequiredService() : Services.GetRequiredKeyedService(serviceKey); + return await RunAsync(() => function(service)).ConfigureAwait(false); + } + +#if NET9_0_OR_GREATER + /// + /// Executes the that performs the logic on the specified . + /// + /// The configured service to instantiate. + /// The result value . + /// The function performing the logic. + /// The optional keyed service key. + /// The resulting . + [OverloadResolutionPriority(2)] + public async Task> RunAsync(Func> function, object? serviceKey = null) where TService : class + { + var service = serviceKey is null ? Services.GetRequiredService() : Services.GetRequiredKeyedService(serviceKey); + return await RunAsync(() => function(service).AsTask()).ConfigureAwait(false); + } + +#endif + + /// + /// Executes the that performs the logic on the specified . + /// + /// The configured service to instantiate. + /// The result value . + /// The function performing the logic. + /// The factory to create the instance. + /// The resulting . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public async Task> RunAsync(Func> function, Func serviceFactory) where TService : class + { + var service = serviceFactory(Services); + return await RunAsync(() => function(service)).ConfigureAwait(false); + } + +#if NET9_0_OR_GREATER /// /// Executes the that performs the logic on the specified . /// /// The configured service to instantiate. /// The result value . /// The function performing the logic. + /// The factory to create the instance. /// The resulting . - public Task> RunAsync(Func> function) where TService : class + [OverloadResolutionPriority(2)] + public async Task> RunAsync(Func> function, Func serviceFactory) where TService : class { - var service = Services.GetRequiredService(); - return RunAsync(() => function(service)); + var service = serviceFactory(Services); + return await RunAsync(() => function(service).AsTask()).ConfigureAwait(false); } +#endif + /// /// Executes the that performs the logic. /// /// The result value . /// The function performing the logic. /// The resulting . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif public async Task> RunAsync(Func> function) { ArgumentNullException.ThrowIfNull(function); @@ -217,13 +555,26 @@ public async Task> RunAsync(Func> fun Implementor.WriteLine("GENERIC TESTER..."); Implementor.WriteLine(""); + await OnBeforeRunAsync().ConfigureAwait(false); + if (OnBeforeRunFuncAsync is not null) + await OnBeforeRunFuncAsync().ConfigureAwait(false); + Exception? exception = null; + IServiceScope? scope = null; var sw = System.Diagnostics.Stopwatch.StartNew(); TValue value = default!; try { + scope = _runAsScoped ? Services.CreateScope() : null; + if (scope is not null) + { + await OnRunScopeAsync(scope).ConfigureAwait(false); + if (_onRunScopeFuncAsync is not null) + await _onRunScopeFuncAsync(scope).ConfigureAwait(false); + } + value = await function().ConfigureAwait(false); } catch (AggregateException aex) @@ -234,6 +585,10 @@ public async Task> RunAsync(Func> fun { exception = ex; } + finally + { + scope?.Dispose(); + } sw.Stop(); @@ -270,13 +625,37 @@ public async Task> RunAsync(Func> fun } } - Implementor.WriteLine(""); - Implementor.WriteLine(new string('=', 80)); - Implementor.WriteLine(""); - await ExpectationsArranger.AssertAsync(messages, exception).ConfigureAwait(false); return new ValueAssertor(this, value, exception); } + +#if NET9_0_OR_GREATER + /// + /// Executes the that performs the logic. + /// + /// The result value . + /// The function performing the logic. + /// The resulting . + [OverloadResolutionPriority(2)] + public async Task> RunAsync(Func> function) + => await RunAsync(() => function().AsTask()).ConfigureAwait(false); +#endif + + /// + /// Provides an opportunity to perform any pre-run logic. + /// + protected virtual Task OnBeforeRunAsync() => Task.CompletedTask; + + /// + /// Gets or sets the function to perform any pre-run logic. + /// + public Func? OnBeforeRunFuncAsync { get; set; } + + /// + /// Provides an opportunity to perform any logic as a result of the . + /// + /// This is invoked after the , but before the Run logic. + protected virtual Task OnRunScopeAsync(IServiceScope scope) => Task.CompletedTask; } } \ No newline at end of file diff --git a/src/UnitTestEx/Generic/GenericTesterBaseT.cs b/src/UnitTestEx/Generic/GenericTesterBaseT.cs index e91b4a3..3a52b35 100644 --- a/src/UnitTestEx/Generic/GenericTesterBaseT.cs +++ b/src/UnitTestEx/Generic/GenericTesterBaseT.cs @@ -144,10 +144,6 @@ public async Task> RunAsync(Func> function) } } - Implementor.WriteLine(""); - Implementor.WriteLine(new string('=', 80)); - Implementor.WriteLine(""); - await ExpectationsArranger.AssertAsync(messages, exception).ConfigureAwait(false); return new ValueAssertor(this, value, exception); diff --git a/src/UnitTestEx/Hosting/HostTesterBase.cs b/src/UnitTestEx/Hosting/HostTesterBase.cs index f00af6c..c8b411a 100644 --- a/src/UnitTestEx/Hosting/HostTesterBase.cs +++ b/src/UnitTestEx/Hosting/HostTesterBase.cs @@ -25,7 +25,7 @@ public class HostTesterBase(TesterBase owner, IServiceScope serviceScope) /// /// Gets the owning . /// - protected TesterBase Owner { get; } = owner ?? throw new ArgumentNullException(nameof(owner)); + public TesterBase Owner { get; } = owner ?? throw new ArgumentNullException(nameof(owner)); /// /// Gets the . @@ -45,7 +45,7 @@ public class HostTesterBase(TesterBase owner, IServiceScope serviceScope) /// /// Create (instantiate) the using the to provide the constructor based dependency injection (DI) values. /// - private THost CreateHost() => ServiceScope.ServiceProvider.CreateInstance(); + private THost CreateHost(object? serviceKey) => ServiceScope.ServiceProvider.CreateInstance(serviceKey); /// /// Orchestrates the execution of a method as described by the returning no result. @@ -85,7 +85,7 @@ public class HostTesterBase(TesterBase owner, IServiceScope serviceScope) onBeforeRun?.Invoke(@params, paramAttribute, paramValue); - var h = CreateHost(); + var h = CreateHost(null); var sw = Stopwatch.StartNew(); try @@ -146,7 +146,7 @@ public class HostTesterBase(TesterBase owner, IServiceScope serviceScope) onBeforeRun?.Invoke(@params, paramAttribute, paramValue); - var h = CreateHost(); + var h = CreateHost(null); var sw = Stopwatch.StartNew(); try diff --git a/src/UnitTestEx/Hosting/TypeTester.cs b/src/UnitTestEx/Hosting/TypeTester.cs index 0a6e149..f6b9ea2 100644 --- a/src/UnitTestEx/Hosting/TypeTester.cs +++ b/src/UnitTestEx/Hosting/TypeTester.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using UnitTestEx.Abstractions; using UnitTestEx.Assertors; @@ -15,29 +16,98 @@ namespace UnitTestEx.Hosting { /// - /// Provides the generic unit-testing capabilities. + /// Provides the generic unit-testing capabilities. /// - /// The (must be a class). - public class TypeTester : HostTesterBase, IExpectations> where T : class + /// The service (must be a class). + /// Note that the service instance is created on first use and then reused (see ). + public class TypeTester : HostTesterBase, IExpectations> where TService : class { + private readonly object? _serviceKey; + private readonly Func? _serviceFactory; + private TService? _service; + private bool _runAsScoped; + private Func? _onRunScopeFuncAsync; + /// /// Initializes a new class. /// /// The owning . /// The . - public TypeTester(TesterBase owner, IServiceScope serviceScope) : base(owner, serviceScope) => ExpectationsArranger = new ExpectationsArranger>(owner, this); + /// The optional key for a keyed service. + public TypeTester(TesterBase owner, IServiceScope serviceScope, object? serviceKey = null) : base(owner, serviceScope) + { + _serviceKey = serviceKey; + ExpectationsArranger = new ExpectationsArranger>(owner, this); + } + + /// + /// Initializes a new class with a factory for creating the instance. + /// + /// The owning . + /// The . + /// The factory to create the instance. + /// + public TypeTester(TesterBase owner, IServiceScope serviceScope, Func serviceFactory) : base(owner, serviceScope) + { + _serviceFactory = serviceFactory ?? throw new ArgumentNullException(nameof(serviceFactory)); + ExpectationsArranger = new ExpectationsArranger>(owner, this); + } + + /// + /// Indicates that the underlying Run methods should be scoped (i.e. . + /// + /// The optional function to execute before the primary Run* methods when running as scoped. + /// The tester to support fluent-style method-chaining. + /// By default the Run methods are not scoped. + public TypeTester UseRunAsScoped(Func? onRunAsScoped = null) + { + _runAsScoped = true; + _onRunScopeFuncAsync = onRunAsScoped; + return this; + } + + /// + /// Indicates that the underlying Run methods should be scoped (i.e. . + /// + /// indicates scoped; otherwise, . + /// The tester to support fluent-style method-chaining. + /// By default the Run methods are not scoped. + public TypeTester UseRunAsScoped(bool runAsScoped) + { + _runAsScoped = runAsScoped; + _onRunScopeFuncAsync = null; + return this; + } /// /// Gets the . /// - public ExpectationsArranger> ExpectationsArranger { get; } + public ExpectationsArranger> ExpectationsArranger { get; } + + /// + /// Gets or creates the service instance. + /// + /// This is intended for advanced scenarios; for the most part the Run or RunAsync methods should be used for testing as these encapsulate logging, expectations and assertions. + public TService GetOrCreateService() => _service ??= _serviceFactory is null + ? ServiceScope.ServiceProvider.CreateInstance(_serviceKey) + : _serviceFactory(ServiceScope.ServiceProvider); + + /// + /// Resets the service instance. + /// + /// The tester to support fluent-style method-chaining. + public TypeTester ResetService() + { + _service = default; + return this; + } /// /// Runs the synchronous method with no result. /// /// The function execution. /// A . - public VoidAssertor Run(Action function) => RunAsync(x => { function(x); return Task.CompletedTask; }).GetAwaiter().GetResult(); + public VoidAssertor Run(Action function) => RunAsync(x => { function(x); return Task.CompletedTask; }).GetAwaiter().GetResult(); /// /// Runs the synchronous method with a result. @@ -45,28 +115,59 @@ public class TypeTester : HostTesterBase, IExpectations> whe /// The result value . /// The function execution. /// A . - public ValueAssertor Run(Func function) => RunAsync(x => Task.FromResult(function(x))).GetAwaiter().GetResult(); + public ValueAssertor Run(Func function) => RunAsync(x => Task.FromResult(function(x))).GetAwaiter().GetResult(); /// /// Runs the asynchronous method with no result. /// /// The function execution. /// A . - public VoidAssertor Run(Func function) => RunAsync(function).GetAwaiter().GetResult(); +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public VoidAssertor Run(Func function) => RunAsync(function).GetAwaiter().GetResult(); +#if NET9_0_OR_GREATER /// /// Runs the asynchronous method with no result. /// /// The function execution. /// A . - public async Task RunAsync(Func function) + [OverloadResolutionPriority(2)] + public VoidAssertor Run(Func function) => RunAsync(v => function(v).AsTask()).GetAwaiter().GetResult(); + +#endif + /// + /// Runs the asynchronous method with no result. + /// + /// The function execution. + /// A . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public async Task RunAsync(Func function) { + TestSetUp.LogAutoSetUpOutputs(Implementor); + + IServiceScope? scope = null; Exception? ex = null; var sw = Stopwatch.StartNew(); try { LogHeader(); - var f = ServiceScope.ServiceProvider.CreateInstance(); + await OnBeforeRunAsync().ConfigureAwait(false); + if (OnBeforeRunFuncAsync is not null) + await OnBeforeRunFuncAsync().ConfigureAwait(false); + + scope = _runAsScoped ? ServiceScope.ServiceProvider.CreateScope() : null; + if (scope is not null) + { + await OnRunScopeAsync(scope).ConfigureAwait(false); + if (_onRunScopeFuncAsync is not null) + await _onRunScopeFuncAsync(scope).ConfigureAwait(false); + } + + var f = GetOrCreateService(); await (function ?? throw new ArgumentNullException(nameof(function)))(f).ConfigureAwait(false); } catch (AggregateException aex) @@ -79,42 +180,84 @@ public async Task RunAsync(Func function) } finally { + scope?.Dispose(); sw.Stop(); } await Task.Delay(TestSetUp.TaskDelayMilliseconds).ConfigureAwait(false); var logs = Owner.SharedState.GetLoggerMessages(); LogResult(ex, sw.Elapsed.TotalMilliseconds, logs); - LogTrailer(); await ExpectationsArranger.AssertAsync(logs, ex).ConfigureAwait(false); return new VoidAssertor(Owner, ex); } +#if NET9_0_OR_GREATER + /// + /// Runs the asynchronous method with no result. + /// + /// The function execution. + /// A . + [OverloadResolutionPriority(2)] + public async Task RunAsync(Func function) => await RunAsync(v => function(v).AsTask()).ConfigureAwait(false); + +#endif /// /// Runs the asynchronous method with a result. /// /// The result value . /// The function execution. /// A . - public ValueAssertor Run(Func> function) => RunAsync(function).GetAwaiter().GetResult(); +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public ValueAssertor Run(Func> function) => RunAsync(function).GetAwaiter().GetResult(); +#if NET9_0_OR_GREATER /// /// Runs the asynchronous method with a result. /// /// The result value . /// The function execution. /// A . - public async Task> RunAsync(Func> function) + [OverloadResolutionPriority(2)] + public ValueAssertor Run(Func> function) => RunAsync(v => function(v).AsTask()).GetAwaiter().GetResult(); + +#endif + /// + /// Runs the asynchronous method with a result. + /// + /// The result value . + /// The function execution. + /// A . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public async Task> RunAsync(Func> function) { + TestSetUp.LogAutoSetUpOutputs(Implementor); + + IServiceScope? scope = null; TValue result = default!; Exception? ex = null; var sw = Stopwatch.StartNew(); try { LogHeader(); - var f = ServiceScope.ServiceProvider.CreateInstance(); + await OnBeforeRunAsync().ConfigureAwait(false); + if (OnBeforeRunFuncAsync is not null) + await OnBeforeRunFuncAsync().ConfigureAwait(false); + + scope = _runAsScoped ? ServiceScope.ServiceProvider.CreateScope() : null; + if (scope is not null) + { + await OnRunScopeAsync(scope).ConfigureAwait(false); + if (_onRunScopeFuncAsync is not null) + await _onRunScopeFuncAsync(scope).ConfigureAwait(false); + } + + var f = GetOrCreateService(); result = await (function ?? throw new ArgumentNullException(nameof(function)))(f).ConfigureAwait(false); } catch (AggregateException aex) @@ -127,6 +270,7 @@ public async Task> RunAsync(Func> } finally { + scope?.Dispose(); sw.Stop(); } @@ -148,13 +292,22 @@ public async Task> RunAsync(Func> } } - LogTrailer(); - await ExpectationsArranger.AssertValueAsync(logs, result, ex).ConfigureAwait(false); return new ValueAssertor(Owner, result, ex); } +#if NET9_0_OR_GREATER + /// + /// Runs the asynchronous method with a result. + /// + /// The result value . + /// The function execution. + /// A . + [OverloadResolutionPriority(2)] + public async Task> RunAsync(Func> function) => await RunAsync(v => function(v).AsTask()).ConfigureAwait(false); + +#endif /// /// Logs the header. /// @@ -162,7 +315,7 @@ private void LogHeader() { Implementor.WriteLine(""); Implementor.WriteLine("TYPE TESTER..."); - Implementor.WriteLine($"Type: {typeof(T).Name} [{typeof(T).FullName}]"); + Implementor.WriteLine($"Type: {typeof(TService).Name} [{typeof(TService).FullName}]"); } /// @@ -193,13 +346,19 @@ private void LogResult(Exception? ex, double ms, IEnumerable? logs) } /// - /// Log the trailer. + /// Provides an opportunity to perform any pre-run logic. /// - private void LogTrailer() - { - Implementor.WriteLine(""); - Implementor.WriteLine(new string('=', 80)); - Implementor.WriteLine(""); - } + protected virtual Task OnBeforeRunAsync() => Task.CompletedTask; + + /// + /// Gets or sets the function to perform any pre-run logic. + /// + public Func? OnBeforeRunFuncAsync { get; set; } + + /// + /// Provides an opportunity to perform any logic as a result of the . + /// + /// This is invoked after the , but before the Run logic. + protected virtual Task OnRunScopeAsync(IServiceScope scope) => Task.CompletedTask; } } \ No newline at end of file diff --git a/src/UnitTestEx/Json/JsonElementComparerOptions.cs b/src/UnitTestEx/Json/JsonElementComparerOptions.cs index fc80d3b..3fa9927 100644 --- a/src/UnitTestEx/Json/JsonElementComparerOptions.cs +++ b/src/UnitTestEx/Json/JsonElementComparerOptions.cs @@ -22,6 +22,11 @@ public static JsonElementComparerOptions Default set => _default = value ?? throw new ArgumentNullException(nameof(value)); } + /// + /// Gets or sets the optional preamble text that provides context for the comparison in the resulting error message. + /// + public string? PreambleText { get; set; } = null; + /// /// Gets or sets the to use for comparing JSON paths. /// diff --git a/src/UnitTestEx/Mocking/MockHttpClientRequest.cs b/src/UnitTestEx/Mocking/MockHttpClientRequest.cs index 1f8046d..09f4900 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientRequest.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientRequest.cs @@ -226,7 +226,7 @@ public override string ToString() if (_content == null) return "'No content'"; - if (TesterBase.JsonMediaTypeNames.Contains(_mediaType?.ToLowerInvariant()) && _content is not string) + if (!string.IsNullOrEmpty(_mediaType) && TesterBase.JsonMediaTypeNames.Contains(_mediaType.ToLowerInvariant()) && _content is not string) return JsonSerializer.Serialize(_content); return _content.ToString(); diff --git a/src/UnitTestEx/ObjectComparer.cs b/src/UnitTestEx/ObjectComparer.cs index 7d36daf..105da5c 100644 --- a/src/UnitTestEx/ObjectComparer.cs +++ b/src/UnitTestEx/ObjectComparer.cs @@ -51,7 +51,12 @@ public static void Assert(JsonElementComparerOptions? options, object? expected, var cr = new JsonElementComparer(o).CompareValue(expected, actual, pathsToIgnore); if (cr.HasDifferences) - TestFrameworkImplementor.Create().AssertFail($"Expected and Actual values are not equal:{Environment.NewLine}{cr}"); + { + if (o.PreambleText is null) + TestFrameworkImplementor.Create().AssertFail($"Expected and Actual values are not equal:{Environment.NewLine}{cr}"); + else + TestFrameworkImplementor.Create().AssertFail($"{o.PreambleText}{Environment.NewLine}Expected and Actual values are not equal:{Environment.NewLine}{cr}"); + } } /// diff --git a/src/UnitTestEx/TestSetUp.cs b/src/UnitTestEx/TestSetUp.cs index 4769ae3..3ce0699 100644 --- a/src/UnitTestEx/TestSetUp.cs +++ b/src/UnitTestEx/TestSetUp.cs @@ -114,6 +114,11 @@ public static IConfiguration GetConfiguration(string? environmentVariablePrefix /// The . public static void LogAutoSetUpOutputs(TestFrameworkImplementor implementor) { + // Top-level test dividing line! + implementor.WriteLine(""); + implementor.WriteLine(new string('=', 80)); + + // Output any previously registered auto set up outputs. var output = GetAutoSetUpOutput(); while (!string.IsNullOrEmpty(output)) { diff --git a/tests/UnitTestEx.Api/Controllers/ProductController.cs b/tests/UnitTestEx.Api/Controllers/ProductController.cs index a82ff1b..a97b50e 100644 --- a/tests/UnitTestEx.Api/Controllers/ProductController.cs +++ b/tests/UnitTestEx.Api/Controllers/ProductController.cs @@ -22,7 +22,7 @@ public ProductController(IHttpClientFactory clientFactory) } [HttpGet("{id}")] - public async Task Get(string id) + public async ValueTask Get(string id) { var result = await _httpClient.GetAsync($"products/{id}").ConfigureAwait(false); if (result.StatusCode == HttpStatusCode.NotFound) @@ -62,9 +62,9 @@ public Task GetOK() } [HttpGet("test/problem")] - public Task GetProblem() + public ValueTask GetProblem() { - return Task.FromResult((IActionResult)new JsonResult(new HttpValidationProblemDetails(new Dictionary { { "id", ["Not specified."] } } ))); + return ValueTask.FromResult((IActionResult)new JsonResult(new HttpValidationProblemDetails(new Dictionary { { "id", ["Not specified."] } } ))); } } } \ No newline at end of file diff --git a/tests/UnitTestEx.Api/UnitTestEx.Api.csproj b/tests/UnitTestEx.Api/UnitTestEx.Api.csproj index aca444e..907fc51 100644 --- a/tests/UnitTestEx.Api/UnitTestEx.Api.csproj +++ b/tests/UnitTestEx.Api/UnitTestEx.Api.csproj @@ -1,7 +1,7 @@  - net6.0;net8.0 + net8.0;net9.0 true latest @@ -14,8 +14,8 @@ - - + + diff --git a/tests/UnitTestEx.MSTest.Test/Other/GenericTest.cs b/tests/UnitTestEx.MSTest.Test/Other/GenericTest.cs index 0a733dd..48993f5 100644 --- a/tests/UnitTestEx.MSTest.Test/Other/GenericTest.cs +++ b/tests/UnitTestEx.MSTest.Test/Other/GenericTest.cs @@ -1,5 +1,6 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System; +using System.Threading.Tasks; using UnitTestEx.Expectations; namespace UnitTestEx.MSTest.Test.Other @@ -19,9 +20,21 @@ public void Run_Success() [TestMethod] public void Run_Exception() { + static Func ThrowBadness() => () => throw new DivideByZeroException("Badness."); + + using var test = GenericTester.Create(); + test.ExpectError("Badness.") + .Run(ThrowBadness()); + } + + [TestMethod] + public void Run_Exception_ValueTask() + { + static Func ThrowBadness() => () => throw new DivideByZeroException("Badness."); + using var test = GenericTester.Create(); test.ExpectError("Badness.") - .Run(() => throw new DivideByZeroException("Badness.")); + .Run(ThrowBadness()); } } } \ 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 4c65076..19efd6d 100644 --- a/tests/UnitTestEx.MSTest.Test/UnitTestEx.MSTest.Test.csproj +++ b/tests/UnitTestEx.MSTest.Test/UnitTestEx.MSTest.Test.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0;net9.0 false true true diff --git a/tests/UnitTestEx.NUnit.Test/Other/ExpectationsTest.cs b/tests/UnitTestEx.NUnit.Test/Other/ExpectationsTest.cs index c7ef1ac..684b039 100644 --- a/tests/UnitTestEx.NUnit.Test/Other/ExpectationsTest.cs +++ b/tests/UnitTestEx.NUnit.Test/Other/ExpectationsTest.cs @@ -13,10 +13,10 @@ public class ExpectationsTest public void ExceptionSuccess_ExpectException_Any() { var gt = GenericTester.Create().ExpectException().Any(); - var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(null, null))); Assert.That(ex.Message, Is.EqualTo("Expected an exception; however, the execution was successful.")); + gt.ExpectException().Any(); ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(null, new DivideByZeroException())); } @@ -25,8 +25,11 @@ public void ExceptionSuccess_ExpectException_Message() { var gt = GenericTester.Create().ExpectException("error"); ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(null, new DivideByZeroException("error"))); + + gt.ExpectException("error"); ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(null, new DivideByZeroException("Error"))); + gt.ExpectException("error"); var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(null, new DivideByZeroException("not ok")))); Assert.That(ex.Message, Is.EqualTo("Expected Exception message 'error' is not contained within 'not ok'.")); } @@ -35,13 +38,14 @@ public void ExceptionSuccess_ExpectException_Message() public void ExceptionSuccess_ExpectException_Type() { var gt = GenericTester.Create().ExpectException().Type(); - var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(null, null))); Assert.That(ex.Message, Is.EqualTo("Expected an exception; however, the execution was successful.")); + gt.ExpectException().Type(); ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(null, new NotSupportedException()))); Assert.That(ex.Message, Is.EqualTo("Expected Exception type 'DivideByZeroException' not equal to actual 'NotSupportedException'.")); + gt.ExpectException().Type(); ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(null, new DivideByZeroException())); } @@ -49,7 +53,6 @@ public void ExceptionSuccess_ExpectException_Type() public void ExpectError_None() { var gt = GenericTester.Create().ExpectError("No error will be raised."); - var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(null, null))); Assert.That(ex.Message, Is.EqualTo("Expected one or more errors; however, none were returned.")); } @@ -60,11 +63,13 @@ public void ExpectValue_Simple() var gt = GenericTester.CreateFor().ExpectValue("bob"); ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, "bob")); + gt.ExpectValue("bob"); var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, "jenny"))); - Assert.That(ex.Message.Contains("Value is not equal: \"bob\" != \"jenny\"."), Is.True); + Assert.That(ex.Message, Does.Contain("Value is not equal: \"bob\" != \"jenny\".")); + gt.ExpectValue("bob"); ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, null))); - Assert.That(ex.Message.Contains("Kind is not equal: String != Null."), Is.True); + Assert.That(ex.Message, Does.Contain("Kind is not equal: String != Null.")); } [Test] @@ -73,23 +78,27 @@ public void ExpectValue_WithFunc() var gt = GenericTester.CreateFor().ExpectValue(_ => "bob"); ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, "bob")); + gt.ExpectValue(_ => "bob"); var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, "jenny"))); - Assert.That(ex.Message.Contains("Value is not equal: \"bob\" != \"jenny\"."), Is.True); + Assert.That(ex.Message, Does.Contain("Value is not equal: \"bob\" != \"jenny\".")); + gt.ExpectValue(_ => "bob"); ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, null))); - Assert.That(ex.Message.Contains("Kind is not equal: String != Null."), Is.True); + Assert.That(ex.Message, Does.Contain("Kind is not equal: String != Null.")); } [Test] public void ExpectValueComplex() { var gt = GenericTester.CreateFor>().ExpectValue(new Entity { Id = 88, Name = "bob" }); - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { Id = 88, Name = "bob" })); + + gt.ExpectValue(new Entity { Id = 88, Name = "bob" }); ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new { Id = 88, Name = "bob" })); + gt.ExpectValue(new Entity { Id = 88, Name = "bob" }); var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new { Id = 99, Name = "bob" }))); - Assert.That(ex.Message.Contains("Path '$.id': Value is not equal: 88 != 99."), Is.True); + Assert.That(ex.Message, Does.Contain("Path '$.id': Value is not equal: 88 != 99.")); gt = GenericTester.CreateFor>().ExpectValue(new Entity { Id = 88, Name = "bob" }, "id"); ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new { Id = 99, Name = "bob" })); diff --git a/tests/UnitTestEx.NUnit.Test/Other/GenericTest.cs b/tests/UnitTestEx.NUnit.Test/Other/GenericTest.cs index 890b70c..891da67 100644 --- a/tests/UnitTestEx.NUnit.Test/Other/GenericTest.cs +++ b/tests/UnitTestEx.NUnit.Test/Other/GenericTest.cs @@ -32,11 +32,14 @@ public void Run_Success_AssertJSON() [Test] public void Run_Exception() { + static Func ThrowBadness() => () => throw new DivideByZeroException("Badness."); + using var test = GenericTester.Create(); test.ExpectException("Badness.") - .Run(() => throw new DivideByZeroException("Badness.")); + .Run(ThrowBadness()); } + [Test] public void Run_Service() { @@ -46,16 +49,30 @@ public void Run_Service() .AssertSuccess() .AssertValue(1); +#if NET9_0_OR_GREATER + // For .NET 9.0 and greater, we can use ValueTask directly. test.Run(gin => gin.PourAsync()) .AssertSuccess() .AssertValue(1); + test.Run(async gin => await gin.PourAsync()) + .AssertSuccess() + .AssertValue(1); +#else + test.Run(async gin => await gin.PourAsync()) + .AssertSuccess() + .AssertValue(1); +#endif + test.Run(gin => gin.Shake()) .AssertSuccess(); test.Run(gin => gin.ShakeAsync()) .AssertSuccess(); + test.Run(async gin => await gin.ShakeAsync()) + .AssertSuccess(); + test.Run(gin => gin.Stir()) .AssertException("As required by Bond; shaken, not stirred."); @@ -82,7 +99,7 @@ public class Gin public void Shake() { } public Task ShakeAsync() => Task.CompletedTask; public int Pour() => 1; - public Task PourAsync() => Task.FromResult(1); + public ValueTask PourAsync() => ValueTask.FromResult(1); } public class EntryPoint diff --git a/tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj b/tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj index 914a231..a33b8cc 100644 --- a/tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj +++ b/tests/UnitTestEx.NUnit.Test/UnitTestEx.NUnit.Test.csproj @@ -1,7 +1,7 @@  - net8.0 + net8.0;net9.0 false preview diff --git a/tests/UnitTestEx.Xunit.Test/Other/GenericTest.cs b/tests/UnitTestEx.Xunit.Test/Other/GenericTest.cs index 32dd75b..db2d13a 100644 --- a/tests/UnitTestEx.Xunit.Test/Other/GenericTest.cs +++ b/tests/UnitTestEx.Xunit.Test/Other/GenericTest.cs @@ -1,4 +1,6 @@ -using UnitTestEx.Expectations; +using System; +using System.Threading.Tasks; +using UnitTestEx.Expectations; using Xunit; using Xunit.Abstractions; @@ -20,9 +22,11 @@ public void Run_Success() [Fact] public void Run_Exception() { + static Func ThrowBadness() => () => throw new DivideByZeroException("Badness."); + using var test = GenericTester.Create(); test.ExpectError("Badness.") - .Run(() => throw new System.ArithmeticException("Badness.")); + .Run(ThrowBadness()); } } } \ No newline at end of file diff --git a/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj b/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj index 17c30c9..c7a8c47 100644 --- a/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj +++ b/tests/UnitTestEx.Xunit.Test/UnitTestEx.Xunit.Test.csproj @@ -1,8 +1,7 @@  - net8.0 - + net8.0;net9.0 false From 79bce7ef3f85c363b5c5cc706ac0429b0d11cb97 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Thu, 14 Aug 2025 11:49:04 -0700 Subject: [PATCH 2/7] Add `Delay`. --- CHANGELOG.md | 1 + src/UnitTestEx/Abstractions/TesterBaseT.cs | 15 +++++++++++++++ .../UnitTestEx.NUnit.Test/PersonControllerTest.cs | 2 +- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7115b58..55b6ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Represents the **NuGet** versions. - *Enhancement:* Added `HttpResultAssertor` for ASP.NET Minimal APIs `Results` (e.g. `Results.Ok()`, `Results.NotFound()`, etc.) to enable assertions via the `ToHttpResponseMessageAssertor`. - *Enhancement:* `TesterBase`, `GenericTester` and `TypeTester` updated to support keyed services. - *Enhancement:* `GenericTester` and `TypeTester` updated to support the test run execution within a DI scope (using `UseRunAsScoped`). +- *Enhancement:* Added `TesterBase.Delay` method to enable delays to be easily added in a test where needed. - *Fixed:* The `ExpectationsArranger` updated to `Clear` versus `Reset` after an assertion run to ensure no cross-test contamination. ## v5.5.0 diff --git a/src/UnitTestEx/Abstractions/TesterBaseT.cs b/src/UnitTestEx/Abstractions/TesterBaseT.cs index f2422dc..0363c14 100644 --- a/src/UnitTestEx/Abstractions/TesterBaseT.cs +++ b/src/UnitTestEx/Abstractions/TesterBaseT.cs @@ -5,6 +5,7 @@ using Moq; using System; using System.Collections.Generic; +using System.Threading.Tasks; using UnitTestEx.Json; namespace UnitTestEx.Abstractions @@ -357,6 +358,20 @@ public TSelf UseAdditionalConfiguration(IEnumerableThe to support fluent-style method-chaining. public TSelf ReplaceKeyedTransient(object? serviceKey, bool autoResetHost = true) where TService : class where TImplementation : class, TService => ConfigureServices(sc => sc.ReplaceKeyedTransient(serviceKey), autoResetHost); + /// + /// Delays the execution of the test for the specified . + /// + /// The amount of time to delay the operation. Must be a non-negative . + /// The to support fluent-style method-chaining. + public TSelf Delay(TimeSpan duration) => Task.Delay(duration).ContinueWith(_ => (TSelf)this).Result; + + /// + /// Delays the execution of the test for the specified . + /// + /// The amount of time to delay the operation. Must be a non-negative . + /// The to support fluent-style method-chaining. + public TSelf Delay(int durationInMilliseconds) => Delay(TimeSpan.FromMilliseconds(durationInMilliseconds)); + /// /// Wraps the host execution to perform required start-up style activities; specifically resetting the . /// diff --git a/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs b/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs index 01b0b11..de45bd0 100644 --- a/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs +++ b/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs @@ -16,7 +16,7 @@ public class PersonControllerTest [Test] public async Task Get_Test1() { - using var test = ApiTester.Create(); + using var test = ApiTester.Create().Delay(1000); (await test.Controller() .ExpectLogContains("Get using identifier 1") .RunAsync(c => c.Get(1))) From 1c2ad313f54cb734ccbc8dec9bda88f632dd2167 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Mon, 18 Aug 2025 08:57:47 -0700 Subject: [PATCH 3/7] ScopedTypeTester --- CHANGELOG.md | 6 +- .../Azure/Functions/FunctionTesterBase.cs | 8 +- .../Azure/Functions/HttpTriggerTester.cs | 4 +- .../Functions/ServiceBusTriggerTester.cs | 4 +- src/UnitTestEx.Xunit/WithApiTester.cs | 2 + .../Abstractions/TestSharedState.cs | 9 +- src/UnitTestEx/Abstractions/TesterBase.cs | 40 ++- src/UnitTestEx/Abstractions/TesterBaseT.cs | 204 +++++++++++--- src/UnitTestEx/AspNetCore/ApiTesterBase.cs | 25 +- src/UnitTestEx/Generic/GenericTesterBase.cs | 82 +----- src/UnitTestEx/Generic/GenericTesterCore.cs | 6 +- src/UnitTestEx/Hosting/HostTesterBase.cs | 31 ++- src/UnitTestEx/Hosting/HostTesterBaseT.cs | 16 ++ src/UnitTestEx/Hosting/ScopedTypeTester.cs | 258 ++++++++++++++++++ src/UnitTestEx/Hosting/TypeTester.cs | 119 ++------ .../PersonControllerTest.cs | 5 +- 16 files changed, 558 insertions(+), 261 deletions(-) create mode 100644 src/UnitTestEx/Hosting/HostTesterBaseT.cs create mode 100644 src/UnitTestEx/Hosting/ScopedTypeTester.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b6ac6..1e2d8cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,9 @@ Represents the **NuGet** versions. ## v5.6.0 - *Enhancement:* The `RunAsync` methods updated to support `ValueTask` as well as `Task` for the `TypeTester` and `GenericTester` (.NET 9+ only). - *Enhancement:* Added `HttpResultAssertor` for ASP.NET Minimal APIs `Results` (e.g. `Results.Ok()`, `Results.NotFound()`, etc.) to enable assertions via the `ToHttpResponseMessageAssertor`. -- *Enhancement:* `TesterBase`, `GenericTester` and `TypeTester` updated to support keyed services. -- *Enhancement:* `GenericTester` and `TypeTester` updated to support the test run execution within a DI scope (using `UseRunAsScoped`). -- *Enhancement:* Added `TesterBase.Delay` method to enable delays to be easily added in a test where needed. +- *Enhancement:* `TesterBase` updated to support keyed services. +- *Enhancement* `ScopedTypeTester` created to support pre-instantiated scoped service where multiple tests can be run against the same scoped instance. The existing `TypeTester` will continue to create a new non-scoped instance. These now exist on the `TesterBase`. +- *Enhancement:* Added `TesterBase.Delay` method to enable delays to be added in a test where needed. - *Fixed:* The `ExpectationsArranger` updated to `Clear` versus `Reset` after an assertion run to ensure no cross-test contamination. ## v5.5.0 diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs index 4849f88..d3fe49b 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs @@ -139,7 +139,7 @@ private IHost GetHost() var ep2 = ep as FunctionsStartup; var ep3 = new EntryPoint(ep); - return _host = new HostBuilder() + _host = new HostBuilder() .UseEnvironment(UnitTestEx.TestSetUp.Environment) .ConfigureLogging((lb) => { lb.SetMinimumLevel(SetUp.MinimumLogLevel); lb.ClearProviders(); lb.AddProvider(LoggerProvider); }) .ConfigureHostConfiguration(cb => @@ -180,6 +180,10 @@ private IHost GetHost() SetUp.ConfigureServices?.Invoke(sc); AddConfiguredServices(sc); }).Build(); + + OnHostStartUp(); + + return _host; } } @@ -229,7 +233,7 @@ protected override void ResetHost() /// The to be tested. /// The optional keyed service key. /// The . - public TypeTester Type(object? serviceKey = null) where T : class => new(this, HostExecutionWrapper(() => GetHost().Services.CreateScope()), serviceKey); + public TypeTester Type(object? serviceKey = null) where T : class => new(this, HostExecutionWrapper(() => GetHost().Services), serviceKey); /// /// Specifies the Function that utilizes the that is to be tested. diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs index fbcdabf..315b785 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/HttpTriggerTester.cs @@ -36,7 +36,7 @@ namespace UnitTestEx.Azure.Functions /// /// 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. /// - public class HttpTriggerTester : HostTesterBase, IExpectations> where TFunction : class + public class HttpTriggerTester : HostTesterBase>, IExpectations> where TFunction : class { private bool _methodCheck = true; private RouteCheckOption _routeCheckOption = RouteCheckOption.PathAndQuery; @@ -47,7 +47,7 @@ public class HttpTriggerTester : HostTesterBase, IExpectat /// /// The owning . /// The . - public HttpTriggerTester(TesterBase owner, IServiceScope serviceScope) : base(owner, serviceScope) + public HttpTriggerTester(TesterBase owner, IServiceScope serviceScope) : base(owner, serviceScope.ServiceProvider) { ExpectationsArranger = new ExpectationsArranger>(owner, this); this.SetHttpMethodCheck(owner.SetUp); diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusTriggerTester.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusTriggerTester.cs index 6b43a9d..d1f6ac2 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusTriggerTester.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/ServiceBusTriggerTester.cs @@ -31,7 +31,7 @@ public class ServiceBusTriggerTester : HostTesterBase, IEx /// /// The owning . /// The . - public ServiceBusTriggerTester(TesterBase owner, IServiceScope serviceScope) : base(owner, serviceScope) => ExpectationsArranger = new ExpectationsArranger>(owner, this); + public ServiceBusTriggerTester(TesterBase owner, IServiceScope serviceScope) : base(owner, serviceScope.ServiceProvider) => ExpectationsArranger = new ExpectationsArranger>(owner, this); /// /// Gets the . @@ -81,7 +81,7 @@ public async Task RunAsync(Expression> expre if (validateTriggerProperties && a is not null) { - var config = ServiceScope.ServiceProvider.GetRequiredService(); + var config = Services.GetRequiredService(); var sbta = a as Microsoft.Azure.WebJobs.ServiceBusTriggerAttribute; if (sbta is not null) VerifyServiceBusTriggerProperties(config, sbta); diff --git a/src/UnitTestEx.Xunit/WithApiTester.cs b/src/UnitTestEx.Xunit/WithApiTester.cs index c5aade7..335b0ad 100644 --- a/src/UnitTestEx.Xunit/WithApiTester.cs +++ b/src/UnitTestEx.Xunit/WithApiTester.cs @@ -6,7 +6,9 @@ using Xunit; using Xunit.Abstractions; +#pragma warning disable IDE0130 // Namespace does not match folder structure; improves usability. namespace UnitTestEx +#pragma warning restore IDE0130 { /// /// Provides a shared to enable usage of the same underlying instance across multiple tests. diff --git a/src/UnitTestEx/Abstractions/TestSharedState.cs b/src/UnitTestEx/Abstractions/TestSharedState.cs index 59252de..2d4921a 100644 --- a/src/UnitTestEx/Abstractions/TestSharedState.cs +++ b/src/UnitTestEx/Abstractions/TestSharedState.cs @@ -6,6 +6,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading; namespace UnitTestEx.Abstractions { @@ -14,7 +15,11 @@ namespace UnitTestEx.Abstractions /// public sealed class TestSharedState { +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else private readonly object _lock = new(); +#endif private readonly ConcurrentDictionary> _logOutput = new(); /// @@ -37,7 +42,7 @@ public void AddLoggerMessage(string? message) lock (_lock) { - var logs = _logOutput.GetOrAdd(id, _ => new()); + var logs = _logOutput.GetOrAdd(id, _ => []); // Parse in the message date where possible to ensure correct sequencing; assumes date/time is first 25 characters. DateTime now = DateTime.Now; @@ -80,7 +85,7 @@ private string GetRequestId() if (!string.IsNullOrEmpty(requestId) && _logOutput.TryRemove(requestId, out var l2) && l2 != null) logs.AddRange(l2); - return logs.OrderBy(x => x.Item1).Select(x => x.Item2).ToArray(); + return [.. logs.OrderBy(x => x.Item1).Select(x => x.Item2)]; } } diff --git a/src/UnitTestEx/Abstractions/TesterBase.cs b/src/UnitTestEx/Abstractions/TesterBase.cs index 24af1cb..50a3f50 100644 --- a/src/UnitTestEx/Abstractions/TesterBase.cs +++ b/src/UnitTestEx/Abstractions/TesterBase.cs @@ -28,6 +28,7 @@ public abstract class TesterBase private string? _userName; private readonly List> _configureServices = []; private IEnumerable>? _additionalConfiguration; + private readonly List _hostStart = []; /// /// Static constructor. @@ -166,7 +167,7 @@ public JsonElementComparer CreateJsonComparer() /// /// Resets the underlying host to instantiate a new instance. /// - /// Indicates whether to reset the previously configured services. + /// Indicates whether to reset the previously configured services and start-ups. public void ResetHost(bool resetConfiguredServices = false) { lock (SyncRoot) @@ -184,14 +185,45 @@ public void ResetHost(bool resetConfiguredServices = false) /// protected abstract void ResetHost(); + /// + /// Enables opportunity to execute logic immediately after the underlying host has been started. + /// + /// Where overridding ensure the base is invoked first to avoid unintended side-effects as will invoke the registered . + /// Note: a host lifetime can span one or more tests so this should not be used for per-test set-up/configuration. Equally, a will result in a new host instantiation on first access. + protected virtual void OnHostStartUp() + { + foreach (var start in _hostStart) + { + start(); + } + } + + /// + /// Provides an opportunity to execute logic immediately after the underlying host has been started. + /// + /// A start . + /// Indicates whether to automatically (passing false) when configuring the services. + /// This can be called multiple times prior to the underlying host being instantiated. + /// See . + protected void OnHostStart(Action start, bool autoResetHost = true) + { + lock (SyncRoot) + { + if (autoResetHost) + ResetHost(false); + + _hostStart.Add(start); + + } + } + /// /// Provides an opportunity to further configure the services before the underlying host is instantiated. /// /// A delegate for configuring . /// Indicates whether to automatically (passing false) when configuring the services. - /// This can be called multiple times prior to the underlying host being instantiated. Internally, the is queued and then played in order when the host is initially instantiated. - /// Once instantiated, further calls will result in a unless a is performed. - public void ConfigureServices(Action configureServices, bool autoResetHost = true) + /// This can be called multiple times prior to the underlying host being instantiated. Internally, the is queued and then played in order when the host is initially instantiated. + protected void ConfigureServices(Action configureServices, bool autoResetHost = true) { lock (SyncRoot) { diff --git a/src/UnitTestEx/Abstractions/TesterBaseT.cs b/src/UnitTestEx/Abstractions/TesterBaseT.cs index 0363c14..525a65b 100644 --- a/src/UnitTestEx/Abstractions/TesterBaseT.cs +++ b/src/UnitTestEx/Abstractions/TesterBaseT.cs @@ -5,7 +5,9 @@ using Moq; using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Threading.Tasks; +using UnitTestEx.Hosting; using UnitTestEx.Json; namespace UnitTestEx.Abstractions @@ -131,14 +133,26 @@ public TSelf UseAdditionalConfiguration(IEnumerableA delegate for configuring . /// Indicates whether to automatically (passing false) when configuring the services. /// The to support fluent-style method-chaining. - /// This can be called multiple times prior to the underlying host being instantiated. Internally, the is queued and then played in order when the host is initially instantiated. - /// Once instantiated, further calls will result in a unless a is performed. + /// This can be called multiple times prior to the underlying host being instantiated. Internally, the is queued and then played in order when the host is initially instantiated. public new TSelf ConfigureServices(Action configureServices, bool autoResetHost = true) { base.ConfigureServices(configureServices, autoResetHost); return (TSelf)this; } + /// + /// Provides an opportunity to execute logic immediately after the underlying host has been started. + /// + /// A start . + /// Indicates whether to automatically (passing false) when configuring the services. + /// This can be called multiple times prior to the underlying host being instantiated. + /// See . + public new TSelf OnHostStart(Action start, bool autoResetHost = true) + { + base.OnHostStart(start, autoResetHost); + return (TSelf)this; + } + /// /// Replaces (where existing), or adds, a singleton service with the . /// @@ -150,7 +164,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a singleton service with a mock object. /// - /// The service being mocked. + /// The service being mocked. /// The . /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. @@ -159,7 +173,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a scoped service with a mock object. /// - /// The service being mocked. + /// The service being mocked. /// The . /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. @@ -168,7 +182,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a transient service with a mock object. /// - /// The service being mocked. + /// The service being mocked. /// The . /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. @@ -177,7 +191,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a singleton service . /// - /// The service . + /// The service . /// The instance value. /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. @@ -186,7 +200,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a singleton service using an . /// - /// The service . + /// The service . /// The implementation factory. /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. @@ -195,7 +209,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a singleton service. /// - /// The service . + /// The service . /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. public TSelf ReplaceSingleton(bool autoResetHost = true) where TService : class => ConfigureServices(sc => sc.ReplaceSingleton(), autoResetHost); @@ -203,8 +217,8 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a singleton service. /// - /// The service . - /// The implementation . + /// The service . + /// The implementation . /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. public TSelf ReplaceSingleton(bool autoResetHost = true) where TService : class where TImplementation : class, TService => ConfigureServices(sc => sc.ReplaceSingleton(), autoResetHost); @@ -212,7 +226,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a singleton service . /// - /// The service . + /// The service . /// The instance value. /// The service key. /// Indicates whether to automatically (passing false) when configuring the service. @@ -222,7 +236,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a singleton service using an . /// - /// The service . + /// The service . /// The implementation factory. /// The service key. /// Indicates whether to automatically (passing false) when configuring the service. @@ -232,7 +246,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a singleton service. /// - /// The service . + /// The service . /// The service key. /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. @@ -241,8 +255,8 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a singleton service. /// - /// The service . - /// The implementation . + /// The service . + /// The implementation . /// The service key. /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. @@ -251,7 +265,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a scoped service using an . /// - /// The service . + /// The service . /// The implementation factory. /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. @@ -260,7 +274,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a scoped service. /// - /// The service . + /// The service . /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. public TSelf ReplaceScoped(bool autoResetHost = true) where TService : class => ConfigureServices(sc => sc.ReplaceScoped(), autoResetHost); @@ -268,8 +282,8 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a scoped service. /// - /// The service . - /// The implementation . + /// The service . + /// The implementation . /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. public TSelf ReplaceScoped(bool autoResetHost = true) where TService : class where TImplementation : class, TService => ConfigureServices(sc => sc.ReplaceScoped(), autoResetHost); @@ -277,7 +291,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a scoped service using an . /// - /// The service . + /// The service . /// The service key. /// The implementation factory. /// Indicates whether to automatically (passing false) when configuring the service. @@ -287,7 +301,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a scoped service. /// - /// The service . + /// The service . /// The service key. /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. @@ -296,8 +310,8 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a scoped service. /// - /// The service . - /// The implementation . + /// The service . + /// The implementation . /// The service key. /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. @@ -306,7 +320,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a transient service using an . /// - /// The service . + /// The service . /// The implementation factory. /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. @@ -315,7 +329,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a transient service. /// - /// The service . + /// The service . /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. public TSelf ReplaceTransient(bool autoResetHost = true) where TService : class => ConfigureServices(sc => sc.ReplaceTransient(), autoResetHost); @@ -323,8 +337,8 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a transient service. /// - /// The service . - /// The implementation . + /// The service . + /// The implementation . /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. public TSelf ReplaceTransient(bool autoResetHost = true) where TService : class where TImplementation : class, TService => ConfigureServices(sc => sc.ReplaceTransient(), autoResetHost); @@ -332,7 +346,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a transient service using an . /// - /// The service . + /// The service . /// The service key. /// The implementation factory. /// Indicates whether to automatically (passing false) when configuring the service. @@ -342,7 +356,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a transient service. /// - /// The service . + /// The service . /// The service key. /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. @@ -351,8 +365,8 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Replaces (where existing), or adds, a transient service. /// - /// The service . - /// The implementation . + /// The service . + /// The implementation . /// The service key. /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. @@ -375,7 +389,7 @@ public TSelf UseAdditionalConfiguration(IEnumerable /// Wraps the host execution to perform required start-up style activities; specifically resetting the . /// - /// The result . + /// The result . /// The function to create the result. /// The . protected T HostExecutionWrapper(Func result) @@ -383,5 +397,131 @@ protected T HostExecutionWrapper(Func result) SharedState.Reset(); return result(); } + + /// + /// Enables a specified (of ) to be tested. + /// + /// The to be tested. + /// The optional keyed service key. + /// The . + public TypeTester Type(object? serviceKey = null) where TService : class => new(this, HostExecutionWrapper(() => Services), serviceKey); + + /// + /// Enables a specified (of ) to be tested. + /// + /// The to be tested. + /// The factory to create the instance. + /// The . + public TypeTester Type(Func serviceFactory) where TService : class => new(this, HostExecutionWrapper(() => Services), serviceFactory); + + /// + /// Enables a instance to be tested managed within a . + /// + /// The service to be tested. + /// The testing function. + /// The optional keyed service key. + /// The to support fluent-style method-chaining. + public TSelf ScopedType(Action> scopedTester, object? serviceKey = null) where TService : class + { + ArgumentNullException.ThrowIfNull(scopedTester); + + using var scope = HostExecutionWrapper(Services.CreateScope); + var tester = new ScopedTypeTester(this, scope.ServiceProvider, scope.ServiceProvider.CreateInstance(serviceKey)); + scopedTester(tester); + return (TSelf)this; + } + + /// + /// Enables a instance to be tested managed within a . + /// + /// The service to be tested. + /// The factory to create the instance. + /// The testing function. + /// The to support fluent-style method-chaining. + public TSelf ScopedType(Func serviceFactory, Action> scopedTester) where TService : class + { + ArgumentNullException.ThrowIfNull(scopedTester); + using var scope = HostExecutionWrapper(Services.CreateScope); + var tester = new ScopedTypeTester(this, scope.ServiceProvider, serviceFactory(scope.ServiceProvider)); + scopedTester(tester); + return (TSelf)this; + } + + /// + /// Enables a instance to be tested managed within a . + /// + /// The service to be tested. + /// The testing function. + /// The optional keyed service key. + /// The to support fluent-style method-chaining. +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public TSelf ScopedType(Func, Task> scopedTesterAsync, object? serviceKey = null) where TService : class + { + ArgumentNullException.ThrowIfNull(scopedTesterAsync); + + using var scope = HostExecutionWrapper(Services.CreateScope); + var tester = new ScopedTypeTester(this, scope.ServiceProvider, scope.ServiceProvider.CreateInstance(serviceKey)); + scopedTesterAsync(tester).GetAwaiter().GetResult(); + return (TSelf)this; + } + + /// + /// Enables a instance to be tested managed within a . + /// + /// The service to be tested. + /// The factory to create the instance. + /// The testing function. + /// The to support fluent-style method-chaining. +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public TSelf ScopedType(Func serviceFactory, Func, Task> scopedTesterAsync) where TService : class + { + ArgumentNullException.ThrowIfNull(scopedTesterAsync); + using var scope = HostExecutionWrapper(Services.CreateScope); + var tester = new ScopedTypeTester(this, scope.ServiceProvider, serviceFactory(scope.ServiceProvider)); + scopedTesterAsync(tester).GetAwaiter().GetResult(); + return (TSelf)this; + } + +#if NET9_0_OR_GREATER + /// + /// Enables a instance to be tested managed within a . + /// + /// The service to be tested. + /// The testing function. + /// The optional keyed service key. + /// The to support fluent-style method-chaining. + [OverloadResolutionPriority(2)] + public TSelf ScopedType(Func, ValueTask> scopedTesterAsync, object? serviceKey = null) where TService : class + { + ArgumentNullException.ThrowIfNull(scopedTesterAsync); + + using var scope = HostExecutionWrapper(Services.CreateScope); + var tester = new ScopedTypeTester(this, scope.ServiceProvider, scope.ServiceProvider.CreateInstance(serviceKey)); + scopedTesterAsync(tester).AsTask().GetAwaiter().GetResult(); + return (TSelf)this; + } + + /// + /// Enables a instance to be tested managed within a . + /// + /// The service to be tested. + /// The factory to create the instance. + /// The testing function. + /// The to support fluent-style method-chaining. + [OverloadResolutionPriority(2)] + public TSelf ScopedType(Func serviceFactory, Func, ValueTask> scopedTesterAsync) where TService : class + { + ArgumentNullException.ThrowIfNull(scopedTesterAsync); + using var scope = HostExecutionWrapper(Services.CreateScope); + var tester = new ScopedTypeTester(this, scope.ServiceProvider, serviceFactory(scope.ServiceProvider)); + scopedTesterAsync(tester).AsTask().GetAwaiter().GetResult(); + return (TSelf)this; + } + +#endif } } \ No newline at end of file diff --git a/src/UnitTestEx/AspNetCore/ApiTesterBase.cs b/src/UnitTestEx/AspNetCore/ApiTesterBase.cs index c416ee3..920b6f0 100644 --- a/src/UnitTestEx/AspNetCore/ApiTesterBase.cs +++ b/src/UnitTestEx/AspNetCore/ApiTesterBase.cs @@ -11,8 +11,9 @@ using Microsoft.Extensions.Logging; using System; using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; using UnitTestEx.Abstractions; -using UnitTestEx.Hosting; namespace UnitTestEx.AspNetCore { @@ -62,7 +63,7 @@ protected WebApplicationFactory GetWebApplicationFactory() if (_waf != null) return _waf; - return _waf = new WebApplicationFactory().WithWebHostBuilder(whb => + _waf = new WebApplicationFactory().WithWebHostBuilder(whb => whb.UseSolutionRelativeContentRoot(Environment.CurrentDirectory) .ConfigureAppConfiguration((_, cb) => { @@ -83,6 +84,10 @@ protected WebApplicationFactory GetWebApplicationFactory() SetUp.ConfigureServices?.Invoke(sc); AddConfiguredServices(sc); }).ConfigureLogging(lb => { lb.SetMinimumLevel(SetUp.MinimumLogLevel); lb.ClearProviders(); lb.AddProvider(LoggerProvider); })); + + OnHostStartUp(); + + return _waf; } } @@ -151,22 +156,6 @@ protected override void ResetHost() /// The . public HttpTester Http() => new(this, GetTestServer()); - /// - /// Enables a specified (of ) to be tested. - /// - /// The to be tested. - /// The optional keyed service key. - /// The . - public TypeTester Type(object? serviceKey = null) where T : class => new(this, HostExecutionWrapper(Services.CreateScope), serviceKey); - - /// - /// Enables a specified (of ) to be tested. - /// - /// The to be tested. - /// The factory to create the instance. - /// The . - public TypeTester Type(Func serviceFactory) where T : class => new(this, HostExecutionWrapper(Services.CreateScope), serviceFactory); - /// /// Gets the underlying . /// diff --git a/src/UnitTestEx/Generic/GenericTesterBase.cs b/src/UnitTestEx/Generic/GenericTesterBase.cs index 6961a91..f8e2a62 100644 --- a/src/UnitTestEx/Generic/GenericTesterBase.cs +++ b/src/UnitTestEx/Generic/GenericTesterBase.cs @@ -18,38 +18,9 @@ namespace UnitTestEx.Generic /// The . /// The to support inheriting fluent-style method-chaining. /// The . - public abstract class GenericTesterBase(TestFrameworkImplementor implementor) + public abstract class GenericTesterBase(TestFrameworkImplementor implementor) : GenericTesterCore>(implementor) where TEntryPoint : class where TSelf : GenericTesterBase { - private bool _runAsScoped; - private Func? _onRunScopeFuncAsync; - - /// - /// Indicates that the underlying Run methods should be scoped (i.e. . - /// - /// The optional function to execute before the primary Run* methods when running as scoped. - /// The tester to support fluent-style method-chaining. - /// By default the Run methods are not scoped. - public TSelf UseRunAsScoped(Func? onRunAsScoped = null) - { - _runAsScoped = true; - _onRunScopeFuncAsync = onRunAsScoped; - return (TSelf)this; - } - - /// - /// Indicates that the underlying Run methods should be scoped (i.e. . - /// - /// indicates scoped; otherwise, . - /// The tester to support fluent-style method-chaining. - /// By default the Run methods are not scoped. - public TSelf UseRunAsScoped(bool runAsScoped) - { - _runAsScoped = runAsScoped; - _onRunScopeFuncAsync = null; - return (TSelf)this; - } - /// /// Executes the that performs the logic. /// @@ -256,25 +227,11 @@ public async Task RunAsync(Func function) Implementor.WriteLine("GENERIC TESTER..."); Implementor.WriteLine(""); - await OnBeforeRunAsync().ConfigureAwait(false); - if (OnBeforeRunFuncAsync is not null) - await OnBeforeRunFuncAsync().ConfigureAwait(false); - Exception? exception = null; - - IServiceScope? scope = null; var sw = System.Diagnostics.Stopwatch.StartNew(); try { - scope = _runAsScoped ? Services.CreateScope() : null; - if (scope is not null) - { - await OnRunScopeAsync(scope).ConfigureAwait(false); - if (_onRunScopeFuncAsync is not null) - await _onRunScopeFuncAsync(scope).ConfigureAwait(false); - } - await function().ConfigureAwait(false); } catch (AggregateException aex) @@ -285,10 +242,6 @@ public async Task RunAsync(Func function) { exception = ex; } - finally - { - scope?.Dispose(); - } sw.Stop(); @@ -555,26 +508,13 @@ public async Task> RunAsync(Func> fun Implementor.WriteLine("GENERIC TESTER..."); Implementor.WriteLine(""); - await OnBeforeRunAsync().ConfigureAwait(false); - if (OnBeforeRunFuncAsync is not null) - await OnBeforeRunFuncAsync().ConfigureAwait(false); - Exception? exception = null; - IServiceScope? scope = null; var sw = System.Diagnostics.Stopwatch.StartNew(); TValue value = default!; try { - scope = _runAsScoped ? Services.CreateScope() : null; - if (scope is not null) - { - await OnRunScopeAsync(scope).ConfigureAwait(false); - if (_onRunScopeFuncAsync is not null) - await _onRunScopeFuncAsync(scope).ConfigureAwait(false); - } - value = await function().ConfigureAwait(false); } catch (AggregateException aex) @@ -585,10 +525,6 @@ public async Task> RunAsync(Func> fun { exception = ex; } - finally - { - scope?.Dispose(); - } sw.Stop(); @@ -641,21 +577,5 @@ public async Task> RunAsync(Func> fun public async Task> RunAsync(Func> function) => await RunAsync(() => function().AsTask()).ConfigureAwait(false); #endif - - /// - /// Provides an opportunity to perform any pre-run logic. - /// - protected virtual Task OnBeforeRunAsync() => Task.CompletedTask; - - /// - /// Gets or sets the function to perform any pre-run logic. - /// - public Func? OnBeforeRunFuncAsync { get; set; } - - /// - /// Provides an opportunity to perform any logic as a result of the . - /// - /// This is invoked after the , but before the Run logic. - protected virtual Task OnRunScopeAsync(IServiceScope scope) => Task.CompletedTask; } } \ No newline at end of file diff --git a/src/UnitTestEx/Generic/GenericTesterCore.cs b/src/UnitTestEx/Generic/GenericTesterCore.cs index 4f37ffc..ba87fa3 100644 --- a/src/UnitTestEx/Generic/GenericTesterCore.cs +++ b/src/UnitTestEx/Generic/GenericTesterCore.cs @@ -106,9 +106,10 @@ private IHost GetHost() AddConfiguredServices(builder.Services); _host = builder.Build(); + OnHostStartUp(); return _host; #else - return _host ??= Host.CreateDefaultBuilder() + _host ??= Host.CreateDefaultBuilder() .UseEnvironment(TestSetUp.Environment) .ConfigureLogging((lb) => { lb.SetMinimumLevel(SetUp.MinimumLogLevel); lb.ClearProviders(); lb.AddProvider(LoggerProvider); }) .ConfigureHostConfiguration(cb => @@ -138,6 +139,9 @@ private IHost GetHost() SetUp.ConfigureServices?.Invoke(sc); AddConfiguredServices(sc); }).Build(); + + OnHostStartUp(); + return _host; #endif } } diff --git a/src/UnitTestEx/Hosting/HostTesterBase.cs b/src/UnitTestEx/Hosting/HostTesterBase.cs index c8b411a..0e7e0bb 100644 --- a/src/UnitTestEx/Hosting/HostTesterBase.cs +++ b/src/UnitTestEx/Hosting/HostTesterBase.cs @@ -1,6 +1,7 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -17,25 +18,32 @@ namespace UnitTestEx.Hosting /// /// Provides the base host unit-testing capabilities. /// - /// The host . + /// The host/service . /// The owning . - /// The . - public class HostTesterBase(TesterBase owner, IServiceScope serviceScope) where THost : class + /// The . + public class HostTesterBase(TesterBase owner, IServiceProvider serviceProvider) where TService : class { + private ILogger? _logger; + /// /// Gets the owning . /// public TesterBase Owner { get; } = owner ?? throw new ArgumentNullException(nameof(owner)); /// - /// Gets the . + /// Gets the . /// - protected IServiceScope ServiceScope { get; } = serviceScope ?? throw new ArgumentNullException(nameof(serviceScope)); + public IServiceProvider Services { get; } = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); /// /// Gets the . /// - protected TestFrameworkImplementor Implementor => Owner.Implementor; + public TestFrameworkImplementor Implementor => Owner.Implementor; + + /// + /// Gets the for the . + /// + public ILogger Logger => _logger ??= Owner.LoggerProvider.CreateLogger(GetType().Name); /// /// Gets or sets the . @@ -43,9 +51,9 @@ public class HostTesterBase(TesterBase owner, IServiceScope serviceScope) protected IJsonSerializer JsonSerializer => Owner.JsonSerializer; /// - /// Create (instantiate) the using the to provide the constructor based dependency injection (DI) values. + /// Create (instantiate) the using the to provide the constructor based dependency injection (DI) values. /// - private THost CreateHost(object? serviceKey) => ServiceScope.ServiceProvider.CreateInstance(serviceKey); + private TService CreateHost(object? serviceKey) => Services.CreateInstance(serviceKey); /// /// Orchestrates the execution of a method as described by the returning no result. @@ -54,7 +62,7 @@ public class HostTesterBase(TesterBase owner, IServiceScope serviceScope) /// The optional parameter (s) to find. /// Action to verify the method parameters prior to method invocation. /// The resulting exception if any and elapsed milliseconds. - protected async Task<(Exception? Exception, double ElapsedMilliseconds)> RunAsync(Expression> expression, Type[]? paramAttributeTypes, OnBeforeRun? onBeforeRun) + protected async Task<(Exception? Exception, double ElapsedMilliseconds)> RunAsync(Expression> expression, Type[]? paramAttributeTypes, OnBeforeRun? onBeforeRun) { TestSetUp.LogAutoSetUpOutputs(Implementor); @@ -115,7 +123,7 @@ public class HostTesterBase(TesterBase owner, IServiceScope serviceScope) /// 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 async Task<(TValue Result, Exception? Exception, double ElapsedMilliseconds)> RunAsync(Expression>> expression, Type[]? paramAttributeTypes, OnBeforeRun? onBeforeRun) { TestSetUp.LogAutoSetUpOutputs(Implementor); @@ -181,8 +189,7 @@ public class HostTesterBase(TesterBase owner, IServiceScope serviceScope) /// The . internal static MethodCallExpression MethodCallExpressionValidate([NotNull] Expression expression) { - if (expression == null) - throw new ArgumentNullException(nameof(expression)); + ArgumentNullException.ThrowIfNull(expression, nameof(expression)); if (expression is not LambdaExpression lex) throw new ArgumentException($"Expression must be of Type '{nameof(LambdaExpression)}'.", nameof(expression)); diff --git a/src/UnitTestEx/Hosting/HostTesterBaseT.cs b/src/UnitTestEx/Hosting/HostTesterBaseT.cs new file mode 100644 index 0000000..71a8a39 --- /dev/null +++ b/src/UnitTestEx/Hosting/HostTesterBaseT.cs @@ -0,0 +1,16 @@ +using System; +using UnitTestEx.Abstractions; + +namespace UnitTestEx.Hosting +{ + /// + /// Provides the base host unit-testing capabilities. + /// + /// The host/service . + /// The to support inheriting fluent-style method-chaining. + /// The owning . + /// The . + public class HostTesterBase(TesterBase owner, IServiceProvider serviceProvider) : HostTesterBase(owner, serviceProvider) where TService : class where TSelf : HostTesterBase + { + } +} \ No newline at end of file diff --git a/src/UnitTestEx/Hosting/ScopedTypeTester.cs b/src/UnitTestEx/Hosting/ScopedTypeTester.cs new file mode 100644 index 0000000..0120417 --- /dev/null +++ b/src/UnitTestEx/Hosting/ScopedTypeTester.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using UnitTestEx.Abstractions; +using UnitTestEx.Assertors; +using UnitTestEx.Expectations; +using UnitTestEx.Json; + +namespace UnitTestEx.Hosting; + +/// +/// Provides a pre-scoped unit-testing capabilities from a parent/owning host (see ). +/// +/// The service (must be a class). +/// The scoped instance lifetime is managed outside of lifetime. +public class ScopedTypeTester : HostTesterBase>, IExpectations> where TService : class +{ + /// + /// Initializes a new instance of the class. + /// + /// The owning . + /// The . + /// The instance. + public ScopedTypeTester(TesterBase owner, IServiceProvider serviceProvider, TService service) : base(owner, serviceProvider) + { + Service = service ?? throw new ArgumentNullException(nameof(service)); + ExpectationsArranger = new ExpectationsArranger>(owner, this); + } + + /// + /// Gets the instance being tested. + /// + public TService Service { get; } + + /// + /// Gets the . + /// + public ExpectationsArranger> ExpectationsArranger { get; } + + /// + /// Runs the synchronous method with no result. + /// + /// The function execution. + /// A . + public VoidAssertor Run(Action function) => RunAsync(x => { function(x); return Task.CompletedTask; }).GetAwaiter().GetResult(); + + /// + /// Runs the synchronous method with a result. + /// + /// The result value . + /// The function execution. + /// A . + public ValueAssertor Run(Func function) => RunAsync(x => Task.FromResult(function(x))).GetAwaiter().GetResult(); + + /// + /// Runs the asynchronous method with no result. + /// + /// The function execution. + /// A . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public VoidAssertor Run(Func function) => RunAsync(function).GetAwaiter().GetResult(); + +#if NET9_0_OR_GREATER + /// + /// Runs the asynchronous method with no result. + /// + /// The function execution. + /// A . + [OverloadResolutionPriority(2)] + public VoidAssertor Run(Func function) => RunAsync(v => function(v).AsTask()).GetAwaiter().GetResult(); + +#endif + /// + /// Runs the asynchronous method with no result. + /// + /// The function execution. + /// A . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public async Task RunAsync(Func function) + { + TestSetUp.LogAutoSetUpOutputs(Implementor); + + Exception? ex = null; + var sw = Stopwatch.StartNew(); + LogHeader(); + + try + { + await (function ?? throw new ArgumentNullException(nameof(function)))(Service).ConfigureAwait(false); + } + catch (AggregateException aex) + { + ex = aex.InnerException ?? aex; + } + catch (Exception uex) + { + ex = uex; + } + finally + { + sw.Stop(); + } + + await Task.Delay(TestSetUp.TaskDelayMilliseconds).ConfigureAwait(false); + var logs = Owner.SharedState.GetLoggerMessages(); + LogResult(ex, sw.Elapsed.TotalMilliseconds, logs); + + await ExpectationsArranger.AssertAsync(logs, ex).ConfigureAwait(false); + + return new VoidAssertor(Owner, ex); + } + +#if NET9_0_OR_GREATER + /// + /// Runs the asynchronous method with no result. + /// + /// The function execution. + /// A . + [OverloadResolutionPriority(2)] + public async Task RunAsync(Func function) => await RunAsync(v => function(v).AsTask()).ConfigureAwait(false); + +#endif + /// + /// Runs the asynchronous method with a result. + /// + /// The result value . + /// The function execution. + /// A . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public ValueAssertor Run(Func> function) => RunAsync(function).GetAwaiter().GetResult(); + +#if NET9_0_OR_GREATER + /// + /// Runs the asynchronous method with a result. + /// + /// The result value . + /// The function execution. + /// A . + [OverloadResolutionPriority(2)] + public ValueAssertor Run(Func> function) => RunAsync(v => function(v).AsTask()).GetAwaiter().GetResult(); + +#endif + /// + /// Runs the asynchronous method with a result. + /// + /// The result value . + /// The function execution. + /// A . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public async Task> RunAsync(Func> function) + { + TestSetUp.LogAutoSetUpOutputs(Implementor); + + TValue result = default!; + Exception? ex = null; + var sw = Stopwatch.StartNew(); + LogHeader(); + + try + { + result = await (function ?? throw new ArgumentNullException(nameof(function)))(Service).ConfigureAwait(false); + } + catch (AggregateException aex) + { + ex = aex.InnerException ?? aex; + } + catch (Exception uex) + { + ex = uex; + } + finally + { + sw.Stop(); + } + + await Task.Delay(TestSetUp.TaskDelayMilliseconds).ConfigureAwait(false); + var logs = Owner.SharedState.GetLoggerMessages(); + LogResult(ex, sw.Elapsed.TotalMilliseconds, logs); + + if (ex == null) + { + if (result is string str) + Implementor.WriteLine($"Result: {str}"); + else if (result is IFormattable ifm) + Implementor.WriteLine($"Result: {ifm.ToString(null, CultureInfo.CurrentCulture)}"); + else + { + Implementor.WriteLine($"Result: {(result == null ? "" : result.GetType().Name)}"); + if (result != null) + Implementor.WriteLine(JsonSerializer.Serialize(result, JsonWriteFormat.Indented)); + } + } + + await ExpectationsArranger.AssertValueAsync(logs, result, ex).ConfigureAwait(false); + + return new ValueAssertor(Owner, result, ex); + } + +#if NET9_0_OR_GREATER + /// + /// Runs the asynchronous method with a result. + /// + /// The result value . + /// The function execution. + /// A . + [OverloadResolutionPriority(2)] + public async Task> RunAsync(Func> function) => await RunAsync(v => function(v).AsTask()).ConfigureAwait(false); + +#endif + /// + /// Logs the header. + /// + private void LogHeader() + { + Implementor.WriteLine(""); + Implementor.WriteLine("TYPE TESTER..."); + Implementor.WriteLine($"Type: {typeof(TService).Name} [{typeof(TService).FullName}]"); + } + + /// + /// Log the elapsed execution time. + /// + private void LogResult(Exception? ex, double ms, IEnumerable? logs) + { + Implementor.WriteLine(""); + Implementor.WriteLine("LOGGING >"); + if (logs is not null && logs.Any()) + { + foreach (var msg in logs) + { + Implementor.WriteLine(msg); + } + } + else + Implementor.WriteLine("None."); + + Implementor.WriteLine(""); + Implementor.WriteLine("RESULT >"); + Implementor.WriteLine($"Elapsed (ms): {ms}"); + if (ex != null) + { + Implementor.WriteLine($"Exception: {ex.Message} [{ex.GetType().Name}]"); + Implementor.WriteLine(ex.ToString()); + } + } +} \ No newline at end of file diff --git a/src/UnitTestEx/Hosting/TypeTester.cs b/src/UnitTestEx/Hosting/TypeTester.cs index f6b9ea2..8b7af1b 100644 --- a/src/UnitTestEx/Hosting/TypeTester.cs +++ b/src/UnitTestEx/Hosting/TypeTester.cs @@ -16,25 +16,23 @@ namespace UnitTestEx.Hosting { /// - /// Provides the generic unit-testing capabilities. + /// Provides unit-testing capabilities from a parent/owning host (see ). /// /// The service (must be a class). - /// Note that the service instance is created on first use and then reused (see ). - public class TypeTester : HostTesterBase, IExpectations> where TService : class + /// Note that the service instance is created within a scope during an underlying Run. + public class TypeTester : HostTesterBase>, IExpectations> where TService : class { private readonly object? _serviceKey; private readonly Func? _serviceFactory; private TService? _service; - private bool _runAsScoped; - private Func? _onRunScopeFuncAsync; /// /// Initializes a new class. /// /// The owning . - /// The . + /// The . /// The optional key for a keyed service. - public TypeTester(TesterBase owner, IServiceScope serviceScope, object? serviceKey = null) : base(owner, serviceScope) + public TypeTester(TesterBase owner, IServiceProvider serviceProvider, object? serviceKey = null) : base(owner, serviceProvider) { _serviceKey = serviceKey; ExpectationsArranger = new ExpectationsArranger>(owner, this); @@ -44,63 +42,24 @@ public TypeTester(TesterBase owner, IServiceScope serviceScope, object? serviceK /// Initializes a new class with a factory for creating the instance. /// /// The owning . - /// The . + /// The . /// The factory to create the instance. /// - public TypeTester(TesterBase owner, IServiceScope serviceScope, Func serviceFactory) : base(owner, serviceScope) + public TypeTester(TesterBase owner, IServiceProvider serviceProvider, Func serviceFactory) : base(owner, serviceProvider) { _serviceFactory = serviceFactory ?? throw new ArgumentNullException(nameof(serviceFactory)); ExpectationsArranger = new ExpectationsArranger>(owner, this); } - /// - /// Indicates that the underlying Run methods should be scoped (i.e. . - /// - /// The optional function to execute before the primary Run* methods when running as scoped. - /// The tester to support fluent-style method-chaining. - /// By default the Run methods are not scoped. - public TypeTester UseRunAsScoped(Func? onRunAsScoped = null) - { - _runAsScoped = true; - _onRunScopeFuncAsync = onRunAsScoped; - return this; - } - - /// - /// Indicates that the underlying Run methods should be scoped (i.e. . - /// - /// indicates scoped; otherwise, . - /// The tester to support fluent-style method-chaining. - /// By default the Run methods are not scoped. - public TypeTester UseRunAsScoped(bool runAsScoped) - { - _runAsScoped = runAsScoped; - _onRunScopeFuncAsync = null; - return this; - } - /// /// Gets the . /// public ExpectationsArranger> ExpectationsArranger { get; } /// - /// Gets or creates the service instance. - /// - /// This is intended for advanced scenarios; for the most part the Run or RunAsync methods should be used for testing as these encapsulate logging, expectations and assertions. - public TService GetOrCreateService() => _service ??= _serviceFactory is null - ? ServiceScope.ServiceProvider.CreateInstance(_serviceKey) - : _serviceFactory(ServiceScope.ServiceProvider); - - /// - /// Resets the service instance. + /// Creates the scoped service instance. /// - /// The tester to support fluent-style method-chaining. - public TypeTester ResetService() - { - _service = default; - return this; - } + private TService CreateService(IServiceScope scope) => _service ??= _serviceFactory is null ? scope.ServiceProvider.CreateInstance(_serviceKey) : _serviceFactory(scope.ServiceProvider); /// /// Runs the synchronous method with no result. @@ -149,26 +108,15 @@ public async Task RunAsync(Func function) { TestSetUp.LogAutoSetUpOutputs(Implementor); - IServiceScope? scope = null; Exception? ex = null; var sw = Stopwatch.StartNew(); + LogHeader(); + try { - LogHeader(); - await OnBeforeRunAsync().ConfigureAwait(false); - if (OnBeforeRunFuncAsync is not null) - await OnBeforeRunFuncAsync().ConfigureAwait(false); - - scope = _runAsScoped ? ServiceScope.ServiceProvider.CreateScope() : null; - if (scope is not null) - { - await OnRunScopeAsync(scope).ConfigureAwait(false); - if (_onRunScopeFuncAsync is not null) - await _onRunScopeFuncAsync(scope).ConfigureAwait(false); - } - - var f = GetOrCreateService(); - await (function ?? throw new ArgumentNullException(nameof(function)))(f).ConfigureAwait(false); + using var scope = Services.CreateScope(); + var service = CreateService(scope); + await (function ?? throw new ArgumentNullException(nameof(function)))(service).ConfigureAwait(false); } catch (AggregateException aex) { @@ -180,7 +128,6 @@ public async Task RunAsync(Func function) } finally { - scope?.Dispose(); sw.Stop(); } @@ -238,27 +185,16 @@ public async Task> RunAsync(Func> RunAsync(Func? logs) Implementor.WriteLine(ex.ToString()); } } - - /// - /// Provides an opportunity to perform any pre-run logic. - /// - protected virtual Task OnBeforeRunAsync() => Task.CompletedTask; - - /// - /// Gets or sets the function to perform any pre-run logic. - /// - public Func? OnBeforeRunFuncAsync { get; set; } - - /// - /// Provides an opportunity to perform any logic as a result of the . - /// - /// This is invoked after the , but before the Run logic. - protected virtual Task OnRunScopeAsync(IServiceScope scope) => Task.CompletedTask; } } \ No newline at end of file diff --git a/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs b/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs index de45bd0..00f7c7b 100644 --- a/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs +++ b/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using NUnit.Framework; using System.Collections.Generic; using System.Net.Http; @@ -313,7 +314,7 @@ public void Type_IActionResult() { using var test = ApiTester.Create(); var hr = test.CreateHttpRequest(HttpMethod.Get, "Person/1"); - hr.HttpContext.Response.Headers.Add("X-Test", "Test"); + hr.HttpContext.Response.Headers.Append("X-Test", "Test"); var iar = new OkResult(); From 1d1a1087e53d2829a509b1bc40f31a5855fe68ea Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Mon, 18 Aug 2025 15:42:17 -0700 Subject: [PATCH 4/7] Clean up tweaks --- .../Azure/Functions/FunctionTesterBase.cs | 8 -------- src/UnitTestEx.MSTest/WithApiTester.cs | 2 +- src/UnitTestEx.NUnit/WithApiTester.cs | 2 +- src/UnitTestEx.Xunit/WithApiTester.cs | 4 ++-- src/UnitTestEx/TestSetUp.cs | 1 + 5 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs index d3fe49b..da83d3a 100644 --- a/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs +++ b/src/UnitTestEx.Azure.Functions/Azure/Functions/FunctionTesterBase.cs @@ -227,14 +227,6 @@ protected override void ResetHost() /// The . public HttpTriggerTester HttpTrigger() where TFunction : class => new(this, HostExecutionWrapper(() => GetHost().Services.CreateScope())); - /// - /// Enables a specified (of ) to be tested. - /// - /// The to be tested. - /// The optional keyed service key. - /// The . - public TypeTester Type(object? serviceKey = null) where T : class => new(this, HostExecutionWrapper(() => GetHost().Services), serviceKey); - /// /// Specifies the Function that utilizes the that is to be tested. /// diff --git a/src/UnitTestEx.MSTest/WithApiTester.cs b/src/UnitTestEx.MSTest/WithApiTester.cs index 5e11982..c0ca5e2 100644 --- a/src/UnitTestEx.MSTest/WithApiTester.cs +++ b/src/UnitTestEx.MSTest/WithApiTester.cs @@ -17,7 +17,7 @@ public abstract class WithApiTester : IDisposable where TEntryPoint private ApiTester? _apiTester = ApiTester.Create(); /// - /// Gets the shared for testing. + /// Gets the underlying for testing. /// public ApiTester Test => _apiTester ?? throw new ObjectDisposedException(nameof(Test)); diff --git a/src/UnitTestEx.NUnit/WithApiTester.cs b/src/UnitTestEx.NUnit/WithApiTester.cs index 5e11982..c0ca5e2 100644 --- a/src/UnitTestEx.NUnit/WithApiTester.cs +++ b/src/UnitTestEx.NUnit/WithApiTester.cs @@ -17,7 +17,7 @@ public abstract class WithApiTester : IDisposable where TEntryPoint private ApiTester? _apiTester = ApiTester.Create(); /// - /// Gets the shared for testing. + /// Gets the underlying for testing. /// public ApiTester Test => _apiTester ?? throw new ObjectDisposedException(nameof(Test)); diff --git a/src/UnitTestEx.Xunit/WithApiTester.cs b/src/UnitTestEx.Xunit/WithApiTester.cs index 335b0ad..5dafc50 100644 --- a/src/UnitTestEx.Xunit/WithApiTester.cs +++ b/src/UnitTestEx.Xunit/WithApiTester.cs @@ -21,7 +21,7 @@ public abstract class WithApiTester : UnitTestBase, IClassFixture /// Initializes a new instance of the class. /// - /// The shared . + /// The . /// The . public WithApiTester(ApiTestFixture fixture, ITestOutputHelper output) : base(output) { @@ -30,7 +30,7 @@ public WithApiTester(ApiTestFixture fixture, ITestOutputHelper outp } /// - /// Gets the shared for testing. + /// Gets the underlying for testing. /// public ApiTester Test { get; } } diff --git a/src/UnitTestEx/TestSetUp.cs b/src/UnitTestEx/TestSetUp.cs index 3ce0699..de0701c 100644 --- a/src/UnitTestEx/TestSetUp.cs +++ b/src/UnitTestEx/TestSetUp.cs @@ -117,6 +117,7 @@ public static void LogAutoSetUpOutputs(TestFrameworkImplementor implementor) // Top-level test dividing line! implementor.WriteLine(""); implementor.WriteLine(new string('=', 80)); + implementor.WriteLine($"Timestamp: {DateTime.UtcNow} (UTC)."); // Output any previously registered auto set up outputs. var output = GetAutoSetUpOutput(); From 17e3cb3fd0f8d2227d45a3989d4463659d1ab3f9 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Mon, 18 Aug 2025 17:34:12 -0700 Subject: [PATCH 5/7] Update changelog. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e2d8cf..8ab9645 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Represents the **NuGet** versions. - *Enhancement:* The `RunAsync` methods updated to support `ValueTask` as well as `Task` for the `TypeTester` and `GenericTester` (.NET 9+ only). - *Enhancement:* Added `HttpResultAssertor` for ASP.NET Minimal APIs `Results` (e.g. `Results.Ok()`, `Results.NotFound()`, etc.) to enable assertions via the `ToHttpResponseMessageAssertor`. - *Enhancement:* `TesterBase` updated to support keyed services. -- *Enhancement* `ScopedTypeTester` created to support pre-instantiated scoped service where multiple tests can be run against the same scoped instance. The existing `TypeTester` will continue to create a new non-scoped instance. These now exist on the `TesterBase`. +- *Enhancement* `ScopedTypeTester` created to support pre-instantiated scoped service where multiple tests can be run against the same scoped instance. The existing `TypeTester` will continue to directly execute a one-off scoped instance. These now exist on the `TesterBase` enabling broader usage. - *Enhancement:* Added `TesterBase.Delay` method to enable delays to be added in a test where needed. - *Fixed:* The `ExpectationsArranger` updated to `Clear` versus `Reset` after an assertion run to ensure no cross-test contamination. From e2cafc72c2752d09ee2ebcaf78127f9d4fa3f5fc Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Mon, 18 Aug 2025 18:06:12 -0700 Subject: [PATCH 6/7] Fix pull request errors. --- .github/workflows/ci.yml | 4 ++-- .../UnitTestEx.Azure.ServiceBus.csproj | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1521e5c..e11152c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,8 @@ jobs: - name: Explicit MSTest test run: | - cp tests/UnitTestEx.Api/bin/Debug/net6.0/UnitTestEx.Api.deps.json tests/UnitTestEx.MSTest.Test/bin/Debug/net6.0 - cd tests/UnitTestEx.MSTest.Test/bin/Debug/net6.0 + cp tests/UnitTestEx.Api/bin/Debug/net6.0/UnitTestEx.Api.deps.json tests/UnitTestEx.MSTest.Test/bin/Debug/net8.0 + cd tests/UnitTestEx.MSTest.Test/bin/Debug/net8.0 dotnet test UnitTestEx.MSTest.Test.dll --no-build --verbosity normal - name: Explicit NUnit test diff --git a/src/UnitTestEx.Azure.ServiceBus/UnitTestEx.Azure.ServiceBus.csproj b/src/UnitTestEx.Azure.ServiceBus/UnitTestEx.Azure.ServiceBus.csproj index 9dbe5a4..ef57da5 100644 --- a/src/UnitTestEx.Azure.ServiceBus/UnitTestEx.Azure.ServiceBus.csproj +++ b/src/UnitTestEx.Azure.ServiceBus/UnitTestEx.Azure.ServiceBus.csproj @@ -10,7 +10,7 @@ - + From 585121ac9b0ade0c2a5fceba9860ce8ad1a584da Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Tue, 19 Aug 2025 07:53:32 -0700 Subject: [PATCH 7/7] Fix CI vers path. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e11152c..9d19c12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - name: Explicit MSTest test run: | - cp tests/UnitTestEx.Api/bin/Debug/net6.0/UnitTestEx.Api.deps.json tests/UnitTestEx.MSTest.Test/bin/Debug/net8.0 + cp tests/UnitTestEx.Api/bin/Debug/net8.0/UnitTestEx.Api.deps.json tests/UnitTestEx.MSTest.Test/bin/Debug/net8.0 cd tests/UnitTestEx.MSTest.Test/bin/Debug/net8.0 dotnet test UnitTestEx.MSTest.Test.dll --no-build --verbosity normal