Teststrategien in .NET MAUI: Unit-Tests, UI-Tests und CI/CD richtig umsetzen

Lernen Sie, wie Sie Unit-Tests, Integrationstests und UI-Tests für .NET MAUI aufbauen — von der Projektstruktur über Mocking mit Moq bis zur CI/CD-Automatisierung mit GitHub Actions.

Warum Testen in .NET MAUI so wichtig ist (und oft vernachlässigt wird)

Hand aufs Herz: Wer hat nicht schon mal eine MAUI-App ohne ordentliche Tests ausgeliefert? Der Code lief auf dem eigenen Gerät perfekt, und dann kam der erste Bug-Report von einem Nutzer mit einem älteren iPhone. Kennern kommt das bekannt vor.

.NET MAUI bringt eine besondere Herausforderung mit sich — der Code muss auf Android, iOS, macOS und Windows gleichermaßen funktionieren. Ein Button, der auf dem Pixel 9 einwandfrei reagiert, kann auf einem älteren iPhone plötzlich Probleme machen. Ein ViewModel, das lokal sauber arbeitet, versagt möglicherweise bei schlechter Netzwerkverbindung.

Ohne eine durchdachte Teststrategie wird das Debugging schnell zum Albtraum.

Dabei geht's nicht nur um Bugfixing. Eine solide Teststrategie sorgt für Vertrauen in den eigenen Code, ermöglicht furchtloses Refactoring und bildet das Rückgrat einer funktionierenden CI/CD-Pipeline. In diesem Leitfaden zeige ich Ihnen, wie Sie Unit-Tests, Integrationstests und UI-Tests für Ihre .NET MAUI-Anwendung aufbauen — von der Projektstruktur über Mocking-Strategien bis zur Automatisierung in der Build-Pipeline.

Die Testpyramide für .NET MAUI verstehen

Bevor wir in den Code einsteigen, kurz zur Theorie. Die Testpyramide besteht aus drei Ebenen, die sich in Geschwindigkeit, Kosten und Abdeckung unterscheiden:

  • Unit-Tests (Basis): Schnell, isoliert, testen einzelne Methoden und ViewModels. Sie bilden das Fundament und sollten den größten Anteil ausmachen.
  • Integrationstests (Mitte): Prüfen das Zusammenspiel mehrerer Komponenten — etwa Services mit Datenbanken oder HTTP-Clients mit APIs.
  • UI-Tests (Spitze): Simulieren echte Benutzerinteraktionen auf dem Gerät oder Emulator. Langsam, aber unverzichtbar für die Validierung der Benutzererfahrung.

Für .NET MAUI hat sich ein Verhältnis von etwa 70 % Unit-Tests, 20 % Integrationstests und 10 % UI-Tests bewährt. Klingt erstmal nach viel Aufwand, aber dieses Verhältnis maximiert die Testabdeckung bei minimaler Ausführungszeit. Und ehrlich gesagt: Die meisten Bugs finden sich sowieso in der Geschäftslogik, nicht in der UI.

Projektstruktur: Das Testprojekt richtig aufsetzen

Die Einrichtung eines Testprojekts für .NET MAUI hat ihre Eigenheiten. Da MAUI-Projekte Multi-Target-Projekte sind, muss das Testprojekt entsprechend konfiguriert werden.

xUnit-Testprojekt erstellen

Erstellen Sie zunächst ein neues xUnit-Testprojekt über die Kommandozeile:

dotnet new xunit -n MeineApp.Tests
cd MeineApp.Tests
dotnet add package Moq
dotnet add package FluentAssertions
dotnet add package Microsoft.Maui.Controls --version 10.0.*

Die Projektdatei muss so konfiguriert werden, dass sie die gleichen Ziel-Frameworks wie das MAUI-Hauptprojekt unterstützt:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="coverlet.collector" Version="6.*" />
    <PackageReference Include="FluentAssertions" Version="7.*" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
    <PackageReference Include="Moq" Version="4.*" />
    <PackageReference Include="xunit" Version="2.*" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\MeineApp\MeineApp.csproj" />
  </ItemGroup>
</Project>

Die empfohlene Ordnerstruktur

Eine saubere Ordnerstruktur macht das Leben deutlich einfacher — gerade wenn das Projekt wächst:

