Tests dans .NET MAUI 10 : Guide Complet — Tests Unitaires, ViewModels et UI avec Appium

Guide complet 2026 pour tester vos apps .NET MAUI 10 : pyramide de tests, xUnit avec CommunityToolkit.Mvvm, intégration HTTP et SQLite, tests UI multi-plateformes Appium, snapshots visuels et CI GitHub Actions.

.NET MAUI 10 Tests : Guide Complet 2026

Tester une application .NET MAUI 10 en 2026, ça ne se résume plus à écrire deux ou trois tests unitaires sur les ViewModels et à se dire que c'est bon. La plateforme a mûri, et avec elle la réalité multi-cibles : Android, iOS, Mac Catalyst et Windows doivent tous tenir debout sous une stratégie de tests cohérente. Honnêtement, après avoir vu des dizaines d'équipes tomber dans le piège, je peux affirmer une chose : les pannes en production ne viennent presque jamais de la logique métier — elles surgissent au point de jonction entre le code partagé, les handlers natifs et les particularités de chaque plateforme.

Ce guide présente une stratégie éprouvée pour .NET MAUI 10 : tests unitaires des ViewModels avec xUnit et CommunityToolkit.Mvvm, tests d'intégration des services, tests d'interface multi-plateformes avec Appium, et intégration continue via GitHub Actions. Tout le code est compatible avec .NET 10 et le SDK MAUI 10 sortis fin 2025.

Allez, on entre dans le vif du sujet.

La pyramide de tests adaptée à .NET MAUI

La pyramide de tests classique se traduit en mobile par trois couches complémentaires. Comprendre leur rôle évite le piège que je vois revenir presque chaque mois en mission : trop de tests UI lents et fragiles, pas assez de tests unitaires rapides.

  • Tests unitaires (70%) — ViewModels, services, validateurs, convertisseurs. Exécution en millisecondes, sans handlers natifs ni MauiApp.
  • Tests d'intégration (20%) — appels HTTP avec HttpClient mocké, accès SQLite, conteneur d'injection de dépendances, navigation Shell.
  • Tests UI (10%) — flux critiques (login, paiement, onboarding) sur émulateurs Android/iOS et Windows via Appium.

En pratique, lancez les tests unitaires et d'intégration à chaque commit, et réservez la suite Appium aux nightly builds ou aux branches de release. Le coût d'infrastructure d'Appium sur chaque pull request est rarement justifiable avant qu'une équipe n'atteigne dix développeurs (j'ai testé, ça finit toujours par coûter plus cher que prévu).

Configurer un projet de tests unitaires xUnit pour .NET MAUI

Le piège le plus courant ? Ajouter le SDK MAUI à un projet de tests unitaires. C'est inutile et ça plombe la compilation. Un projet xUnit standard ciblant net10.0 suffit largement pour tester les ViewModels et la logique métier — à condition que ce code ne dépende pas de Microsoft.Maui.Controls.

Structure de solution recommandée

MaSolution/
├── src/
│   ├── MonApp.Core/              # Modèles, services (net10.0)
│   ├── MonApp.ViewModels/        # ViewModels (net10.0)
│   └── MonApp.Maui/              # UI MAUI (net10.0-android, -ios, etc.)
└── tests/
    ├── MonApp.Core.Tests/        # xUnit, net10.0
    ├── MonApp.ViewModels.Tests/  # xUnit, net10.0
    └── MonApp.UITests/           # Appium, multi-plateformes

Création du projet de tests via la CLI

dotnet new xunit -n MonApp.ViewModels.Tests -f net10.0
cd MonApp.ViewModels.Tests
dotnet add package Microsoft.NET.Test.Sdk
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package NSubstitute
dotnet add package FluentAssertions
dotnet add reference ../../src/MonApp.ViewModels/MonApp.ViewModels.csproj

Le couple NSubstitute + FluentAssertions reste, à mon avis, la combinaison la plus lisible en 2026. Moq fonctionne aussi, mais sa syntaxe lambda est nettement plus verbeuse — surtout quand vous enchaînez les setups. Et surtout, évitez de mélanger plusieurs frameworks de mocking dans le même projet, sinon les nouveaux arrivants dans l'équipe vont passer leur première semaine à comprendre pourquoi.

Tester un ViewModel avec CommunityToolkit.Mvvm

Prenons un ViewModel typique qui charge une liste de produits via un service. Avec [ObservableProperty] et [RelayCommand] du CommunityToolkit, le code reste concis et entièrement testable.

Le ViewModel à tester

