From maui-skills
Guides xUnit unit testing for .NET MAUI apps: ViewModel testing, mocking MAUI services like SecureStorage and Connectivity, test project setup, code coverage, on-device runners.
npx claudepluginhub davidortinau/maui-skills --plugin maui-skillsThis skill uses the workspace's default tool permissions.
For project templates, xUnit examples, ViewModel test patterns, and CLI commands, see `references/unit-testing-api.md`.
Guards .NET MAUI projects against deprecated, obsolete, or removed APIs in XAML/C#, Blazor Hybrid, and MauiReactor. Detects target frameworks/library versions and provides replacement patterns for code generation/review/editing.
Configures dependency injection in .NET MAUI: registers services in MauiProgram.cs, selects lifetimes, enables constructor injection, Shell auto-resolution, and platform-specific implementations.
Provides XCTest patterns for unit tests, UI tests, mocks, async/await testing, and property testing in Swift/SwiftUI iOS apps.
Share bugs, ideas, or general feedback.
For project templates, xUnit examples, ViewModel test patterns, and CLI commands, see references/unit-testing-api.md.
<!-- ❌ 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>
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>
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// ❌ 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));
| 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 |
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.
Application.Current or Shell.Current in ViewModels — wrap in injectable servicesObservableCollection<T> and [ObservableProperty] (MVVM Toolkit) for testable stateCanExecute, not UI bindingsTaskCompletionSource to test async waiting flowsdotnet test in CI to catch regressions earlyxunit.runner.devices for real platform APIs (sensors, camera, Bluetooth)net9.0/net10.0 (not platform-specific TFMs)OutputType for test TFMIDispatcher mocked to invoke synchronously in testsdotnet test runs green in CI