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 Taskzurückgeben und alleawait-Aufrufe korrekt gesetzt sind. Ein fehlenderawaitkann zu Tests führen, die immer grün sind — obwohl sie eigentlich fehlschlagen sollten. - AutomationId vergessen: Ohne
AutomationIdkö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:
- Saubere Architektur: Trennen Sie Logik von UI durch MVVM und Dependency Injection.
- Die richtigen Werkzeuge: xUnit als Testframework, Moq für Mocking, FluentAssertions für lesbare Assertions und Appium für UI-Tests.
- Automatisierung: Integrieren Sie Tests in Ihre CI/CD-Pipeline mit GitHub Actions oder Azure DevOps.
- 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.