Testing .NET MAUI 2026: Unit Test, UI Test e CI/CD
Guida pratica al testing di app .NET MAUI nel 2026: unit test dei ViewModel con xUnit e NSubstitute, device test per handler custom, UI test end-to-end con Appium 2.11 e integrazione CI/CD con GitHub Actions, con esempi di codice reali.
Testare un'app .NET MAUI nel 2026 richiede tre livelli complementari: unit test per ViewModel e servizi (xUnit + NSubstitute), device test per handler e controlli custom (Microsoft.Maui.TestUtils.DeviceTests), e UI test end-to-end su dispositivi reali con Appium 2.x o il nuovo runner ufficiale Microsoft.Maui.Controls.UITesting. Nel mio team abbiamo scoperto che saltare uno di questi livelli significa scoprire regressioni solo dopo la pubblicazione, e con code review multipiattaforma non è un rischio accettabile.
La piramide di testing in .NET MAUI 10 prevede unit test in-process (60%), device test sul renderer nativo (25%) e UI test end-to-end (15%).
xUnit 2.9 rimane lo standard de facto per unit test; NSubstitute 5 ha superato Moq dopo la vicenda SponsorLink del 2023.
Per testare i ViewModel non serve un emulatore: bastano l'astrazione di IDispatcher e un finto INavigationService iniettati via DI.
Appium 2.11 con il driver UiAutomator2 (Android) e XCUITest (iOS) è ancora la scelta più stabile per UI test cross-platform nel 2026.
Il code coverage tramite Coverlet + ReportGenerator si integra nativamente con GitHub Actions e Azure DevOps, con soglia consigliata dell'80% sui progetti core.
Il tempo di feedback ideale è <3 minuti per unit test in CI e <20 minuti per la suite completa UI test su un solo device pool.
Perchè il testing di .NET MAUI è diverso
Chi arriva da ASP.NET o da una libreria .NET pura sottovaluta quasi sempre la complessità di testare un'app mobile cross-platform. Il motivo? Un'app .NET MAUI in produzione è in realtà tre binari distinti (un APK/AAB per Android, un IPA per iOS, un MSIX per Windows) che condividono il codice C# ma vengono renderizzati da handler nativi completamente diversi. Un ViewModel testato in isolamento può funzionare perfettamente, mentre il rendering del CollectionView collegato produce un NullReferenceException solo su iOS 18 quando lo scroll supera i 200 elementi.
Nel mio team applichiamo la regola dei "tre bug canonici": ogni bug che sfugge alla produzione viene classificato come logico (risolvibile con unit test), di rendering (risolvibile con device test) o di flusso (risolvibile con UI test). Solo così capisci che tipo di copertura ti manca davvero. Abbandonare l'idea che "un unit test basta" è il primo passo, e il costo di setup dei device test in CI è molto più basso oggi rispetto all'era Xamarin.UITest, che Microsoft ha ufficialmente dismesso a favore del nuovo runner integrato.
Stack di testing consigliato per il 2026
Dopo aver sperimentato con tre progetti diversi negli ultimi 18 mesi, ecco lo stack che consigliamo ai team che iniziano da zero oggi. La tabella confronta le opzioni realistiche per ogni livello. Abbiamo scartato librerie con manutenzione ferma o incompatibilità note con .NET 10.
Livello
Libreria consigliata
Alternativa
Note 2026
Unit test runner
xUnit 2.9
NUnit 4.2
xUnit ha migliore integrazione con dotnet test e parallelizzazione più prevedibile
Mocking
NSubstitute 5.3
FakeItEasy 8.3
Moq è ancora usabile ma sconsigliato dopo SponsorLink; NSubstitute ha API più leggibili
Assertion
FluentAssertions 7 (o Shouldly 4.3)
xUnit built-in
FluentAssertions è diventato commerciale dalla v8; molti team restano su v7 o migrano a Shouldly
Device test
Microsoft.Maui.TestUtils.DeviceTests
MSTest custom runner
Ora è parte del workload ufficiale maui e non serve più clonare il repo maui
UI test
Appium 2.11 + WebDriver 4
Microsoft.Maui.Controls.UITesting (preview)
Il runner ufficiale MAUI UITesting è ancora in preview; Appium resta la scelta di produzione
Snapshot test
Verify.Xunit 25
Snapper 2.4
Verify supporta serializzazione di ViewModel e output visivi
Code coverage
Coverlet 6.1
dotnet-coverage
Coverlet integra con MSBuild e produce cobertura+lcov nativamente
Come si testano i ViewModel senza dispositivo
Il 60% dei nostri test è unit test in-process sui ViewModel. Non richiedono emulatore, girano in meno di 200ms e sono l'unico tipo di test che eseguiamo su ogni push. La chiave è avere un ViewModel testabile: se hai seguito il pattern MVVM con dependency injection, che descrivo in dettaglio nella guida MVVM con MVVM Toolkit, i tuoi ViewModel accettano già le dipendenze via costruttore.
Ecco un esempio realistico. Immagina un OrderListViewModel che carica ordini da un servizio remoto e naviga al dettaglio:
Il test corrispondente usa xUnit + NSubstitute + FluentAssertions:
// OrderListViewModelTests.cs
public class OrderListViewModelTests
{
private readonly IOrderService _orderService = Substitute.For<IOrderService>();
private readonly INavigationService _navigation = Substitute.For<INavigationService>();
[Fact]
public async Task LoadOrdersAsync_PopulatesCollection_WhenServiceReturnsData()
{
// Arrange
_orderService.GetOrdersAsync().Returns(new[]
{
new Order(1, "Cliente A", 129.90m),
new Order(2, "Cliente B", 42.00m)
});
var vm = new OrderListViewModel(_orderService, _navigation);
// Act
await vm.LoadOrdersCommand.ExecuteAsync(null);
// Assert
vm.Orders.Should().HaveCount(2);
vm.IsLoading.Should().BeFalse();
vm.ErrorMessage.Should().BeNull();
}
[Fact]
public async Task LoadOrdersAsync_SetsErrorMessage_WhenNetworkFails()
{
_orderService.GetOrdersAsync()
.Returns(Task.FromException<IReadOnlyList<Order>>(
new HttpRequestException("Timeout")));
var vm = new OrderListViewModel(_orderService, _navigation);
await vm.LoadOrdersCommand.ExecuteAsync(null);
vm.ErrorMessage.Should().Contain("Timeout");
vm.Orders.Should().BeEmpty();
}
}
Nota due dettagli importanti: nessun riferimento a MainThread, nessuna dipendenza da Application.Current. Se hai bisogno di eseguire codice sul dispatcher UI dentro un ViewModel, astrai sempre dietro un'interfaccia IUiDispatcher. Nei test la sostituisci con un'implementazione sincrona e via.
Testare handler e controlli custom con Device Tests
Quando scrivi un handler custom (per esempio per esporre una proprietà nativa come il ripple color di Material 3 su Android), gli unit test non bastano: devi verificare che la PlatformView nativa venga configurata correttamente. Qui entra in gioco Microsoft.Maui.TestUtils.DeviceTests, che dal SDK .NET MAUI 10 è disponibile senza dover clonare il repository ufficiale.
Il pattern è semplice: crei un progetto Tests.Devices che è a tutti gli effetti un'app MAUI, ma il cui MauiProgram.CreateMauiApp() avvia lo HeadlessRunner invece di una Shell. Esempio di test per un handler:
// GradientButtonHandlerTests.cs
[Category(TestCategory.Handler)]
public class GradientButtonHandlerTests : HandlerTestBase
{
[Fact(DisplayName = "Il GradientButton applica il colore iniziale su Android")]
public async Task InitialColor_IsAppliedToPlatformView()
{
var button = new GradientButton { StartColor = Colors.Coral };
var platformView = await InvokeOnMainThreadAsync(() =>
CreateHandler<GradientButtonHandler>(button).PlatformView);
#if ANDROID
var drawable = (GradientDrawable)platformView.Background!;
drawable.Colors![0].Should().Be(Colors.Coral.ToPlatform());
#elif IOS
var layer = (CAGradientLayer)platformView.Layer.Sublayers![0];
layer.Colors[0].Should().Be(Colors.Coral.ToCGColor());
#endif
}
}
Un dettaglio operativo, imparato a mie spese: i device test partono lenti (30-90 secondi solo per il boot del runner), ma poi ogni test costa 10-50 ms. Non lanciarli su ogni commit. Noi li eseguiamo sulla pipeline notturna e su ogni PR che tocca la cartella Handlers/ tramite path filter di GitHub Actions.
UI test end-to-end con Appium 2.x
Gli UI test simulano l'utente reale: aprono l'app, toccano bottoni, verificano che il flusso funzioni. Nel 2026 il runner ufficiale Microsoft.Maui.Controls.UITesting è ancora in preview (aspettiamo la GA con .NET 10 GA di novembre 2026), quindi la scelta di produzione resta Appium 2.11 con Selenium WebDriver 4.
La configurazione minima usa un progetto NUnit separato che si connette a un Appium server locale (in dev) o Sauce Labs / BrowserStack (in CI). Ecco un test che verifica il login:
// LoginFlowTests.cs
public class LoginFlowTests
{
private AppiumDriver _driver = null!;
[SetUp]
public void Setup()
{
var options = new AppiumOptions
{
PlatformName = "Android",
AutomationName = "UiAutomator2",
DeviceName = "Pixel_8_API_35",
App = Path.GetFullPath("../../../../artifacts/com.mycorp.app.apk")
};
_driver = new AndroidDriver(new Uri("http://127.0.0.1:4723"), options);
_driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(10);
}
[Test]
public void SuccessfulLogin_NavigatesToHome()
{
_driver.FindElement(By.Id("EmailEntry")).SendKeys("[email protected]");
_driver.FindElement(By.Id("PasswordEntry")).SendKeys("Password123!");
_driver.FindElement(By.Id("LoginButton")).Click();
var welcome = _driver.FindElement(By.Id("WelcomeLabel"));
Assert.That(welcome.Text, Does.StartWith("Ciao, "));
}
[TearDown] public void Cleanup() => _driver?.Quit();
}
Per raggiungere schermate profonde senza riprodurre l'intero flusso, usa il deep linking descritto nella guida a Shell Navigation: il tuo test può aprire direttamente myapp://order/42 e verificare solo lo schermo di interesse.
Snapshot e visual regression testing
Lo snapshot testing serializza un output (di solito un ViewModel o uno screenshot) e lo confronta con un file "golden" versionato in git. E' il modo più veloce per accorgersi che qualcuno ha rotto un ordine di sort, una formattazione localizzata, o un layout. Con Verify.Xunit è banale:
[Fact]
public Task OrderSummary_MatchesSnapshot()
{
var order = new Order(42, "Mario Rossi", 89.90m,
new[] { new LineItem("Caffè", 2, 1.50m) });
var summary = OrderSummaryBuilder.Build(order, culture: "it-IT");
return Verify(summary);
}
Al primo run Verify crea un file .received.txt: lo leggi, se è corretto lo rinomini in .verified.txt e lo commetti. Ogni run successivo confronta byte per byte; se differisce, il test fallisce e ti mostra il diff. Onestamente, è imbattibile per output complessi (ricevute, email HTML, PDF metadata).
Per il visual regression sul rendering (screenshot pixel-perfect) usiamo Percy integrato con Appium: ogni test UI carica lo screenshot su Percy che confronta con la baseline e blocca il merge se cambia qualcosa oltre una soglia. E' una spesa aggiuntiva (~200 EUR/mese per team piccoli) ma ha già salvato tre rilasci di UI critiche.
Integrare i test in CI/CD con GitHub Actions
La regola d'oro è: unit test su ogni push, device test e UI test su main e su PR con label. Ecco un workflow GitHub Actions che copre entrambi:
Un tempo tenevamo tutte le suite su main con un solo runner: il feedback loop era di 45 minuti e nessuno guardava più i risultati. Da quando abbiamo separato le job e usato il label run-ui-tests per gli UI test opzionali su PR, il tempo mediano è sceso a 6 minuti sulle PR normali. Piccola differenza operativa, enorme differenza culturale.
Code coverage e reportistica
Coverlet genera report Cobertura al termine dei test; ReportGenerator li trasforma in HTML e badge. Il setup completo richiede due pacchetti e un target MSBuild:
Sulla nostra codebase reale (42.000 righe C# escludendo XAML) abbiamo mantenuto 82% di coverage sul progetto core (Services, ViewModels, Domain) e 45% sul progetto MAUI (pagine, handler). Non punteremo mai a coprire il 100% del codice XAML/handler: il ROI cala drasticamente sopra il 70% e devi passare i minuti risparmiati a scrivere test veri sui casi limite.
Errori comuni da evitare
Dopo cinque migrazioni Xamarin verso MAUI e altrettante nuove app MAUI, questi sono i pattern che vediamo ripetersi. Li elenco in ordine di frequenza reale nei code review del mio team.
Testare troppo la UI e troppo poco la logica. Un test UI costa 30-90 secondi, un unit test 200 ms. Ogni volta che puoi spostare una regola di business dal code-behind al ViewModel, ottieni un test 200x più veloce.
Non isolare il tempo.DateTime.UtcNow disseminato nei ViewModel rende impossibili i test deterministici. Introduci un IClock e iniettalo.
Ignorare i test su iOS "perchè il mac non c'è". Con GitHub Actions macos-14 costa 8 centesimi/minuto; girare la suite iOS in notturna costa meno di un caffè al mese.
Test flaky lasciati passare. Un test che ogni tanto fallisce e ogni tanto passa vale meno di zero: distrugge la fiducia nella suite. Marcalo [Skip] e apri un bug, sempre.
Assert su testi visibili invece che AutomationId. Ogni traduzione o refresh grafico rompe la suite. Usa sempre identificatori tecnici.
Domande frequenti
Qual è il miglior framework di UI testing per .NET MAUI nel 2026?
Nel 2026 la scelta di produzione è Appium 2.11 con driver UiAutomator2 (Android) e XCUITest (iOS). Il runner ufficiale Microsoft.Maui.Controls.UITesting è ancora in preview e Xamarin.UITest è stato ritirato con il fine supporto di Xamarin il 1° maggio 2024.
Sì possono testare i ViewModel .NET MAUI senza un dispositivo?
Sì, i ViewModel scritti seguendo MVVM con dependency injection sono testabili come qualsiasi altra classe .NET. Basta astrarre dispatcher, navigazione e accesso ai servizi dietro interfacce e sostituirle con test double (NSubstitute) nei test. Il test gira in-process in meno di 200 ms senza emulatore.
Come si integra Appium con .NET MAUI in una pipeline CI?
Su GitHub Actions serve un runner macos-14 per iOS o un runner con Android emulator, l'installazione di Appium via npm ([email protected]), la pubblicazione dell'app (APK o app bundle) e un progetto NUnit/xUnit che si connette a http://127.0.0.1:4723. Per parallelizzare su più device conviene delegare a Sauce Labs o BrowserStack.
Quale copertura di code coverage è realistica per un'app .NET MAUI?
Un obiettivo pragmatico è 75-85% sul progetto core (Services, ViewModels, Domain) e 40-55% sul progetto MAUI (pagine, XAML, handler). Il ROI dei test cala drasticamente sopra l'80% sul codice piattaforma-dipendente ed è meglio investire nei device test per handler critici.
Come si migrano i test da Xamarin.UITest a .NET MAUI?
Xamarin.UITest non è compatibile con .NET MAUI e non riceve più fix. La strada consigliata è riscrivere i test in Appium 2 con Selenium WebDriver: la sintassi è diversa ma le query per AutomationId restano concettualmente identiche. In alcuni progetti abbiamo riscritto 120 test in 5 giorni-uomo mantenendo la stessa struttura page-object.
Cross-platform engineering lead who's shipped apps to millions on both Play Store and App Store. Believes shared codebases shouldn't mean shared mediocrity.
Playbook DevOps completo per firmare e distribuire un'app .NET MAUI 10 nel 2026: keystore Android, provisioning iOS, workflow GitHub Actions pronto all'uso e gotcha su Play Store e App Store.
Gli Handler di .NET MAUI 10 sono il ponte tra i controlli cross-platform e le view native. Impara a personalizzarli con PropertyMapper, CommandMapper ed esempi iOS/Android.