Δοκιμές σε .NET MAUI: Οδηγός για Unit Tests, UI Tests με Appium & CI/CD (2026)
Πλήρης οδηγός για δοκιμές σε .NET MAUI: πυραμίδα unit/integration/UI tests, xUnit με NSubstitute, Appium 2 setup ανά πλατφόρμα, XHarness για on-device tests, και ρεαλιστικό CI/CD workflow με GitHub Actions που τρέχει σε λιγότερο από δύο λεπτά.
Οι δοκιμές σε .NET MAUI εφαρμογή στηρίζονται σε μια πυραμίδα τριών επιπέδων: unit tests που τρέχουν σε milliseconds σε καθαρό .NET (xUnit ή NUnit) και επικυρώνουν τα ViewModels και τη business logic, integration tests που ελέγχουν services και επίπεδα δεδομένων, και UI tests με Appium 2 που τρέχουν πραγματικά σε Android, iOS και Windows μέσω WebDriver. Στην πράξη, όσα ομαδικά έχω δει να παραδίδουν σταθερές MAUI εφαρμογές, τρέχουν τα unit και integration tests σε κάθε commit και τα UI tests σε ένα nightly ή release pipeline, όχι σε κάθε PR, γιατί το κόστος χρόνου απλά δεν το δικαιολογεί.
Το MVVM δεν είναι απλώς αρχιτεκτονικό pattern. Είναι η προϋπόθεση για να έχουμε testable κώδικα σε .NET MAUI, γιατί επιτρέπει στα ViewModels να τρέχουν χωρίς MAUI runtime.
Χρησιμοποιούμε xUnit σε νέα projects (η επίσημη κοινότητα .NET MAUI έχει μεταναστεύσει προς τα εκεί) και NSubstitute ή Moq για mocking των services.
Το Appium 2 (με τους αντίστοιχους drivers ανά πλατφόρμα) είναι η προτεινόμενη λύση της Microsoft για UI automation και υποστηρίζει επίσημα .NET MAUI 8, 9 και 10.
Κάθε στοιχείο του UI που θέλουμε να ελέγξουμε πρέπει να έχει μοναδικό AutomationId. Αν το ξεχάσουμε, οι UI tests σπάνε ή γίνονται εξαιρετικά fragile.
Ένα GitHub Actions workflow με actions/setup-dotnet@v4 και dotnet workload install maui τρέχει unit tests σε λιγότερο από δύο λεπτά· τα UI tests χρειάζονται εμφανώς περισσότερους πόρους (emulators, WinAppDriver, μερικές φορές BrowserStack).
Στη γενική πρακτική, στοχεύουμε 70-80% code coverage στα ViewModels και services, αλλά όχι στον UI-only κώδικα (XAML, code-behind, handlers).
Η πυραμίδα δοκιμών σε μια MAUI εφαρμογή
Όταν παίρνω μια MAUI codebase για review, το πρώτο πράγμα που κοιτάω είναι η αναλογία των tests. Η κλασική πυραμίδα του Mike Cohn ισχύει και εδώ, με μικρές προσαρμογές για το mobile: βάση είναι πολλά unit tests που ελέγχουν business logic και ViewModels, μέση είναι integration tests που αγγίζουν πραγματικές SQLite βάσεις ή HTTP APIs, και κορυφή είναι λίγα, στοχευμένα UI tests που περνούν από πραγματικά user flows.
Στην ομάδα μου, αν βρω μια codebase που έχει μόνο UI tests και καθόλου unit tests, ξέρω ότι θα χρειαστούμε τουλάχιστον ένα μήνα να αναδιαρθρώσουμε τον κώδικα ώστε να γίνει testable, γιατί σχεδόν πάντα η business logic είναι θαμμένη μέσα σε code-behind και handlers. Αντίθετα, μια εφαρμογή με 200 unit tests και 10 UI tests συχνά έχει καλύτερη ποιότητα από μια με 100 UI tests, γιατί τα unit tests πιάνουν τα edge cases πριν φτάσουμε καν σε emulator.
Ένας πρακτικός κανόνας που εφαρμόζουμε: κάθε public μέθοδος σε ViewModel ή service πρέπει να έχει τουλάχιστον ένα happy-path test και ένα edge-case test. Αν μια μέθοδος δεν αξίζει τεστ, πιθανότατα δεν χρειάζεται καν να είναι public.
Πώς κάνουμε unit test σε ViewModels με xUnit
Η καρδιά του unit testing σε .NET MAUI είναι τα ViewModels που κληρονομούν από ObservableObject του CommunityToolkit.Mvvm. Επειδή αυτά τα ViewModels δεν αγγίζουν καθόλου το MAUI runtime, μπορούν να τρέξουν σε απλό net9.0 test project, χωρίς emulator και χωρίς handlers.
Το πρώτο πράγμα που στήνουμε είναι το test project. Ειλικρινά, δεν χρησιμοποιώ πλέον το wizard του Visual Studio γι' αυτό, γιατί συχνά δημιουργεί project με λάθος target framework. Το κάνω από CLI:
Εδώ κάτι σημαντικό: το MyApp.Core.csproj είναι ένα ξεχωριστό class library που περιέχει τα ViewModels, τα models και τις interfaces των services. Το κύριο MAUI project (MyApp.csproj) απλά κάνει reference το Core. Αυτή η διάσπαση είναι μη διαπραγματεύσιμη. Αν έχουμε τα ViewModels μέσα στο MAUI project, το test project δεν μπορεί να τα φορτώσει χωρίς να τραβήξει όλα τα platform targets (net9.0-android, net9.0-ios κ.λπ.) και τότε ξεκινούν τα προβλήματα.
Ένα τυπικό ViewModel που ελέγχει καταστάσεις login:
public class LoginViewModelTests
{
private readonly IAuthenticationService _auth = Substitute.For<IAuthenticationService>();
private readonly INavigationService _nav = Substitute.For<INavigationService>();
[Fact]
public async Task LoginAsync_WithEmptyEmail_SetsErrorMessage()
{
var vm = new LoginViewModel(_auth, _nav) { Email = "", Password = "x" };
await vm.LoginCommand.ExecuteAsync(null);
vm.ErrorMessage.Should().Be("Συμπληρώστε email και κωδικό.");
await _auth.DidNotReceive().SignInAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
public async Task LoginAsync_OnSuccess_NavigatesHome()
{
_auth.SignInAsync("[email protected]", "pw")
.Returns(new AuthResult(true, null));
var vm = new LoginViewModel(_auth, _nav) { Email = "[email protected]", Password = "pw" };
await vm.LoginCommand.ExecuteAsync(null);
await _nav.Received(1).GoToAsync("//home");
vm.ErrorMessage.Should().BeNull();
}
}
Δύο πράγματα να προσέξουμε εδώ. Πρώτον, δεν καλούμε LoginAsync() κατευθείαν, καλούμε το LoginCommand, γιατί έτσι ενεργοποιείται και το CanExecute logic του RelayCommand. Δεύτερον, χρησιμοποιούμε Substitute.For από το NSubstitute αντί για Moq. Στην ομάδα μου έχουμε καταλήξει στο NSubstitute γιατί το syntax είναι πιο καθαρό, αλλά και το Moq δουλεύει μια χαρά.
Mocking των services με NSubstitute
Η πλήρης δύναμη του unit testing φαίνεται όταν αρχίζουμε να δουλεύουμε με πιο σύνθετα scenarios: retry logic, network errors, race conditions. Το NSubstitute μας επιτρέπει να στήνουμε ακριβώς αυτά τα σενάρια χωρίς να χρειαζόμαστε πραγματικό HTTP server. Έχω πέσει πάνω σε αυτόν ακριβώς τον έλεγχο σε τουλάχιστον τρία projects, οπότε αξίζει να τον δείτε:
[Fact]
public async Task SignInAsync_On401_RaisesInvalidCredentialsError()
{
var http = Substitute.For<IHttpClient>();
http.PostAsync<LoginResponse>(Arg.Any<string>(), Arg.Any<object>())
.Returns<LoginResponse>(_ => throw new HttpRequestException(
"401", null, System.Net.HttpStatusCode.Unauthorized));
var svc = new AuthenticationService(http);
var result = await svc.SignInAsync("[email protected]", "bad");
result.Success.Should().BeFalse();
result.Error.Should().Be("Λανθασμένα στοιχεία σύνδεσης.");
}
Ένα λάθος που έχω δει πολλές φορές: developers δημιουργούν έναν HttpClient κατευθείαν μέσα στον service και μετά «δεν μπορούν να το τεστάρουν». Η λύση είναι πάντα να ορίζουμε ένα thin abstraction (IHttpClient ή IApiClient) και να το κάνουμε inject. Το ίδιο ισχύει για Preferences, SecureStorage, Geolocation και οποιοδήποτε Microsoft.Maui.Essentials API· τα τυλίγουμε σε δικά μας interfaces.
UI tests με Appium 2 σε .NET MAUI
Το Appium είναι το επίσημα υποστηριζόμενο εργαλείο για end-to-end UI testing σε .NET MAUI εφαρμογές, όπως τεκμηριώνει η επίσημη τεκμηρίωση για UI testing του Microsoft Learn. Λειτουργεί ως server που δέχεται WebDriver εντολές και τις μεταφράζει σε native χειρισμούς μέσω πλατφορμικών drivers: UiAutomator2 για Android, XCUITest για iOS/Mac Catalyst, WinAppDriver για Windows.
Το setup σε Windows machine (developer laptop ή CI runner) απαιτεί συγκεκριμένες εκδόσεις:
# Node.js (LTS) και Appium 2
npm install -g appium@next
appium --version # πρέπει να είναι 2.x
# Platform drivers
appium driver install uiautomator2 # Android
appium driver install xcuitest # iOS (μόνο σε macOS)
appium driver install --source=npm appium-windows-driver
# WinAppDriver 1.2.1 (αυστηρά αυτή την έκδοση)
# https://github.com/microsoft/WinAppDriver/releases/tag/v1.2.1
Το test project είναι επίσης xUnit ή NUnit, αλλά με ξεχωριστή δομή. Το επίσημο BasicAppiumSample repository του Gerald Versluis δείχνει την προτεινόμενη δομή: ένα UITests.Shared project που περιέχει τους tests, και ξεχωριστά projects ανά πλατφόρμα που παρέχουν το AppiumSetup.
public sealed class AppiumSetup : IDisposable
{
private AndroidDriver? _driver;
public AppiumDriver Driver => _driver
?? throw new InvalidOperationException("Driver not initialized");
public AppiumSetup()
{
var options = new AppiumOptions
{
AutomationName = "UIAutomator2",
PlatformName = "Android",
DeviceName = "Android Emulator",
App = Path.Combine(AppContext.BaseDirectory, "com.mycompany.myapp-Signed.apk")
};
_driver = new AndroidDriver(new Uri("http://127.0.0.1:4723"), options);
}
public void Dispose() => _driver?.Quit();
}
Και ένα απλό UI test:
public class LoginPageUITests : IClassFixture<AppiumSetup>
{
private readonly AppiumDriver _driver;
public LoginPageUITests(AppiumSetup setup) => _driver = setup.Driver;
[Fact]
public void Login_WithValidCredentials_NavigatesToHome()
{
var email = _driver.FindElement(MobileBy.Id("EmailEntry"));
var password = _driver.FindElement(MobileBy.Id("PasswordEntry"));
var button = _driver.FindElement(MobileBy.Id("LoginButton"));
email.SendKeys("[email protected]");
password.SendKeys("SecretPw!23");
button.Click();
var welcome = new WebDriverWait(_driver, TimeSpan.FromSeconds(10))
.Until(d => d.FindElement(MobileBy.Id("WelcomeLabel")));
Assert.Equal("Καλωσορίσατε", welcome.Text);
}
}
AutomationId, selectors και stable UI tests
Ο νούμερο ένα λόγος που τα UI tests σπάνε στην πραγματικότητα δεν είναι bug στην εφαρμογή, είναι fragile selectors. Αν βασίζουμε την εύρεση στοιχείου σε XPath τύπου //android.widget.EditText[2], κάθε φορά που ο designer προσθέτει ένα padding, το test σπάει.
Η λύση είναι απλή: κάθε interactive element στο XAML παίρνει σταθερό AutomationId. Αυτό μεταφράζεται εσωτερικά σε accessibility identifier και είναι το ίδιο πράγμα που χρησιμοποιεί το screen reader για ονομασία στοιχείων, άρα κάνουμε δύο δουλειές μαζί.
Στη BaseTest class συνήθως έχουμε ένα helper για να «γεφυρώσουμε» τη διαφορά ανάμεσα σε MobileBy.Id (Android/iOS) και MobileBy.AccessibilityId (Windows). Στο τεκμηριωμένο pattern της Microsoft:
Το XHarness είναι ένα .NET CLI tool της Microsoft που τρέχει test bundles σε πραγματικές συσκευές και emulators, με visual ή headless runner. Είναι κυρίως χρήσιμο όταν θέλουμε να τρέξουμε τα κλασικά μας unit tests μέσα στην εφαρμογή, ώστε να ελέγξουμε πράγματα που εξαρτώνται από πλατφορμικές διαφορές (π.χ. serialization σε iOS AOT, JIT σε Android).
dotnet tool install -g Microsoft.DotNet.XHarness.CLI --version "9.*-*"
# Τρέχει ένα Android APK που περιέχει xunit device runner
xharness android test \
--app=./bin/Release/net9.0-android/com.myapp.Signed.apk \
--package-name=com.myapp \
--output-directory=./TestResults
Στην πράξη, δεν χρησιμοποιώ XHarness σε κάθε project. Είναι μια επιπλέον στρώση πολυπλοκότητας και για το 90% των εφαρμογών, αρκούν τα xUnit tests μαζί με το Appium. Το XHarness αξίζει όταν έχουμε custom native handlers ή πράγματα όπως Native AOT, όπου η συμπεριφορά μπορεί να αλλάξει σε production build. Αν θέλετε να δείτε πώς παίζουν Native AOT και compiled bindings στην πλευρά της απόδοσης, ο οδηγός βελτιστοποίησης απόδοσης .NET MAUI είναι ένα καλό προαπαιτούμενο διάβασμα.
CI/CD με GitHub Actions για .NET MAUI
Το ένα κομμάτι της διαδικασίας που κάνει τη διαφορά σε production ομάδα είναι το CI. Ας δούμε ένα ρεαλιστικό workflow που τρέχει unit tests σε κάθε PR και UI tests μόνο σε merges στο main.
Το κόστος σε GitHub-hosted runners είναι πραγματικό: ένα Android UI test job σε macos-14 τρέχει γύρω στα 12-18 λεπτά και χρεώνεται 10× σε σχέση με Linux. Στην πράξη, οι περισσότερες ομάδες που ξέρω χρησιμοποιούν self-hosted runners για UI tests (ένα Mac mini στο γραφείο ή στο cloud είναι αρκετό για μικρή ομάδα) και GitHub-hosted μόνο για unit tests.
Πραγματικές συσκευές: BrowserStack και LambdaTest
Οι emulators είναι εξαιρετικοί για smoke tests, αλλά ορισμένα bugs εμφανίζονται μόνο σε πραγματικές συσκευές: fingerprint sensors, camera modules, χαμηλή μνήμη σε παλιά Android, δικτυακές συνθήκες που δεν αναπαράγονται σε emulator. Εδώ μπαίνουν οι cloud device farms.
Χαρακτηριστικό
BrowserStack App Automate
LambdaTest Real Devices
Sauce Labs
Native Appium 2 support
Ναι (πλήρες)
Ναι (πλήρες)
Ναι (πλήρες)
Πλήθος πραγματικών συσκευών
3000+
3000+
2000+
Τιμή (single parallel, μηνιαία)
$199
$99
$149
iOS latest OS support
1-2 εβδομάδες μετά την κυκλοφορία
1-2 εβδομάδες
1-3 εβδομάδες
Video/logs επί test
Ναι
Ναι
Ναι
Επίσημη ενσωμάτωση Microsoft για MAUI
Ναι (documented sample)
Όχι
Όχι
Η Microsoft διατηρεί επίσημο .NET MAUI sample για UI testing σε BrowserStack που δείχνει πώς μετατρέπουμε το local Appium project σε cloud-ready με ελάχιστες αλλαγές (ουσιαστικά αλλάζουμε το endpoint και προσθέτουμε bstack:options capabilities).
Code coverage και ρεαλιστικοί στόχοι
Οι μετρικές είναι χρήσιμες όσο δεν γίνονται σκοπός. Στα MAUI projects που έχω δουλέψει, στοχεύουμε:
ViewModels & services: 70-85% line coverage. Ό,τι πέφτει κάτω, σημαίνει ότι έχουμε happy-path-only tests χωρίς να καλύπτουμε error handling.
Models/DTOs: 0-20% (δεν έχει νόημα να τεστάρουμε auto-properties).
Handlers & custom controls: καλύπτονται μέσω UI tests, όχι unit tests. Coverage tools δεν τα μετράνε σωστά έτσι κι αλλιώς.
XAML code-behind: στόχος είναι να είναι όσο πιο άδειο γίνεται. Αν έχει logic, τη μεταφέρουμε στο ViewModel.
Χρησιμοποιούμε coverlet.collector (έρχεται by default με xUnit templates) και τα αποτελέσματα ανεβαίνουν σε Codecov ή SonarCloud για trending. Το να ξεκινήσετε από 30% coverage και να ανεβαίνετε σταδιακά είναι πιο υγιές από το να επιβάλετε 90% gate σε ήδη υπάρχουσα codebase, γιατί θα φτιάξετε τυπικά, μη ουσιαστικά tests μόνο για να ικανοποιήσετε το gate.
Συχνές ερωτήσεις
Ποιο test framework είναι καλύτερο για .NET MAUI: xUnit ή NUnit;
Και τα δύο δουλεύουν εξίσου καλά, αλλά η ίδια η ομάδα .NET MAUI της Microsoft χρησιμοποιεί xUnit σε όλα τα νέα projects και μεταφέρει τα legacy NUnit tests σε αυτό. Για συνέπεια με το ecosystem και καλύτερη υποστήριξη από Visual Studio 2022 και Rider, ξεκινήστε με xUnit εκτός αν έχετε συγκεκριμένο λόγο για το αντίθετο.
Μπορώ να κάνω unit test XAML pages απευθείας;
Όχι με τον τρόπο που υπονοεί η ερώτηση. Οι XAML pages χρειάζονται MAUI runtime για να στηθούν, οπότε δεν φορτώνουν σε καθαρό xUnit project. Αντί να τεστάρετε τη σελίδα, τεστάρετε το ViewModel της (όλη η business logic πρέπει να είναι εκεί) και χρησιμοποιήστε Appium ή XHarness device tests αν όντως πρέπει να ελέγξετε συμπεριφορά της σελίδας.
Πόσο χρόνο παίρνει να στηθεί Appium 2 σε ένα νέο MAUI project;
Στην πράξη, ένας developer που ξέρει Appium χρειάζεται περίπου 4-6 ώρες για να στήσει το πρώτο σκελετό (project structure, AppiumSetup ανά πλατφόρμα, ένα smoke test που ανοίγει την εφαρμογή). Το πρώτο πραγματικό test suite με 10-15 tests συνήθως θέλει 2-3 ημέρες δουλειάς, κυρίως λόγω AutomationId αλλαγών στο XAML.
Πρέπει να τρέχω UI tests σε κάθε pull request;
Όχι, το κόστος CI χρόνου και η αστάθεια που εισάγει το κάνει counterproductive. Ο κανόνας που εφαρμόζουμε: unit και integration tests σε κάθε PR, UI tests σε merges στο main ή nightly, και ένα πλήρες UI regression suite πριν από κάθε release. Έτσι κρατάτε το developer feedback loop γρήγορο και οι UI regressions πιάνονται πριν φύγει η έκδοση.
Πώς τεστάρω κώδικα που καλεί Microsoft.Maui.Essentials APIs;
Με abstraction. Τα Essentials APIs (Preferences, SecureStorage, Geolocation κ.λπ.) είναι στατικά και δεν κάνουν mock κατευθείαν. Δημιουργήστε ένα interface (π.χ. IStorage) που τυλίγει την κλήση, κάντε την υλοποίηση thin wrapper γύρω από το Essentials API, και τεστάρετε ό,τι εξαρτάται από αυτό μέσω του interface. Είναι έξτρα boilerplate αλλά είναι η μόνη ρεαλιστική διαδρομή.
Cross-platform engineering lead who's shipped apps to millions on both Play Store and App Store. Believes shared codebases shouldn't mean shared mediocrity.