xUnit v3 Support
DeviceRunners supports xUnit v3 alongside xUnit v2 and NUnit for device testing. This guide covers how to set up and use xUnit v3 with DeviceRunners.
Package Architecture
xUnit v3 changed its architecture so that test projects are executables. For device testing, where the MAUI app is the host, test assemblies need to remain as class libraries. DeviceRunners handles this by using the lower-level xUnit v3 packages:
| Your test library references | Purpose |
|---|---|
xunit.v3.extensibility.core |
Test attributes ([Fact], [Theory], etc.) |
xunit.v3.assert |
Assert.* methods |
Important: Do NOT reference
xunit.v3orxunit.v3.corein your test class libraries. Those packages force the project to become an executable and inject aMainmethod, which conflicts with the MAUI app host.
Quick Start
1. Create a Test Class Library
Create a .NET class library for your tests. Multi-target it with your device platforms so the same library can be loaded by the MAUI visual runner:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0;net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
<UseMaui>true</UseMaui>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3.extensibility.core" />
<PackageReference Include="xunit.v3.assert" />
</ItemGroup>
<!-- Optional: enable 'dotnet test' on the host TFM (net10.0) -->
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == ''">
<OutputType>Exe</OutputType>
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
</PropertyGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == ''">
<PackageReference Include="xunit.v3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\YourApp\YourApp.csproj" />
</ItemGroup>
</Project>
Tip: The conditional
xunit.v3reference andOutputType=Exeon the host TFM lets you rundotnet test -f net10.0for quick local iteration. Device TFMs use the MAUI visual runner instead.
2. Write Tests
Write tests exactly as you would with xUnit v3:
using Xunit;
public class MyTests
{
[Fact]
public void BasicTest()
{
Assert.True(true);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
public void ParameterizedTest(int value)
{
Assert.NotEqual(0, value);
}
[Fact(Skip = "Not ready yet")]
public void SkippedTest() { }
}
3. Configure the MAUI Test Runner
In your MAUI test app's MauiProgram.cs, add the xUnit v3 runner:
var builder = MauiApp.CreateBuilder();
builder
.UseVisualTestRunner(conf => conf
.AddConsoleResultChannel()
.AddTestAssembly(typeof(MauiProgram).Assembly)
.AddTestAssemblies(typeof(MyTests).Assembly)
.AddXunit3()); // Enable xUnit v3 support
You can use .AddXunit3() alongside .AddXunit() and .AddNUnit() to run tests from multiple frameworks simultaneously.
Using Multiple Test Frameworks
DeviceRunners supports running xUnit v2, xUnit v3, and NUnit tests together:
builder.UseVisualTestRunner(conf => conf
.AddConsoleResultChannel()
.AddTestAssembly(typeof(MauiProgram).Assembly)
.AddTestAssemblies(typeof(XunitV2Tests).Assembly)
.AddTestAssemblies(typeof(Xunit3Tests).Assembly)
.AddTestAssemblies(typeof(NUnitTests).Assembly)
.AddXunit() // xUnit v2
.AddXunit3() // xUnit v3
.AddNUnit()); // NUnit
Each framework's discoverer will only find and run tests from its own framework. Tests from different frameworks can coexist in the visual runner UI.
How It Works
DeviceRunners.VisualRunners.Xunit3 uses xUnit v3's in-process extensibility APIs directly:
- Discovery: Uses
ExtensibilityPointFactory.GetTestFramework(assembly)(orInMemoryXunit3TestFrameworkon platforms whereAssembly.Locationis empty) to obtain the xUnit v3 framework, then callsITestFrameworkDiscoverer.Find()with a callback to collect discovered test cases - Execution: Uses
ITestFrameworkExecutor.RunTestCases()with the previously discoveredITestCaseobjects filtered to the selected tests - Results: Implements
IMessageSinkto receiveITestPassed,ITestFailed,ITestSkipped, andITestNotRunmessages and map them to DeviceRunners' result model - Diagnostics: Framework diagnostic messages are forwarded to
IDiagnosticsManagerwhen available - Error handling: Framework-level errors (
IErrorMessage, cleanup failures) are surfaced through diagnostics
All execution happens in-process on the device — no separate test process is launched.
Current Limitations
[TestFramework]attribute on in-memory platforms: On platforms whereAssembly.Locationis empty (Android, iOS, WASM), DeviceRunners usesInMemoryXunit3TestFrameworkinstead of callingExtensibilityPointFactory.GetTestFramework(). This means any[TestFramework]assembly-level attribute that customizes the xUnit test framework will be ignored on those platforms. Custom test frameworks registered via[TestFramework]only work on desktop (Windows, macOS). This is tracked upstream at xunit/xunit#3096 — ifXunitTestAssembly.AssemblyPathis made virtual orAssembly.Locationhandling improves, this limitation can be removed.
UI Testing
DeviceRunners also provides [UIFact] and [UITheory] attributes for xUnit v3 via the DeviceRunners.UITesting.Xunit3 package. These work the same as their xUnit v2 counterparts — test methods decorated with these attributes will have the entire test lifecycle (class construction, IAsyncLifetime, test method invocation, and disposal) dispatched to the UI thread.
Same namespace as v2: The v3
[UIFact]and[UITheory]attributes are in theXunitnamespace, matching both the v2 convention and the official xUnit v3 framework. Migrating from v2 to v3 requires only swapping the NuGet package — no namespace changes needed.
using Xunit;
public class MyUITests
{
[UIFact]
public void TestOnUIThread()
{
// The entire test lifecycle runs on the UI thread:
// construction, IAsyncLifetime, test method, and disposal
}
[UITheory]
[InlineData(1)]
[InlineData(2)]
public void TheoryOnUIThread(int value)
{
// This also runs on the UI thread
}
}
Platform Compatibility (In-Memory Assembly Handling)
xUnit v3 works on all platforms with automatic detection — no special flags needed. Just use .AddXunit3():
// MAUI (Android, iOS, macOS, Windows)
builder.UseVisualTestRunner(conf => conf
.AddXunit3()
.AddTestAssembly(typeof(MyTests).Assembly));
// Blazor WebAssembly
builder.UseVisualTestRunner(conf => conf
.AddXunit(useReflection: true) // xUnit v2 needs reflection mode on WASM
.AddXunit3() // xUnit v3 works automatically everywhere
.AddTestAssembly(typeof(MyXunit2Tests).Assembly)
.AddTestAssemblies(typeof(MyXunit3Tests).Assembly)
.AddConsoleResultChannel());
The Assembly.Location Problem
On several platforms, Assembly.Location returns an empty string because assemblies are loaded from streams or bundles rather than from disk files:
| Platform | Assembly.Location |
Why |
|---|---|---|
| Windows | ✅ File path | DLLs on disk |
| macOS (Catalyst) | ✅ File path | DLLs in app bundle |
| Android | ❌ Empty string | DLLs inside APK (zip stream) |
| iOS | ❌ Empty string | DLLs in app bundle (AOT/stream) |
| WASM | ❌ Empty string | DLLs loaded as byte arrays |
xUnit v3's XunitTestAssembly uses Assembly.Location as its AssemblyPath, which causes TestAssemblyRunner.OnTestAssemblyStarting to crash when it's empty (it calls Path.GetFileNameWithoutExtension(AssemblyPath) on the empty string).
How DeviceRunners Handles This
DeviceRunners detects Assembly.Location at runtime and automatically uses in-memory replacements:
InMemoryXunit3TestAssembly— Subclass ofXunitTestAssemblythat re-implements theIXunitTestAssemblyinterface, providing a logical assembly path (AssemblyName + ".dll") instead of the emptyAssembly.Location. The interface must be re-declared on the subclass to force C# interface dispatch remapping, sinceXunitTestAssembly.AssemblyPathis not virtual.InMemoryXunit3TestFramework— Subclass ofXunitTestFrameworkthat overridesCreateDiscovererandCreateExecutorto useInMemoryXunit3TestAssemblywhenAssembly.Locationis empty.
The Xunit3TestDiscoverer and Xunit3TestRunner both use a CreateTestFramework() helper that checks Assembly.Location at runtime:
- Empty → creates
InMemoryXunit3TestFramework(in-memory path — Android, iOS, WASM) - Non-empty → uses
ExtensibilityPointFactory.GetTestFramework()(standard path — Windows, macOS)
Desktop vs Device/WASM Differences
| Aspect | Desktop (Windows, macOS) | Device / WASM (Android, iOS, WASM) |
|---|---|---|
| Setup | .AddXunit3() |
.AddXunit3() (same) |
| Assembly location | Assembly.Location returns file path |
Assembly.Location is empty string |
| Test framework | XunitTestFramework via ExtensibilityPointFactory |
InMemoryXunit3TestFramework (auto-detected) |
| Test assembly | XunitTestAssembly |
InMemoryXunit3TestAssembly (logical path) |
[TestFramework] attribute |
Supported (via ExtensibilityPointFactory) |
Not supported (bypassed when in-memory) |
| Threading | Multi-threaded | Multi-threaded (MAUI) / Single-threaded (WASM) |
| Result output | TCP socket + console | TCP (MAUI) / Console NDJSON (WASM) |
Note: Unlike xUnit v2 which requires
useReflection: trueon platforms without filesystem access (becauseXunitFrontControllerneeds file paths), xUnit v3 works with plain.AddXunit3()everywhere. The in-memory workaround is internal and transparent.
Comparison with xUnit v2 Approach
xUnit v2 handles the Assembly.Location problem differently per platform:
- Android:
FileSystemUtils.GetAssemblyFileName()creates a dummy file on disk soXunitFrontControllerhas a valid path to open. This is wasteful but functional. - WASM: A completely different discoverer (
XunitReflectionTestDiscoverer) and runner (XunitReflectionTestRunner) bypassXunitFrontControllerentirely, using xUnit's internal reflection APIs to scan assemblies in memory.
xUnit v3 takes a cleaner approach: the same discoverer and runner work on all platforms. Only the IXunitTestAssembly instance is swapped to provide a logical path, and the ITestFramework creation is redirected to avoid ExtensibilityPointFactory (which also uses file paths internally). No dummy files, no alternate code paths.
Differences from xUnit v2
| Feature | xUnit v2 (AddXunit()) |
xUnit v3 (AddXunit3()) |
|---|---|---|
| Package for tests | xunit |
xunit.v3.extensibility.core + xunit.v3.assert |
| Test discovery API | XunitFrontController.Find() |
ExtensibilityPointFactory + ITestFrameworkDiscoverer.Find() |
| Test execution API | XunitFrontController.RunTests() |
ITestFrameworkExecutor.RunTestCases() |
| Message handling | Event-based TestMessageSink |
IMessageSink.OnMessage() |
| Selective execution | ITestCase object references |
Cached ITestCase objects from discovery |
| Configuration | Loads xunit.runner.json |
Loads xunit.runner.json |
| UI testing attributes | DeviceRunners.UITesting.Xunit |
DeviceRunners.UITesting.Xunit3 |
| WASM support | Requires useReflection: true |
Automatic (transparent in-memory detection) |
IAsyncLifetime |
Returns Task |
Returns ValueTask |
Known Limitations
The DeviceRunners visual runner executes xUnit v3 tests in-process within a MAUI app. This is different from a standard xUnit v3 test project which runs as a standalone executable via dotnet test. Note that dotnet test still works for the host TFM (net10.0) of your test libraries — only the device TFMs use the in-process visual runner.
Platform Workarounds (Visual Runner Only)
| Limitation | Details | Tracking |
|---|---|---|
Assembly.Location is empty |
On Android, iOS, and WASM, Assembly.Location returns an empty string. DeviceRunners works around this with InMemoryXunit3TestAssembly which provides a logical path. |
xunit/xunit#3577 |
| Config file discovery | Standard xUnit v3 loads xunit.runner.json from the filesystem next to the assembly DLL. DeviceRunners loads it from app package resources via OpenAppPackageFile instead, since assemblies may not be on disk. |
|
[TestFramework] attribute ignored on in-memory platforms |
When Assembly.Location is empty, DeviceRunners creates InMemoryXunit3TestFramework directly instead of using ExtensibilityPointFactory, which means any [TestFramework] assembly attribute is not honored. |
Visual Runner Feature Gaps
These limitations apply only to the in-app visual runner, not to dotnet test on the host TFM:
| Feature | Standard dotnet test |
DeviceRunners Visual Runner |
|---|---|---|
[Fact(Explicit = true)] |
✅ Runs with --filter or explicit opt-in |
⚠️ Executor supports it, but the visual runner UI has no way to opt-in to running explicit tests |
dotnet test --filter expressions |
✅ Full filter syntax | ✅ Headless runs honor --filter (a documented subset); the interactive UI also has its own filtering |
| Source information | ✅ IDE navigation to test source | ❌ Not available on in-memory platforms |
Behavioral Defaults
DeviceRunners uses the standard xUnit v3 defaults for all configuration options. Users can customize behavior via xunit.runner.json (placed in app package resources). Notable defaults:
PreEnumerateTheories:false(a[Theory]with 3[InlineData]appears as 1 test case, not 3). Set totrueinxunit.runner.jsonto see individual theory data rows in the visual runner.SynchronousMessageReporting:false(messages delivered asynchronously). The visual runner's message sink is thread-safe.- Parallelization: Follows xUnit v3 defaults (parallel by collection). Configurable via
xunit.runner.json.
Verified xUnit v3 Features
The following xUnit v3 features have been tested through DeviceRunners' unit and device test suites:
[Fact],[Theory],[InlineData]IAsyncLifetime(construction and disposal on UI thread for[UIFact]/[UITheory])IDisposabletest class cleanup[Skip],[Fact(Skip = "...")]ITestOutputHelper(output captured and reported)ISelfExecutingXunitTestCase(used by[UIFact]/[UITheory])- Theory row aggregation (
PreEnumerateTheories=false: a failing row marks the test case as failed)
The following are supported by xUnit v3 and expected to work through DeviceRunners, but do not have dedicated tests in this repository:
[MemberData],[ClassData][Collection]for test serialization- Test assembly parallelization control
xunit.runner.jsonconfiguration loading