.NET MAUI Testing 2026: Chiến Lược Unit Test, UI Test Và Automation Toàn Diện
Chiến lược kiểm thử .NET MAUI 2026 hoàn chỉnh: unit test ViewModel với xUnit + NSubstitute, MAUI Device Tests cho custom handler, Appium 2.x đa nền tảng và pipeline GitHub Actions với code ví dụ production chạy được.
Chiến lược testing cho .NET MAUI 2026 tối ưu gồm ba tầng: unit test cho ViewModel và service bằng xUnit + NSubstitute, integration test cho platform code bằng MAUI Device Tests, và UI test đa nền tảng bằng Appium 2.x chạy trên emulator hoặc BrowserStack. Tôi đã áp dụng mô hình này trong ba app production, và thật lòng mà nói, nó cho phép mình ship với confidence kể cả khi upgrade lên phiên bản .NET MAUI mới. Bài viết đi qua từng tầng với code chạy được thay vì lý thuyết chung chung, vì testing MAUI vẫn còn nhiều cạm bẫy mà tài liệu chính thức chưa nói hết.
Tách UI khỏi logic bằng MVVM chặt chẽ là điều kiện tiên quyết. ViewModel không nên biết gì về Application.Current hay Shell.Current.
xUnit 2.9 là lựa chọn mặc định cho unit test; NSubstitute 5.x nhẹ và dễ đọc hơn Moq cho code base MAUI.
MAUI Device Tests (dự án template built-in) là cách duy nhất chạy test bằng runtime thật của platform, bắt buộc cho custom handler và platform effects.
Appium 2.13+ hỗ trợ chính thức WinAppDriver, UiAutomator2 và XCUITest, đủ cho Windows, Android và iOS trong một test suite.
Snapshot testing bằng Verify.Xunit giúp phát hiện regression UI với 20 dòng code, không cần visual regression cluster.
GitHub Actions với runner macOS-15 và windows-2025 chạy được full test suite (kể cả iOS Simulator) dưới 12 phút cho một solution vừa.
Tại sao testing .NET MAUI khó hơn ASP.NET Core?
Câu trả lời ngắn: MAUI mang theo runtime của bốn platform (Android, iOS, MacCatalyst, Windows), và mỗi platform chỉ khởi động được trên máy chủ tương ứng. Khác với ASP.NET Core nơi WebApplicationFactory spin up server in-process, MAUI không có host tương đương cho MauiApp. Điều đó nghĩa là bạn không thể new MauiApp() trong một test project net9.0 thông thường. Bất kỳ code nào chạm vào Microsoft.Maui.Controls, MainThread, Preferences hay SecureStorage sẽ ném PlatformNotSupportedException tức thì.
Hệ quả trực tiếp: chỉ code không phụ thuộc runtime MAUI mới unit test được ở tầng net9.0. Đây là lý do chiến lược testing MAUI bắt buộc phải chia làm ba tầng thay vì gộp làm một. Nhiều team tôi tư vấn khi bắt đầu đều mắc lỗi cùng một cách: viết ViewModel gọi trực tiếp Shell.Current.GoToAsync, rồi không hiểu tại sao xUnit không chạy được. Cách sửa không phải mock Shell, mà là thiết kế lại để ViewModel gọi một INavigationService mà bạn kiểm soát được.
Điểm thứ hai cần hiểu: UI test khác với UI automation test. UI test kiểu snapshot chỉ so sánh output XAML render; UI automation test thực sự khởi động app trên simulator/emulator và interact bằng tap/swipe. Cả hai đều cần, nhưng vì lý do khác nhau. Snapshot bắt regression về layout, còn automation bắt regression về flow người dùng.
Kiến trúc testable: MVVM và Dependency Injection
Trước khi viết một dòng test nào, kiểm tra ba điều trong kiến trúc:
ViewModel không được reference Microsoft.Maui.Controls. Nếu ViewModel của bạn using Microsoft.Maui.Controls; chỉ vì gọi Application.Current.MainPage, hãy trích xuất thành interface IDialogService. Test suite không cần load Microsoft.Maui.Controls.dll, và điều đó tiết kiệm cả giây khởi động cho mỗi test class.
Navigation phía sau interface.INavigationService.NavigateToAsync<TViewModel>() là pattern tôi khuyên. Implementation production dùng Shell.Current.GoToAsync, implementation test là FakeNavigationService ghi lại lịch sử.
Platform API sau abstraction.SecureStorage, Preferences, Connectivity... tất cả đều có sẵn tương đương interface trong Microsoft.Maui.Essentials.Interfaces. Dùng ngay, đừng wrap thêm một lớp nữa.
Kết hợp với MVVM source generators trong .NET MAUI, bạn có được ViewModel gọn 40-60 dòng, thuần POCO, test được trong 5 ms mỗi case. Đây là nền tảng. Nếu bỏ qua, mọi kỹ thuật testing bên dưới sẽ chỉ là dán băng cứu thương lên vấn đề kiến trúc.
Test tương ứng với NSubstitute rất ngắn, đọc như tài liệu đặc tả:
public class ProductListViewModelTests
{
private readonly IProductService _productService = Substitute.For<IProductService>();
private readonly INavigationService _navigation = Substitute.For<INavigationService>();
[Fact]
public async Task LoadAsync_KhiThanhCong_DienDanhSachVaTatIsLoading()
{
_productService.GetAllAsync().Returns(new[]
{
new Product(1, "iPhone 17 Pro"),
new Product(2, "Galaxy S26")
});
var vm = new ProductListViewModel(_productService, _navigation);
await vm.LoadCommand.ExecuteAsync(null);
vm.Products.Should().HaveCount(2);
vm.IsLoading.Should().BeFalse();
vm.ErrorMessage.Should().BeNull();
}
[Fact]
public async Task LoadAsync_KhiHttpFail_GhiErrorMessage()
{
_productService.GetAllAsync()
.Returns<IEnumerable<Product>>(_ => throw new HttpRequestException("timeout"));
var vm = new ProductListViewModel(_productService, _navigation);
await vm.LoadCommand.ExecuteAsync(null);
vm.ErrorMessage.Should().Contain("timeout");
vm.IsLoading.Should().BeFalse();
}
[Fact]
public async Task OpenDetailAsync_GoiNavigationServiceDungTham()
{
var vm = new ProductListViewModel(_productService, _navigation);
var product = new Product(42, "Pixel 10");
await vm.OpenDetailCommand.ExecuteAsync(product);
await _navigation.Received(1).NavigateToAsync<ProductDetailViewModel>(
Arg.Is<object>(o => o.GetType().GetProperty("Id")!.GetValue(o)!.Equals(42)));
}
}
Ba điều đáng chú ý về pattern này. Thứ nhất, không có Task.Delay hay await Task.Yield(), vì RelayCommand trả về Task nên ExecuteAsync đã đợi hoàn tất. Thứ hai, tôi test ở tầng command chứ không phải trực tiếp gọi method private, điều đó phản ánh cách UI thực sự trigger. Thứ ba, khi assert navigation, tôi so sánh property của anonymous object bằng reflection thay vì so sánh reference (vì mỗi lần gọi OpenDetailAsync sẽ tạo instance mới).
Test platform-specific code với MAUI Device Tests
Nếu bạn viết custom handler, effect, hoặc code trong thư mục Platforms/Android, Substitute.For không cứu được. Bạn cần chạy test trên simulator thực. Microsoft cung cấp template maui-blazor có tùy chọn tạo project Device Tests, hoặc bạn tạo thủ công bằng cách clone cấu trúc từ TestUtils của repo dotnet/maui.
Về bản chất, Device Tests đóng gói xUnit runner vào bên trong một MAUI app. Khi bạn chạy app trên emulator, nó tự động phát hiện test class, chạy và emit kết quả qua TCP về máy host. Ví dụ test handler cho Entry:
[Category(TestCategory.Entry)]
public partial class EntryHandlerTests : HandlerTestBase<EntryHandler, Entry>
{
[Fact(DisplayName = "Placeholder color áp dụng đúng trên Android 15")]
public async Task PlaceholderColor_KhiSetDoTrenAndroid15()
{
var entry = new Entry
{
Placeholder = "Nhập tên",
PlaceholderColor = Colors.Red
};
var platformColor = await GetValueAsync(entry, handler =>
{
var nativeEntry = handler.PlatformView; // AppCompatEditText
return nativeEntry.CurrentHintTextColor;
});
Assert.Equal(Colors.Red.ToAndroid(), new AColor(platformColor));
}
}
Điểm mấu chốt: Device Tests là lớp bảo vệ cuối cùng khi upgrade phiên bản MAUI. Trước khi merge PR upgrade từ MAUI 10.0.0-preview.4 lên 10.0.0-rc.1, tôi luôn chạy full device test trên cả API level Android 21, 33 và 15. Ba lần trong hai năm gần đây, chính suite này bắt được regression về placeholder color mà unit test và UI test đều bỏ qua. (Đúng kiểu bug nếu không có device test thì sẽ chỉ phát hiện ra khi user báo.)
UI test đa nền tảng với Appium 2.x
Appium 2.13 chính thức hỗ trợ .NET MAUI apps trên cả ba driver: UiAutomator2 (Android), XCUITest (iOS) và WinAppDriver (Windows). So với Xamarin.UITest (đã bị Microsoft ngừng hỗ trợ cùng App Center), Appium là con đường mainstream duy nhất cho MAUI hiện tại.
Text-based selector sẽ vỡ ngay khi bạn thêm localization tiếng Anh, hoặc khi designer đổi placeholder từ "Email" sang "Địa chỉ email". AutomationId là contract giữa developer và test, thay đổi phải chủ đích.
Snapshot testing và visual regression
Verify.Xunit cung cấp snapshot testing với 15 dòng setup. Nó serialize output XAML hoặc ViewModel state thành file .verified.txt, và fail test nếu output tương lai khác. Rất hiệu quả để bắt regression về format tiền tệ, ngày tháng, danh sách sắp xếp:
public class InvoiceViewModelSnapshotTests
{
[Fact]
public Task InvoiceSummary_FormatVND_KhopMauChuan()
{
var vm = new InvoiceViewModel(new[]
{
new LineItem("Ốp lưng iPhone", 1, 350_000m),
new LineItem("Sạc nhanh 30W", 2, 480_000m)
});
return Verify(new
{
vm.Subtotal,
vm.Tax,
vm.Total,
vm.FormattedTotal
});
}
}
Lần chạy đầu tiên tạo file InvoiceSummary_FormatVND_KhopMauChuan.verified.txt. Reviewer commit file này như một phần của PR. Lần chạy sau, nếu output đổi (ví dụ format từ "1.310.000 ₫" thành "1,310,000 ₫"), test fail kèm diff, developer chủ động approve bằng cách xóa file .received.txt.
Đối với visual regression thực sự (screenshot pixel-diff), tôi khuyên tích hợp với Percy hoặc Chromatic thông qua Appium screenshot API, nhưng chỉ khi app có UI cực kỳ pixel-sensitive như banking. Cho đa số case, snapshot text-based đã đủ.
Chạy toàn bộ test suite trên GitHub Actions
Runner macos-15 và windows-2025 có sẵn workload MAUI vào 2026, và pipeline dưới đây chạy cả unit test, device test và Appium UI test cho Android trong khoảng 12 phút cho một solution 40k dòng code:
Ba mẹo cụ thể từ kinh nghiệm production. Thứ nhất, tách unit test và UI test thành job riêng, vì unit test fail nhanh trên PR còn UI test đắt hơn và có thể chạy nightly. Thứ hai, cache workload MAUI (~/.dotnet/workloads) để tiết kiệm 3-4 phút cho mỗi build. Thứ ba, khi UI test fail trên CI mà local pass, gần như luôn là do WebDriverWait quá ngắn. CI runner luôn chậm hơn máy dev, chuyện thường thấy.
Những lỗi phổ biến khi test MAUI trong production
Sau khi review test suite của khoảng 12 team MAUI, đây là danh sách sai lầm tôi gặp lặp đi lặp lại:
Test Application.Current.MainPage trực tiếp. Đây là dấu hiệu ViewModel đang gánh trách nhiệm không phải của nó. Refactor thành IDialogService.
Không test edge case của ObservableCollection. Thêm vào collection từ background thread ném exception cross-thread trên iOS nhưng silently ignore trên Android debug build. Cần test explicit với ConfigureAwait(false).
Mock DateTime.Now bằng thư viện thay vì inject TimeProvider. .NET 8+ đã có TimeProvider abstraction chính thức. Dùng nó, đừng dùng Bogus hay Rhino.Mocks.
Bỏ qua flow refresh token JWT. Đây là source lỗi race condition kinh điển, luôn test với concurrent request giả lập.
Chạy Appium test sequential. Với emulator farm, chạy parallel bằng xUnit.Runner.Parallel giảm 3x thời gian, nhưng chỉ khi mỗi test class có Appium driver riêng.
Nếu bạn đang chuẩn bị scale test suite lên hơn 500 case, tham khảo thêm bài kiến trúc offline-first với .NET MAUI để hiểu vì sao repository layer thiết kế tốt sẽ làm test suite nhẹ hơn nhiều.
Câu hỏi thường gặp
Có nên dùng xUnit hay NUnit cho .NET MAUI testing?
xUnit là lựa chọn mặc định vào 2026 vì tương thích tốt hơn với dotnet CLI, hỗ trợ parallel test class native, và là runner chính thức trong template MAUI Device Tests. NUnit vẫn hoạt động tốt nếu team đã quen, nhưng dự án mới nên bắt đầu với xUnit 2.9+.
Làm sao mock SecureStorage và Preferences trong unit test?
Dùng ISecureStorage và IPreferences từ namespace Microsoft.Maui.Storage. Cả hai đều có sẵn interface, đăng ký trong DI container ở MauiProgram.cs, rồi inject vào ViewModel. Trong test, dùng Substitute.For<ISecureStorage>() của NSubstitute.
Appium có thay thế được Xamarin.UITest cho .NET MAUI không?
Có, và đó là lựa chọn duy nhất được khuyến nghị. Xamarin.UITest đã ngừng hỗ trợ cùng với App Center vào tháng 3/2025. Appium 2.13+ cùng driver uiautomator2, xcuitest và appium-windows-driver phủ đầy đủ ba platform mà MAUI target.
Có cách nào test View XAML mà không cần khởi động app không?
Không hoàn toàn. Bất kỳ code XAML nào cũng cần MAUI runtime để parse. Cách gần nhất là snapshot testing với Verify.Xunit trên ViewModel output, hoặc dùng MAUI Device Tests chạy trên simulator để test rendering. Việc "test XAML tinh khiết" trên môi trường net9.0 thuần là không khả thi.
Code coverage tối thiểu nên đặt là bao nhiêu cho dự án MAUI?
Từ kinh nghiệm ship ba app: 75-80% cho ViewModel và Service layer là mục tiêu thực tế. Đừng cố ép coverage cho code trong Platforms/ vì cost/benefit không tốt. Focus vào branch coverage của logic nghiệp vụ và integration point với API.
Hướng dẫn xây dựng ứng dụng .NET MAUI Blazor Hybrid — chia sẻ Razor component giữa web, mobile và desktop qua Razor Class Library, xử lý platform-specific code, tối ưu hiệu suất WebView với .NET 10.
Hướng dẫn sử dụng MVVM Source Generators trong .NET MAUI với CommunityToolkit.Mvvm 8.4 — từ ObservableProperty, RelayCommand đến BindableProperty. Kèm cú pháp partial properties, ví dụ thực tế và mẹo sửa lỗi thường gặp.
Hướng dẫn xây dựng hệ thống xác thực JWT trong .NET MAUI — lưu trữ token an toàn với SecureStorage, tự động gắn và refresh token bằng DelegatingHandler, tích hợp IHttpClientFactory và MVVM pattern.