diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1521e5c..9d19c12 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/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 - name: Explicit NUnit test diff --git a/CHANGELOG.md b/CHANGELOG.md index f88c60b..8ab9645 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ 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` 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 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. + ## 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..da83d3a 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; } } @@ -223,13 +227,6 @@ protected override void ResetHost() /// The . public HttpTriggerTester HttpTrigger() where TFunction : class => new(this, HostExecutionWrapper(() => GetHost().Services.CreateScope())); - /// - /// Specifies the of that is to be tested. - /// - /// The to be tested. - /// The . - public TypeTester Type() where T : class => new(this, HostExecutionWrapper(() => GetHost().Services.CreateScope())); - /// /// 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..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); @@ -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..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); @@ -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.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 @@ - + 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 c5aade7..5dafc50 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. @@ -19,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) { @@ -28,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/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..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,12 +85,12 @@ 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)]; } } /// - /// 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..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. @@ -67,7 +68,6 @@ public TesterBase(TestFrameworkImplementor implementor) SetUp = TestSetUp.Default.Clone(); JsonSerializer = SetUp.JsonSerializer; JsonComparerOptions = SetUp.JsonComparerOptions; - TestSetUp.LogAutoSetUpOutputs(Implementor); } /// @@ -167,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) @@ -185,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) { @@ -253,7 +284,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 +301,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..525a65b 100644 --- a/src/UnitTestEx/Abstractions/TesterBaseT.cs +++ b/src/UnitTestEx/Abstractions/TesterBaseT.cs @@ -5,6 +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 @@ -130,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 . /// @@ -149,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. @@ -158,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. @@ -167,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. @@ -176,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. @@ -185,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. @@ -194,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); @@ -202,16 +217,55 @@ 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); + /// + /// 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 . /// - /// The service . + /// The service . /// The implementation factory. /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. @@ -220,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); @@ -228,16 +282,45 @@ 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); + /// + /// 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 . /// - /// The service . + /// The service . /// The implementation factory. /// Indicates whether to automatically (passing false) when configuring the service. /// The to support fluent-style method-chaining. @@ -246,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); @@ -254,23 +337,191 @@ 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); + /// + /// 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); + + /// + /// 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 . /// - /// The result . + /// The result . /// The function to create the result. /// The . protected T HostExecutionWrapper(Func result) { - TestSetUp.LogAutoSetUpOutputs(Implementor); 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 9b6317f..920b6f0 100644 --- a/src/UnitTestEx/AspNetCore/ApiTesterBase.cs +++ b/src/UnitTestEx/AspNetCore/ApiTesterBase.cs @@ -11,15 +11,16 @@ 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 { /// /// 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 { @@ -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; } } @@ -121,7 +126,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 +139,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,19 +150,12 @@ 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. - /// - /// The to be tested. - /// The . - public TypeTester Type() where T : class => new(this, HostExecutionWrapper(Services.CreateScope)); - /// /// 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..f8e2a62 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; @@ -17,7 +18,7 @@ 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 { /// @@ -36,10 +37,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 +65,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 . - public VoidAssertor Run(Func function) where TService : class +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public VoidAssertor Run(Func function, object? serviceKey = null) where TService : class { - var service = Services.GetRequiredService(); + 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 . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public VoidAssertor Run(Func function, Func serviceFactory) where TService : class + { + 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 . + [OverloadResolutionPriority(2)] + public VoidAssertor 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 function performing the logic. + /// The optional keyed service key. /// The resulting . - public Task RunAsync(Func function) where TService : class +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public async Task RunAsync(Func function, object? serviceKey = null) where TService : class { - var service = Services.GetRequiredService(); - return RunAsync(() => function(service)); + 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); @@ -91,7 +228,6 @@ public async Task RunAsync(Func function) Implementor.WriteLine(""); Exception? exception = null; - var sw = System.Diagnostics.Stopwatch.StartNew(); try @@ -132,15 +268,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 +302,26 @@ 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, object? serviceKey = null) where TService : class => RunAsync(() => + { + 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(); + + /// + /// 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) where TService : class => RunAsync(() => + public ValueAssertor Run(Func function, Func serviceFactory) where TService : class => RunAsync(() => { - var service = Services.GetRequiredService(); + var service = serviceFactory(Services); TValue value = (function ?? throw new ArgumentNullException(nameof(function))).Invoke(service); return Task.FromResult(value); }).GetAwaiter().GetResult(); @@ -173,40 +332,172 @@ public ValueAssertor Run(Func functi /// 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 . - public ValueAssertor Run(Func> function) where TService : class +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public ValueAssertor Run(Func> function, object? serviceKey = null) where TService : class { - var service = Services.GetRequiredService(); + 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 . - public Task> RunAsync(Func> function) where TService : class + [OverloadResolutionPriority(2)] + public ValueAssertor Run(Func> function, object? serviceKey = null) where TService : class { - var service = Services.GetRequiredService(); - return RunAsync(() => function(service)); + 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 . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public ValueAssertor Run(Func> function, Func serviceFactory) where TService : class + { + 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 . + [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 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); @@ -270,13 +561,21 @@ 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 } } \ 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/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 f00af6c..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 . /// - protected TesterBase Owner { get; } = owner ?? throw new ArgumentNullException(nameof(owner)); + 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() => ServiceScope.ServiceProvider.CreateInstance(); + 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); @@ -85,7 +93,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 @@ -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); @@ -146,7 +154,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 @@ -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 0a6e149..8b7af1b 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,57 @@ namespace UnitTestEx.Hosting { /// - /// Provides the generic unit-testing capabilities. + /// Provides unit-testing capabilities from a parent/owning host (see ). /// - /// 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 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; + /// /// Initializes a new class. /// /// The owning . - /// The . - public TypeTester(TesterBase owner, IServiceScope serviceScope) : base(owner, serviceScope) => ExpectationsArranger = new ExpectationsArranger>(owner, this); + /// The . + /// The optional key for a keyed service. + public TypeTester(TesterBase owner, IServiceProvider serviceProvider, object? serviceKey = null) : base(owner, serviceProvider) + { + _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, IServiceProvider serviceProvider, Func serviceFactory) : base(owner, serviceProvider) + { + _serviceFactory = serviceFactory ?? throw new ArgumentNullException(nameof(serviceFactory)); + ExpectationsArranger = new ExpectationsArranger>(owner, this); + } /// /// Gets the . /// - public ExpectationsArranger> ExpectationsArranger { get; } + public ExpectationsArranger> ExpectationsArranger { get; } + + /// + /// Creates the scoped service instance. + /// + private TService CreateService(IServiceScope scope) => _service ??= _serviceFactory is null ? scope.ServiceProvider.CreateInstance(_serviceKey) : _serviceFactory(scope.ServiceProvider); /// /// 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,29 +74,49 @@ 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); + Exception? ex = null; var sw = Stopwatch.StartNew(); + LogHeader(); + try { - LogHeader(); - var f = ServiceScope.ServiceProvider.CreateInstance(); - 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) { @@ -85,37 +134,67 @@ public async Task RunAsync(Func function) 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); + TValue result = default!; Exception? ex = null; var sw = Stopwatch.StartNew(); + LogHeader(); + try { - LogHeader(); - var f = ServiceScope.ServiceProvider.CreateInstance(); - result = await (function ?? throw new ArgumentNullException(nameof(function)))(f).ConfigureAwait(false); + using var scope = Services.CreateScope(); + var service = CreateService(scope); + result = await (function ?? throw new ArgumentNullException(nameof(function)))(service).ConfigureAwait(false); } catch (AggregateException aex) { @@ -148,13 +227,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 +250,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}]"); } /// @@ -191,15 +279,5 @@ private void LogResult(Exception? ex, double ms, IEnumerable? logs) Implementor.WriteLine(ex.ToString()); } } - - /// - /// Log the trailer. - /// - private void LogTrailer() - { - Implementor.WriteLine(""); - Implementor.WriteLine(new string('=', 80)); - Implementor.WriteLine(""); - } } } \ 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..de0701c 100644 --- a/src/UnitTestEx/TestSetUp.cs +++ b/src/UnitTestEx/TestSetUp.cs @@ -114,6 +114,12 @@ 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)); + implementor.WriteLine($"Timestamp: {DateTime.UtcNow} (UTC)."); + + // 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/PersonControllerTest.cs b/tests/UnitTestEx.NUnit.Test/PersonControllerTest.cs index 01b0b11..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; @@ -16,7 +17,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))) @@ -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(); 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