public partial class ProductListViewModel : ObservableObject
{
    private readonly IProductService _productService;
    private readonly ILogger<ProductListViewModel> _logger;

    [ObservableProperty]
    private ObservableCollection<Product> products = new();

    [ObservableProperty]
    private bool isLoading;

    [ObservableProperty]
    private string? errorMessage;

    public ProductListViewModel(
        IProductService productService,
        ILogger<ProductListViewModel> logger)
    {
        _productService = productService;
        _logger = logger;
    }

    [RelayCommand]
    private async Task LoadProductsAsync(CancellationToken ct)
    {
        IsLoading = true;
        ErrorMessage = null;
        try
        {
            var items = await _productService.GetAllAsync(ct);
            Products = new ObservableCollection<Product>(items);
        }
        catch (HttpRequestException ex)
        {
            _logger.LogError(ex, "Échec du chargement des produits");
            ErrorMessage = "Impossible de charger les produits.";
        }
        finally
        {
            IsLoading = false;
        }
    }
}

Tests unitaires correspondants

public class ProductListViewModelTests
{
    private readonly IProductService _productService;
    private readonly ILogger<ProductListViewModel> _logger;
    private readonly ProductListViewModel _sut;

    public ProductListViewModelTests()
    {
        _productService = Substitute.For<IProductService>();
        _logger = Substitute.For<ILogger<ProductListViewModel>>();
        _sut = new ProductListViewModel(_productService, _logger);
    }

    [Fact]
    public async Task LoadProductsAsync_charge_la_liste_avec_succes()
    {
        // Arrange
        var produitsAttendus = new[]
        {
            new Product(1, "iPhone 17", 1199m),
            new Product(2, "Pixel 10", 899m)
        };
        _productService.GetAllAsync(Arg.Any<CancellationToken>())
            .Returns(produitsAttendus);

        // Act
        await _sut.LoadProductsCommand.ExecuteAsync(null);

        // Assert
        _sut.Products.Should().HaveCount(2);
        _sut.Products[0].Name.Should().Be("iPhone 17");
        _sut.IsLoading.Should().BeFalse();
        _sut.ErrorMessage.Should().BeNull();
    }

    [Fact]
    public async Task LoadProductsAsync_definit_le_message_d_erreur_en_cas_d_echec_reseau()
    {
        // Arrange
        _productService.GetAllAsync(Arg.Any<CancellationToken>())
            .Returns<IEnumerable<Product>>(_ => throw new HttpRequestException("Hors ligne"));

        // Act
        await _sut.LoadProductsCommand.ExecuteAsync(null);

        // Assert
        _sut.ErrorMessage.Should().Be("Impossible de charger les produits.");
        _sut.Products.Should().BeEmpty();
        _sut.IsLoading.Should().BeFalse();
    }

    [Fact]
    public void IsLoading_declenche_PropertyChanged()
    {
        // Arrange
        var proprietesChangees = new List<string?>();
        _sut.PropertyChanged += (_, e) => proprietesChangees.Add(e.PropertyName);

        // Act
        _sut.IsLoading = true;

        // Assert
        proprietesChangees.Should().Contain(nameof(ProductListViewModel.IsLoading));
    }
}

Trois pièges à éviter

  1. N'invoquez pas la méthode privée — appelez LoadProductsCommand.ExecuteAsync. C'est ce que la vue fera réellement, et au passage vous testez aussi la génération de la commande.
  2. Vérifiez IsLoading avant et après — un test qui valide uniquement l'état final passe à côté des bugs où le spinner reste figé à l'écran. Vous rigolez maintenant, mais ça arrive plus souvent qu'on ne le croit.
  3. Utilisez un CancellationToken — il est essentiel à la robustesse de votre code, et son absence dans les tests masque des problèmes en production (typiquement, les requêtes qui continuent quand l'utilisateur a quitté l'écran).

Tests d'intégration : services HTTP et accès SQLite

Les tests d'intégration vérifient que vos composants collaborent correctement. Pour les services HTTP, je recommande un HttpMessageHandler personnalisé plutôt qu'une bibliothèque tierce — le contrôle est plus fin, et vous évitez d'ajouter une dépendance pour quinze lignes de code.

Mock d'un client HTTP avec HttpMessageHandler

public class FakeHttpMessageHandler : HttpMessageHandler
{
    private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;

    public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
        => _responder = responder;

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
        => Task.FromResult(_responder(request));
}

