diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs index 96dfd701e9..2af31d5d1d 100644 --- a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs +++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Reflection; using System.Threading.Tasks; using Avalonia.Threading; using NUnit.Framework.Interfaces; @@ -13,28 +12,14 @@ internal class AvaloniaTestMethodCommand : TestCommand { private readonly HeadlessUnitTestSession _session; private readonly TestCommand _innerCommand; - private readonly List _beforeTest; - private readonly List _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 readonly List> _beforeTest; + private readonly List> _afterTest; private AvaloniaTestMethodCommand( HeadlessUnitTestSession session, TestCommand innerCommand, - List beforeTest, - List afterTest) + List> beforeTest, + List> afterTest) : base(innerCommand.Test) { _session = session; @@ -45,47 +30,58 @@ internal class AvaloniaTestMethodCommand : TestCommand public static TestCommand ProcessCommand(HeadlessUnitTestSession session, TestCommand command) { - return ProcessCommand(session, command, new List(), new List()); + return ProcessCommand(session, command, [], []); } - private static TestCommand ProcessCommand(HeadlessUnitTestSession session, TestCommand command, List before, List after) + private static TestCommand ProcessCommand( + HeadlessUnitTestSession session, + TestCommand command, + List> before, + List> after) { - if (command is BeforeAndAfterTestCommand beforeAndAfterTestCommand) + var beforeAndAfterTestCommand = command as BeforeAndAfterTestCommand; + if (beforeAndAfterTestCommand is not null) { - if (s_beforeTest.GetValue(beforeAndAfterTestCommand) is Action beforeTest) - { - Action beforeAction = c => before.Add(() => beforeTest(c)); - s_beforeTest.SetValue(beforeAndAfterTestCommand, beforeAction); - } - if (s_afterTest.GetValue(beforeAndAfterTestCommand) is Action afterTest) + ref var beforeTest = ref beforeAndAfterTestCommand.BeforeTest(); + if (beforeTest is not null) { - Action afterAction = c => after.Add(() => afterTest(c)); - s_afterTest.SetValue(beforeAndAfterTestCommand, afterAction); + before.Add(beforeTest); + beforeTest = _ => { }; } } - - if (command is DelegatingTestCommand delegatingTestCommand - && s_innerCommand.GetValue(delegatingTestCommand) is TestCommand inner) + + var delegatingTestCommand = command as DelegatingTestCommand; + if (delegatingTestCommand is not null) { - s_innerCommand.SetValue(delegatingTestCommand, ProcessCommand(session, inner, before, after)); + ref var innerCommand = ref delegatingTestCommand.InnerCommand(); + innerCommand = ProcessCommand(session, innerCommand, before, after); } - else if (command is TestMethodCommand methodCommand) + + if (beforeAndAfterTestCommand is not null) { - return new AvaloniaTestMethodCommand(session, methodCommand, before, after); + ref var afterTest = ref beforeAndAfterTestCommand.AfterTest(); + if (afterTest is not null) + { + after.Add(afterTest); + afterTest = _ => { }; + } } + + if (delegatingTestCommand is null && command is TestMethodCommand methodCommand) + return new AvaloniaTestMethodCommand(session, methodCommand, before, after); return command; } 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. private async Task ExecuteTestMethod(TestExecutionContext context) { - _beforeTest.ForEach(a => a()); + _beforeTest.ForEach(a => a(context)); var testMethod = _innerCommand.Test.Method; var methodInfo = testMethod!.MethodInfo; @@ -108,7 +104,7 @@ internal class AvaloniaTestMethodCommand : TestCommand if (context.ExecutionStatus != TestExecutionStatus.AbortRequested) { - _afterTest.ForEach(a => a()); + _afterTest.ForEach(a => a(context)); Dispatcher.UIThread.RunJobs(); } diff --git a/src/Headless/Avalonia.Headless.NUnit/NUnitReflection.cs b/src/Headless/Avalonia.Headless.NUnit/NUnitReflection.cs new file mode 100644 index 0000000000..65369ac7ae --- /dev/null +++ b/src/Headless/Avalonia.Headless.NUnit/NUnitReflection.cs @@ -0,0 +1,69 @@ +using System; +using System.Runtime.CompilerServices; +using NUnit.Framework.Internal; +using NUnit.Framework.Internal.Commands; + +namespace Avalonia.Headless.NUnit; + +/// +/// 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. +/// +internal static class NUnitReflection +{ + [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? BeforeAndAfterTestCommand_BeforeTest(BeforeAndAfterTestCommand instance); + + [UnsafeAccessor(UnsafeAccessorKind.Field, Name = ReflectionBeforeAndAfterTestCommand.AfterTestFieldName)] + private static extern ref Action? BeforeAndAfterTestCommand_AfterTest(BeforeAndAfterTestCommand instance); + + extension(DelegatingTestCommand instance) + { + public ref TestCommand InnerCommand() + => ref DelegatingTestCommand_InnerCommand(instance); + } + + extension(BeforeAndAfterTestCommand instance) + { + public ref Action? BeforeTest() + => ref BeforeAndAfterTestCommand_BeforeTest(instance); + + public ref Action? AfterTest() + => ref BeforeAndAfterTestCommand_AfterTest(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); + } +} diff --git a/tests/Avalonia.Headless.UnitTests/SetupTests.cs b/tests/Avalonia.Headless.UnitTests/SetupTests.cs new file mode 100644 index 0000000000..77f8d25842 --- /dev/null +++ b/tests/Avalonia.Headless.UnitTests/SetupTests.cs @@ -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; + } +}