Testowanie aplikacji .NET MAUI w 2026: xUnit, DeviceTests i Appium (kompletny przewodnik)
Przewodnik po testowaniu aplikacji .NET MAUI w 2026: testy jednostkowe ViewModel z xUnit v3, DeviceTests dla handlerów, testy UI z Appium 2 oraz gotowy pipeline GitHub Actions z pokryciem kodu i mutation testingiem.
Testowanie aplikacji .NET MAUI w 2026 roku opiera się na trzech niezależnych warstwach: testach jednostkowych ViewModel z użyciem xUnit i Moq/NSubstitute, testach na urządzeniu (DeviceTests) uruchamianych przez runner MAUI dla handlerów i kontrolek, oraz testach UI end-to-end z Appium 2 lub oficjalnego pakietu Microsoft.Maui.Controls.UITesting. Każda warstwa ma inny cel, koszt i szybkość. Dobra strategia łączy je w piramidę, gdzie 70% to testy jednostkowe, 20% integracyjne i 10% pełne testy UI na iOS/Android. W tym przewodniku pokazuję kompletną konfigurację z .NET 10, xUnit v3 i Appium 2.11, wraz z pipeline'em GitHub Actions, którego sam używam w produkcji od kilkunastu miesięcy.
Piramida testów w .NET MAUI: testy jednostkowe ViewModel (xUnit v3) jako fundament, DeviceTests dla platformozależnych handlerów, oraz Appium 2 / MAUI UI Testing dla scenariuszy end-to-end.
Wraz z .NET MAUI 10 Microsoft ustabilizował pakiet Microsoft.Maui.Controls.UITesting zbudowany na Appium 2, eliminując zależność od wycofanego Xamarin.UITest.
Testy ViewModel wymagają wzorca MVVM z CommunityToolkit.Mvvm, wstrzykiwania zależności i fake implementacji serwisów, dzięki czemu logika biznesowa jest testowana bez emulatora.
DeviceTests uruchamiane są w kontekście aplikacji MAUI, więc mają dostęp do MauiApp, DI i platformozależnych API. To idealne środowisko dla własnych handlerów.
W CI/CD (GitHub Actions) macierz windows-latest plus macos-14 wystarcza do budowy i uruchomienia testów Android emulatora oraz iOS Simulator.
Pokrycie kodu warto mierzyć przez coverlet.collector i publikować raport z ReportGenerator, celując w 70–80% dla warstwy ViewModel oraz Services.
Piramida testów w .NET MAUI
Szczerze mówiąc, w mojej praktyce z aplikacjami .NET MAUI dostarczonymi do produkcji od 2023 roku jedna zasada pozostaje niezmienna: to, co da się przetestować bez emulatora, powinno być przetestowane bez emulatora. Emulator Android startuje 40–90 sekund, a iOS Simulator 20–30 sekund. Jeżeli 500 testów wymaga urządzenia, pipeline CI trwa 45 minut zamiast 4.
Dlatego dobra piramida testów wygląda tak: na dole stoją setki testów jednostkowych ViewModel, serwisów i mapperów (czysty C#, brak MAUI). Środek to testy integracyjne z prawdziwą bazą SQLite (in-memory lub tymczasowy plik) i kilkanaście DeviceTests dla własnych handlerów. Wierzchołek to 20–30 testów UI end-to-end pokrywających kluczowe ścieżki użytkownika, na przykład logowanie, dodawanie zamówienia czy offline sync.
Testy jednostkowe uruchamiają się w milisekundach, DeviceTests w sekundach, a testy UI w dziesiątkach sekund. Jeśli odwrócisz proporcje (80% testów UI i 20% jednostkowych), otrzymasz pipeline, w którym każdy pull request kosztuje 30 minut CI i zniechęca zespół do pisania testów w ogóle.
Analogicznie jak w architekturze aplikacji MAUI, separacja warstw jest tu kluczowa. Wzorzec MVVM w .NET MAUI z CommunityToolkit istnieje właśnie po to, by logika biznesowa była testowalna bez UI. Podobne podejście stosujemy przy optymalizacji: dobre profilowanie wydajności .NET MAUI wymaga metryk, a metryki wymagają powtarzalnych testów.
Konfiguracja projektu testowego
Zaczynamy od utworzenia dwóch osobnych projektów: MyApp.UnitTests jako standardowa biblioteka .NET 10 oraz MyApp.DeviceTests jako projekt MAUI z runnerem xUnit. Struktura solucji powinna wyglądać następująco: src/MyApp (aplikacja MAUI), src/MyApp.Core (logika biznesowa jako biblioteka .NET), tests/MyApp.UnitTests, tests/MyApp.DeviceTests i tests/MyApp.UITests.
Wydzielenie MyApp.Core to jeden z najważniejszych kroków. Pozwala ono testować ViewModel i serwisy bez ciężaru workloadu MAUI, przyspiesza dotnet restore nawet dziesięciokrotnie i eliminuje konieczność instalacji Android SDK na maszynach deweloperów pracujących wyłącznie nad logiką biznesową. W moim ostatnim projekcie CI dla samej warstwy Core zszedł z 6 minut do 40 sekund po tej jednej zmianie.
Plik MyApp.UnitTests.csproj powinien mieć następującą zawartość:
Kluczowa uwaga: dla testów ViewModel nigdy nie referuj projektu MAUI. Referencja do MyApp.Core wystarczy, a jeżeli ViewModel potrzebuje typów MAUI (np. INavigation, IDispatcher), zdefiniuj je jako abstrakcje w Core i wstrzykuj implementacje. To pozwala projektowi testowemu uruchomić się jako czysty net10.0, bez workloadu net10.0-android czy net10.0-ios.
Jak testować ViewModel w .NET MAUI?
Testy ViewModel są sercem strategii testowej każdej aplikacji MVVM. Rozważmy prosty LoginViewModel z CommunityToolkit.Mvvm, który waliduje dane logowania, wywołuje serwis autoryzacji i nawiguje do ekranu głównego. Aby test był w pełni niezależny od MAUI, wszystkie zależności (serwis autoryzacji, nawigacja, dispatcher) są wstrzykiwane przez konstruktor.
Poniżej pokazuję kod produkcyjny, który testujemy, a następnie zestaw testów pokrywających trzy najważniejsze ścieżki: walidację, sukces logowania oraz błąd zwrócony przez serwis.
Zauważ, że w tym teście nigdzie nie pojawia się MauiApp, MainThread ani Dispatcher. To dlatego, że logika ViewModel została zaprojektowana jako czysty C#. Atrybut [RelayCommand] z CommunityToolkit generuje command asynchroniczny, który nie wymaga wątku UI. Jeżeli w kodzie masz MainThread.BeginInvokeOnMainThread, opakuj go w abstrakcję IUiDispatcher i wstrzykuj. W przeciwnym razie testy będą wymagały specyficznego kontekstu synchronizacji.
DeviceTests: testowanie handlerów i platformozależnego kodu
DeviceTests to koncepcja wprowadzona przez zespół .NET MAUI, w której runner xUnit uruchamia się wewnątrz aplikacji MAUI na docelowej platformie (Android, iOS, macOS, Windows). Dzięki temu masz dostęp do prawdziwego MauiApp, kontenera DI oraz platformowych API. To idealne środowisko dla testów własnych handlerów MAUI (dokumentacja Microsoft), custom renderowania czy weryfikacji, że twoja kontrolka faktycznie tworzy natywny UIView lub android.view.View.
Aby dodać DeviceTests do projektu, użyj szablonu maui-unittests z SDK MAUI (dostępny od .NET 8, ustabilizowany w .NET 10):
dotnet new install Microsoft.Maui.Templates.net10
dotnet new maui-unittests -n MyApp.DeviceTests -o tests/MyApp.DeviceTests
Wygenerowany projekt zawiera MauiProgram.cs zbliżony do aplikacji głównej, ale rejestruje testowy runner. Przykładowy test handlera dla własnej kontrolki BadgeView, która renderuje okrągły znacznik z liczbą:
// MyApp.DeviceTests/Handlers/BadgeViewHandlerTests.cs
public class BadgeViewHandlerTests
{
[Fact]
public async Task Handler_creates_native_view_with_correct_text()
{
var badge = new BadgeView { Count = 5 };
await InvokeOnMainThreadAsync(() =>
{
var handler = badge.ToHandler(MauiContext);
#if ANDROID
var native = handler.PlatformView as global::Android.Widget.TextView;
Assert.NotNull(native);
Assert.Equal("5", native!.Text);
#elif IOS
var native = handler.PlatformView as UIKit.UILabel;
Assert.NotNull(native);
Assert.Equal("5", native!.Text);
#endif
});
}
[Fact]
public async Task Setting_count_above_99_shows_plus_indicator()
{
var badge = new BadgeView { Count = 128 };
await InvokeOnMainThreadAsync(() =>
{
var handler = badge.ToHandler(MauiContext);
var text = badge.DisplayText;
Assert.Equal("99+", text);
});
}
}
Testy UI z Appium 2 i .NET MAUI UI Testing
Do końca 2024 roku standardem był Xamarin.UITest, który został formalnie wycofany razem z Xamarin.Forms 1 maja 2024 roku. Wraz z .NET MAUI 10 Microsoft dostarcza oficjalny pakiet Microsoft.Maui.Controls.UITesting zbudowany na Appium 2, który upraszcza pisanie testów end-to-end w C#. Alternatywnie można używać czystego Appium 2 z Appium.WebDriver. Daje to większą kontrolę, ale wymaga ręcznej konfiguracji sesji. Szczegóły dostępnych sterowników znajdziesz w oficjalnej dokumentacji Appium 2.
Instalacja Appium 2 i sterowników dla Androida oraz iOS:
Przykładowy test logowania w Appium 2, oparty na WebDriver. Aplikacja MAUI musi być zbudowana w konfiguracji Release z odblokowaną automatyzacją. Dla Androida wystarczy standardowy APK, natomiast dla iOS potrzebny jest Debug lub specjalny build z zainstalowanym Xcode Automation.
// MyApp.UITests/Scenarios/LoginScenarioTests.cs
public class LoginScenarioTests : IAsyncLifetime
{
private AndroidDriver _driver = null!;
public async Task InitializeAsync()
{
var options = new AppiumOptions
{
AutomationName = "UiAutomator2",
PlatformName = "Android",
DeviceName = "Pixel_8_API_35",
App = Path.GetFullPath("../../../artifacts/com.myapp.apk"),
NewCommandTimeout = TimeSpan.FromMinutes(2),
};
_driver = new AndroidDriver(new Uri("http://127.0.0.1:4723"), options);
await Task.CompletedTask;
}
public Task DisposeAsync()
{
_driver?.Quit();
return Task.CompletedTask;
}
[Fact]
public void User_can_log_in_with_valid_credentials()
{
// AutomationId="EmailEntry" w XAML -> accessibility id w Appium
_driver.FindElement(MobileBy.AccessibilityId("EmailEntry"))
.SendKeys("[email protected]");
_driver.FindElement(MobileBy.AccessibilityId("PasswordEntry"))
.SendKeys("Test1234!");
_driver.FindElement(MobileBy.AccessibilityId("LoginButton"))
.Click();
var welcome = new WebDriverWait(_driver, TimeSpan.FromSeconds(10))
.Until(d => d.FindElement(MobileBy.AccessibilityId("WelcomeLabel")));
welcome.Text.Should().StartWith("Witaj");
}
}
Kluczowe: ustaw AutomationId na każdym elemencie XAML, który chcesz testować (<Entry AutomationId="EmailEntry" />). W Appium jest to następnie mapowane na MobileBy.AccessibilityId. Bez tego zostajesz z lokalizacją elementów po XPath, który jest kruchy i wolny. Dodatkowo AutomationId poprawia dostępność aplikacji dla czytników ekranu, więc korzyść jest podwójna.
Testy integracyjne z SQLite in-memory
Jeżeli twoja aplikacja przechowuje dane lokalnie (a większość aplikacji MAUI to robi), warto pokryć testami integracyjnymi warstwę repozytorium wraz z EF Core i SQLite. Zamiast mockować DbContext, co jest anty-wzorcem, bo nie testujesz prawdziwych zapytań, użyj SQLite w trybie in-memory. Uruchamia się w milisekundach i daje realne zachowanie bazy, wliczając transakcje i migracje. Ta strategia świetnie komponuje się z architekturą offline-first w .NET MAUI z SQLite i EF Core, gdzie warstwa danych jest kluczowym miejscem powstawania błędów.
// MyApp.UnitTests/Data/OrderRepositoryTests.cs
public class OrderRepositoryTests : IAsyncLifetime
{
private SqliteConnection _connection = null!;
private AppDbContext _context = null!;
public async Task InitializeAsync()
{
// Kluczowe: trzymamy otwarte polaczenie, inaczej baza in-memory znika
_connection = new SqliteConnection("DataSource=:memory:");
await _connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
_context = new AppDbContext(options);
await _context.Database.EnsureCreatedAsync();
}
public async Task DisposeAsync()
{
await _context.DisposeAsync();
await _connection.DisposeAsync();
}
[Fact]
public async Task Adding_order_persists_it_with_generated_id()
{
var repo = new OrderRepository(_context);
var order = new Order { CustomerName = "Anna Kowalska", Total = 129.50m };
var saved = await repo.AddAsync(order);
var loaded = await repo.GetByIdAsync(saved.Id);
loaded.Should().NotBeNull();
loaded!.CustomerName.Should().Be("Anna Kowalska");
}
}
CI/CD w GitHub Actions dla testów MAUI
Uruchamianie pełnej piramidy testów w CI wymaga trzech jobów: unit (najszybszy, na każdym push), device (na PR do main) i ui (nocny lub przed release). Poniższy workflow uruchamia testy jednostkowe na Ubuntu (najtaniej, bez workloadu MAUI), a DeviceTests i UITests na macos-14 z emulatorem Androida.
Pokrycie kodu w .NET zbieramy przez coverlet.collector (zawarty w szablonie xUnit) i przetwarzamy raport za pomocą ReportGenerator. Wynik można wypchnąć do Codecov lub SonarCloud, aby monitorować trendy. Realistyczne cele: 75–85% dla warstwy Core (ViewModel, Services, Mappers) i 40–60% dla projektu MAUI (dużo kodu XAML i platformozależnego, których nie da się sensownie mierzyć). Nie ścigaj się do 100%. Testy pisane dla samego pokrycia szybko stają się kruchym balastem.
Metryki, które faktycznie pomagają: liczba testów per warstwa (Core, Services, ViewModels), czas wykonania (jeśli suita jednostkowa przekracza 30 sekund, coś jest nie tak z zależnościami I/O w testach), oraz mutation score mierzony przez Stryker.NET. Mutation testing pokazuje, ile mutacji kodu twoje testy faktycznie wykrywają. To realny sprawdzian jakości pokrycia, znacznie ostrzejszy od zwykłego procenta linii.
Najczęstsze błędy w testach .NET MAUI
W ciągu ostatnich dwóch lat konsultacji przy migracji z Xamarin.UITest do .NET MAUI Testing widziałem te same błędy w kilkunastu zespołach. Oto lista, którą warto mieć przed oczami przed rozpoczęciem pisania pierwszej suity testów:
Testowanie ViewModel z MainThread w środku. Zawsze abstrahuj do IUiDispatcher. Inaczej testy działają lokalnie, ale wywalają się w CI.
Brak AutomationId na elementach XAML. Bez tego testy Appium używają XPath, który pęka przy każdej zmianie hierarchii Grid.
Referowanie projektu MAUI z projektu testów jednostkowych. Zwiększa czas restore z 5 sekund do 45 i wymusza instalację workloadu MAUI na CI dla testów, które go nie potrzebują.
Statyczny DateTime.Now. Wstrzykuj TimeProvider (dostępny od .NET 8), inaczej testy walidacji dat będą flaky raz na jakiś czas.
Ignorowanie ConfigureAwait(false) w bibliotece Core. W testach xUnit v3 z paralelizacją może prowadzić do deadlocków, jeśli SynchronizationContext jest ustawiony.
Uruchamianie testów UI na każdym push. To pęka pipeline czasowo i finansowo. Trzymaj UITests na PR do main lub w cyklu nocnym.
Najczęściej zadawane pytania
Czy .NET MAUI ma wbudowaną obsługę testów UI?
Od .NET MAUI 10 tak. Pakiet Microsoft.Maui.Controls.UITesting zbudowany na Appium 2 jest oficjalnym sposobem pisania testów UI. Wcześniej używano Xamarin.UITest, który został wycofany 1 maja 2024 roku wraz z Xamarin.Forms. Alternatywą pozostaje czysty Appium 2 z pakietem Appium.WebDriver, który daje większą kontrolę nad sesją i konfiguracją.
Jaki framework testowy jest zalecany dla .NET MAUI?
Dla testów jednostkowych najlepszym wyborem jest xUnit v3 (świetna integracja z .NET 10 i mocna paralelizacja). Dla mockowania używaj NSubstitute lub Moq. Dla asercji sprawdza się FluentAssertions albo wbudowane Assert. Dla testów UI Appium 2 z Microsoft.Maui.Controls.UITesting. Dla mutation testingu Stryker.NET.
Jak testować kod używający MainThread lub Dispatcher w .NET MAUI?
Nigdy nie wywołuj MainThread bezpośrednio w ViewModel. Zdefiniuj abstrakcję IUiDispatcher z metodą Task InvokeOnUiThreadAsync(Func<Task> action) i wstrzykuj implementację przez DI. W testach użyj implementacji fake, która po prostu wywołuje delegata synchronicznie. To eliminuje potrzebę uruchamiania testów jednostkowych w kontekście UI.
Czy można uruchamiać testy .NET MAUI na Linux?
Tak, ale tylko testy jednostkowe warstwy Core (czyste biblioteki .NET). DeviceTests dla Androida również działają na Linuxie z emulatorem, ale wymagają zainstalowania Android SDK i włączenia KVM. Testy iOS wymagają wyłącznie macOS z Xcode. To twarde ograniczenie Apple, którego nie da się obejść w CI.
Ile testów UI powinno mieć aplikacja .NET MAUI?
W praktyce 15–40 testów UI wystarczy nawet dla dużej aplikacji, jeśli pokrywają one najważniejsze ścieżki biznesowe (logowanie, główny flow zakupowy, obsługa błędów sieci, synchronizacja offline). Reszta powinna być pokryta testami jednostkowymi i integracyjnymi. Powyżej 100 testów UI koszt utrzymania i czas CI zaczyna przewyższać wartość tych testów.