
Maui Unit Testing
- 47 installs
- 163 repo stars
- Updated July 6, 2026
- davidortinau/maui-skills
Provides xUnit testing guidance for .NET MAUI apps including ViewModel testing, mocking MAUI services, test project setup, and code coverage.
About
Guides xUnit testing for .NET MAUI apps covering ViewModel testing, mocking MAUI services, test project setup, code coverage and on-device test runners. A developer uses it when writing unit tests for a MAUI app.
- ViewModel testing and mocking MAUI services with xUnit
- Test project setup, code coverage, and on-device runners
Maui Unit Testing by the numbers
- 47 all-time installs (skills.sh)
- Ranked #1,237 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/davidortinau/maui-skills --skill maui-unit-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 47 |
|---|---|
| repo stars | ★ 163 |
| Last updated | July 6, 2026 |
| Repository | davidortinau/maui-skills ↗ |
What it does
Provides xUnit testing guidance for .NET MAUI apps including ViewModel testing, mocking MAUI services, test project setup, and code coverage.
Files
.NET MAUI Unit Testing — Gotchas & Best Practices
For project templates, xUnit examples, ViewModel test patterns, and CLI commands, see references/unit-testing-api.md.
⚠️ TFM Trap: Don't Use Platform-Specific TFMs
<!-- ❌ xUnit can't run on platform-specific TFMs -->
<TargetFramework>net9.0-ios</TargetFramework>
<!-- ✅ Use plain .NET TFM for desktop test host -->
<TargetFrameworks>net9.0;net10.0</TargetFrameworks>⚠️ OutputType Trap: App Project as Test Dependency
If your test project references the app project, the app tries to build as Exe for the test TFM — this fails. Add a conditional OutputType:
<!-- ❌ App .csproj without conditional — breaks test builds -->
<OutputType>Exe</OutputType>
<!-- ✅ Library for test TFM, Exe for platform TFMs -->
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0'">
<OutputType>Library</OutputType>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' != 'net9.0'">
<OutputType>Exe</OutputType>
</PropertyGroup>Common Mistakes
❌ Using Static MAUI APIs in ViewModels
ViewModels that directly call static MAUI APIs are untestable:
// ❌ Untestable — Shell.Current requires a running MAUI app
public async Task GoToDetail(int id)
=> await Shell.Current.GoToAsync($"detail?id={id}");
// ✅ Inject an interface — fully testable
public class MyViewModel(INavigationService nav)
{
public async Task GoToDetail(int id)
=> await nav.GoToAsync($"detail?id={id}");
}Static APIs to wrap behind interfaces:
Shell.Current→INavigationServiceApplication.Current→ avoid entirelySecureStorage.Default→ISecureStorageConnectivity.Current→IConnectivity
❌ Asserting on UI Bindings Instead of ViewModel State
// ❌ Testing the binding — fragile, needs a running UI
Assert.Equal("Hello", label.Text);
// ✅ Testing the ViewModel — fast, no platform dependency
Assert.Equal("Hello", viewModel.Title);
Assert.True(viewModel.SaveCommand.CanExecute(null));Mocking Strategy for MAUI Services
| MAUI Service | Mock Strategy |
|---|---|
ISecureStorage | Mock<ISecureStorage> — stub GetAsync/SetAsync |
IPreferences | Mock<IPreferences> — stub Get/Set/Remove |
IConnectivity | Mock<IConnectivity> — return NetworkAccess |
IGeolocation | Mock<IGeolocation> — return fixed Location |
IFilePicker | Mock<IFilePicker> — return FileResult |
IMediaPicker | Mock<IMediaPicker> — return FileResult |
| Shell navigation | Abstract behind INavigationService |
IDispatcher | Stub Dispatch to invoke action synchronously |
Architecture: Interface-First for Testability
Define service interfaces so ViewModels have zero MAUI platform dependencies:
// ✅ These make your entire ViewModel layer testable
public interface INavigationService
{
Task GoToAsync(string route);
Task GoBackAsync();
}
public interface IDialogService
{
Task<bool> ConfirmAsync(string title, string message);
}Register implementations in MauiProgram.cs; inject interfaces into ViewModels.
Tips
- No `Application.Current` or `Shell.Current` in ViewModels — wrap in injectable services
- Use `ObservableCollection<T>` and `[ObservableProperty]` (MVVM Toolkit) for testable state
- Assert on ViewModel properties and `CanExecute`, not UI bindings
- Use `TaskCompletionSource` to test async waiting flows
- Run `dotnet test` in CI to catch regressions early
- On-device tests: Use
xunit.runner.devicesfor real platform APIs (sensors, camera, Bluetooth)
Checklist
- [ ] Test project targets plain
net9.0/net10.0(not platform-specific TFMs) - [ ] App project has conditional
OutputTypefor test TFM - [ ] All MAUI static APIs wrapped behind injectable interfaces
- [ ] ViewModels tested via properties and commands, not UI bindings
- [ ]
IDispatchermocked to invoke synchronously in tests - [ ]
dotnet testruns green in CI
Unit Testing API Reference
Test Project Setup
Create a class library targeting the same TFM as your MAUI app:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net9.0;net10.0</TargetFrameworks>
<IsPackable>false</IsPackable>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="coverlet.collector" Version="6.*" />
<PackageReference Include="Moq" Version="4.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyApp\MyApp.csproj" />
</ItemGroup>
</Project>Conditional OutputType for App Projects
If your test project references an app project directly, prevent the app from building as an executable for the test TFM:
<!-- In the MAUI app .csproj -->
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0'">
<OutputType>Library</OutputType>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' != 'net9.0'">
<OutputType>Exe</OutputType>
</PropertyGroup>---
xUnit Fundamentals
[Fact] — Single Test Case
public class CalculatorTests
{
[Fact]
public void Add_TwoNumbers_ReturnsSum()
{
// Arrange
var calculator = new Calculator();
// Act
var result = calculator.Add(2, 3);
// Assert
Assert.Equal(5, result);
}
}[Theory] / [InlineData] — Parameterised Tests
public class ConverterTests
{
[Theory]
[InlineData(0, 32)]
[InlineData(100, 212)]
[InlineData(-40, -40)]
public void CelsiusToFahrenheit_ReturnsExpected(double celsius, double expected)
{
var result = TemperatureConverter.CelsiusToFahrenheit(celsius);
Assert.Equal(expected, result, precision: 2);
}
}---
ViewModel Testing Pattern
public class ItemsViewModelTests
{
private readonly Mock<IItemService> _itemServiceMock = new();
private readonly Mock<INavigationService> _navMock = new();
private ItemsViewModel CreateSut() =>
new(_itemServiceMock.Object, _navMock.Object);
[Fact]
public async Task LoadItems_PopulatesCollection()
{
// Arrange
var items = new List<Item> { new("A"), new("B") };
_itemServiceMock.Setup(s => s.GetAllAsync()).ReturnsAsync(items);
var sut = CreateSut();
// Act
await sut.LoadItemsCommand.ExecuteAsync(null);
// Assert
Assert.Equal(2, sut.Items.Count);
Assert.False(sut.IsBusy);
}
[Fact]
public async Task SelectItem_NavigatesToDetail()
{
// Arrange
var sut = CreateSut();
var item = new Item("Test");
// Act
await sut.SelectItemCommand.ExecuteAsync(item);
// Assert
_navMock.Verify(n => n.GoToAsync($"detail?id={item.Id}"), Times.Once);
}
}---
Running Tests
# Run all tests
dotnet test
# Run with verbosity
dotnet test --verbosity normal
# Filter by class or method
dotnet test --filter "FullyQualifiedName~ItemsViewModelTests"
# Code coverage with coverlet (outputs to TestResults/)
dotnet test --collect:"XPlat Code Coverage"
# Generate HTML coverage report (requires reportgenerator tool)
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:TestResults/**/coverage.cobertura.xml \
-targetdir:TestResults/CoverageReport -reporttypes:Html---
On-Device Testing
For tests requiring real platform APIs (sensors, camera, Bluetooth), use the xunit.runner.devices package to run xUnit tests inside a MAUI app on a simulator or physical device. This is separate from dotnet test and runs within the app process.