[Fact]
public async Task GetAllAsync_renvoie_les_produits_deserialises()
{
    // Arrange
    var json = """[{"id":1,"name":"iPhone 17","price":1199}]""";
    var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage
    {
        StatusCode = HttpStatusCode.OK,
        Content = new StringContent(json, Encoding.UTF8, "application/json")
    });
    var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com") };
    var service = new ProductService(httpClient);

    // Act
    var produits = await service.GetAllAsync(CancellationToken.None);

    // Assert
    produits.Should().ContainSingle().Which.Name.Should().Be("iPhone 17");
}

Tester l'accès SQLite avec une base en mémoire

[Fact]
public async Task ProductRepository_persiste_et_recupere_un_produit()
{
    // Arrange — base SQLite en mémoire, isolée par test
    using var connection = new SqliteConnection("Data Source=:memory:");
    await connection.OpenAsync();
    var options = new DbContextOptionsBuilder<AppDbContext>()
        .UseSqlite(connection)
        .Options;

    using var context = new AppDbContext(options);
    await context.Database.EnsureCreatedAsync();
    var repo = new ProductRepository(context);

    // Act
    await repo.AddAsync(new Product(0, "Galaxy S26", 999m));
    var produits = await repo.GetAllAsync();

    // Assert
    produits.Should().ContainSingle(p => p.Name == "Galaxy S26");
}

Petit détail qui m'a fait perdre une après-midi entière la première fois : avec SQLite en mémoire, il faut maintenir la connexion ouverte pendant toute la durée du test. Sinon la base disparaît entre l'écriture et la lecture, et vous vous retrouvez à chercher un bug fantôme dans votre code de repository.

Tests d'interface utilisateur multi-plateformes avec Appium

Appium est devenu en 2026 le standard de fait pour les tests UI .NET MAUI, recommandé officiellement par Microsoft depuis la dépréciation de Xamarin.UITest. Il fonctionne sur Android, iOS, Mac Catalyst et Windows avec une API unifiée — ce qui, soyons honnêtes, n'était pas gagné d'avance.

Prérequis d'installation

# Node.js 20 LTS requis
npm install -g appium@2

# Drivers par plateforme
appium driver install uiautomator2   # Android
appium driver install xcuitest       # iOS / Mac Catalyst
appium driver install windows        # Windows (WinAppDriver 1.2.1)

# Vérification
appium driver list --installed

Sur macOS, vous pouvez tester Android, iOS et Mac Catalyst. Sur Windows, vous êtes limité à Android et Windows. Pour iOS sans Mac, passez par un service cloud comme BrowserStack ou Sauce Labs (la facture est salée, mais c'est moins cher qu'un Mac Mini dédié sur le long terme).

Structure recommandée du projet UITests

tests/
├── MonApp.UITests.Shared/    # NoTargets — code commun
│   ├── BaseTest.cs
│   ├── PageObjects/
│   └── Tests/
│       └── LoginTests.cs
├── MonApp.UITests.Android/   # net10.0 — config Android
├── MonApp.UITests.iOS/       # net10.0 — config iOS
└── MonApp.UITests.Windows/   # net10.0 — config Windows

Tous les projets partagent le même namespace. C'est une exigence de NUnit : l'attribut [SetUpFixture] ne s'exécute que pour les fixtures du même namespace. Si vos namespaces divergent, le driver Appium ne sera tout simplement pas initialisé, et vous passerez deux heures à comprendre pourquoi vos tests plantent au tout premier appel.

Définir des AutomationId sur la vue XAML

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             x:Class="MonApp.Pages.LoginPage"
             Title="Connexion">
    <VerticalStackLayout Spacing="16" Padding="24">
        <Entry x:Name="EmailEntry"
               AutomationId="LoginEmailEntry"
               Placeholder="[email protected]" />
        <Entry x:Name="PasswordEntry"
               AutomationId="LoginPasswordEntry"
               Placeholder="Mot de passe"
               IsPassword="True" />
        <Button Text="Se connecter"
                AutomationId="LoginSubmitButton"
                Command="{Binding LoginCommand}" />
        <Label AutomationId="LoginErrorLabel"
               TextColor="Red"
               Text="{Binding ErrorMessage}"
               IsVisible="{Binding HasError}" />
    </VerticalStackLayout>
</ContentPage>

Sans AutomationId, Appium ne peut pas localiser les éléments de manière fiable. Adoptez une convention de nommage stricte — <Page><Element><Type> par exemple, comme LoginEmailEntry — et exposez ces identifiants dans des constantes partagées avec les tests. Ça paraît trivial. Ça vous sauvera des heures de débogage.

BaseTest partagée

public abstract class BaseTest
{
    protected AppiumDriver App => AppiumSetup.App;

