.NET MAUI Testing Guide: xUnit, NUnit and UI Testing
.NET MAUI (Multi-platform App UI) lets you build native iOS, Android, macOS, and Windows apps from a single C# codebase. Testing MAUI apps is more complex than testing web apps because you're dealing with native platform layers, device-specific behaviors, and UI frameworks that require running on actual devices or simulators. This guide covers the full spectrum from unit tests that run anywhere to UI tests that exercise your app on real platforms.
The MAUI Testing Pyramid
MAUI testing works best with a clear separation of concerns:
- Unit tests (xUnit/NUnit): Business logic, services, ViewModels — these run on any platform without a device
- Integration tests: Data access, platform services with mocks, API clients
- UI tests (Appium/UITest): Full app behavior on a device or simulator — slow but realistic
The key architectural decision that makes MAUI testable is separating platform-independent code from platform-specific code through proper MVVM architecture.
Setting Up Unit Tests for MAUI
Create a separate test project targeting net8.0 (not a MAUI target framework):
<!-- MyApp.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<!-- Reference only the platform-independent parts of your app -->
<ProjectReference Include="../MyApp.Core/MyApp.Core.csproj" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
</ItemGroup>
</Project>Split your app into layers:
MyApp.Core— business logic, ViewModels, services (no MAUI dependencies)MyApp— MAUI app, Views, platform-specific code
This makes the core testable without a MAUI runtime.
Testing ViewModels
MVVM is the recommended pattern for MAUI, and ViewModels are the most testable part:
// The ViewModel
public class LoginViewModel : ObservableObject
{
private readonly IAuthService _authService;
private readonly INavigationService _navigationService;
[ObservableProperty]
private string email = "";
[ObservableProperty]
private string password = "";
[ObservableProperty]
private string errorMessage = "";
[ObservableProperty]
private bool isLoading;
public IAsyncRelayCommand LoginCommand { get; }
public LoginViewModel(IAuthService authService, INavigationService navigationService)
{
_authService = authService;
_navigationService = navigationService;
LoginCommand = new AsyncRelayCommand(LoginAsync);
}
private async Task LoginAsync()
{
IsLoading = true;
ErrorMessage = "";
try
{
var result = await _authService.LoginAsync(Email, Password);
if (result.Success)
await _navigationService.NavigateToAsync("/dashboard");
else
ErrorMessage = result.ErrorMessage ?? "Login failed";
}
finally
{
IsLoading = false;
}
}
}
// The tests
public class LoginViewModelTest
{
private readonly Mock<IAuthService> _mockAuthService = new();
private readonly Mock<INavigationService> _mockNavigationService = new();
private LoginViewModel CreateSut() =>
new LoginViewModel(_mockAuthService.Object, _mockNavigationService.Object);
[Fact]
public async Task SuccessfulLoginNavigatesToDashboard()
{
_mockAuthService
.Setup(s => s.LoginAsync("user@example.com", "password123"))
.ReturnsAsync(new AuthResult { Success = true });
var vm = CreateSut();
vm.Email = "user@example.com";
vm.Password = "password123";
await vm.LoginCommand.ExecuteAsync(null);
_mockNavigationService.Verify(n => n.NavigateToAsync("/dashboard"), Times.Once);
Assert.Empty(vm.ErrorMessage);
}
[Fact]
public async Task FailedLoginShowsErrorMessage()
{
_mockAuthService
.Setup(s => s.LoginAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new AuthResult { Success = false, ErrorMessage = "Invalid credentials" });
var vm = CreateSut();
vm.Email = "user@example.com";
vm.Password = "wrongpassword";
await vm.LoginCommand.ExecuteAsync(null);
Assert.Equal("Invalid credentials", vm.ErrorMessage);
_mockNavigationService.Verify(n => n.NavigateToAsync(It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task IsLoadingTrueWhileLoggingIn()
{
var loginStarted = new TaskCompletionSource<bool>();
var loginReady = new TaskCompletionSource<bool>();
_mockAuthService
.Setup(s => s.LoginAsync(It.IsAny<string>(), It.IsAny<string>()))
.Returns(async () =>
{
loginStarted.SetResult(true);
await loginReady.Task;
return new AuthResult { Success = true };
});
var vm = CreateSut();
var loginTask = vm.LoginCommand.ExecuteAsync(null);
await loginStarted.Task;
Assert.True(vm.IsLoading);
loginReady.SetResult(true);
await loginTask;
Assert.False(vm.IsLoading);
}
}Testing with CommunityToolkit.Mvvm
The CommunityToolkit.Mvvm package (used in the ViewModel above) generates boilerplate with source generators. Tests don't need any special setup — the generated code works in standard .NET unit test projects:
public class CartViewModelTest
{
[Fact]
public void AddItemIncreasesItemCount()
{
var vm = new CartViewModel(new Mock<ICartService>().Object);
vm.AddItemCommand.Execute(new CartItem("prod-1", "Widget", 9.99m));
Assert.Equal(1, vm.ItemCount);
Assert.Equal(9.99m, vm.Total);
}
[Fact]
public void PropertyChangedFiresOnItemAdd()
{
var vm = new CartViewModel(new Mock<ICartService>().Object);
var propertyChanges = new List<string?>();
vm.PropertyChanged += (_, e) => propertyChanges.Add(e.PropertyName);
vm.AddItemCommand.Execute(new CartItem("prod-1", "Widget", 9.99m));
Assert.Contains("ItemCount", propertyChanges);
Assert.Contains("Total", propertyChanges);
}
}Testing Services with Dependency Injection
MAUI uses MauiApp.CreateBuilder() for DI. For tests, use plain IServiceCollection:
public class OrderServiceTest
{
private readonly ServiceProvider _serviceProvider;
public OrderServiceTest()
{
var services = new ServiceCollection();
services.AddSingleton<IOrderRepository, InMemoryOrderRepository>();
services.AddSingleton<IInventoryService, FakeInventoryService>();
services.AddSingleton<OrderService>();
_serviceProvider = services.BuildServiceProvider();
}
[Fact]
public async Task PlaceOrderReducesInventory()
{
var orderService = _serviceProvider.GetRequiredService<OrderService>();
var inventoryService = _serviceProvider.GetRequiredService<IInventoryService>()
as FakeInventoryService;
inventoryService!.SetQuantity("product-A", 10);
await orderService.PlaceOrderAsync("product-A", quantity: 3);
Assert.Equal(7, inventoryService.GetQuantity("product-A"));
}
}Testing Data Access with SQLite
MAUI apps frequently use SQLite via sqlite-net-pcl. Test with an in-memory database:
public class ProductRepositoryTest
{
private SQLiteAsyncConnection _db = null!;
private ProductRepository _repository = null!;
[SetUp] // NUnit
public async Task SetUp()
{
_db = new SQLiteAsyncConnection(":memory:");
await _db.CreateTableAsync<Product>();
_repository = new ProductRepository(_db);
}
[Test]
public async Task SaveAndRetrieveProduct()
{
var product = new Product { Name = "Test Widget", Price = 9.99m };
await _repository.SaveAsync(product);
Assert.That(product.Id, Is.GreaterThan(0));
var retrieved = await _repository.GetByIdAsync(product.Id);
Assert.That(retrieved, Is.Not.Null);
Assert.That(retrieved!.Name, Is.EqualTo("Test Widget"));
}
[Test]
public async Task GetAllReturnsAllProducts()
{
await _repository.SaveAsync(new Product { Name = "Product A", Price = 5m });
await _repository.SaveAsync(new Product { Name = "Product B", Price = 15m });
var all = await _repository.GetAllAsync();
Assert.That(all, Has.Count.EqualTo(2));
}
[TearDown]
public async Task TearDown()
{
await _db.CloseAsync();
}
}Testing Platform-Specific Code
Abstract platform-specific APIs behind interfaces to make them testable:
// Interface in Core layer
public interface IDeviceInfoService
{
string Platform { get; }
string Version { get; }
bool IsEmulator { get; }
}
// MAUI implementation
public class MauiDeviceInfoService : IDeviceInfoService
{
public string Platform => DeviceInfo.Platform.ToString();
public string Version => DeviceInfo.VersionString;
public bool IsEmulator => DeviceInfo.DeviceType == DeviceType.Virtual;
}
// Test double
public class FakeDeviceInfoService : IDeviceInfoService
{
public string Platform { get; set; } = "Android";
public string Version { get; set; } = "14.0";
public bool IsEmulator { get; set; } = true;
}
// Test
public class DeviceSpecificFeatureTest
{
[Fact]
public void ShowsEmulatorWarning()
{
var deviceInfo = new FakeDeviceInfoService { IsEmulator = true };
var vm = new SettingsViewModel(deviceInfo);
Assert.True(vm.ShowEmulatorWarning);
}
}Running Tests in CI
For unit/ViewModel tests:
- name: Run unit tests
run: dotnet test MyApp.Tests/ --configuration Release --logger trxFor UI tests (requires a simulator):
- name: Run iOS UI tests
run: |
dotnet build -t:Run -f net8.0-ios \
-p:_DeviceName=platform=iOS\ Simulator,name=iPhone\ 15Connecting Tests to Production Monitoring
MAUI apps run on user devices you can't control. Unit tests verify logic; UI tests verify flows on specific devices. Neither catches what happens on the hundreds of device/OS combinations your users run.
For the API backend your MAUI app depends on, HelpMeTest provides continuous monitoring — verifying that your backend API behaves correctly around the clock, so MAUI app functionality stays reliable.
Summary
- Separate
MyApp.CorefromMyApp— only Core needs to be testable without MAUI runtime - ViewModels with CommunityToolkit.Mvvm test cleanly in standard .NET unit test projects
- Use in-memory SQLite (
:memory:) for data access layer tests — no device required - Abstract platform APIs behind interfaces and swap them in tests with
FakeXyzimplementations IAsyncRelayCommand.ExecuteAsync(null)is how you invoke commands in ViewModel tests- NUnit's
[SetUp]/[TearDown]and xUnit's constructor/IDisposableboth work — pick one and stay consistent