Headless NUnit: Handle async SetUp/TearDown (#20612)
* Headless NUnit: Fix TearDown not always working * Headless NUnit: Handle async SetUp/TearDown
This commit is contained in:
committed by
GitHub
parent
cac4650c98
commit
9f02346cd6
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Reflection;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using NUnit.Framework.Interfaces;
|
using NUnit.Framework.Interfaces;
|
||||||
@@ -13,28 +12,14 @@ internal class AvaloniaTestMethodCommand : TestCommand
|
|||||||
{
|
{
|
||||||
private readonly HeadlessUnitTestSession _session;
|
private readonly HeadlessUnitTestSession _session;
|
||||||
private readonly TestCommand _innerCommand;
|
private readonly TestCommand _innerCommand;
|
||||||
private readonly List<Action> _beforeTest;
|
private readonly List<Func<TestExecutionContext, Task>> _beforeTest;
|
||||||
private readonly List<Action> _afterTest;
|
private readonly List<Func<TestExecutionContext, Task>> _afterTest;
|
||||||
|
|
||||||
// There are multiple problems with NUnit integration at the moment when we wrote this integration.
|
|
||||||
// NUnit doesn't have extensibility API for running on custom dispatcher/sync-context.
|
|
||||||
// See https://github.com/nunit/nunit/issues/2917 https://github.com/nunit/nunit/issues/2774
|
|
||||||
// To workaround that we had to replace inner TestMethodCommand with our own implementation while keeping original hierarchy of commands.
|
|
||||||
// Which will respect proper async/await awaiting code that works with our session and can be block-awaited to fit in NUnit.
|
|
||||||
// Also, we need to push BeforeTest/AfterTest callbacks to the very same session call.
|
|
||||||
// I hope there will be a better solution without reflection, but for now that's it.
|
|
||||||
private static FieldInfo s_innerCommand = typeof(DelegatingTestCommand)
|
|
||||||
.GetField("innerCommand", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
||||||
private static FieldInfo s_beforeTest = typeof(BeforeAndAfterTestCommand)
|
|
||||||
.GetField("BeforeTest", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
||||||
private static FieldInfo s_afterTest = typeof(BeforeAndAfterTestCommand)
|
|
||||||
.GetField("AfterTest", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
||||||
|
|
||||||
private AvaloniaTestMethodCommand(
|
private AvaloniaTestMethodCommand(
|
||||||
HeadlessUnitTestSession session,
|
HeadlessUnitTestSession session,
|
||||||
TestCommand innerCommand,
|
TestCommand innerCommand,
|
||||||
List<Action> beforeTest,
|
List<Func<TestExecutionContext, Task>> beforeTest,
|
||||||
List<Action> afterTest)
|
List<Func<TestExecutionContext, Task>> afterTest)
|
||||||
: base(innerCommand.Test)
|
: base(innerCommand.Test)
|
||||||
{
|
{
|
||||||
_session = session;
|
_session = session;
|
||||||
@@ -45,61 +30,65 @@ internal class AvaloniaTestMethodCommand : TestCommand
|
|||||||
|
|
||||||
public static TestCommand ProcessCommand(HeadlessUnitTestSession session, TestCommand command)
|
public static TestCommand ProcessCommand(HeadlessUnitTestSession session, TestCommand command)
|
||||||
{
|
{
|
||||||
return ProcessCommand(session, command, new List<Action>(), new List<Action>());
|
return ProcessCommand(session, command, [], []);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TestCommand ProcessCommand(HeadlessUnitTestSession session, TestCommand command, List<Action> before, List<Action> after)
|
private static TestCommand ProcessCommand(
|
||||||
|
HeadlessUnitTestSession session,
|
||||||
|
TestCommand command,
|
||||||
|
List<Func<TestExecutionContext, Task>> before,
|
||||||
|
List<Func<TestExecutionContext, Task>> after)
|
||||||
{
|
{
|
||||||
if (command is BeforeAndAfterTestCommand beforeAndAfterTestCommand)
|
var beforeAndAfterTestCommand = command as BeforeAndAfterTestCommand;
|
||||||
|
if (beforeAndAfterTestCommand is not null)
|
||||||
{
|
{
|
||||||
if (s_beforeTest.GetValue(beforeAndAfterTestCommand) is Action<TestExecutionContext> beforeTest)
|
ref var beforeTest = ref beforeAndAfterTestCommand.BeforeTest();
|
||||||
|
if (beforeTest is not null)
|
||||||
{
|
{
|
||||||
Action<TestExecutionContext> beforeAction = c => before.Add(() => beforeTest(c));
|
AddBeforeOrAfterAction(beforeTest, before);
|
||||||
s_beforeTest.SetValue(beforeAndAfterTestCommand, beforeAction);
|
beforeTest = _ => { };
|
||||||
}
|
}
|
||||||
if (s_afterTest.GetValue(beforeAndAfterTestCommand) is Action<TestExecutionContext> afterTest)
|
}
|
||||||
|
|
||||||
|
var delegatingTestCommand = command as DelegatingTestCommand;
|
||||||
|
if (delegatingTestCommand is not null)
|
||||||
|
{
|
||||||
|
ref var innerCommand = ref delegatingTestCommand.InnerCommand();
|
||||||
|
innerCommand = ProcessCommand(session, innerCommand, before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (beforeAndAfterTestCommand is not null)
|
||||||
|
{
|
||||||
|
ref var afterTest = ref beforeAndAfterTestCommand.AfterTest();
|
||||||
|
if (afterTest is not null)
|
||||||
{
|
{
|
||||||
Action<TestExecutionContext> afterAction = c => after.Add(() => afterTest(c));
|
AddBeforeOrAfterAction(afterTest, after);
|
||||||
s_afterTest.SetValue(beforeAndAfterTestCommand, afterAction);
|
afterTest = _ => { };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command is DelegatingTestCommand delegatingTestCommand
|
if (delegatingTestCommand is null && command is TestMethodCommand methodCommand)
|
||||||
&& s_innerCommand.GetValue(delegatingTestCommand) is TestCommand inner)
|
|
||||||
{
|
|
||||||
s_innerCommand.SetValue(delegatingTestCommand, ProcessCommand(session, inner, before, after));
|
|
||||||
}
|
|
||||||
else if (command is TestMethodCommand methodCommand)
|
|
||||||
{
|
|
||||||
return new AvaloniaTestMethodCommand(session, methodCommand, before, after);
|
return new AvaloniaTestMethodCommand(session, methodCommand, before, after);
|
||||||
}
|
|
||||||
|
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override TestResult Execute(TestExecutionContext context)
|
public override TestResult Execute(TestExecutionContext context)
|
||||||
{
|
{
|
||||||
return _session.DispatchCore(() => ExecuteTestMethod(context), true, default).GetAwaiter().GetResult();
|
return _session.DispatchCore(() => ExecuteTestMethod(context), true, context.CancellationToken).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unfortunately, NUnit has issues with custom synchronization contexts, which means we need to add some hacks to make it work.
|
// Unfortunately, NUnit has issues with custom synchronization contexts, which means we need to add some hacks to make it work.
|
||||||
private async Task<TestResult> ExecuteTestMethod(TestExecutionContext context)
|
private async Task<TestResult> ExecuteTestMethod(TestExecutionContext context)
|
||||||
{
|
{
|
||||||
_beforeTest.ForEach(a => a());
|
foreach (var beforeTest in _beforeTest)
|
||||||
|
await beforeTest(context);
|
||||||
|
|
||||||
var testMethod = _innerCommand.Test.Method;
|
var testMethod = _innerCommand.Test.Method;
|
||||||
var methodInfo = testMethod!.MethodInfo;
|
var methodInfo = testMethod!.MethodInfo;
|
||||||
|
|
||||||
var result = methodInfo.Invoke(context.TestObject, _innerCommand.Test.Arguments);
|
var result = methodInfo.Invoke(context.TestObject, _innerCommand.Test.Arguments);
|
||||||
// Only Task, non generic ValueTask are supported in async context. No ValueTask<> nor F# tasks.
|
await ToTask(result);
|
||||||
if (result is Task task)
|
|
||||||
{
|
|
||||||
await task;
|
|
||||||
}
|
|
||||||
else if (result is ValueTask valueTask)
|
|
||||||
{
|
|
||||||
await valueTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.CurrentResult.SetResult(ResultState.Success);
|
context.CurrentResult.SetResult(ResultState.Success);
|
||||||
|
|
||||||
@@ -108,10 +97,55 @@ internal class AvaloniaTestMethodCommand : TestCommand
|
|||||||
|
|
||||||
if (context.ExecutionStatus != TestExecutionStatus.AbortRequested)
|
if (context.ExecutionStatus != TestExecutionStatus.AbortRequested)
|
||||||
{
|
{
|
||||||
_afterTest.ForEach(a => a());
|
foreach (var afterTest in _afterTest)
|
||||||
|
await afterTest(context);
|
||||||
|
|
||||||
Dispatcher.UIThread.RunJobs();
|
Dispatcher.UIThread.RunJobs();
|
||||||
}
|
}
|
||||||
|
|
||||||
return context.CurrentResult;
|
return context.CurrentResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void AddBeforeOrAfterAction(Action<TestExecutionContext> action, List<Func<TestExecutionContext, Task>> targets)
|
||||||
|
{
|
||||||
|
// We need to extract the SetUp and TearDown methods to run them asynchronously on Avalonia's synchronization context.
|
||||||
|
if (action.Target is SetUpTearDownItem setUpTearDownItem)
|
||||||
|
{
|
||||||
|
var methods = action.Method.Name switch
|
||||||
|
{
|
||||||
|
nameof(SetUpTearDownItem.RunSetUp) => setUpTearDownItem.SetUpMethods(),
|
||||||
|
nameof(SetUpTearDownItem.RunTearDown) => setUpTearDownItem.TearDownMethods(),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (methods is not null)
|
||||||
|
{
|
||||||
|
foreach (var method in methods)
|
||||||
|
{
|
||||||
|
targets.Add(context =>
|
||||||
|
{
|
||||||
|
var result = method.Invoke(method.IsStatic ? null : context.TestObject, null);
|
||||||
|
return ToTask(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targets.Add(context =>
|
||||||
|
{
|
||||||
|
action(context);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task ToTask(object? result)
|
||||||
|
// Only Task, non generic ValueTask are supported in async context. No ValueTask<> nor F# tasks.
|
||||||
|
=> result switch
|
||||||
|
{
|
||||||
|
Task task => task,
|
||||||
|
ValueTask valueTask => valueTask.AsTask(),
|
||||||
|
_ => Task.CompletedTask
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using NUnit.Framework.Interfaces;
|
||||||
|
using NUnit.Framework.Internal;
|
||||||
|
using NUnit.Framework.Internal.Commands;
|
||||||
|
|
||||||
|
namespace Avalonia.Headless.NUnit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 2023-05-10, original comment from Max about NUnit 3:
|
||||||
|
/// There are multiple problems with NUnit integration at the moment when we wrote this integration.
|
||||||
|
/// NUnit doesn't have extensibility API for running on custom dispatcher/sync-context.
|
||||||
|
/// See https://github.com/nunit/nunit/issues/2917 https://github.com/nunit/nunit/issues/2774
|
||||||
|
/// To workaround that we had to replace inner TestMethodCommand with our own implementation while keeping original hierarchy of commands.
|
||||||
|
/// Which will respect proper async/await awaiting code that works with our session and can be block-awaited to fit in NUnit.
|
||||||
|
/// Also, we need to push BeforeTest/AfterTest callbacks to the very same session call.
|
||||||
|
/// I hope there will be a better solution without reflection, but for now that's it.
|
||||||
|
///
|
||||||
|
/// 2026-02-04: the situation hasn't changed at all with NUnit 4.
|
||||||
|
/// </summary>
|
||||||
|
internal static class NUnitReflectionHelper
|
||||||
|
{
|
||||||
|
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = ReflectionDelegatingTestCommand.InnerCommandFieldName)]
|
||||||
|
private static extern ref TestCommand DelegatingTestCommand_InnerCommand(DelegatingTestCommand instance);
|
||||||
|
|
||||||
|
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = ReflectionBeforeAndAfterTestCommand.BeforeTestFieldName)]
|
||||||
|
private static extern ref Action<TestExecutionContext>? BeforeAndAfterTestCommand_BeforeTest(BeforeAndAfterTestCommand instance);
|
||||||
|
|
||||||
|
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = ReflectionBeforeAndAfterTestCommand.AfterTestFieldName)]
|
||||||
|
private static extern ref Action<TestExecutionContext>? BeforeAndAfterTestCommand_AfterTest(BeforeAndAfterTestCommand instance);
|
||||||
|
|
||||||
|
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_setUpMethods")]
|
||||||
|
private static extern ref IList<IMethodInfo> SetUpTearDownItem_SetUpMethods(SetUpTearDownItem instance);
|
||||||
|
|
||||||
|
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_tearDownMethods")]
|
||||||
|
private static extern ref IList<IMethodInfo> SetUpTearDownItem_TearDownMethods(SetUpTearDownItem instance);
|
||||||
|
|
||||||
|
extension(DelegatingTestCommand instance)
|
||||||
|
{
|
||||||
|
public ref TestCommand InnerCommand()
|
||||||
|
=> ref DelegatingTestCommand_InnerCommand(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension(BeforeAndAfterTestCommand instance)
|
||||||
|
{
|
||||||
|
public ref Action<TestExecutionContext>? BeforeTest()
|
||||||
|
=> ref BeforeAndAfterTestCommand_BeforeTest(instance);
|
||||||
|
|
||||||
|
public ref Action<TestExecutionContext>? AfterTest()
|
||||||
|
=> ref BeforeAndAfterTestCommand_AfterTest(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension(SetUpTearDownItem instance)
|
||||||
|
{
|
||||||
|
public ref IList<IMethodInfo> SetUpMethods()
|
||||||
|
=> ref SetUpTearDownItem_SetUpMethods(instance);
|
||||||
|
|
||||||
|
public ref IList<IMethodInfo> TearDownMethods()
|
||||||
|
=> ref SetUpTearDownItem_TearDownMethods(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ReflectionDelegatingTestCommand : DelegatingTestCommand
|
||||||
|
{
|
||||||
|
public ReflectionDelegatingTestCommand(TestCommand innerCommand)
|
||||||
|
: base(innerCommand)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public const string InnerCommandFieldName = nameof(innerCommand);
|
||||||
|
|
||||||
|
public override TestResult Execute(TestExecutionContext context)
|
||||||
|
=> throw new NotSupportedException("Reflection-only type, this method should never be called");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ReflectionBeforeAndAfterTestCommand : BeforeAndAfterTestCommand
|
||||||
|
{
|
||||||
|
public ReflectionBeforeAndAfterTestCommand(TestCommand innerCommand)
|
||||||
|
: base(innerCommand)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public const string BeforeTestFieldName = nameof(BeforeTest);
|
||||||
|
public const string AfterTestFieldName = nameof(AfterTest);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#if NUNIT
|
||||||
|
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Avalonia.Headless.UnitTests;
|
||||||
|
|
||||||
|
public class AsyncSetupTests
|
||||||
|
{
|
||||||
|
private static int s_instanceCount;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public async Task SetUp()
|
||||||
|
{
|
||||||
|
await Task.Delay(100);
|
||||||
|
++s_instanceCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaTest, TestCase(1), TestCase(2)]
|
||||||
|
public void Async_Setup_TearDown_Should_Work(int index)
|
||||||
|
{
|
||||||
|
AssertHelper.Equal(1, s_instanceCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public async Task TearDown()
|
||||||
|
{
|
||||||
|
await Task.Delay(100);
|
||||||
|
--s_instanceCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Avalonia.Headless.UnitTests;
|
||||||
|
|
||||||
|
public class SetupTests
|
||||||
|
#if XUNIT
|
||||||
|
: IDisposable
|
||||||
|
#endif
|
||||||
|
{
|
||||||
|
private static int s_instanceCount;
|
||||||
|
|
||||||
|
#if NUNIT
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
#elif XUNIT
|
||||||
|
public SetupTests()
|
||||||
|
#endif
|
||||||
|
{
|
||||||
|
++s_instanceCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if NUNIT
|
||||||
|
[AvaloniaTest, TestCase(1), TestCase(2)]
|
||||||
|
#elif XUNIT
|
||||||
|
[AvaloniaTheory, InlineData(1), InlineData(2)]
|
||||||
|
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(
|
||||||
|
"Usage",
|
||||||
|
"xUnit1026:Theory methods should use all of their parameters",
|
||||||
|
Justification = "Used to run the test several times")]
|
||||||
|
#endif
|
||||||
|
public void Setup_TearDown_Should_Work(int index)
|
||||||
|
{
|
||||||
|
AssertHelper.Equal(1, s_instanceCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
#if NUNIT
|
||||||
|
[TearDown]
|
||||||
|
public void TearDown()
|
||||||
|
#elif XUNIT
|
||||||
|
public void Dispose()
|
||||||
|
#endif
|
||||||
|
{
|
||||||
|
--s_instanceCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user