MeineApp.sln
├── MeineApp/
│   ├── Models/
│   ├── ViewModels/
│   ├── Services/
│   ├── Views/
│   └── MauiProgram.cs
├── MeineApp.Tests/
│   ├── ViewModels/
│   │   ├── MainViewModelTests.cs
│   │   └── DetailViewModelTests.cs
│   ├── Services/
│   │   ├── ApiServiceTests.cs
│   │   └── DatabaseServiceTests.cs
│   ├── Helpers/
│   │   └── TestFixtures.cs
│   └── MeineApp.Tests.csproj
└── MeineApp.UITests/
    ├── Tests/
    │   ├── NavigationTests.cs
    │   └── LoginTests.cs
    ├── Pages/
    │   └── MainPageObject.cs
    └── MeineApp.UITests.csproj

Wichtig: Unit-Tests und UI-Tests gehören in separate Projekte. UI-Tests brauchen völlig andere Abhängigkeiten und Ausführungsumgebungen — das zusammenzuwerfen führt nur zu Frust.

Unit-Tests für ViewModels schreiben

ViewModels sind das Herzstück einer MVVM-Architektur und gleichzeitig die am einfachsten testbaren Komponenten. Also fangen wir hier an.

Ein testbares ViewModel entwerfen

Zunächst brauchen wir ein ViewModel, das sauber von seinen Abhängigkeiten getrennt ist. Hier ein typisches Beispiel mit dem MVVM Community Toolkit:

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

public partial class ProduktListeViewModel : ObservableObject
{
    private readonly IProduktService _produktService;
    private readonly INavigationService _navigationService;
    private readonly IConnectivityService _connectivityService;

    public ProduktListeViewModel(
        IProduktService produktService,
        INavigationService navigationService,
        IConnectivityService connectivityService)
    {
        _produktService = produktService;
        _navigationService = navigationService;
        _connectivityService = connectivityService;
    }

    [ObservableProperty]
    private ObservableCollection<Produkt> _produkte = new();

    [ObservableProperty]
    private bool _istLaden;

    [ObservableProperty]
    private string _fehlermeldung = string.Empty;

    [RelayCommand]
    private async Task ProdukteladenAsync()
    {
        if (!_connectivityService.IstOnline)
        {
            Fehlermeldung = "Keine Internetverbindung verfügbar.";
            return;
        }

        try
        {
            IstLaden = true;
            Fehlermeldung = string.Empty;
            var ergebnis = await _produktService.HoleProdukteAsync();
            Produkte = new ObservableCollection<Produkt>(ergebnis);
        }
        catch (Exception ex)
        {
            Fehlermeldung = $"Fehler beim Laden: {ex.Message}";
        }
        finally
        {
            IstLaden = false;
        }
    }

    [RelayCommand]
    private async Task ProduktAuswaehlenAsync(Produkt produkt)
    {
        if (produkt is null) return;
        await _navigationService.NavigiereZuAsync("DetailSeite",
            new Dictionary<string, object>
            {
                { "Produkt", produkt }
            });
    }
}

Das Entscheidende hier: Alle Abhängigkeiten werden über den Konstruktor injiziert. Keine statischen Aufrufe, keine hartkodierten Services. Das macht das ViewModel testbar.

Die zugehörigen Service-Interfaces

Die Interfaces sind der Schlüssel zur Testbarkeit — sie ermöglichen das Ersetzen realer Implementierungen durch Mocks:

public interface IProduktService
{
    Task<IEnumerable<Produkt>> HoleProdukteAsync();
    Task<Produkt?> HoleProduktNachIdAsync(int id);
}

public interface INavigationService
{
    Task NavigiereZuAsync(string route,
        IDictionary<string, object>? parameter = null);
    Task ZurueckAsync();
}

public interface IConnectivityService
{
    bool IstOnline { get; }
}

Umfassende Unit-Tests mit Moq

Jetzt wird's spannend — die eigentlichen Tests. Wir verwenden das Arrange-Act-Assert-Muster und Moq zum Erstellen der Mock-Objekte:

using FluentAssertions;
using Moq;
using Xunit;

public class ProduktListeViewModelTests
{
    private readonly Mock<IProduktService> _produktServiceMock;
    private readonly Mock<INavigationService> _navigationServiceMock;
    private readonly Mock<IConnectivityService> _connectivityServiceMock;
    private readonly ProduktListeViewModel _sut; // System Under Test

    public ProduktListeViewModelTests()
    {
        _produktServiceMock = new Mock<IProduktService>();
        _navigationServiceMock = new Mock<INavigationService>();
        _connectivityServiceMock = new Mock<IConnectivityService>();

        // Standardmäßig online
        _connectivityServiceMock
            .Setup(c => c.IstOnline)
            .Returns(true);

        _sut = new ProduktListeViewModel(
            _produktServiceMock.Object,
            _navigationServiceMock.Object,
            _connectivityServiceMock.Object);
    }

