راستش را بخواهید، یکی از بزرگترین دردسرهای ما توسعهدهندگان .NET MAUI، نبود یک مرجع جامع و بهروز برای استراتژی تستنویسی است. برخلاف دنیای وب که Selenium و Cypress سالهاست جاافتادهاند، در MAUI ابزارها هنوز در حال تثبیتاند و خیلی از مقالههای موجود — حتی همان نتایج اول گوگل — به نسخههای قدیمی Xamarin.UITest اشاره میکنند که مدتیست از پشتیبانی خارج شده.
پس بیایید مستقیم برویم سر اصل مطلب. در این راهنمای ۲۰۲۶، یک استراتژی کامل تست برای پروژههای .NET MAUI 9 و .NET 10 میچینیم: از تست واحد (Unit Test) با xUnit و NSubstitute، تا تست رابط کاربری با Appium 2.x، Snapshot Testing با Verify، و در نهایت اتوماسیون کامل در GitHub Actions.
چرا تستنویسی در اپلیکیشنهای موبایل اهمیت ویژه دارد؟
یک باگ در اپلیکیشن وب با یک Deploy ساده برطرف میشود. در موبایل اما، داستان فرق دارد — چرخهی انتشار خیلی طولانیتر است:
- بررسی فروشگاه: Apple App Store ممکن است ۲۴ تا ۴۸ ساعت زمان ببرد (و گاهی بیشتر) و اگر رد شود، چرخه از نو شروع میشود.
- بهروزرسانی کاربر: حتی پس از انتشار، فقط بخشی از کاربران اپ را بهروز میکنند. باگهای نسخههای قدیمی تا ماهها همراه شما خواهند بود.
- تنوع دستگاه: صدها مدل اندروید با نسخههای متفاوت API و دهها مدل iPhone و iPad باید پشتیبانی شوند.
به همین دلیل ساده، هرم تست در موبایل باید پایهی محکمی از Unit Test داشته باشد، با لایهای از Integration Test تقویت شود و با تعداد محدودی UI Test روی سناریوهای حیاتی تکمیل گردد. هیچ راه میانبُری وجود ندارد.
ساختار پروژه و جدا کردن لایهها برای تستپذیری
مهمترین گام پیش از نوشتن اولین تست، طراحی معماری تستپذیر است. این جمله را من بارها در پروژههای مختلف به سختی یاد گرفتهام: اگر کد شما بهشدت به MainThread، DependencyService یا کلاسهای Sealed پلتفرم وابسته باشد، تستنویسی بسیار دشوار خواهد شد. خیلی دشوار.
تقسیم Solution به چند پروژه
MyApp.sln
├── src/
│ ├── MyApp.Core/ # PCL / netstandard2.1 - منطق کسبوکار
│ ├── MyApp.Data/ # دسترسی به داده (Repository، EF Core، SQLite)
│ ├── MyApp.Services/ # سرویسهای API، Auth، Cache
│ └── MyApp.Maui/ # پروژهی UI (XAML، Pages، ViewModels)
└── tests/
├── MyApp.Core.Tests/ # xUnit - تست واحد
├── MyApp.Services.Tests/ # تست با Mock
├── MyApp.Integration.Tests/ # تست با SQLite واقعی و WireMock
└── MyApp.UI.Tests/ # Appium UI Test
این تقسیمبندی باعث میشود حدود ۸۰٪ منطق برنامه در پروژههایی قرار گیرد که هیچ وابستگی به MAUI ندارند — یعنی میتوانید آنها را با سرعت بالا و بدون نیاز به امولهایتور تست کنید. تجربهی شخصی من میگوید همین یک تغییر معماری، زمان اجرای تستهای CI ما را تقریباً نصف کرد.
Unit Test با xUnit و FluentAssertions
برای تست واحد در .NET MAUI، ترکیب xUnit + FluentAssertions + Moq یا جایگزین مدرنتر آن یعنی NSubstitute توصیه میشود. در سال ۲۰۲۶، NSubstitute به دلیل API سادهتر و عدم نیاز به Expression Tree، انتخاب اول بسیاری از تیمها شده است (و صادقانه بگویم، اولین بار که syntax تمیزش را دیدم، دیگر به Moq برنگشتم).
نصب پکیجهای لازم
dotnet new xunit -n MyApp.Core.Tests
cd MyApp.Core.Tests
dotnet add package FluentAssertions
dotnet add package NSubstitute
dotnet add package Microsoft.NET.Test.Sdk
dotnet add package coverlet.collector
تست یک ViewModel با CommunityToolkit.Mvvm
فرض کنید یک LoginViewModel داریم که از IAuthService برای ورود کاربر استفاده میکند. هدف ما تست رفتار ViewModel بدون نیاز به سرور واقعی است:
public partial class LoginViewModel : ObservableObject
{
private readonly IAuthService _authService;
private readonly INavigationService _navigation;
[ObservableProperty]
private string email = string.Empty;
[ObservableProperty]
private string password = string.Empty;
[ObservableProperty]
private string? errorMessage;
[ObservableProperty]
private bool isBusy;
public LoginViewModel(IAuthService authService, INavigationService navigation)
{
_authService = authService;
_navigation = navigation;
}
[RelayCommand]
private async Task LoginAsync()
{
if (IsBusy) return;
IsBusy = true;
ErrorMessage = null;
try
{
var result = await _authService.LoginAsync(Email, Password);
if (result.IsSuccess)
await _navigation.GoToAsync("//main");
else
ErrorMessage = result.Error;
}
finally
{
IsBusy = false;
}
}
}
و حالا تست واحد آن با xUnit و NSubstitute:
public class LoginViewModelTests
{
private readonly IAuthService _authService = Substitute.For<IAuthService>();
private readonly INavigationService _navigation = Substitute.For<INavigationService>();
private LoginViewModel CreateSut() => new(_authService, _navigation);
[Fact]
public async Task LoginAsync_WithValidCredentials_NavigatesToMain()
{
// Arrange
_authService.LoginAsync("[email protected]", "secret")
.Returns(AuthResult.Success(new User { Id = 1 }));
var sut = CreateSut();
sut.Email = "[email protected]";
sut.Password = "secret";
// Act
await sut.LoginCommand.ExecuteAsync(null);
// Assert
await _navigation.Received(1).GoToAsync("//main");
sut.ErrorMessage.Should().BeNull();
sut.IsBusy.Should().BeFalse();
}
[Fact]
public async Task LoginAsync_WithInvalidCredentials_SetsErrorMessage()
{
_authService.LoginAsync(Arg.Any<string>(), Arg.Any<string>())
.Returns(AuthResult.Failure("اعتبارنامه نامعتبر"));
var sut = CreateSut();
await sut.LoginCommand.ExecuteAsync(null);
sut.ErrorMessage.Should().Be("اعتبارنامه نامعتبر");
await _navigation.DidNotReceive().GoToAsync(Arg.Any<string>());
}
[Fact]
public async Task LoginAsync_WhenAlreadyBusy_DoesNotCallService()
{
var sut = CreateSut();
sut.IsBusy = true;
await sut.LoginCommand.ExecuteAsync(null);
await _authService.DidNotReceive().LoginAsync(Arg.Any<string>(), Arg.Any<string>());
}
}
تست کد وابسته به MainThread
این یکی از همان تلههاست. کدهایی که از MainThread.BeginInvokeOnMainThread استفاده میکنند، در محیط تست واحد بهطور پیشفرض کار نمیکنند. راهحل صحیح، استفاده از یک Dispatcher Abstraction است:
public interface IDispatcher
{
Task InvokeAsync(Func<Task> action);
}
public class MauiDispatcher : IDispatcher
{
public Task InvokeAsync(Func<Task> action)
=> MainThread.InvokeOnMainThreadAsync(action);
}
public class TestDispatcher : IDispatcher
{
public Task InvokeAsync(Func<Task> action) => action();
}
به این ترتیب در تستها TestDispatcher را تزریق میکنید و در اپ واقعی MauiDispatcher. تمام.
Integration Test با SQLite و WireMock.Net
تست واحد به تنهایی کافی نیست — این درس را به سختی یاد گرفتیم. باید مطمئن شوید لایهی داده و سرویسهای HTTP شما بهدرستی با هم کار میکنند. در ۲۰۲۶، ترکیب SQLite In-Memory برای دیتابیس و WireMock.Net برای شبیهسازی API، استاندارد طلایی است.
تست Repository با SQLite واقعی
public class ProductRepositoryTests : IAsyncLifetime
{
private SQLiteAsyncConnection _connection = null!;
private ProductRepository _sut = null!;
public async Task InitializeAsync()
{
_connection = new SQLiteAsyncConnection(":memory:");
await _connection.CreateTableAsync<Product>();
_sut = new ProductRepository(_connection);
}
public Task DisposeAsync() => _connection.CloseAsync();
[Fact]
public async Task AddAsync_PersistsProduct()
{
var product = new Product { Name = "گوشی", Price = 25_000_000 };
await _sut.AddAsync(product);
var stored = await _sut.GetByIdAsync(product.Id);
stored.Should().NotBeNull();
stored!.Name.Should().Be("گوشی");
}
}
شبیهسازی API با WireMock.Net
public class ApiClientTests : IAsyncLifetime
{
private WireMockServer _server = null!;
private ApiClient _sut = null!;
public Task InitializeAsync()
{
_server = WireMockServer.Start();
var http = new HttpClient { BaseAddress = new Uri(_server.Url!) };
_sut = new ApiClient(http);
return Task.CompletedTask;
}
public Task DisposeAsync()
{
_server.Stop();
return Task.CompletedTask;
}
[Fact]
public async Task GetUserAsync_ReturnsDeserializedUser()
{
_server.Given(Request.Create().WithPath("/api/users/1").UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithBodyAsJson(new { id = 1, name = "علی" }));
var user = await _sut.GetUserAsync(1);
user.Name.Should().Be("علی");
}
[Fact]
public async Task GetUserAsync_When500_RetriesAndThrows()
{
_server.Given(Request.Create().WithPath("/api/users/1"))
.RespondWith(Response.Create().WithStatusCode(500));
var act = () => _sut.GetUserAsync(1);
await act.Should().ThrowAsync<HttpRequestException>();
_server.LogEntries.Should().HaveCountGreaterThan(1, "Polly باید Retry انجام دهد");
}
}
Snapshot Testing با Verify
حالا برسیم به یکی از تکنیکهای قدرتمندی که کمتر در منابع فارسی به آن پرداخته شده: Snapshot Testing. ایدهاش ساده است (تقریباً بهطور خندهداری ساده): خروجی یک تابع را یک بار بهعنوان «حقیقت» ذخیره میکنید و در اجراهای بعدی، تست هر تغییری در آن خروجی را گزارش میدهد.
این روش بهویژه برای DTOهای JSON، Localization و خروجیهای پیچیدهی سریالایز شده عالی است. شخصاً اولین باری که از Verify استفاده کردم، چند باگ صامت در serialization پیدا کرد که با تستهای سنتی هرگز کشف نمیشد.
dotnet add package Verify.Xunit
[UsesVerify]
public class OrderSerializerTests
{
[Fact]
public Task SerializeOrder_ProducesExpectedJson()
{
var order = new Order
{
Id = 42,
CreatedAt = new DateTime(2026, 5, 17, 10, 0, 0, DateTimeKind.Utc),
Items = new[]
{
new OrderItem { Name = "کتاب", Quantity = 2, Price = 150_000 },
new OrderItem { Name = "خودکار", Quantity = 5, Price = 12_000 }
}
};
var json = OrderSerializer.Serialize(order);
return Verify(json);
}
}
در اولین اجرا، فایلی به نام OrderSerializerTests.SerializeOrder_ProducesExpectedJson.received.txt ساخته میشود. شما آن را به .verified.txt تغییر نام میدهید و Commit میکنید. از آن پس، هر تغییر در خروجی، تست را Fail میکند و یک Diff قابل بازبینی نمایش میدهد — چیزی شبیه به Code Review برای خروجیهای شما.
UI Test با Appium 2.x در .NET MAUI
پس از حذف Xamarin.UITest، تیم Microsoft رسماً Appium 2.x را به عنوان ابزار توصیهشده برای UI Test در .NET MAUI معرفی کرده. در سال ۲۰۲۶، الگوی غالب استفاده از Appium.WebDriver در یک پروژهی xUnit جداگانه است.
راهاندازی Appium Server
npm install -g appium@2
appium driver install uiautomator2 # برای اندروید
appium driver install xcuitest # برای iOS
appium --port 4723
تنظیم Capabilities و اولین تست
public class AppFixture : IAsyncLifetime
{
public AndroidDriver Driver { get; private set; } = null!;
public Task InitializeAsync()
{
var options = new AppiumOptions
{
AutomationName = "UiAutomator2",
PlatformName = "Android",
DeviceName = "Pixel_7_API_34"
};
options.AddAdditionalAppiumOption("appPackage", "com.companyname.myapp");
options.AddAdditionalAppiumOption("appActivity", "crc64...MainActivity");
options.AddAdditionalAppiumOption("noReset", false);
Driver = new AndroidDriver(new Uri("http://127.0.0.1:4723"), options);
Driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(10);
return Task.CompletedTask;
}
public Task DisposeAsync()
{
Driver.Quit();
return Task.CompletedTask;
}
}
public class LoginUiTests : IClassFixture<AppFixture>
{
private readonly AppFixture _fixture;
public LoginUiTests(AppFixture fixture) => _fixture = fixture;
[Fact]
public void Login_WithValidCredentials_ShowsHomePage()
{
var driver = _fixture.Driver;
var emailField = driver.FindElement(MobileBy.AccessibilityId("EmailEntry"));
emailField.SendKeys("[email protected]");
var passwordField = driver.FindElement(MobileBy.AccessibilityId("PasswordEntry"));
passwordField.SendKeys("secret");
driver.FindElement(MobileBy.AccessibilityId("LoginButton")).Click();
var welcome = new WebDriverWait(driver, TimeSpan.FromSeconds(15))
.Until(d => d.FindElement(MobileBy.AccessibilityId("WelcomeLabel")));
welcome.Text.Should().Contain("خوش آمدید");
}
}
نکتهی کلیدی: تنظیم AutomationId
این نکتهای است که بسیاری ابتدا فراموش میکنند. برای آنکه Appium بتواند کنترلهای شما را پیدا کند، باید روی هر کنترل قابل تعامل AutomationId تنظیم کنید:
<Entry x:Name="EmailEntry"
AutomationId="EmailEntry"
Placeholder="ایمیل" />
<Button Text="ورود"
AutomationId="LoginButton"
Command="{Binding LoginCommand}" />
این مقدار در اندروید به resource-id و در iOS به accessibility identifier ترجمه میشود و باعث میشود تستهای شما مستقل از زبان UI پایدار باقی بمانند. یعنی حتی اگر فردا متن دکمه را از «ورود» به «ورود به حساب» تغییر دادید، تستهایتان نمیشکنند.
الگوی Page Object برای نگهداری آسان
بهجای نوشتن سلکتورها در هر تست (که یک کابوس نگهداری است)، از Page Object Model استفاده کنید:
public class LoginPage
{
private readonly AppiumDriver _driver;
public LoginPage(AppiumDriver driver) => _driver = driver;
private IWebElement Email => _driver.FindElement(MobileBy.AccessibilityId("EmailEntry"));
private IWebElement Password => _driver.FindElement(MobileBy.AccessibilityId("PasswordEntry"));
private IWebElement SubmitBtn => _driver.FindElement(MobileBy.AccessibilityId("LoginButton"));
public HomePage LoginAs(string email, string password)
{
Email.SendKeys(email);
Password.SendKeys(password);
SubmitBtn.Click();
return new HomePage(_driver);
}
}
Code Coverage و گزارشگیری
برای اندازهگیری پوشش کد در .NET MAUI، Coverlet ابزار استاندارد است. ابتدا پکیج آن را اضافه کنید و سپس:
dotnet test --collect:"XPlat Code Coverage" --results-directory ./TestResults /p:Threshold=70 /p:ThresholdType=line
سپس با ReportGenerator یک گزارش HTML تولید کنید:
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:"coveragereport" -reporttypes:Html
هدف معقول در ۲۰۲۶ برای پروژههای موبایل، پوشش ۷۰٪ تا ۸۰٪ روی پروژههای Core و Services و حدود ۴۰٪ تا ۵۰٪ روی پروژهی MAUI است. تلاش برای ۱۰۰٪ معمولاً به نوشتن تستهای بیارزش منجر میشود — تستهایی که فقط برای رضایت متریکها نوشته شدهاند و هیچ باگی را پیدا نمیکنند.
اجرای خودکار تستها در GitHub Actions
یک Pipeline حداقلی برای پروژهی MAUI که هم Unit Test و هم Android UI Test اجرا کند:
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
unit-tests:
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
- run: dotnet restore
- run: dotnet test tests/MyApp.Core.Tests --collect:"XPlat Code Coverage"
- uses: codecov/codecov-action@v4
android-ui-tests:
runs-on: macos-14
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm install -g appium@2 && appium driver install uiautomator2
- run: dotnet workload install maui-android
- run: dotnet build -t:Run -f net9.0-android src/MyApp.Maui &
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
script: |
appium --port 4723 &
sleep 5
dotnet test tests/MyApp.UI.Tests
تست عملکرد (Performance) با BenchmarkDotNet
اگر بخشهایی از منطق شما حساس به عملکرد است (مثلاً پارس JSON، الگوریتمهای جستوجو، یا پردازش تصویر)، یک پروژهی Benchmark در کنار تستها اضافه کنید:
[MemoryDiagnoser]
public class JsonParserBenchmarks
{
private string _json = null!;
[GlobalSetup]
public void Setup() => _json = File.ReadAllText("sample.json");
[Benchmark(Baseline = true)]
public Order? SystemTextJson() =>
JsonSerializer.Deserialize<Order>(_json);
[Benchmark]
public Order? Newtonsoft() =>
JsonConvert.DeserializeObject<Order>(_json);
}
BenchmarkDotNet به شما میگوید کدام پیادهسازی سریعتر است و چقدر تخصیص حافظه میکند — اطلاعاتی حیاتی برای اپهای موبایلی که منابع محدود دارند (و کاربر گوشی متوسط، صبر چندانی برای انیمیشنهای لگدار ندارد).
اشتباهات رایج در تستنویسی .NET MAUI
- تست کردن Framework به جای کد خود: نوشتن تست برای اینکه «
ObservablePropertyرویداد PropertyChanged را Raise میکند» اتلاف وقت محض است. این کار Source Generator است، نه کد شما. - وابستگی به ترتیب اجرا: هر تست باید مستقل باشد. اگر تست B فقط زمانی پاس میشود که تست A قبل از آن اجرا شده باشد، یعنی State نشت کرده — و این مشکل خیلی زود به شما برمیگردد.
- Mock کردن Typeهای ساده: اگر یک کلاس فقط چند Property دارد، آن را Mock نکنید؛ یک Instance واقعی بسازید.
- UI Test بیش از حد: هر سناریوی منطقی را با Unit Test پوشش دهید. UI Test تنها برای جریانهای End-to-End حیاتی (Login، Checkout، Onboarding).
- نادیده گرفتن Flaky Test: تستی که گاهی پاس و گاهی Fail میشود، بدتر از نبود تست است. علت را پیدا کنید (معمولاً Race Condition یا Wait نامناسب). هرگز با
[Retry]صورتمسئله را پاک نکنید.
سؤالات متداول
آیا میتوان از MSTest به جای xUnit استفاده کرد؟
بله، MSTest و NUnit هم پشتیبانی میشوند، اما اکوسیستم xUnit در داتنت مدرن غنیتر است: ابزارهایی مثل Verify، Bunit و TestContainers ابتدا با xUnit سازگار میشوند. اگر پروژهی جدیدی شروع میکنید، xUnit انتخاب امنتری است.
تفاوت Appium و Maui.Controls.AppiumTests چیست؟
پکیج Microsoft.Maui.Controls.AppiumTests یک Wrapper سبک حول Appium است که توسط خود تیم MAUI برای تست داخلی Toolkit استفاده میشود. در پروژههای واقعی، استفادهی مستقیم از Appium.WebDriver انعطاف بیشتری میدهد. توصیهی ما برای Production در ۲۰۲۶، همان Appium.WebDriver خام است.
چطور UI Test روی iOS را در GitHub Actions اجرا کنیم؟
به Runnerهای macos-14 یا macos-15 نیاز دارید که هزینهی دقیقهای ۱۰ برابر اوبونتو است. توصیه میکنیم UI Test اندروید را در هر Pull Request اجرا کنید اما iOS UI Test را فقط روی Push به main یا Nightly Build اجرا کنید تا هزینهها از کنترل خارج نشوند.
آیا Snapshot Testing برای XAML هم کار میکند؟
بهطور مستقیم خیر، چون رندر XAML نیاز به Runtime MAUI دارد. اما میتوانید Visual Tree یک View را به یک شیء ساده تبدیل کنید و آن را با Verify مقایسه نمایید. برای تصویر واقعی، باید از Screenshot Testing در Appium استفاده کنید که ابزار Verify.ImageMagick برای مقایسهی تصاویر کاربرد دارد.
چهقدر تست برای یک پروژهی MAUI کافی است؟
یک قانون سرانگشتی: به ازای هر ساعت کدنویسی منطق کسبوکار، حدود ۳۰ تا ۴۵ دقیقه برای تست کنار بگذارید. این هزینه در ابتدا زیاد به نظر میرسد ولی در ماه ششم پروژه — زمانی که هر تغییر کوچک میتواند سناریوهای دور را بشکند — چندین برابر بازگشت سرمایه خواهد داشت.
جمعبندی
یک استراتژی تست منسجم در .NET MAUI شامل سه لایه است: Unit Test سریع و فراوان برای منطق کسبوکار، Integration Test برای داده و سرویسها، و تعداد محدودی UI Test با Appium برای سناریوهای حیاتی End-to-End. ابزارهای ۲۰۲۶ شامل xUnit، NSubstitute، Verify، WireMock.Net، Appium 2.x و BenchmarkDotNet هستند و همگی بهخوبی در GitHub Actions اتوماسیون میشوند.
اما مهمتر از هر ابزار، طراحی معماری تستپذیر از روز اول است: تزریق وابستگی، جداسازی منطق از UI، و استفاده از Abstraction برای کارهای پلتفرممحور. اگر این پایه را درست بگذارید، تستنویسی نه یک تعهد دردناک، بلکه ابزاری برای حرکت سریعتر و مطمئنتر در طول عمر پروژه خواهد بود. و این، شاید بزرگترین هدیهای باشد که میتوانید به آیندهی تیم خود بدهید.