    protected AppiumElement FindByAutomationId(string id)
    {
        // Sur Windows, AutomationId => AccessibilityId.
        // Sur Android/iOS/macOS => Id.
        return App is WindowsDriver
            ? App.FindElement(MobileBy.AccessibilityId(id))
            : App.FindElement(MobileBy.Id(id));
    }

    protected void TapByAutomationId(string id) => FindByAutomationId(id).Click();

    protected void EnterText(string id, string text)
    {
        var el = FindByAutomationId(id);
        el.Clear();
        el.SendKeys(text);
    }
}

Test de connexion (NUnit)

[TestFixture]
public class LoginTests : BaseTest
{
    [Test]
    public void Connexion_avec_identifiants_invalides_affiche_un_message()
    {
        // Arrange — l'app démarre sur LoginPage
        // Act
        EnterText("LoginEmailEntry", "[email protected]");
        EnterText("LoginPasswordEntry", "mauvais-mdp");
        TapByAutomationId("LoginSubmitButton");

        // Assert — l'élément d'erreur devient visible sous 5s
        var erreur = new WebDriverWait(App, TimeSpan.FromSeconds(5))
            .Until(_ => FindByAutomationId("LoginErrorLabel"));
        Assert.That(erreur.Text, Does.Contain("incorrect"));
    }
}

Snapshot tests visuels avec Microsoft.Maui.TestUtils.DeviceTests

Pour les composants graphiques personnalisés (GraphicsView, handlers custom, dessins SkiaSharp), les tests UI classiques ne suffisent pas. Le projet open source Microsoft.Maui.TestUtils.DeviceTests.Runners — utilisé par l'équipe MAUI pour valider plus de 5000 tests par build, rien que ça — permet d'exécuter des tests xUnit directement sur l'appareil et de comparer des captures d'écran.

// Dans un projet de DeviceTests qui s'exécute sur l'appareil
public class CustomBadgeTests
{
    [Fact(DisplayName = "Le badge affiche un compteur supérieur à 9 sous la forme 9+")]
    public async Task Badge_affiche_9_plus_au_dela_de_neuf()
    {
        var badge = new Badge { Count = 42 };
        var bitmap = await badge.RenderAsync();
        await bitmap.AssertContainsTextAsync("9+");
    }
}

Cette approche détecte les régressions visuelles introduites par une mise à jour de SDK ou un changement de handler, là où un test ViewModel passerait sans rien voir. C'est exactement ce qui m'est arrivé l'an dernier sur un composant de notation : les étoiles avaient changé de taille de 2 pixels après une mise à jour mineure du SDK, et seul le snapshot test l'a vu.

Intégration continue avec GitHub Actions

Voici un workflow minimal mais complet qui exécute les tests unitaires sur chaque push et déclenche les tests Appium Android sur les nightly builds.

name: ci

on:
  push:
    branches: [main]
  pull_request:

jobs:
  unit-tests:
    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 MonApp.sln
      - name: Tests unitaires + intégration
        run: dotnet test MonApp.sln
             --filter "Category!=UI"
             --logger "trx;LogFileName=results.trx"
             --collect:"XPlat Code Coverage"
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: '**/results.trx'

  ui-tests-android:
    if: github.event_name == 'schedule'
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - name: Installer Appium
        run: |
          npm install -g appium@2
          appium driver install uiautomator2
      - name: Démarrer l'émulateur Android
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          arch: x86_64
          script: dotnet test tests/MonApp.UITests.Android

Trois recommandations pour réduire la facture CI :

  • Filtrez les tests UI hors des pull requests avec Category!=UI.
  • Mettez en cache ~/.nuget/packages entre les runs — gain typique de 30 à 60 secondes par build.
  • Pour iOS, planifiez les tests sur macos-14 uniquement la nuit. Les runners macOS sont facturés 10× plus cher que Linux, et ça se sent vite sur la facture mensuelle.

Couverture de code et seuils de qualité

Mesurer la couverture sans seuil est inutile. Configurez coverlet avec un seuil minimal directement dans le .csproj du projet de tests :

<ItemGroup>
  <PackageReference Include="coverlet.collector" Version="6.0.4" />
  <PackageReference Include="coverlet.msbuild" Version="6.0.4" />
</ItemGroup>
<PropertyGroup>
  <Threshold>75</Threshold>
  <ThresholdType>line,branch</ThresholdType>
  <ThresholdStat>total</ThresholdStat>
  <Exclude>[*]MonApp.Maui.Resources.*,[*]*.Generated.*</Exclude>
</PropertyGroup>