    [Fact]
    public async Task ProdukteladenAsync_BeiErfolg_FuelltProduktliste()
    {
        // Arrange
        var erwarteteProdukte = new List<Produkt>
        {
            new() { Id = 1, Name = "Laptop", Preis = 999.99m },
            new() { Id = 2, Name = "Smartphone", Preis = 699.99m }
        };
        _produktServiceMock
            .Setup(s => s.HoleProdukteAsync())
            .ReturnsAsync(erwarteteProdukte);

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

        // Assert
        _sut.Produkte.Should().HaveCount(2);
        _sut.Produkte.First().Name.Should().Be("Laptop");
        _sut.Fehlermeldung.Should().BeEmpty();
        _sut.IstLaden.Should().BeFalse();
    }

    [Fact]
    public async Task ProdukteladenAsync_OhneVerbindung_ZeigtFehlermeldung()
    {
        // Arrange
        _connectivityServiceMock
            .Setup(c => c.IstOnline)
            .Returns(false);

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

        // Assert
        _sut.Fehlermeldung.Should().Be(
            "Keine Internetverbindung verfügbar.");
        _sut.Produkte.Should().BeEmpty();
        _produktServiceMock.Verify(
            s => s.HoleProdukteAsync(), Times.Never);
    }

    [Fact]
    public async Task ProdukteladenAsync_BeiFehler_BehandeltAusnahme()
    {
        // Arrange
        _produktServiceMock
            .Setup(s => s.HoleProdukteAsync())
            .ThrowsAsync(new HttpRequestException("Timeout"));

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

        // Assert
        _sut.Fehlermeldung.Should().Contain("Timeout");
        _sut.IstLaden.Should().BeFalse();
    }

    [Fact]
    public async Task ProdukteladenAsync_Setzt_IstLaden_Korrekt()
    {
        // Arrange
        var tcs = new TaskCompletionSource<IEnumerable<Produkt>>();
        _produktServiceMock
            .Setup(s => s.HoleProdukteAsync())
            .Returns(tcs.Task);

        // Act
        var ladeTask = _sut.ProdukteladenCommand.ExecuteAsync(null);

        // Assert — während des Ladens
        _sut.IstLaden.Should().BeTrue();

        // Lade-Vorgang abschließen
        tcs.SetResult(new List<Produkt>());
        await ladeTask;

        // Assert — nach dem Laden
        _sut.IstLaden.Should().BeFalse();
    }

    [Fact]
    public async Task ProduktAuswaehlenAsync_NavigiertZuDetailSeite()
    {
        // Arrange
        var produkt = new Produkt { Id = 1, Name = "Laptop" };

        // Act
        await _sut.ProduktAuswaehlenCommand.ExecuteAsync(produkt);

        // Assert
        _navigationServiceMock.Verify(
            n => n.NavigiereZuAsync(
                "DetailSeite",
                It.Is<IDictionary<string, object>>(
                    d => d.ContainsKey("Produkt")
                         && d["Produkt"] == produkt)),
            Times.Once);
    }

    [Fact]
    public async Task ProduktAuswaehlenAsync_MitNull_NavigiertNicht()
    {
        // Act
        await _sut.ProduktAuswaehlenCommand.ExecuteAsync(null);

        // Assert
        _navigationServiceMock.Verify(
            n => n.NavigiereZuAsync(
                It.IsAny<string>(),
                It.IsAny<IDictionary<string, object>>()),
            Times.Never);
    }
}

Ein Tipp aus der Praxis: Der Test ProdukteladenAsync_Setzt_IstLaden_Korrekt mit der TaskCompletionSource ist besonders wertvoll. Damit lässt sich prüfen, ob der Ladezustand während einer laufenden Operation korrekt gesetzt wird — genau das, was bei Ladeanimationen in der UI entscheidend ist.

INotifyPropertyChanged testen

Ein häufig übersehener Aspekt: Property-Change-Benachrichtigungen testen. Die sind für die Datenbindung in der UI entscheidend, und wenn sie fehlen, sieht die UI einfach keine Änderungen.

