Hai să fim sinceri: testarea aplicațiilor mobile a fost mult timp un punct sensibil pentru ecosistemul .NET. Eu am pornit prin 2019 cu Xamarin.UITest pe un proiect bancar și încă mai am flashback-uri cu testele care pic random pe pipeline. Vestea bună e că, în 2026, lucrurile arată complet diferit.
Cu .NET 9 deja stabil (și .NET 10 în pregătire), Microsoft a investit serios în uneltele de testare pentru .NET MAUI. Avem suport oficial pentru UI tests prin Microsoft.Maui.TestUtils.DeviceTests, integrare nativă cu Appium 2.x și un set matur de pattern-uri pentru testarea ViewModels-urilor.
Ghidul ăsta îți arată o strategie completă de testare pentru o aplicație .NET MAUI reală — de la piramida testelor până la rularea automată în GitHub Actions. Toate exemplele de cod sunt validate pe .NET 9.0.100 și sunt direct copiabile (le folosesc chiar eu, săptămânal).
Piramida testelor pentru aplicații .NET MAUI
Înainte să scriem un singur test, e important să înțelegem distribuția. Pentru o aplicație MAUI tipică (să zicem 50.000+ linii de cod), distribuția recomandată în 2026 arată cam așa:
- 70% Unit tests — ViewModels, servicii, mapere, validatori. Rulează în milisecunde, fără emulator.
- 20% Integration tests — interacțiuni cu SQLite, HTTP clients, secure storage, dependency injection.
- 10% UI / End-to-end tests — fluxuri critice (login, plată, navigare principală) pe device real sau emulator.
Greșeala clasică? Să sărim direct la UI tests „pentru că par mai aproape de utilizator". Costul lor de mentenanță e însă disproporționat. O aplicație cu 200 de UI tests pe iOS și Android te costă liniștit 20-30 de minute la fiecare PR. Nu e scalabil — și am văzut echipe întregi sufocate de exact problema asta.
Structura proiectului: separarea logicii de UI
Un .NET MAUI testabil începe cu o arhitectură care nu cuplează logica de business de Page-uri. Recomandarea mea în 2026 (și nu sunt singurul care zice asta) e structura următoare:
MyApp.sln
├── src/
│ ├── MyApp.Core/ # POCOs, interfaces, business logic (netstandard2.1)
│ ├── MyApp.Infrastructure/ # SQLite, HTTP, secure storage (net9.0)
│ ├── MyApp.ViewModels/ # ViewModels cu CommunityToolkit.Mvvm (net9.0)
│ └── MyApp/ # Proiectul .NET MAUI propriu-zis
└── tests/
├── MyApp.Core.Tests/ # xUnit, fără dependențe de MAUI
├── MyApp.ViewModels.Tests/ # xUnit + NSubstitute
├── MyApp.Integration.Tests/ # xUnit + Testcontainers
└── MyApp.UITests/ # Appium 2.x + .NET MAUI TestUtils
Cheia: MyApp.ViewModels nu referențiază Microsoft.Maui.Controls deloc. Asta îți permite să testezi 100% din logica de prezentare fără să pornești MAUI. Garantat, prima dată când vei face refactoring serios, o să-mi mulțumești.
Unit tests pentru ViewModels cu xUnit și NSubstitute
Începem cu pachetele necesare pentru proiectul de teste. În MyApp.ViewModels.Tests.csproj:
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
Să presupunem că avem un ProductsViewModel cu paginare și filtrare — un caz cât se poate de tipic:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;
public partial class ProductsViewModel : ObservableObject
{
private readonly IProductService _service;
public ProductsViewModel(IProductService service) => _service = service;
[ObservableProperty]
private ObservableCollection<Product> products = new();
[ObservableProperty]
private string searchTerm = string.Empty;
[ObservableProperty]
private bool isLoading;
[RelayCommand]
private async Task LoadAsync(CancellationToken ct)
{
IsLoading = true;
try
{
var items = await _service.SearchAsync(SearchTerm, ct);
Products = new ObservableCollection<Product>(items);
}
finally
{
IsLoading = false;
}
}
}
Și testul corespunzător:
using FluentAssertions;
using NSubstitute;
using Xunit;
public class ProductsViewModelTests
{
[Fact]
public async Task LoadAsync_WithSearchTerm_PopulatesProducts()
{
// Arrange
var service = Substitute.For<IProductService>();
service.SearchAsync("phone", Arg.Any<CancellationToken>())
.Returns(new[] { new Product("Pixel 9"), new Product("iPhone 17") });
var sut = new ProductsViewModel(service) { SearchTerm = "phone" };
// Act
await sut.LoadCommand.ExecuteAsync(null);
// Assert
sut.Products.Should().HaveCount(2);
sut.IsLoading.Should().BeFalse();
await service.Received(1).SearchAsync("phone", Arg.Any<CancellationToken>());
}
[Fact]
public async Task LoadAsync_WhenServiceThrows_ResetsLoadingFlag()
{
var service = Substitute.For<IProductService>();
service.SearchAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns<IEnumerable<Product>>(_ => throw new HttpRequestException());
var sut = new ProductsViewModel(service);
var act = async () => await sut.LoadCommand.ExecuteAsync(null);
await act.Should().ThrowAsync<HttpRequestException>();
sut.IsLoading.Should().BeFalse(); // try/finally protejează starea
}
}
Observă că nu am pornit niciun emulator. Toate aceste teste rulează în <100ms pe orice runner CI. Asta e magia.
Testarea propagării INotifyPropertyChanged
O greșeală frecventă (am pățit-o personal de vreo trei ori): schimbi proprietatea în setter, dar UI-ul nu reacționează pentru că nu invoci OnPropertyChanged. Source generator-ul din CommunityToolkit.Mvvm rezolvă asta, dar e bine să verificăm explicit:
[Fact]
public void SearchTerm_WhenSet_RaisesPropertyChanged()
{
var sut = new ProductsViewModel(Substitute.For<IProductService>());
using var monitor = sut.Monitor();
sut.SearchTerm = "tablet";
monitor.Should().RaisePropertyChangeFor(x => x.SearchTerm);
}
Integration tests cu SQLite și HttpClient
Pentru testarea repository-urilor SQLite recomand Microsoft.Data.Sqlite cu provider :memory:. Asta îți dă o bază de date izolată per test, fără dependențe externe — fără Docker, fără mocking exagerat, fără bătăi de cap:
public class ProductRepositoryTests : IAsyncLifetime
{
private SqliteConnection _connection = null!;
private ProductRepository _repository = null!;
public async Task InitializeAsync()
{
_connection = new SqliteConnection("Data Source=:memory:");
await _connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
var context = new AppDbContext(options);
await context.Database.EnsureCreatedAsync();
_repository = new ProductRepository(context);
}
public Task DisposeAsync()
{
_connection.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task AddAsync_PersistProduct()
{
var product = new Product("Galaxy S25");
await _repository.AddAsync(product);
var fetched = await _repository.GetBySkuAsync(product.Sku);
fetched.Should().NotBeNull();
fetched!.Name.Should().Be("Galaxy S25");
}
}
Pentru testarea HTTP-ului, cea mai sigură variantă în 2026 e RichardSzalay.MockHttp sau un HttpMessageHandler manual. Evită Moq pentru HttpClient — e fragil și greu de citit. Serios, am moștenit niște teste cu Moq pe HttpClient și am stat o săptămână să le rescriu.
[Fact]
public async Task GetProductsAsync_RetriesOnce_OnTransient500()
{
var mockHttp = new MockHttpMessageHandler();
mockHttp.Expect(HttpMethod.Get, "https://api.test/products")
.Respond(HttpStatusCode.InternalServerError);
mockHttp.Expect(HttpMethod.Get, "https://api.test/products")
.Respond("application/json", "[{\"sku\":\"X\",\"name\":\"Test\"}]");
var http = new HttpClient(mockHttp) { BaseAddress = new Uri("https://api.test/") };
var client = new ProductApiClient(http, retryCount: 1);
var products = await client.GetProductsAsync();
products.Should().ContainSingle(p => p.Name == "Test");
mockHttp.VerifyNoOutstandingExpectation();
}
UI tests cu Appium 2.x și .NET MAUI TestUtils
Începând cu .NET 9, Microsoft oferă Microsoft.Maui.TestUtils.DeviceTests — un wrapper peste Appium care simplifică interacțiunea cu controale MAUI prin AutomationId. Setup-ul în 2026:
<PackageReference Include="Appium.WebDriver" Version="6.0.0" />
<PackageReference Include="Microsoft.Maui.TestUtils.DeviceTests.Runners" Version="9.0.10" />
<PackageReference Include="xunit" Version="2.9.2" />
În aplicație, asignează AutomationId pe controalele relevante. Pare banal, dar uneori uităm:
<Entry x:Name="EmailEntry"
AutomationId="loginEmail"
Placeholder="Email" />
<Button x:Name="LoginButton"
AutomationId="loginSubmit"
Text="Autentificare"
Command="{Binding LoginCommand}" />
Și testul propriu-zis:
public class LoginUITests : IClassFixture<AppiumFixture>
{
private readonly AppiumDriver _driver;
public LoginUITests(AppiumFixture fixture) => _driver = fixture.Driver;
[Fact]
public void Login_WithValidCredentials_NavigatesToHome()
{
var email = _driver.FindElement(MobileBy.AccessibilityId("loginEmail"));
var submit = _driver.FindElement(MobileBy.AccessibilityId("loginSubmit"));
email.SendKeys("[email protected]");
_driver.FindElement(MobileBy.AccessibilityId("loginPassword"))
.SendKeys("Parola123!");
submit.Click();
var welcome = _driver.FindElement(MobileBy.AccessibilityId("homeWelcome"),
timeout: TimeSpan.FromSeconds(10));
welcome.Text.Should().Contain("Bună");
}
}
AppiumFixture: pornirea sesiunii o singură dată
Pornitul unei sesiuni Appium e scump. O fac o dată per colecție și apoi o reutilizez:
public sealed class AppiumFixture : IAsyncLifetime
{
public AppiumDriver Driver { get; private set; } = null!;
private AppiumLocalService? _service;
public Task InitializeAsync()
{
_service = new AppiumServiceBuilder().UsingAnyFreePort().Build();
_service.Start();
var options = new AppiumOptions
{
PlatformName = "Android",
AutomationName = "UiAutomator2",
App = Path.GetFullPath("../../../../artifacts/com.myapp.apk"),
DeviceName = "emulator-5554",
};
Driver = new AndroidDriver(_service.ServiceUrl, options,
TimeSpan.FromMinutes(2));
return Task.CompletedTask;
}
public Task DisposeAsync()
{
Driver?.Quit();
_service?.Dispose();
return Task.CompletedTask;
}
}
Rularea testelor în GitHub Actions
Un workflow funcțional în 2026, care rulează unit + integration tests pe fiecare PR și UI tests pe Android emulator doar pe main (ca să nu blocheze fiecare push):
name: tests
on:
pull_request:
push:
branches: [main]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- run: dotnet workload install maui-android
- run: dotnet test tests/MyApp.ViewModels.Tests --logger "trx"
- run: dotnet test tests/MyApp.Integration.Tests --logger "trx"
ui-android:
if: github.ref == 'refs/heads/main'
runs-on: macos-14 # mai rapid decât ubuntu pentru emulator
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- run: dotnet workload install maui
- run: dotnet publish src/MyApp -f net9.0-android -c Release -o artifacts/
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
arch: x86_64
script: dotnet test tests/MyApp.UITests
Capcane întâlnite în producție
Acum, partea pe care o vei reciti probabil de câteva ori. Astea sunt lucrurile care m-au costat ore (și uneori, weekend-uri):
- Async void în RelayCommand — tot ce e
asynctrebuie să returnezeTask. Source generator-ul din MVVM Toolkit generează automat unAsyncRelayCommandcare se poate aștepta în teste. - MainThread.BeginInvokeOnMainThread — nu poate fi apelat în unit tests. Abstractizează prin
IDispatcherși injectează un fake (NSubstituteîl poate construi în 2 linii). - Preferences și SecureStorage — sunt API-uri statice. Învelește-le în interfețe (
IPreferences,ISecureStorage), chiar dacă pare overkill — economisești ore de debug. Promit. - Test parallelism — UI tests pe același emulator NU pot rula în paralel. Adaugă
[CollectionDefinition(DisableParallelization = true)]pe colecția UI. - Code coverage — folosește
coverlet.collector+ReportGenerator. Țintă realistă: 70% pe ViewModels, 60% pe Infrastructure, ignoră generated code.
FAQ
Cum testez navigarea Shell într-un .NET MAUI ViewModel?
Nu testezi Shell.Current.GoToAsync direct. Definește o interfață INavigationService cu metoda NavigateAsync(string route, IDictionary<string,object>? args = null). Implementarea de producție apelează Shell, iar în teste folosești un fake care înregistrează rutele primite. Apoi verifici cu Received() că ruta corectă a fost cerută.
Care e diferența între Microsoft.Maui.TestUtils.DeviceTests și Xamarin.UITest?
Xamarin.UITest a fost depreciat în 2024 și nu mai primește update-uri. Microsoft.Maui.TestUtils.DeviceTests rulează pe Appium 2.x, suportă iOS, Android, Windows și macOS, și se integrează direct cu xUnit/NUnit. Pentru proiecte noi în 2026, alegerea e clară.
Pot testa hot reload sau XAML compilation errors?
XAML compilation errors le prinzi la build — nu necesită test runtime. Pentru a verifica că un BindingContext are toate proprietățile pe care le accesează vizualizarea, folosește XamlC cu opțiunea ValidateBindings activată în .csproj. Buggy bindings devin erori de compilare. Aur curat, sincer.
Câte UI tests sunt prea multe?
Regula empirică: dacă suita ta de UI tests pe un singur platform depășește 15 minute, rescrie cele care duplică logica deja acoperită de unit tests. UI tests trebuie rezervate fluxurilor unde interacțiunea cross-componentă e singura modalitate de a valida — autentificare, plată, sincronizare offline.
Cum rulez testele pe iOS în CI fără device fizic?
Pe runner-ele macos-14 ale GitHub Actions, simulatorul iOS e preinstalat. Folosește xcrun simctl boot pentru a porni un simulator înainte de dotnet test, iar în AppiumOptions setează PlatformName = "iOS" și AutomationName = "XCUITest". Atenție la timpul de boot — adaugă 60 de secunde ca să eviți timeout-uri (am pățit-o, nu e plăcut).
Concluzie
Pe scurt: în 2026, ecosistemul de testing pentru .NET MAUI e în sfârșit la nivel de maturitate comparabil cu cel al iOS-ului nativ și Android-ului nativ. Cu separarea corectă între ViewModels și UI, cu xUnit + NSubstitute pentru logică, cu SQLite in-memory pentru integration și cu Appium 2.x pentru UI critic, ai o suită care îți dă feedback rapid și încredere reală în deploy-uri.
Recomandarea mea? Începe cu unit tests pentru ViewModels — sunt cel mai ieftin câștig — apoi adaugă integration tests pentru repository-uri și UI tests doar pentru cele 5-10 fluxuri care îți dau migrene în producție. Restul, lasă-l pe seama type system-ului și a code review-ului. Ai încredere în compiler, ai încredere în colegi, dar mai ales — ai încredere în testele care rulează în 100ms.