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.

Testing .NET MAUI 2026: Unit Test e CI/CD

Aggiornato: 6 luglio 2026

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.

LivelloLibreria consigliataAlternativaNote 2026
Unit test runnerxUnit 2.9NUnit 4.2xUnit ha migliore integrazione con dotnet test e parallelizzazione più prevedibile
MockingNSubstitute 5.3FakeItEasy 8.3Moq è ancora usabile ma sconsigliato dopo SponsorLink; NSubstitute ha API più leggibili
AssertionFluentAssertions 7 (o Shouldly 4.3)xUnit built-inFluentAssertions è diventato commerciale dalla v8; molti team restano su v7 o migrano a Shouldly
Device testMicrosoft.Maui.TestUtils.DeviceTestsMSTest custom runnerOra è parte del workload ufficiale maui e non serve più clonare il repo maui
UI testAppium 2.11 + WebDriver 4Microsoft.Maui.Controls.UITesting (preview)Il runner ufficiale MAUI UITesting è ancora in preview; Appium resta la scelta di produzione
Snapshot testVerify.Xunit 25Snapper 2.4Verify supporta serializzazione di ViewModel e output visivi
Code coverageCoverlet 6.1dotnet-coverageCoverlet 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:

// OrderListViewModel.cs
public partial class OrderListViewModel : ObservableObject
{
    private readonly IOrderService _orderService;
    private readonly INavigationService _navigation;

    public ObservableCollection<Order> Orders { get; } = new();

    [ObservableProperty] private bool _isLoading;
    [ObservableProperty] private string? _errorMessage;

    public OrderListViewModel(IOrderService orderService, INavigationService navigation)
    {
        _orderService = orderService;
        _navigation = navigation;
    }

    [RelayCommand]
    public async Task LoadOrdersAsync()
    {
        IsLoading = true;
        ErrorMessage = null;
        try
        {
            var orders = await _orderService.GetOrdersAsync();
            Orders.Clear();
            foreach (var order in orders) Orders.Add(order);
        }
        catch (HttpRequestException ex)
        {
            ErrorMessage = $"Impossibile caricare gli ordini: {ex.Message}";
        }
        finally { IsLoading = false; }
    }

    [RelayCommand]
    private Task OpenOrderAsync(Order order) =>
        _navigation.GoToAsync($"//orderDetail?id={order.Id}");
}

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:

# .github/workflows/test.yml
name: Test
on:
  push:
    branches: [main]
  pull_request:

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '10.0.x' }
      - name: Restore
        run: dotnet restore MyApp.slnx
      - name: Unit tests
        run: dotnet test tests/MyApp.UnitTests --configuration Release
                --collect:"XPlat Code Coverage"
                --logger "trx;LogFileName=unit.trx"
      - uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: '**/coverage.cobertura.xml'

  ui-android:
    if: contains(github.event.pull_request.labels.*.name, 'run-ui-tests')
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '10.0.x' }
      - name: Install MAUI workload
        run: dotnet workload install maui
      - name: Build Android APK
        run: dotnet publish src/MyApp -f net10.0-android -c Release
      - name: Start Appium
        run: npm install -g [email protected] && appium &
      - name: Run UI tests
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 35
          arch: x86_64
          script: dotnet test tests/MyApp.UITests

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:

<PackageReference Include="coverlet.collector" Version="6.1.0" />
<PackageReference Include="ReportGenerator" Version="5.4.4" />
reportgenerator \
  -reports:"**/coverage.cobertura.xml" \
  -targetdir:"coverage-report" \
  -reporttypes:"HtmlInline;Badges;MarkdownSummaryGithub"

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.

  1. 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.
  2. Non isolare il tempo. DateTime.UtcNow disseminato nei ViewModel rende impossibili i test deterministici. Introduci un IClock e iniettalo.
  3. 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.
  4. 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.
  5. 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.

Priya Sharma
Sull'Autore Priya Sharma

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.