[Fact]
public async Task ProdukteladenAsync_LoestPropertyChanged_Aus()
{
    // Arrange
    _produktServiceMock
        .Setup(s => s.HoleProdukteAsync())
        .ReturnsAsync(new List<Produkt>());

    var geaenderteProperties = new List<string>();
    _sut.PropertyChanged += (sender, args) =>
        geaenderteProperties.Add(args.PropertyName!);

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

    // Assert
    geaenderteProperties.Should().Contain("IstLaden");
    geaenderteProperties.Should().Contain("Produkte");
    geaenderteProperties.Should().Contain("Fehlermeldung");
}

Integrationstests: Services und Datenbank prüfen

Integrationstests überprüfen das Zusammenspiel mehrerer Komponenten. In .NET MAUI sind sie besonders wichtig für Services, die mit externen Systemen interagieren.

SQLite-Datenbankservice testen

Viele MAUI-Apps setzen auf SQLite für die lokale Datenspeicherung. Das Schöne daran: Mit einer In-Memory-Datenbank lassen sich Datenbank-Tests richtig schnell und ohne Dateisystem-Abhängigkeiten umsetzen.

using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

public class ProduktDatenbankServiceTests : IDisposable
{
    private readonly SqliteConnection _verbindung;
    private readonly AppDbContext _kontext;
    private readonly ProduktDatenbankService _sut;

    public ProduktDatenbankServiceTests()
    {
        _verbindung = new SqliteConnection("DataSource=:memory:");
        _verbindung.Open();

        var optionen = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlite(_verbindung)
            .Options;

        _kontext = new AppDbContext(optionen);
        _kontext.Database.EnsureCreated();

        _sut = new ProduktDatenbankService(_kontext);
    }

    [Fact]
    public async Task SpeichereProdukt_UndLeseSpaeterAus()
    {
        // Arrange
        var produkt = new Produkt
        {
            Name = "Testprodukt",
            Preis = 49.99m,
            Beschreibung = "Ein Testprodukt"
        };

        // Act
        await _sut.SpeichereProduktAsync(produkt);
        var ergebnis = await _sut.HoleProduktNachIdAsync(produkt.Id);

        // Assert
        ergebnis.Should().NotBeNull();
        ergebnis!.Name.Should().Be("Testprodukt");
        ergebnis.Preis.Should().Be(49.99m);
    }

    [Fact]
    public async Task LoescheProdukt_EntferntAusDatenbank()
    {
        // Arrange
        var produkt = new Produkt { Name = "Zum Löschen", Preis = 10m };
        await _sut.SpeichereProduktAsync(produkt);

        // Act
        await _sut.LoescheProduktAsync(produkt.Id);
        var ergebnis = await _sut.HoleProduktNachIdAsync(produkt.Id);

        // Assert
        ergebnis.Should().BeNull();
    }

    [Fact]
    public async Task SucheProdukte_FiltertKorrekt()
    {
        // Arrange
        await _sut.SpeichereProduktAsync(
            new Produkt { Name = "Gaming Laptop", Preis = 1499m });
        await _sut.SpeichereProduktAsync(
            new Produkt { Name = "Business Laptop", Preis = 999m });
        await _sut.SpeichereProduktAsync(
            new Produkt { Name = "Smartphone", Preis = 699m });

        // Act
        var ergebnisse = await _sut.SucheProdukteAsync("Laptop");

        // Assert
        ergebnisse.Should().HaveCount(2);
        ergebnisse.Should().AllSatisfy(p =>
            p.Name.Should().Contain("Laptop"));
    }

    public void Dispose()
    {
        _kontext.Dispose();
        _verbindung.Dispose();
    }
}

HTTP-Services mit HttpMessageHandler testen

Für das Testen von API-Aufrufen verwenden wir einen benutzerdefinierten HttpMessageHandler. Damit vermeiden wir echte Netzwerkaufrufe und haben volle Kontrolle über die Antworten:

public class MockHttpMessageHandler : HttpMessageHandler
{
    private readonly Func<HttpRequestMessage, HttpResponseMessage>
        _handlerFunc;

    public MockHttpMessageHandler(
        Func<HttpRequestMessage, HttpResponseMessage> handlerFunc)
    {
        _handlerFunc = handlerFunc;
    }

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        return Task.FromResult(_handlerFunc(request));
    }
}