Excluez les ressources générées (.g.cs du XAML) et les resource designers — leur couverture artificielle gonfle les chiffres sans valeur ajoutée. Visez 75–80 % sur les ViewModels et services, 0 % imposé sur le code XAML code-behind. Les chiffres au-delà de 90 % sont presque toujours le signe qu'on teste l'implémentation et plus le comportement.

Bonnes pratiques pour des tests durables

  • Un test = une assertion logique. Vérifier 5 propriétés dans un seul [Fact] rend les échecs illisibles.
  • Nommez vos tests en français explicite — par ex. LoadProductsAsync_definit_le_message_d_erreur_en_cas_d_echec_reseau. La lisibilité prime sur la concision.
  • Évitez les Thread.Sleep dans les tests UI. Toujours WebDriverWait avec une condition explicite.
  • Isolez les tests d'intégration. Chaque test crée sa propre base SQLite en mémoire — pas de partage d'état.
  • Stockez les AutomationId dans des constantes partagées entre l'app et les tests, pour éliminer les fautes de frappe non détectables.
  • Mesurez la durée des tests. Un test unitaire qui prend plus de 100 ms cache souvent un appel I/O caché (système de fichiers, DI réelle, etc.).

Foire aux questions

Quel framework de tests choisir pour .NET MAUI : xUnit, NUnit ou MSTest ?

Pour les tests unitaires, xUnit est recommandé par Microsoft depuis .NET 8 et reste le standard en 2026. Pour les tests UI Appium, NUnit reste légèrement plus mature grâce à [SetUpFixture], qui simplifie le partage du driver entre tests. Mélanger les deux est parfaitement acceptable : xUnit côté unitaire, NUnit côté UI. Personne ne vous mettra en prison pour ça.

Peut-on tester le code XAML directement ?

Pas de manière fiable en tests unitaires. Le XAML compile en code C# généré, mais l'instanciation d'une ContentPage requiert un MauiContext et des handlers natifs disponibles uniquement à l'exécution sur l'appareil. La bonne approche : tester la liaison de données via le ViewModel (couverture 100 % de la logique) et déléguer la validation visuelle aux tests UI Appium ou aux device tests.

Comment tester du code qui utilise Microsoft.Maui.Storage.Preferences ou SecureStorage ?

N'appelez jamais ces APIs directement depuis vos ViewModels. Wrappez-les derrière une interface ISettingsService que vous injectez. En production, l'implémentation utilise Preferences.Default ; en tests, vous fournissez un dictionnaire en mémoire. C'est la même règle que pour HttpClient : tout ce qui touche le système doit passer par une abstraction.

Xamarin.UITest fonctionne-t-il encore avec .NET MAUI 10 ?

Non. Microsoft a officiellement déprécié Xamarin.UITest pour .NET MAUI. Le chemin de migration validé est Appium, idéalement avec la bibliothèque Plugin.Maui.UITestHelpers qui réimplémente les helpers familiers de Xamarin.UITest (WaitForElement, Tap, EnterText) au-dessus d'Appium. Pour migrer, gardez vos tests existants comme spécification et réécrivez-les progressivement, fonctionnalité par fonctionnalité. Pas la peine de tout casser d'un coup.

Combien de temps doivent durer les tests UI dans la CI ?

Un projet sain conserve ses tests unitaires sous 30 secondes et ses tests d'intégration sous 2 minutes. Les tests UI Appium prennent typiquement 10 à 30 secondes par scénario, donc une suite de 50 tests UI tourne autour de 15 à 25 minutes. Au-delà d'une heure, parallélisez sur plusieurs runners ou réduisez la couverture UI aux flux critiques uniquement.

Conclusion

Une stratégie de tests solide pour .NET MAUI 10 ne dépend pas d'un outil miracle — elle repose sur la discipline de séparer ce qui est testable rapidement (ViewModels, services) de ce qui exige un appareil (UI, handlers, SQLite réel). En 2026, l'écosystème est mature : xUnit pour les unités, Appium pour l'UI, GitHub Actions pour orchestrer le tout, et Microsoft.Maui.TestUtils.DeviceTests pour les cas exotiques.

Mon conseil ? Commencez petit. Couvrez d'abord vos trois ViewModels les plus critiques avec 5 tests chacun, mesurez la couverture, et ajoutez progressivement les tests d'intégration. Les tests UI viennent en dernier, une fois que la base est stable. Cette approche pyramidale vous donnera un filet de sécurité robuste sans transformer votre CI en goulot d'étranglement de plusieurs heures — ce qui, croyez-moi, est le scénario que vous voulez éviter à tout prix.

À propos de l'auteur Editorial Team

Our team of expert writers and editors.