Browse Source

Headless AvaloniaTestIsolationLevel (#20000)

* Remove Unstable from HeadlessUnitTestSession

* Implement headless AvaloniaTestIsolationLevel

* Duplicate headless unit tests with different levels of isolation

* Fix accidental ABI breaking change

* Adjust docs

* Add IsolationTests

* Fix RunCoreLibsTests nukebuild target

* Headless: reuse sync context, instead of always forcing avalonia context
Max Katz 3 weeks ago
parent
commit
a4bfae1c29

+ 28 - 14
Avalonia.sln

@@ -270,10 +270,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Headless.NUnit", "
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Generators.Tests", "tests\Avalonia.Generators.Tests\Avalonia.Generators.Tests.csproj", "{4B8EBBEB-A1AD-49EC-8B69-B93ED15BFA64}"
 EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Headless.NUnit.UnitTests", "tests\Avalonia.Headless.NUnit.UnitTests\Avalonia.Headless.NUnit.UnitTests.csproj", "{2999D79E-3C20-4A90-B651-CA7E0AC92D35}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Headless.XUnit.UnitTests", "tests\Avalonia.Headless.XUnit.UnitTests\Avalonia.Headless.XUnit.UnitTests.csproj", "{F83FC908-A4E3-40DE-B4CF-A4BA1E92CDB3}"
-EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Metal", "src\Avalonia.Metal\Avalonia.Metal.csproj", "{60B4ED1F-ECFA-453B-8A70-1788261C8355}"
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Build.Tasks.UnitTest", "tests\Avalonia.Build.Tasks.UnitTest\Avalonia.Build.Tasks.UnitTest.csproj", "{B0FD6A48-FBAB-4676-B36A-DE76B0922B12}"
@@ -302,6 +298,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlCatalog.MacCatalyst"
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlCatalog.tvOS", "samples\ControlCatalog.tvOS\ControlCatalog.tvOS.csproj", "{14342787-B4EF-4076-8C91-BA6C523DE8DF}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Headless.NUnit.PerAssembly.UnitTests", "tests\Avalonia.Headless.NUnit.PerAssembly.UnitTests\Avalonia.Headless.NUnit.PerAssembly.UnitTests.csproj", "{A175EFAE-476C-4DAA-87D5-742C18CFCC27}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Headless.NUnit.PerTest.UnitTests", "tests\Avalonia.Headless.NUnit.PerTest.UnitTests\Avalonia.Headless.NUnit.PerTest.UnitTests.csproj", "{09EC467F-0F25-4E6F-A836-2BAEC8F6AB0C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Headless.XUnit.PerAssembly.UnitTests", "tests\Avalonia.Headless.XUnit.PerAssembly.UnitTests\Avalonia.Headless.XUnit.PerAssembly.UnitTests.csproj", "{342D2657-2F84-493C-B74B-9D2CAE5D9DAB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Headless.XUnit.PerTest.UnitTests", "tests\Avalonia.Headless.XUnit.PerTest.UnitTests\Avalonia.Headless.XUnit.PerTest.UnitTests.csproj", "{26918642-829D-4FA2-B60A-BE8D83F4E063}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -652,14 +656,6 @@ Global
 		{4B8EBBEB-A1AD-49EC-8B69-B93ED15BFA64}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{4B8EBBEB-A1AD-49EC-8B69-B93ED15BFA64}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{4B8EBBEB-A1AD-49EC-8B69-B93ED15BFA64}.Release|Any CPU.Build.0 = Release|Any CPU
-		{2999D79E-3C20-4A90-B651-CA7E0AC92D35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{2999D79E-3C20-4A90-B651-CA7E0AC92D35}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{2999D79E-3C20-4A90-B651-CA7E0AC92D35}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{2999D79E-3C20-4A90-B651-CA7E0AC92D35}.Release|Any CPU.Build.0 = Release|Any CPU
-		{F83FC908-A4E3-40DE-B4CF-A4BA1E92CDB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{F83FC908-A4E3-40DE-B4CF-A4BA1E92CDB3}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{F83FC908-A4E3-40DE-B4CF-A4BA1E92CDB3}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{F83FC908-A4E3-40DE-B4CF-A4BA1E92CDB3}.Release|Any CPU.Build.0 = Release|Any CPU
 		{60B4ED1F-ECFA-453B-8A70-1788261C8355}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{60B4ED1F-ECFA-453B-8A70-1788261C8355}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{60B4ED1F-ECFA-453B-8A70-1788261C8355}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -704,6 +700,22 @@ Global
 		{14342787-B4EF-4076-8C91-BA6C523DE8DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{14342787-B4EF-4076-8C91-BA6C523DE8DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{14342787-B4EF-4076-8C91-BA6C523DE8DF}.Release|Any CPU.Build.0 = Release|Any CPU
+		{A175EFAE-476C-4DAA-87D5-742C18CFCC27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{A175EFAE-476C-4DAA-87D5-742C18CFCC27}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{A175EFAE-476C-4DAA-87D5-742C18CFCC27}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{A175EFAE-476C-4DAA-87D5-742C18CFCC27}.Release|Any CPU.Build.0 = Release|Any CPU
+		{09EC467F-0F25-4E6F-A836-2BAEC8F6AB0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{09EC467F-0F25-4E6F-A836-2BAEC8F6AB0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{09EC467F-0F25-4E6F-A836-2BAEC8F6AB0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{09EC467F-0F25-4E6F-A836-2BAEC8F6AB0C}.Release|Any CPU.Build.0 = Release|Any CPU
+		{342D2657-2F84-493C-B74B-9D2CAE5D9DAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{342D2657-2F84-493C-B74B-9D2CAE5D9DAB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{342D2657-2F84-493C-B74B-9D2CAE5D9DAB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{342D2657-2F84-493C-B74B-9D2CAE5D9DAB}.Release|Any CPU.Build.0 = Release|Any CPU
+		{26918642-829D-4FA2-B60A-BE8D83F4E063}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{26918642-829D-4FA2-B60A-BE8D83F4E063}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{26918642-829D-4FA2-B60A-BE8D83F4E063}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{26918642-829D-4FA2-B60A-BE8D83F4E063}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -779,8 +791,6 @@ Global
 		{F47F8316-4D4B-4026-8EF3-16B2CFDA8119} = {FF237916-7150-496B-89ED-6CA3292896E7}
 		{ED976634-B118-43F8-8B26-0279C7A7044F} = {FF237916-7150-496B-89ED-6CA3292896E7}
 		{4B8EBBEB-A1AD-49EC-8B69-B93ED15BFA64} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B}
-		{2999D79E-3C20-4A90-B651-CA7E0AC92D35} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B}
-		{F83FC908-A4E3-40DE-B4CF-A4BA1E92CDB3} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B}
 		{B0FD6A48-FBAB-4676-B36A-DE76B0922B12} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B}
 		{9D6AEF22-221F-4F4B-B335-A4BA510F002C} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B}
 		{5BF0C3B8-E595-4940-AB30-2DA206C2F085} = {9D6AEF22-221F-4F4B-B335-A4BA510F002C}
@@ -793,6 +803,10 @@ Global
 		{255614F5-CB64-4ECA-A026-E0B1AF6A2EF4} = {9B9E3891-2366-4253-A952-D08BCEB71098}
 		{DE3C28DD-B602-4750-831D-345102A54CA0} = {9B9E3891-2366-4253-A952-D08BCEB71098}
 		{14342787-B4EF-4076-8C91-BA6C523DE8DF} = {9B9E3891-2366-4253-A952-D08BCEB71098}
+		{A175EFAE-476C-4DAA-87D5-742C18CFCC27} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B}
+		{09EC467F-0F25-4E6F-A836-2BAEC8F6AB0C} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B}
+		{342D2657-2F84-493C-B74B-9D2CAE5D9DAB} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B}
+		{26918642-829D-4FA2-B60A-BE8D83F4E063} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {87366D66-1391-4D90-8999-95A620AD786A}

+ 4 - 2
nukebuild/Build.cs

@@ -297,8 +297,10 @@ partial class Build : NukeBuild
             RunCoreTest("Avalonia.Markup.Xaml.UnitTests");
             RunCoreTest("Avalonia.Skia.UnitTests");
             RunCoreTest("Avalonia.ReactiveUI.UnitTests");
-            RunCoreTest("Avalonia.Headless.NUnit.UnitTests");
-            RunCoreTest("Avalonia.Headless.XUnit.UnitTests");
+            RunCoreTest("Avalonia.Headless.NUnit.PerAssembly.UnitTests");
+            RunCoreTest("Avalonia.Headless.NUnit.PerTest.UnitTests");
+            RunCoreTest("Avalonia.Headless.XUnit.PerAssembly.UnitTests");
+            RunCoreTest("Avalonia.Headless.XUnit.PerTest.UnitTests");
         });
 
     Target RunRenderTests => _ => _

+ 47 - 0
src/Headless/Avalonia.Headless/HeadlessUnitTestIsolationAttribute.cs

@@ -0,0 +1,47 @@
+using System;
+
+namespace Avalonia.Headless;
+
+/// <summary>
+/// Defines the isolation level for headless unit tests,
+/// controlling how <see cref="Avalonia.Application"/> and its
+/// associated <see cref="Avalonia.Threading.Dispatcher"/> are managed
+/// between test runs.
+/// </summary>
+public enum AvaloniaTestIsolationLevel
+{
+    /// <summary>
+    /// Reuses a single <see cref="Avalonia.Application"/> and <see cref="Avalonia.Threading.Dispatcher"/>
+    /// instance across all tests within the assembly.
+    /// </summary>
+    /// <remarks>
+    /// Tests must not rely on any global or persistent state that could leak between runs.
+    /// Headless framework won't dispose any resources after tests when using this mode.
+    /// </remarks>
+    PerAssembly,
+
+    /// <summary>
+    /// Recreates the <see cref="Avalonia.Application"/> and  <see cref="Avalonia.Threading.Dispatcher"/>
+    /// for each individual test method.
+    /// </summary>
+    /// <remarks>
+    /// This mode ensures complete test isolation, and should be used for tests that modify global
+    /// application state or rely on a clean dispatcher environment.
+    /// This is the default isolation level if none is specified.
+    /// </remarks>
+    PerTest
+}
+
+/// <summary>
+/// Specifies how headless unit tests should be isolated from each other,
+/// defining when the test runtime should recreate the
+/// <see cref="Avalonia.Application"/> and <see cref="Avalonia.Threading.Dispatcher"/> instances.
+/// </summary>
+[AttributeUsage(AttributeTargets.Assembly)]
+public sealed class AvaloniaTestIsolationAttribute(AvaloniaTestIsolationLevel isolationLevel) : Attribute
+{
+    /// <summary>
+    /// Gets the isolation level for headless tests.
+    /// </summary>
+    public AvaloniaTestIsolationLevel IsolationLevel { get; } = isolationLevel;
+}

+ 57 - 8
src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs

@@ -18,7 +18,6 @@ namespace Avalonia.Headless;
 /// All UI tests are supposed to be executed from one of the <see cref="Dispatch"/> methods to keep execution flow on the UI thread.
 /// Disposing unit test session stops internal dispatcher loop. 
 /// </summary>
-[Unstable("This API is experimental and might be unstable. Use on your risk. API might or might not be changed in a minor update.")]
 public sealed class HeadlessUnitTestSession : IDisposable
 {
     private static readonly Dictionary<Assembly, HeadlessUnitTestSession> s_session = new();
@@ -27,19 +26,25 @@ public sealed class HeadlessUnitTestSession : IDisposable
     private readonly CancellationTokenSource _cancellationTokenSource;
     private readonly BlockingCollection<(Action, ExecutionContext?)> _queue;
     private readonly Task _dispatchTask;
+    private readonly bool _isolated;
+    // Only set and used with PerAssembly isolation
+    private SynchronizationContext? _sharedContext;
 
     internal const DynamicallyAccessedMemberTypes DynamicallyAccessed =
         DynamicallyAccessedMemberTypes.PublicMethods |
         DynamicallyAccessedMemberTypes.NonPublicMethods |
         DynamicallyAccessedMemberTypes.PublicParameterlessConstructor;
 
-    private HeadlessUnitTestSession(AppBuilder appBuilder, CancellationTokenSource cancellationTokenSource,
-        BlockingCollection<(Action, ExecutionContext?)> queue, Task dispatchTask)
+    private HeadlessUnitTestSession(
+        AppBuilder appBuilder, CancellationTokenSource cancellationTokenSource,
+        BlockingCollection<(Action, ExecutionContext?)> queue, Task dispatchTask,
+        bool isolated)
     {
         _appBuilder = appBuilder;
         _cancellationTokenSource = cancellationTokenSource;
         _queue = queue;
         _dispatchTask = dispatchTask;
+        _isolated = isolated;
     }
 
     /// <inheritdoc cref="DispatchCore{TResult}"/>
@@ -93,7 +98,9 @@ public sealed class HeadlessUnitTestSession : IDisposable
 
             try
             {
-                using var application = EnsureApplication();
+                using var application = _isolated
+                    ? EnsureIsolatedApplication()
+                    : EnsureSharedApplication();
                 var task = action();
                 if (task.Status != TaskStatus.RanToCompletion)
                 {
@@ -123,7 +130,27 @@ public sealed class HeadlessUnitTestSession : IDisposable
         return tcs.Task;
     }
 
-    private IDisposable EnsureApplication()
+    private IDisposable EnsureSharedApplication()
+    {
+        var oldContext = SynchronizationContext.Current;
+        if (Application.Current is null)
+        {
+            _appBuilder.SetupUnsafe();
+            _sharedContext = SynchronizationContext.Current;
+        }
+        else
+        {
+            SynchronizationContext.SetSynchronizationContext(_sharedContext);
+        }
+
+        return Disposable.Create(() =>
+        {
+            Dispatcher.UIThread.RunJobs();
+            SynchronizationContext.SetSynchronizationContext(oldContext);
+        });
+    }
+
+    private IDisposable EnsureIsolatedApplication()
     {
         var scope = AvaloniaLocator.EnterScope();
         var oldContext = SynchronizationContext.Current;
@@ -167,6 +194,24 @@ public sealed class HeadlessUnitTestSession : IDisposable
     public static HeadlessUnitTestSession StartNew(
         [DynamicallyAccessedMembers(DynamicallyAccessed)]
         Type entryPointType)
+    {
+        // Cannot be optional parameter for ABI stability
+        // ReSharper disable once IntroduceOptionalParameters.Global
+        return StartNew(entryPointType, AvaloniaTestIsolationLevel.PerTest);
+    }
+
+    /// <summary>
+    /// Creates instance of <see cref="HeadlessUnitTestSession"/>. 
+    /// </summary>
+    /// <param name="entryPointType">
+    /// Parameter from which <see cref="AppBuilder"/> should be created.
+    /// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application.
+    /// </param>
+    /// <param name="isolationLevel">Defines the isolation level for headless unit tests</param>
+    public static HeadlessUnitTestSession StartNew(
+        [DynamicallyAccessedMembers(DynamicallyAccessed)]
+        Type entryPointType,
+        AvaloniaTestIsolationLevel isolationLevel)
     {
         var tcs = new TaskCompletionSource<HeadlessUnitTestSession>();
         var cancellationTokenSource = new CancellationTokenSource();
@@ -178,6 +223,7 @@ public sealed class HeadlessUnitTestSession : IDisposable
             try
             {
                 var appBuilder = AppBuilder.Configure(entryPointType);
+                var runIsolated = isolationLevel == AvaloniaTestIsolationLevel.PerTest;
 
                 // If windowing subsystem wasn't initialized by user, force headless with default parameters.
                 if (appBuilder.WindowingSubsystemName != "Headless")
@@ -186,7 +232,7 @@ public sealed class HeadlessUnitTestSession : IDisposable
                 }
 
                 // ReSharper disable once AccessToModifiedClosure
-                tcs.SetResult(new HeadlessUnitTestSession(appBuilder, cancellationTokenSource, queue, task!));
+                tcs.SetResult(new HeadlessUnitTestSession(appBuilder, cancellationTokenSource, queue, task!, runIsolated));
             }
             catch (Exception e)
             {
@@ -234,9 +280,12 @@ public sealed class HeadlessUnitTestSession : IDisposable
                 var appBuilderEntryPointType = assembly.GetCustomAttribute<AvaloniaTestApplicationAttribute>()
                     ?.AppBuilderEntryPointType;
 
+                var isolationLevel = assembly.GetCustomAttribute<AvaloniaTestIsolationAttribute>()
+                    ?.IsolationLevel ?? AvaloniaTestIsolationLevel.PerTest;
+
                 session = appBuilderEntryPointType is not null ?
-                    StartNew(appBuilderEntryPointType) :
-                    StartNew(typeof(Application));
+                    StartNew(appBuilderEntryPointType, isolationLevel) :
+                    StartNew(typeof(Application), isolationLevel);
 
                 s_session.Add(assembly, session);
             }

+ 8 - 0
tests/Avalonia.Headless.NUnit.PerAssembly.UnitTests/AssemblyInfo.cs

@@ -0,0 +1,8 @@
+global using NUnit.Framework;
+global using Avalonia.Headless.NUnit;
+
+using Avalonia.Headless;
+using Avalonia.Headless.UnitTests;
+
+[assembly: AvaloniaTestApplication(typeof(TestApplication))]
+[assembly: AvaloniaTestIsolation(AvaloniaTestIsolationLevel.PerAssembly)]

+ 0 - 0
tests/Avalonia.Headless.NUnit.UnitTests/Avalonia.Headless.NUnit.UnitTests.csproj → tests/Avalonia.Headless.NUnit.PerAssembly.UnitTests/Avalonia.Headless.NUnit.PerAssembly.UnitTests.csproj


+ 1 - 0
tests/Avalonia.Headless.NUnit.UnitTests/AssemblyInfo.cs → tests/Avalonia.Headless.NUnit.PerTest.UnitTests/AssemblyInfo.cs

@@ -5,3 +5,4 @@ using Avalonia.Headless;
 using Avalonia.Headless.UnitTests;
 
 [assembly: AvaloniaTestApplication(typeof(TestApplication))]
+[assembly: AvaloniaTestIsolation(AvaloniaTestIsolationLevel.PerTest)]

+ 31 - 0
tests/Avalonia.Headless.NUnit.PerTest.UnitTests/Avalonia.Headless.NUnit.PerTest.UnitTests.csproj

@@ -0,0 +1,31 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>$(AvsCurrentTargetFramework)</TargetFramework>
+    <IsTestProject>true</IsTestProject>
+    <DefineConstants>$(DefineConstants);NUNIT</DefineConstants>
+  </PropertyGroup>
+
+  <Import Project="..\..\build\UnitTests.NetCore.targets" />
+  <Import Project="..\..\build\UnitTests.NetFX.props" />
+  <Import Project="..\..\build\Moq.props" />
+  <Import Project="..\..\build\Rx.props" />
+  <Import Project="..\..\build\SharedVersion.props" />
+
+  <ItemGroup>
+    <PackageReference Include="NUnit" Version="3.13.3" />
+    <PackageReference Include="NUnit3TestAdapter" Version="4.4.2" />
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <Compile Include="..\Avalonia.Headless.UnitTests\**\*.cs" />
+    <Compile Remove="..\Avalonia.Headless.UnitTests\bin\**\*.cs" />
+    <Compile Remove="..\Avalonia.Headless.UnitTests\obj\**\*.cs" />
+  </ItemGroup>
+  
+  <ItemGroup>
+    <ProjectReference Include="..\..\src\Avalonia.Themes.Simple\Avalonia.Themes.Simple.csproj" />
+    <ProjectReference Include="..\..\src\Headless\Avalonia.Headless.NUnit\Avalonia.Headless.NUnit.csproj" />
+    <ProjectReference Include="..\..\src\Skia\Avalonia.Skia\Avalonia.Skia.csproj" />
+  </ItemGroup>
+</Project>

+ 63 - 0
tests/Avalonia.Headless.UnitTests/IsolationTests.cs

@@ -0,0 +1,63 @@
+using System;
+using System.Reflection;
+using Avalonia.Threading;
+
+namespace Avalonia.Headless.UnitTests;
+
+public class IsolationTests
+{
+    private static WeakReference<Application> s_previousAppRef;
+    private static WeakReference<Dispatcher> s_previousDispatcherRef;
+
+#if NUNIT
+    [AvaloniaTheory, Timeout(10000)]
+    [TestCase(1), TestCase(2), TestCase(3)]
+#elif XUNIT
+    [AvaloniaTheory]
+    [InlineData(1), InlineData(2), InlineData(3)]
+#endif
+    public void Application_Instance_Should_Match_Isolation_Level(int runIndex)
+    {
+        var currentApp = Application.Current;
+        var currentDispatcher = Dispatcher.UIThread;
+
+        if (s_previousAppRef is not null && s_previousDispatcherRef is not null)
+        {
+            var isolationLevel =
+                GetType().Assembly.GetCustomAttribute<AvaloniaTestIsolationAttribute>()?.IsolationLevel ??
+                AvaloniaTestIsolationLevel.PerTest;
+
+            if (isolationLevel == AvaloniaTestIsolationLevel.PerTest)
+            {
+                GC.Collect();
+                GC.WaitForPendingFinalizers();
+                GC.Collect();
+
+                Assert.False(s_previousAppRef.TryGetTarget(out var previousApp),
+                    "Previous Application instance should have been collected.");
+                Assert.False(s_previousDispatcherRef.TryGetTarget(out var previousDispatcher),
+                    "Previous Dispatcher instance should have been collected.");
+
+                Assert.False(previousApp == currentApp);
+                Assert.False(previousDispatcher == currentDispatcher);
+            }
+            else if (isolationLevel == AvaloniaTestIsolationLevel.PerAssembly)
+            {
+                Assert.True(s_previousAppRef.TryGetTarget(out var previousApp),
+                    "Previous Application instance should still be alive.");
+                Assert.True(s_previousDispatcherRef.TryGetTarget(out var previousDispatcher),
+                    "Previous Dispatcher instance should still be alive.");
+
+                Assert.True(previousApp == currentApp);
+                Assert.True(previousDispatcher == currentDispatcher);
+            }
+            else
+            {
+                throw new InvalidOperationException($"Unknown isolation level: {isolationLevel}");
+            }
+        }
+
+        s_previousAppRef = new WeakReference<Application>(currentApp);
+        s_previousDispatcherRef = new WeakReference<Dispatcher>(currentDispatcher);
+    }
+}

+ 2 - 1
tests/Avalonia.Headless.UnitTests/LeakTests.cs

@@ -42,7 +42,8 @@ public class LeakTests
         GC.WaitForPendingFinalizers();
         GC.Collect();
 
-        if (s_previousFontManager is not null)
+        // Either previous font manager is collected (IsAlive == false), or it is the same as current (shared isolation mode).
+        if (s_previousFontManager is not null && s_previousFontManager.Target != fontManager.Target)
         {
             Assert.False(s_previousFontManager.IsAlive);
         }

+ 8 - 0
tests/Avalonia.Headless.XUnit.PerAssembly.UnitTests/AssemblyInfo.cs

@@ -0,0 +1,8 @@
+global using Xunit;
+global using Avalonia.Headless.XUnit;
+using Avalonia.Headless;
+using Avalonia.Headless.UnitTests;
+
+[assembly: AvaloniaTestApplication(typeof(TestApplication))]
+[assembly: AvaloniaTestIsolation(AvaloniaTestIsolationLevel.PerAssembly)]
+[assembly: CollectionBehavior(CollectionBehavior.CollectionPerAssembly, DisableTestParallelization = true)]

+ 0 - 0
tests/Avalonia.Headless.XUnit.UnitTests/Avalonia.Headless.XUnit.UnitTests.csproj → tests/Avalonia.Headless.XUnit.PerAssembly.UnitTests/Avalonia.Headless.XUnit.PerAssembly.UnitTests.csproj


+ 1 - 0
tests/Avalonia.Headless.XUnit.UnitTests/AssemblyInfo.cs → tests/Avalonia.Headless.XUnit.PerTest.UnitTests/AssemblyInfo.cs

@@ -4,3 +4,4 @@ using Avalonia.Headless;
 using Avalonia.Headless.UnitTests;
 
 [assembly: AvaloniaTestApplication(typeof(TestApplication))]
+[assembly: AvaloniaTestIsolation(AvaloniaTestIsolationLevel.PerTest)]

+ 24 - 0
tests/Avalonia.Headless.XUnit.PerTest.UnitTests/Avalonia.Headless.XUnit.PerTest.UnitTests.csproj

@@ -0,0 +1,24 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>$(AvsCurrentTargetFramework)</TargetFramework>
+    <IsTestProject>true</IsTestProject>
+    <DefineConstants>$(DefineConstants);XUNIT</DefineConstants>
+  </PropertyGroup>
+
+  <Import Project="..\..\build\UnitTests.NetCore.targets" />
+  <Import Project="..\..\build\UnitTests.NetFX.props" />
+  <Import Project="..\..\build\Moq.props" />
+  <Import Project="..\..\build\XUnit.props" />
+  <Import Project="..\..\build\Rx.props" />
+  <Import Project="..\..\build\SharedVersion.props" />
+
+  <ItemGroup>
+    <Compile Include="..\Avalonia.Headless.UnitTests\**\*.cs" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\..\src\Avalonia.Themes.Simple\Avalonia.Themes.Simple.csproj" />
+    <ProjectReference Include="..\..\src\Headless\Avalonia.Headless.XUnit\Avalonia.Headless.XUnit.csproj" />
+    <ProjectReference Include="..\..\src\Skia\Avalonia.Skia\Avalonia.Skia.csproj" />
+  </ItemGroup>
+</Project>