Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TSelf>` 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<TSelf>` enabling broader usage.
- *Enhancement:* Added `TesterBase<TSelf>.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.

Expand Down
2 changes: 1 addition & 1 deletion Common.targets
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>5.5.0</Version>
<Version>5.6.0</Version>
<LangVersion>preview</LangVersion>
<Authors>Avanade</Authors>
<Company>Avanade</Company>
Expand Down
1 change: 1 addition & 0 deletions UnitTestEx.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down Expand Up @@ -180,6 +180,10 @@ private IHost GetHost()
SetUp.ConfigureServices?.Invoke(sc);
AddConfiguredServices(sc);
}).Build();

OnHostStartUp();

return _host;
}
}

Expand Down Expand Up @@ -223,13 +227,6 @@ protected override void ResetHost()
/// <returns>The <see cref="HttpTriggerTester{TFunction}"/>.</returns>
public HttpTriggerTester<TFunction> HttpTrigger<TFunction>() where TFunction : class => new(this, HostExecutionWrapper(() => GetHost().Services.CreateScope()));

/// <summary>
/// Specifies the <see cref="Type"/> of <typeparamref name="T"/> that is to be tested.
/// </summary>
/// <typeparam name="T">The <see cref="Type"/> to be tested.</typeparam>
/// <returns>The <see cref="TypeTester{TFunction}"/>.</returns>
public TypeTester<T> Type<T>() where T : class => new(this, HostExecutionWrapper(() => GetHost().Services.CreateScope()));

/// <summary>
/// Specifies the <i>Function</i> <see cref="Type"/> that utilizes the <see cref="ServiceBusTriggerAttribute"/> that is to be tested.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ namespace UnitTestEx.Azure.Functions
/// </list>
/// 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.
/// </remarks>
public class HttpTriggerTester<TFunction> : HostTesterBase<TFunction>, IExpectations<HttpTriggerTester<TFunction>> where TFunction : class
public class HttpTriggerTester<TFunction> : HostTesterBase<TFunction, HttpTriggerTester<TFunction>>, IExpectations<HttpTriggerTester<TFunction>> where TFunction : class
{
private bool _methodCheck = true;
private RouteCheckOption _routeCheckOption = RouteCheckOption.PathAndQuery;
Expand All @@ -47,7 +47,7 @@ public class HttpTriggerTester<TFunction> : HostTesterBase<TFunction>, IExpectat
/// </summary>
/// <param name="owner">The owning <see cref="TesterBase"/>.</param>
/// <param name="serviceScope">The <see cref="IServiceScope"/>.</param>
public HttpTriggerTester(TesterBase owner, IServiceScope serviceScope) : base(owner, serviceScope)
public HttpTriggerTester(TesterBase owner, IServiceScope serviceScope) : base(owner, serviceScope.ServiceProvider)
{
ExpectationsArranger = new ExpectationsArranger<HttpTriggerTester<TFunction>>(owner, this);
this.SetHttpMethodCheck(owner.SetUp);
Expand Down Expand Up @@ -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("");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public class ServiceBusTriggerTester<TFunction> : HostTesterBase<TFunction>, IEx
/// </summary>
/// <param name="owner">The owning <see cref="TesterBase"/>.</param>
/// <param name="serviceScope">The <see cref="IServiceScope"/>.</param>
public ServiceBusTriggerTester(TesterBase owner, IServiceScope serviceScope) : base(owner, serviceScope) => ExpectationsArranger = new ExpectationsArranger<ServiceBusTriggerTester<TFunction>>(owner, this);
public ServiceBusTriggerTester(TesterBase owner, IServiceScope serviceScope) : base(owner, serviceScope.ServiceProvider) => ExpectationsArranger = new ExpectationsArranger<ServiceBusTriggerTester<TFunction>>(owner, this);

/// <summary>
/// Gets the <see cref="ExpectationsArranger{TSelf}"/>.
Expand Down Expand Up @@ -81,7 +81,7 @@ public async Task<VoidAssertor> RunAsync(Expression<Func<TFunction, Task>> expre

if (validateTriggerProperties && a is not null)
{
var config = ServiceScope.ServiceProvider.GetRequiredService<IConfiguration>();
var config = Services.GetRequiredService<IConfiguration>();
var sbta = a as Microsoft.Azure.WebJobs.ServiceBusTriggerAttribute;
if (sbta is not null)
VerifyServiceBusTriggerProperties(config, sbta);
Expand Down Expand Up @@ -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("");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.18.2" />
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.20.1" />
</ItemGroup>

<ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion src/UnitTestEx.MSTest/WithApiTester.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public abstract class WithApiTester<TEntryPoint> : IDisposable where TEntryPoint
private ApiTester<TEntryPoint>? _apiTester = ApiTester.Create<TEntryPoint>();

/// <summary>
/// Gets the shared <see cref="ApiTester{TEntryPoint}"/> for testing.
/// Gets the underlying <see cref="ApiTester{TEntryPoint}"/> for testing.
/// </summary>
public ApiTester<TEntryPoint> Test => _apiTester ?? throw new ObjectDisposedException(nameof(Test));

Expand Down
2 changes: 1 addition & 1 deletion src/UnitTestEx.NUnit/WithApiTester.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public abstract class WithApiTester<TEntryPoint> : IDisposable where TEntryPoint
private ApiTester<TEntryPoint>? _apiTester = ApiTester.Create<TEntryPoint>();

/// <summary>
/// Gets the shared <see cref="ApiTester{TEntryPoint}"/> for testing.
/// Gets the underlying <see cref="ApiTester{TEntryPoint}"/> for testing.
/// </summary>
public ApiTester<TEntryPoint> Test => _apiTester ?? throw new ObjectDisposedException(nameof(Test));

Expand Down
6 changes: 4 additions & 2 deletions src/UnitTestEx.Xunit/WithApiTester.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
/// <summary>
/// Provides a shared <see cref="Test"/> <see cref="ApiTester{TEntryPoint}"/> to enable usage of the same underlying <see cref="ApiTesterBase{TEntryPoint, TSelf}.GetTestServer"/> instance across multiple tests.
Expand All @@ -19,7 +21,7 @@ public abstract class WithApiTester<TEntryPoint> : UnitTestBase, IClassFixture<A
/// <summary>
/// Initializes a new instance of the <see cref="WithApiTester{TEntryPoint}"/> class.
/// </summary>
/// <param name="fixture">The shared <see cref="ApiTestFixture{TEntryPoint}"/>.</param>
/// <param name="fixture">The <see cref="ApiTestFixture{TEntryPoint}"/>.</param>
/// <param name="output">The <see cref="ITestOutputHelper"/>.</param>
public WithApiTester(ApiTestFixture<TEntryPoint> fixture, ITestOutputHelper output) : base(output)
{
Expand All @@ -28,7 +30,7 @@ public WithApiTester(ApiTestFixture<TEntryPoint> fixture, ITestOutputHelper outp
}

/// <summary>
/// Gets the shared <see cref="ApiTester{TEntryPoint}"/> for testing.
/// Gets the underlying <see cref="ApiTester{TEntryPoint}"/> for testing.
/// </summary>
public ApiTester<TEntryPoint> Test { get; }
}
Expand Down
2 changes: 1 addition & 1 deletion src/UnitTestEx/Abstractions/TestFrameworkImplementor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public static void SetGlobalCreateFactory(Func<TestFrameworkImplementor> createF
public static void SetLocalCreateFactory(Func<TestFrameworkImplementor> 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));
}
Expand Down
11 changes: 8 additions & 3 deletions src/UnitTestEx/Abstractions/TestSharedState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;

namespace UnitTestEx.Abstractions
{
Expand All @@ -14,7 +15,11 @@ namespace UnitTestEx.Abstractions
/// </summary>
public sealed class TestSharedState
{
#if NET9_0_OR_GREATER
private readonly Lock _lock = new();
#else
private readonly object _lock = new();
#endif
private readonly ConcurrentDictionary<string, List<(DateTime, string?)>> _logOutput = new();

/// <summary>
Expand All @@ -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;
Expand Down Expand Up @@ -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)];
}
}

/// <summary>
/// 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).
/// </summary>
public ConcurrentDictionary<string, object?> StateData { get; } = new ConcurrentDictionary<string, object?>();

Expand Down
47 changes: 37 additions & 10 deletions src/UnitTestEx/Abstractions/TesterBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public abstract class TesterBase
private string? _userName;
private readonly List<Action<IServiceCollection>> _configureServices = [];
private IEnumerable<KeyValuePair<string, string?>>? _additionalConfiguration;
private readonly List<Action> _hostStart = [];

/// <summary>
/// Static constructor.
Expand Down Expand Up @@ -67,7 +68,6 @@ public TesterBase(TestFrameworkImplementor implementor)
SetUp = TestSetUp.Default.Clone();
JsonSerializer = SetUp.JsonSerializer;
JsonComparerOptions = SetUp.JsonComparerOptions;
TestSetUp.LogAutoSetUpOutputs(Implementor);
}

/// <summary>
Expand Down Expand Up @@ -167,7 +167,7 @@ public JsonElementComparer CreateJsonComparer()
/// <summary>
/// Resets the underlying host to instantiate a new instance.
/// </summary>
/// <param name="resetConfiguredServices">Indicates whether to reset the previously configured services.</param>
/// <param name="resetConfiguredServices">Indicates whether to reset the previously configured services and start-ups.</param>
public void ResetHost(bool resetConfiguredServices = false)
{
lock (SyncRoot)
Expand All @@ -185,14 +185,45 @@ public void ResetHost(bool resetConfiguredServices = false)
/// </summary>
protected abstract void ResetHost();

/// <summary>
/// Enables opportunity to execute logic immediately after the underlying host has been started.
/// </summary>
/// <remarks>Where overridding ensure the base is invoked first to avoid unintended side-effects as <see cref="TesterBase"/> will invoke the registered <see cref="OnHostStart(Action, bool)"/>.
/// <para><i>Note:</i> a host lifetime can span one or more tests so this should not be used for per-test set-up/configuration. Equally, a <see cref="ResetHost()"/> will result in a new host instantiation on first access.</para></remarks>
protected virtual void OnHostStartUp()
{
foreach (var start in _hostStart)
{
start();
}
}

/// <summary>
/// Provides an opportunity to execute logic immediately after the underlying host has been started.
/// </summary>
/// <param name="start">A start <see cref="Action"/>.</param>
/// <param name="autoResetHost">Indicates whether to automatically <see cref="ResetHost(bool)"/> (passing <c>false</c>) when configuring the services.</param>
/// <remarks>This can be called multiple times prior to the underlying host being instantiated.
/// See <see cref="OnHostStartUp"/>.</remarks>
protected void OnHostStart(Action start, bool autoResetHost = true)
{
lock (SyncRoot)
{
if (autoResetHost)
ResetHost(false);

_hostStart.Add(start);

}
}

/// <summary>
/// Provides an opportunity to further configure the services before the underlying host is instantiated.
/// </summary>
/// <param name="configureServices">A delegate for configuring <see cref="IServiceCollection"/>.</param>
/// <param name="autoResetHost">Indicates whether to automatically <see cref="ResetHost(bool)"/> (passing <c>false</c>) when configuring the services.</param>
/// <remarks>This can be called multiple times prior to the underlying host being instantiated. Internally, the <paramref name="configureServices"/> is queued and then played in order when the host is initially instantiated.
/// Once instantiated, further calls will result in a <see cref="InvalidOperationException"/> unless a <see cref="ResetHost(bool)"/> is performed.</remarks>
public void ConfigureServices(Action<IServiceCollection> configureServices, bool autoResetHost = true)
/// <remarks>This can be called multiple times prior to the underlying host being instantiated. Internally, the <paramref name="configureServices"/> is queued and then played in order when the host is initially instantiated.</remarks>
protected void ConfigureServices(Action<IServiceCollection> configureServices, bool autoResetHost = true)
{
lock (SyncRoot)
{
Expand Down Expand Up @@ -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
{
Expand All @@ -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
Expand Down
Loading
Loading