Skip to content

Repository files navigation

jcamp.Avalonia.Headless.MsTest

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.

Why it is needed

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.

Requirements

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.

Microsoft Testing Platform

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.

Isolation

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.

One thing worth knowing if you write your own tests for this

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.

Licence

MIT.

About

Headless MSTest integration for Avalonia — the [AvaloniaTest] attribute Avalonia ships for NUnit and xUnit but not MSTest

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages