Browse Source

Fix nunit tests adapter losing async locals (#16157)

* Fix NUnit test context not being properly set

* Add failing tests

* Capture ExecutionContext to keep async locals

* Remove explicit EstablishExecutionEnvironment call, as it was a bad idea

* Make ExecutionContext usage disabled by default, and only enabled for NUnit
pull/16373/head
Max Katz 2 years ago
committed by GitHub
parent
commit
8ea60fefe2
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs
  2. 1
      src/Headless/Avalonia.Headless/Avalonia.Headless.csproj
  3. 42
      src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs
  4. 35
      tests/Avalonia.Headless.UnitTests/ThreadingTests.cs

6
src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs

@ -29,7 +29,7 @@ internal class AvaloniaTestMethodCommand : TestCommand
.GetField("BeforeTest", BindingFlags.Instance | BindingFlags.NonPublic)!; .GetField("BeforeTest", BindingFlags.Instance | BindingFlags.NonPublic)!;
private static FieldInfo s_afterTest = typeof(BeforeAndAfterTestCommand) private static FieldInfo s_afterTest = typeof(BeforeAndAfterTestCommand)
.GetField("AfterTest", BindingFlags.Instance | BindingFlags.NonPublic)!; .GetField("AfterTest", BindingFlags.Instance | BindingFlags.NonPublic)!;
private AvaloniaTestMethodCommand( private AvaloniaTestMethodCommand(
HeadlessUnitTestSession session, HeadlessUnitTestSession session,
TestCommand innerCommand, TestCommand innerCommand,
@ -47,7 +47,7 @@ internal class AvaloniaTestMethodCommand : TestCommand
{ {
return ProcessCommand(session, command, new List<Action>(), new List<Action>()); return ProcessCommand(session, command, new List<Action>(), new List<Action>());
} }
private static TestCommand ProcessCommand(HeadlessUnitTestSession session, TestCommand command, List<Action> before, List<Action> after) private static TestCommand ProcessCommand(HeadlessUnitTestSession session, TestCommand command, List<Action> before, List<Action> after)
{ {
if (command is BeforeAndAfterTestCommand beforeAndAfterTestCommand) if (command is BeforeAndAfterTestCommand beforeAndAfterTestCommand)
@ -79,7 +79,7 @@ internal class AvaloniaTestMethodCommand : TestCommand
public override TestResult Execute(TestExecutionContext context) public override TestResult Execute(TestExecutionContext context)
{ {
return _session.Dispatch(() => ExecuteTestMethod(context), default).GetAwaiter().GetResult(); return _session.DispatchCore(() => ExecuteTestMethod(context), true, default).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.

1
src/Headless/Avalonia.Headless/Avalonia.Headless.csproj

@ -16,6 +16,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup Label="InternalsVisibleTo"> <ItemGroup Label="InternalsVisibleTo">
<InternalsVisibleTo Include="Avalonia.Headless.NUnit, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Headless.Vnc, PublicKey=$(AvaloniaPublicKey)" /> <InternalsVisibleTo Include="Avalonia.Headless.Vnc, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.UnitTests, PublicKey=$(AvaloniaPublicKey)" /> <InternalsVisibleTo Include="Avalonia.UnitTests, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Base.UnitTests, PublicKey=$(AvaloniaPublicKey)" /> <InternalsVisibleTo Include="Avalonia.Base.UnitTests, PublicKey=$(AvaloniaPublicKey)" />

42
src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs

@ -23,7 +23,7 @@ public sealed class HeadlessUnitTestSession : IDisposable
private readonly AppBuilder _appBuilder; private readonly AppBuilder _appBuilder;
private readonly CancellationTokenSource _cancellationTokenSource; private readonly CancellationTokenSource _cancellationTokenSource;
private readonly BlockingCollection<Action> _queue; private readonly BlockingCollection<(Action, ExecutionContext?)> _queue;
private readonly Task _dispatchTask; private readonly Task _dispatchTask;
internal const DynamicallyAccessedMemberTypes DynamicallyAccessed = internal const DynamicallyAccessedMemberTypes DynamicallyAccessed =
@ -32,7 +32,7 @@ public sealed class HeadlessUnitTestSession : IDisposable
DynamicallyAccessedMemberTypes.PublicParameterlessConstructor; DynamicallyAccessedMemberTypes.PublicParameterlessConstructor;
private HeadlessUnitTestSession(AppBuilder appBuilder, CancellationTokenSource cancellationTokenSource, private HeadlessUnitTestSession(AppBuilder appBuilder, CancellationTokenSource cancellationTokenSource,
BlockingCollection<Action> queue, Task dispatchTask) BlockingCollection<(Action, ExecutionContext?)> queue, Task dispatchTask)
{ {
_appBuilder = appBuilder; _appBuilder = appBuilder;
_cancellationTokenSource = cancellationTokenSource; _cancellationTokenSource = cancellationTokenSource;
@ -40,20 +40,26 @@ public sealed class HeadlessUnitTestSession : IDisposable
_dispatchTask = dispatchTask; _dispatchTask = dispatchTask;
} }
/// <inheritdoc cref="Dispatch{TResult}(Func{Task{TResult}}, CancellationToken)"/> /// <inheritdoc cref="DispatchCore{TResult}"/>
public Task Dispatch(Action action, CancellationToken cancellationToken) public Task Dispatch(Action action, CancellationToken cancellationToken)
{ {
return Dispatch(() => return DispatchCore(() =>
{ {
action(); action();
return Task.FromResult(0); return Task.FromResult(0);
}, cancellationToken); }, false ,cancellationToken);
} }
/// <inheritdoc cref="Dispatch{TResult}(Func{Task{TResult}}, CancellationToken)"/> /// <inheritdoc cref="DispatchCore{TResult}"/>
public Task<TResult> Dispatch<TResult>(Func<TResult> action, CancellationToken cancellationToken) public Task<TResult> Dispatch<TResult>(Func<TResult> action, CancellationToken cancellationToken)
{ {
return Dispatch(() => Task.FromResult(action()), cancellationToken); return DispatchCore(() => Task.FromResult(action()), false, cancellationToken);
}
/// <inheritdoc cref="DispatchCore{TResult}"/>
public Task<TResult> Dispatch<TResult>(Func<Task<TResult>> action, CancellationToken cancellationToken)
{
return DispatchCore(action, false, cancellationToken);
} }
/// <summary> /// <summary>
@ -61,11 +67,12 @@ public sealed class HeadlessUnitTestSession : IDisposable
/// setting app avalonia services, and runs <paramref name="action"/> parameter. /// setting app avalonia services, and runs <paramref name="action"/> parameter.
/// </summary> /// </summary>
/// <param name="action">Action to execute on the dispatcher thread with avalonia services.</param> /// <param name="action">Action to execute on the dispatcher thread with avalonia services.</param>
/// <param name="captureExecutionContext">Whether dispatch should capture ExecutionContext.</param>
/// <param name="cancellationToken">Cancellation token to cancel execution.</param> /// <param name="cancellationToken">Cancellation token to cancel execution.</param>
/// <exception cref="ObjectDisposedException"> /// <exception cref="ObjectDisposedException">
/// If global session was already cancelled and thread killed, it's not possible to dispatch any actions again /// If global session was already cancelled and thread killed, it's not possible to dispatch any actions again
/// </exception> /// </exception>
public Task<TResult> Dispatch<TResult>(Func<Task<TResult>> action, CancellationToken cancellationToken) internal Task<TResult> DispatchCore<TResult>(Func<Task<TResult>> action, bool captureExecutionContext, CancellationToken cancellationToken)
{ {
if (_cancellationTokenSource.IsCancellationRequested) if (_cancellationTokenSource.IsCancellationRequested)
{ {
@ -73,9 +80,10 @@ public sealed class HeadlessUnitTestSession : IDisposable
} }
var token = _cancellationTokenSource.Token; var token = _cancellationTokenSource.Token;
var executionContext = captureExecutionContext ? ExecutionContext.Capture() : null;
var tcs = new TaskCompletionSource<TResult>(); var tcs = new TaskCompletionSource<TResult>();
_queue.Add(() => _queue.Add((() =>
{ {
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
using var globalCts = token.Register(s => ((CancellationTokenSource)s!).Cancel(), cts, true); using var globalCts = token.Register(s => ((CancellationTokenSource)s!).Cancel(), cts, true);
@ -84,7 +92,6 @@ public sealed class HeadlessUnitTestSession : IDisposable
try try
{ {
using var application = EnsureApplication(); using var application = EnsureApplication();
var task = action(); var task = action();
if (task.Status != TaskStatus.RanToCompletion) if (task.Status != TaskStatus.RanToCompletion)
{ {
@ -110,7 +117,7 @@ public sealed class HeadlessUnitTestSession : IDisposable
{ {
tcs.TrySetException(ex); tcs.TrySetException(ex);
} }
}); }, executionContext));
return tcs.Task; return tcs.Task;
} }
@ -157,7 +164,7 @@ public sealed class HeadlessUnitTestSession : IDisposable
{ {
var tcs = new TaskCompletionSource<HeadlessUnitTestSession>(); var tcs = new TaskCompletionSource<HeadlessUnitTestSession>();
var cancellationTokenSource = new CancellationTokenSource(); var cancellationTokenSource = new CancellationTokenSource();
var queue = new BlockingCollection<Action>(); var queue = new BlockingCollection<(Action, ExecutionContext?)>();
Task? task = null; Task? task = null;
task = Task.Run(() => task = Task.Run(() =>
@ -185,8 +192,15 @@ public sealed class HeadlessUnitTestSession : IDisposable
{ {
try try
{ {
var action = queue.Take(cancellationTokenSource.Token); var (action, executionContext) = queue.Take(cancellationTokenSource.Token);
action(); if (executionContext is not null)
{
ExecutionContext.Run(executionContext, a => ((Action)a!).Invoke(), action);
}
else
{
action();
}
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {

35
tests/Avalonia.Headless.UnitTests/ThreadingTests.cs

@ -1,4 +1,6 @@
using System; using System;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Threading; using Avalonia.Threading;
@ -14,6 +16,7 @@ public class ThreadingTests
#endif #endif
public void Should_Be_On_Dispatcher_Thread() public void Should_Be_On_Dispatcher_Thread()
{ {
ValidateTestContext();
Dispatcher.UIThread.VerifyAccess(); Dispatcher.UIThread.VerifyAccess();
} }
@ -34,20 +37,40 @@ public class ThreadingTests
#endif #endif
public async Task DispatcherTimer_Works_On_The_Same_Thread(int interval) public async Task DispatcherTimer_Works_On_The_Same_Thread(int interval)
{ {
Assert.NotNull(SynchronizationContext.Current);
ValidateTestContext();
var currentThread = Thread.CurrentThread;
await Task.Delay(100); await Task.Delay(100);
var currentThread = Thread.CurrentThread; ValidateTestContext();
Assert.True(currentThread == Thread.CurrentThread);
var tcs = new TaskCompletionSource(); var tcs = new TaskCompletionSource();
var hasCompleted = false;
DispatcherTimer.RunOnce(() => DispatcherTimer.RunOnce(() =>
{ {
hasCompleted = currentThread == Thread.CurrentThread; try
{
tcs.SetResult(); ValidateTestContext();
Assert.True(currentThread == Thread.CurrentThread);
tcs.SetResult();
}
catch (Exception ex)
{
tcs.SetException(ex);
}
}, TimeSpan.FromTicks(interval)); }, TimeSpan.FromTicks(interval));
await tcs.Task; await tcs.Task;
Assert.True(hasCompleted); }
private void ValidateTestContext([CallerMemberName] string runningMethodName = null)
{
#if NUNIT
var testName = TestContext.CurrentContext.Test.Name;
// Test.Name also includes parameters.
Assert.AreEqual(testName.Split('(').First(), runningMethodName);
#endif
} }
} }

Loading…
Cancel
Save