Headless MSTest integration for Avalonia — an [AvaloniaTest] attribute that runs each test body on
Avalonia's headless UI thread.
Avalonia ships this for NUnit (Avalonia.Headless.NUnit) and xUnit (Avalonia.Headless.XUnit). It does
not ship one for MSTest. This is that package, deliberately shaped the same way so moving between them is
attribute-for-attribute.
using Avalonia.Headless;
using jcamp.Avalonia.Headless.MsTest;
[assembly: AvaloniaTestApplication(typeof(MyTestApp))]
[TestClass]
public class ButtonTests
{
[AvaloniaTest] // instead of [TestMethod]
public void It_builds()
{
var window = new Window { Content = new Button { Content = "OK" } };
window.Show();
Assert.IsInstanceOfType<Button>(window.Content);
}
[AvaloniaTest]
public async Task It_survives_an_await()
{
await Task.Yield();
_ = new Window(); // still on the UI thread here
}
}Your application type is declared once per assembly with Avalonia's own
[assembly: AvaloniaTestApplication(...)], exactly as the NUnit package expects it.
Without it, a test body runs on whatever thread the runner supplies, and most Avalonia types cannot be constructed there:
System.InvalidOperationException: Unable to locate 'Avalonia.Platform.IWindowingPlatform'.
That names a platform service, so it reads as a missing dependency rather than as a threading problem — which is what makes it cost an afternoon the first time.
| MSTest | 4.0.0 or later — the attribute overrides TestMethodAttribute.ExecuteAsync, which 3.x does not have |
| Avalonia | 12.0.0 or later (Avalonia.Headless) |
| Frameworks | net8.0, net10.0 |
Both dependencies are referenced at the lowest version that works and left to float: your test project pins its own MSTest and its own Avalonia, and a package that pinned either would drag a second copy into the graph.
Recommended, and required from the .NET 10 SDK onward if you want dotnet test to run MSTest at all —
without it the run fails with "Testing with VSTest target is no longer supported". Put a global.json
at the repo root:
{ "test": { "runner": "Microsoft.Testing.Platform" } }and in the test project:
<OutputType>Exe</OutputType>
<EnableMSTestRunner>true</EnableMSTestRunner>MTP is worth having here beyond the SDK requirement: UI-thread bugs mostly present as something quietly not happening, and MTP surfaces crashes and hangs rather than absorbing them.
Set a timeout — with no deadline, a run that hangs hangs forever:
<TestingPlatformCommandLineArguments>--timeout 60s</TestingPlatformCommandLineArguments>Two things about it are worth knowing before you rely on it, both measured rather than assumed.
A trip does not say "timeout". It reports Canceling the test session..., a summary of Aborted,
total: 0 failed: 0, and exit code 3. Zero failures and zero total reads like almost anything
except a run that ran out of time.
And it cancels rather than kills. If the hung operation cannot be interrupted, the process still
waits for it: a test sleeping 30s under a 5s timeout is cancelled at 5s and still exits at 30s. A true
deadlock — a dispatch that never returns — is not recovered at all, which is exactly what a wrong
Dispatch overload does. So the timeout makes hangs reportable, not impossible; CI still needs its
own job-level limit underneath it.
Every test in an assembly shares one Application and one dispatcher thread — the session is started
with AvaloniaTestIsolationLevel.PerAssembly explicitly.
That is not a performance choice. Under per-test isolation the Application and its dispatcher are
rebuilt for every test, and that path intermittently dies with VerifyAccess — "a different thread
owns it" — before any test body runs. It shows up as an unrelated test failing roughly one run in
twenty on a loaded machine, which is the shape that gets filed as flakiness and re-run until it passes.
It is not flaky, and re-running is how it survives.
What it costs you, per Avalonia's own remarks: state leaks between tests. A window left open stays
open; anything you hang off Application.Current persists. Clean up what you open.
Dispatcher.UIThread.CheckAccess() cannot tell you whether the attribute worked. Dispatcher.UIThread
binds lazily to whichever thread first asks, so it returns true on an ordinary test thread that has
never seen Avalonia — measured here, before any session existed. A test built on it passes whether or not
this package does anything. Assert on constructing a control instead; that is the actual contract, and
the actual symptom.
MIT.