public class ApiProduktServiceTests
{
    [Fact]
    public async Task HoleProdukteAsync_DeserialisertJsonKorrekt()
    {
        // Arrange
        var jsonAntwort = """
        [
            { "id": 1, "name": "Laptop", "preis": 999.99 },
            { "id": 2, "name": "Tablet", "preis": 499.99 }
        ]
        """;

        var handler = new MockHttpMessageHandler(request =>
            new HttpResponseMessage(System.Net.HttpStatusCode.OK)
            {
                Content = new StringContent(
                    jsonAntwort,
                    System.Text.Encoding.UTF8,
                    "application/json")
            });

        var httpClient = new HttpClient(handler)
        {
            BaseAddress = new Uri("https://api.beispiel.de/")
        };

        var service = new ApiProduktService(httpClient);

        // Act
        var produkte = await service.HoleProdukteAsync();

        // Assert
        var produktListe = produkte.ToList();
        produktListe.Should().HaveCount(2);
        produktListe[0].Name.Should().Be("Laptop");
        produktListe[1].Preis.Should().Be(499.99m);
    }

    [Fact]
    public async Task HoleProdukteAsync_Bei500_WirftException()
    {
        // Arrange
        var handler = new MockHttpMessageHandler(request =>
            new HttpResponseMessage(
                System.Net.HttpStatusCode.InternalServerError));

        var httpClient = new HttpClient(handler)
        {
            BaseAddress = new Uri("https://api.beispiel.de/")
        };

        var service = new ApiProduktService(httpClient);

        // Act & Assert
        await Assert.ThrowsAsync<HttpRequestException>(
            () => service.HoleProdukteAsync());
    }
}

UI-Tests mit Appium aufsetzen

Jetzt kommen wir zum spannendsten (und zugegebenermaßen auch aufwändigsten) Teil: UI-Tests. Sie simulieren echte Benutzerinteraktionen und prüfen, ob die App visuell und funktional korrekt arbeitet.

Appium ist hier das Tool der Wahl für .NET MAUI.

Voraussetzungen installieren

Stellen Sie zunächst sicher, dass Appium und die benötigten Treiber installiert sind:

# Appium global installieren
npm install -g appium

# Plattform-Treiber installieren
appium driver install uiautomator2  # für Android
appium driver install xcuitest      # für iOS
appium driver install mac2          # für macOS
appium driver install --source=npm appium-windows-driver  # für Windows

Das UI-Test-Projekt konfigurieren

Erstellen Sie ein separates Testprojekt für die UI-Tests:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Appium.WebDriver" Version="5.*" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
    <PackageReference Include="xunit" Version="2.*" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
  </ItemGroup>
</Project>

Appium-Setup-Klasse erstellen

Die Setup-Klasse verwaltet den Appium-Server und die Treiberverbindung. Hier das Grundgerüst:

using OpenQA.Selenium.Appium;
using OpenQA.Selenium.Appium.Android;
using OpenQA.Selenium.Appium.iOS;
using OpenQA.Selenium.Appium.Service;

public class AppiumSetup : IAsyncLifetime
{
    private AppiumLocalService? _appiumService;
    protected AppiumDriver? Driver { get; private set; }

    public async Task InitializeAsync()
    {
        // Appium-Server starten
        _appiumService = new AppiumServiceBuilder()
            .WithIPAddress("127.0.0.1")
            .UsingPort(4723)
            .Build();
        _appiumService.Start();

        // Für Android konfigurieren
        var androidOptionen = new AppiumOptions
        {
            PlatformName = "Android",
            AutomationName = "UiAutomator2",
            App = "/pfad/zur/meineapp.apk"
        };
        androidOptionen.AddAdditionalAppiumOption(
            "appPackage", "com.meineapp.android");
        androidOptionen.AddAdditionalAppiumOption(
            "appActivity", "crc64...MainActivity");

        Driver = new AndroidDriver(
            new Uri("http://127.0.0.1:4723"), androidOptionen);

        await Task.CompletedTask;
    }

    public async Task DisposeAsync()
    {
        Driver?.Quit();
        _appiumService?.Dispose();
        await Task.CompletedTask;
    }
}

AutomationId in XAML setzen

Damit Appium Ihre UI-Elemente finden kann, müssen Sie die AutomationId-Eigenschaft setzen. Ohne diese Eigenschaft sind Ihre UI-Tests zum Scheitern verurteilt — vertrauen Sie mir, ich hab das auf die harte Tour gelernt:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MeineApp.Views.LoginSeite"
             AutomationId="LoginSeite">
    <VerticalStackLayout Padding="20" Spacing="15">
        <Entry AutomationId="BenutzernameEingabe"
               Placeholder="Benutzername" />
        <Entry AutomationId="PasswortEingabe"
               Placeholder="Passwort"
               IsPassword="True" />
        <Button AutomationId="AnmeldenButton"
                Text="Anmelden" />
        <Label AutomationId="FehlerLabel"
               TextColor="Red"
               IsVisible="False" />
    </VerticalStackLayout>
</ContentPage>

UI-Tests schreiben

Nun können wir UI-Tests schreiben, die echte Benutzerszenarien abbilden:

public class LoginTests : AppiumSetup
{
    [Fact]
    public void ErfolgreicheAnmeldung_NavigiertZurHauptseite()
    {
        // Arrange
        var benutzernamefeld = Driver!.FindElement(
            MobileBy.AccessibilityId("BenutzernameEingabe"));
        var passwortfeld = Driver.FindElement(
            MobileBy.AccessibilityId("PasswortEingabe"));
        var anmeldenButton = Driver.FindElement(
            MobileBy.AccessibilityId("AnmeldenButton"));

        // Act
        benutzernamefeld.SendKeys("testbenutzer");
        passwortfeld.SendKeys("sicheresPasswort123");
        anmeldenButton.Click();

        // Assert — warten auf Navigation
        var wartezeit = new WebDriverWait(Driver, TimeSpan.FromSeconds(10));
        var hauptseite = wartezeit.Until(d =>
            d.FindElement(MobileBy.AccessibilityId("Hauptseite")));
        Assert.NotNull(hauptseite);
    }

    [Fact]
    public void FalscheAnmeldedaten_ZeigtFehlermeldung()
    {
        // Arrange
        var benutzernamefeld = Driver!.FindElement(
            MobileBy.AccessibilityId("BenutzernameEingabe"));
        var passwortfeld = Driver.FindElement(
            MobileBy.AccessibilityId("PasswortEingabe"));
        var anmeldenButton = Driver.FindElement(
            MobileBy.AccessibilityId("AnmeldenButton"));

        // Act
        benutzernamefeld.SendKeys("falscherBenutzer");
        passwortfeld.SendKeys("falschesPasswort");
        anmeldenButton.Click();

        // Assert
        var wartezeit = new WebDriverWait(Driver, TimeSpan.FromSeconds(5));
        var fehlerLabel = wartezeit.Until(d =>
        {
            var element = d.FindElement(
                MobileBy.AccessibilityId("FehlerLabel"));
            return element.Displayed ? element : null;
        });

        Assert.NotNull(fehlerLabel);
        Assert.Contains("ungültig", fehlerLabel!.Text,
            StringComparison.OrdinalIgnoreCase);
    }
}

Das Page-Object-Muster für wartbare UI-Tests

Sobald die Zahl der UI-Tests wächst, wird die direkte Interaktion mit Elementen schnell unübersichtlich. Das Page-Object-Muster schafft hier Abhilfe — es kapselt die Seitenstruktur und macht Tests lesbarer:

public class LoginSeitePO
{
    private readonly AppiumDriver _driver;

    public LoginSeitePO(AppiumDriver driver)
    {
        _driver = driver;
    }

    private AppiumElement BenutzernameEingabe =>
        _driver.FindElement(
            MobileBy.AccessibilityId("BenutzernameEingabe"));

    private AppiumElement PasswortEingabe =>
        _driver.FindElement(
            MobileBy.AccessibilityId("PasswortEingabe"));

    private AppiumElement AnmeldenButton =>
        _driver.FindElement(
            MobileBy.AccessibilityId("AnmeldenButton"));

    private AppiumElement FehlerLabel =>
        _driver.FindElement(
            MobileBy.AccessibilityId("FehlerLabel"));

    public HauptseitePO Anmelden(
        string benutzername, string passwort)
    {
        BenutzernameEingabe.SendKeys(benutzername);
        PasswortEingabe.SendKeys(passwort);
        AnmeldenButton.Click();
        return new HauptseitePO(_driver);
    }

    public string HoleFehlermeldung()
    {
        var wartezeit = new WebDriverWait(
            _driver, TimeSpan.FromSeconds(5));
        wartezeit.Until(d => FehlerLabel.Displayed);
        return FehlerLabel.Text;
    }
}

// Refaktorisierter Test mit Page Object
public class LoginTestsMitPageObject : AppiumSetup
{
    [Fact]
    public void ErfolgreicheAnmeldung_MitPageObject()
    {
        var loginSeite = new LoginSeitePO(Driver!);
        var hauptseite = loginSeite.Anmelden(
            "testbenutzer", "sicheresPasswort123");
        Assert.True(hauptseite.IstSichtbar());
    }
}

Der Unterschied ist sofort sichtbar: Der Test liest sich fast wie Klartext. Und wenn sich die UI ändert, müssen Sie nur das Page Object anpassen — nicht jeden einzelnen Test.

Gerätetests: Tests direkt auf dem Gerät ausführen

Neben den klassischen Unit-Tests und Appium-UI-Tests gibt es noch eine dritte Kategorie, die oft übersehen wird: Gerätetests. Diese laufen direkt auf dem Zielgerät oder Emulator und haben Zugriff auf plattformspezifische APIs.

DeviceRunners einrichten

Das Projekt DeviceRunners von Matt Leibowitz ermöglicht es, xUnit-Tests direkt auf MAUI-Geräten auszuführen:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>
      net10.0-android;net10.0-ios;net10.0-maccatalyst
    </TargetFrameworks>
    <OutputType>Exe</OutputType>
    <UseMaui>true</UseMaui>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="DeviceRunners.XHarness"
                      Version="1.*" />
    <PackageReference Include="DeviceRunners.XHarness.Maui"
                      Version="1.*" />
    <PackageReference Include="xunit" Version="2.*" />
  </ItemGroup>
</Project>

In der MauiProgram.cs konfigurieren Sie dann den Device-Runner:

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseXHarnessTestRunner(conf =>
            {
                conf.AddTestAssembly(
                    typeof(MauiProgram).Assembly);
                conf.AddXunit();
            });

        return builder.Build();
    }
}

Gerätetests sind ideal, um plattformspezifisches Verhalten zu überprüfen — Dateisystemzugriff, Kamera-Berechtigungen, GPS-Funktionen. Also genau die Dinge, die in normalen Unit-Tests einfach nicht abgedeckt werden können.

Testabdeckung messen und Qualitätsziele definieren

Testabdeckung zeigt Ihnen, welche Teile Ihres Codes tatsächlich durch Tests abgedeckt sind. Mit coverlet lässt sich das direkt in den CI-Prozess einbinden:

# Tests mit Coverage-Bericht ausführen
dotnet test MeineApp.Tests/ \
    --collect:"XPlat Code Coverage" \
    --results-directory ./TestErgebnisse

# Bericht in lesbarem Format generieren
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator \
    -reports:./TestErgebnisse/**/coverage.cobertura.xml \
    -targetdir:./CoverageBericht \
    -reporttypes:Html

Und jetzt zur wichtigen Frage: Welche Coverage-Ziele sind realistisch?

  • ViewModels: Mindestens 80 % — hier steckt die meiste Logik
  • Services/Geschäftslogik: 70–90 %
  • Konverter und Hilfsprogramme: 90 % oder höher (die sind meist einfach zu testen)
  • Views/XAML-Code-Behind: Wird über UI-Tests abgedeckt, keine separate Coverage nötig

Ein Wort der Warnung: Jagen Sie nicht blind der 100-Prozent-Marke hinterher. Ich hab Projekte gesehen, in denen Tests geschrieben wurden, nur um die Coverage-Zahl zu drücken — ohne echten Mehrwert. Besser ist es, sich auf die kritischen Pfade zu konzentrieren.

Tests in die CI/CD-Pipeline integrieren

Automatisierte Tests entfalten ihren vollen Wert erst, wenn sie bei jedem Commit automatisch laufen. Alles andere ist, ehrlich gesagt, nur halbe Arbeit.

GitHub Actions Workflow für Unit-Tests

name: .NET MAUI Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: .NET SDK einrichten
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'

      - name: MAUI Workload installieren
        run: dotnet workload install maui

      - name: Abhängigkeiten wiederherstellen
        run: dotnet restore

      - name: Unit-Tests ausführen
        run: |
          dotnet test MeineApp.Tests/ \
            --configuration Release \
            --logger "trx;LogFileName=testergebnisse.trx" \
            --collect:"XPlat Code Coverage" \
            --results-directory ./TestErgebnisse

      - name: Testergebnisse veröffentlichen
        uses: dorny/test-reporter@v1
        if: always()
        with:
          name: Unit-Test-Ergebnisse
          path: ./TestErgebnisse/**/*.trx
          reporter: dotnet-trx

      - name: Coverage prüfen
        uses: danielpalme/ReportGenerator-GitHub-Action@5
        with:
          reports: ./TestErgebnisse/**/coverage.cobertura.xml
          targetdir: CoverageBericht
          reporttypes: HtmlInline;Cobertura

      - name: Coverage-Bericht hochladen
        uses: actions/upload-artifact@v4
        with:
          name: coverage-bericht
          path: CoverageBericht/

Android-UI-Tests in der Pipeline

Für UI-Tests auf Android benötigen Sie einen macOS- oder Linux-Runner mit Android-Emulator. Das Setup ist etwas aufwändiger, aber es lohnt sich:

  android-ui-tests:
    runs-on: macos-latest
    needs: unit-tests
    steps:
      - uses: actions/checkout@v4

      - name: .NET SDK einrichten
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'

      - name: Java 17 einrichten
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Android-Emulator starten
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          target: google_apis
          arch: x86_64
          script: |
            npm install -g appium
            appium driver install uiautomator2
            appium &
            sleep 10
            dotnet test MeineApp.UITests/ \
              --configuration Release \
              --filter "Kategorie=Android"

Bewährte Vorgehensweisen und häufige Fallstricke

Zum Schluss noch die wichtigsten Tipps und häufigsten Fehler, die mir in der Praxis immer wieder begegnen.

Bewährte Vorgehensweisen

  • Testen Sie Verhalten, nicht Implementierung: Prüfen Sie, was passiert, nicht wie es intern funktioniert. So bleiben Tests nach Refactoring stabil.
  • Verwenden Sie aussagekräftige Testnamen: Der Methodenname sollte beschreiben, was getestet wird, unter welchen Bedingungen und was das erwartete Ergebnis ist — z. B. ProdukteladenAsync_OhneVerbindung_ZeigtFehlermeldung.
  • Halten Sie Tests unabhängig: Jeder Test muss isoliert laufen können. Gemeinsame veränderliche Zustände zwischen Tests sind ein Rezept für flaky Tests.
  • Arrange-Act-Assert konsequent nutzen: Dieses Muster macht Tests lesbar und nachvollziehbar. Punkt.
  • Dependency Injection überall: Jede externe Abhängigkeit gehört hinter ein Interface. Das ist die Grundlage für effektives Mocking.
  • Grenzfälle nicht vergessen: Leere Listen, Null-Werte, Netzwerkfehler, Timeouts — genau hier verstecken sich die meisten Bugs.

Häufige Fallstricke vermeiden

  • MAUI-Typen in Unit-Tests: Vermeiden Sie direkte Referenzen auf MAUI-Controls in Unit-Tests. Testen Sie stattdessen ViewModels und Services — das ist viel einfacher und schneller.
  • Zu viele UI-Tests: UI-Tests sind langsam und fragil. Beschränken Sie sich auf die wirklich kritischen Benutzerabläufe.
  • Asynchrone Tests vergessen: In .NET MAUI sind viele Operationen asynchron. Stellen Sie sicher, dass Ihre Tests async Task zurückgeben und alle await-Aufrufe korrekt gesetzt sind. Ein fehlender await kann zu Tests führen, die immer grün sind — obwohl sie eigentlich fehlschlagen sollten.
  • AutomationId vergessen: Ohne AutomationId können UI-Tests Elemente nicht zuverlässig finden. Setzen Sie diese Eigenschaft von Anfang an.
  • Tests nur lokal ausführen: Tests, die nicht in der CI/CD-Pipeline laufen, werden früher oder später ignoriert. Automatisieren Sie die Ausführung von Tag eins.

Fazit: Schritt für Schritt zum testbaren MAUI-Projekt

Eine umfassende Teststrategie für .NET MAUI muss kein Mammutprojekt sein. Fangen Sie mit Unit-Tests für Ihre ViewModels an — die bieten den größten Return on Investment und sind am einfachsten aufzusetzen. Erweitern Sie dann schrittweise um Integrationstests für Ihre Services und fügen Sie schließlich UI-Tests für die kritischsten Benutzerabläufe hinzu.

Die vier Schlüsselelemente:

  1. Saubere Architektur: Trennen Sie Logik von UI durch MVVM und Dependency Injection.
  2. Die richtigen Werkzeuge: xUnit als Testframework, Moq für Mocking, FluentAssertions für lesbare Assertions und Appium für UI-Tests.
  3. Automatisierung: Integrieren Sie Tests in Ihre CI/CD-Pipeline mit GitHub Actions oder Azure DevOps.
  4. Pragmatismus: Streben Sie nicht nach 100 % Abdeckung, sondern nach sinnvoller Abdeckung der kritischen Pfade.

Mit diesen Grundlagen schaffen Sie ein solides Fundament für die Qualitätssicherung Ihrer .NET MAUI-Anwendungen — und können mit Zuversicht neue Features entwickeln und bestehenden Code refactoren, ohne ständig Angst vor versteckten Regressionen zu haben.

Über den Autor Editorial Team

Our team of expert writers and editors.