اختبار تطبيقات .NET MAUI في 2026: دليل عملي للاختبارات الوحدوية واختبارات الواجهة

دليل عملي لاختبار تطبيقات .NET MAUI في 2026: ViewModel بـ xUnit، اختبارات الواجهة بـ Appium 2.x، تغطية الكود، وقالب GitHub Actions جاهز.

اختبار .NET MAUI: دليل عملي 2026

آخر تحديث: 11 يونيو 2026

اختبار تطبيقات .NET MAUI يعني فصل منطق التطبيق عن الواجهة لاختبار ViewModel والخدمات بـ xUnit، ثم اختبار التدفقات الفعلية على الأجهزة عبر Appium أو .NET MAUI UITest. في 2026 ومع .NET MAUI 10، أصبحت قوالب الاختبار جاهزة في القوالب الرسمية، لكن أغلب الفرق ما تزال تكتفي بنوع واحد من الاختبارات وتفاجأ بانهيار التطبيق في الإنتاج. بعد خمس سنوات من شحن تطبيقات MAUI وعدد من الكوارث الليلية، هذا ما يعمل فعلاً.

  • اعزل منطق ViewModel عن MAUI تماماً. هذا يفتح لك ٨٠٪ من تغطية الاختبار بـ xUnit دون أي محاكي.
  • استخدم CommunityToolkit.Mvvm مع حقن تبعيات صارم؛ كل خدمة تعتمد عليها ViewModel يجب أن تكون قابلة للاستبدال بـ Mock.
  • اختبارات الواجهة الحقيقية تتطلب Appium 2.x مع برامج التشغيل UiAutomator2 لـ Android و XCUITest لـ iOS.
  • قس التغطية بـ coverlet.collector واستهدف ٧٠٪+ لطبقة المنطق، وليس للكود كله، فتغطية الـ XAML غير مفيدة.
  • اربط كل شيء بـ GitHub Actions أو Azure Pipelines مع مصفوفة لكل منصة، وإلا ستكسر اختبارات iOS بصمت.
  • Hot Reload يكذب، فلا تعتمد على الاختبار اليدوي بعد كل تعديل، فالفروق بين Debug و Release تظهر فقط في CI.

هرم اختبار MAUI: ما الذي يستحق الجهد

قبل أن نكتب سطر اختبار واحد، يجب أن نتفق على ما نختبر ولماذا. الفكرة الكلاسيكية لهرم الاختبار من Mike Cohn تنطبق على MAUI بشكل أكثر صرامة من تطبيقات الويب: كل اختبار يتطلب جهازاً أو محاكياً فعلياً يدفع ثمناً كبيراً من الوقت في CI، وكل دقيقة إضافية في خط الإنشاء تكلف الفريق إنتاجية.

في تطبيقات MAUI التي شحنتها، أقسم الاختبارات إلى ثلاث طبقات: اختبارات الوحدة (٧٠٪)، اختبارات التكامل للخدمات (٢٠٪)، واختبارات الواجهة الكاملة (١٠٪). السبب بسيط، فاختبار الوحدة لـ ViewModel يعمل في أقل من ٥٠ مللي ثانية على CPU عادي، بينما اختبار واجهة واحد على محاكي iOS قد يأخذ ٤٠ ثانية فقط للإقلاع.

المقايضة هنا حقيقية: الفرق الذي يكتب اختبارات واجهة فقط ينتهي بـ CI يأخذ ساعة، ويبدأ المطورون بتعطيل الاختبارات. الفرق الذي يكتب اختبارات وحدة فقط يفوّت أخطاء التهيئة الخاصة بكل منصة. الحل هو هرم متوازن، وفي MAUI تحديداً، الطبقة الأكثر إهمالاً هي اختبارات التكامل لخدمات HTTP والذاكرة المؤقتة، وهي السبب الأول لتقارير الأعطال من الإنتاج.

إعداد مشروع الاختبار في .NET MAUI 10

في .NET MAUI 10، أصبح القالب الرسمي dotnet new maui يدعم إضافة مشروع اختبار مرافق مباشرة، لكنني أفضل الإعداد اليدوي للتحكم الكامل. الخطوة الأولى هي فصل منطق التطبيق عن مشروع MAUI نفسه إلى مكتبة فئات قياسية، وهذه أهم خطوة، وبدونها لن تستطيع الاستهداف بـ net10.0 العادي في اختباراتك.

ابدأ بهذا الهيكل:

MyApp/
├── MyApp.Core/              (net10.0)        ← ViewModels, Services, Models
├── MyApp/                   (net10.0-android/ios/...)  ← MAUI shell
├── MyApp.Core.Tests/        (net10.0)        ← xUnit unit tests
└── MyApp.UITests/           (net10.0)        ← Appium UI tests

الآن أنشئ مشروع اختبار الوحدة:

dotnet new xunit -n MyApp.Core.Tests -f net10.0
cd MyApp.Core.Tests
dotnet add package Moq --version 4.20.72
dotnet add package FluentAssertions --version 7.0.0
dotnet add package Microsoft.NET.Test.Sdk
dotnet add package coverlet.collector
dotnet add reference ../MyApp.Core/MyApp.Core.csproj

السبب الذي يدفعني لاستخدام FluentAssertions ليس جمالياً فقط، فرسائل الفشل لـ Assert.Equal الافتراضي تكاد تكون عديمة الفائدة عند مقارنة كائنات معقدة، بينما obj.Should().BeEquivalentTo(expected) يعطيك diff واضحاً للحقول المختلفة. على فريق من خمسة مطورين، هذا يوفر ساعات أسبوعياً في تشخيص الاختبارات الفاشلة.

اختبار ViewModel باستخدام xUnit و Moq

اختبار ViewModel هو ٨٠٪ من قيمة الاختبار الفعلية. إذا كنت تستخدم نمط MVVM بشكل صحيح (وقد تناولنا هذا بالتفصيل في دليل MVVM مع CommunityToolkit.Mvvm)، فإن ViewModel لا تعرف شيئاً عن MAUI، ويمكن اختبارها كأي فئة C# عادية.

لنفترض أن لدينا ViewModel لشاشة تسجيل دخول:

public partial class LoginViewModel : ObservableObject
{
    private readonly IAuthService _auth;
    private readonly INavigationService _navigation;

    [ObservableProperty] private string _email = string.Empty;
    [ObservableProperty] private string _password = string.Empty;
    [ObservableProperty] private bool _isBusy;
    [ObservableProperty] private string? _errorMessage;

    public LoginViewModel(IAuthService auth, INavigationService navigation)
    {
        _auth = auth;
        _navigation = navigation;
    }

    [RelayCommand]
    private async Task LoginAsync()
    {
        if (string.IsNullOrWhiteSpace(Email) || string.IsNullOrWhiteSpace(Password))
        {
            ErrorMessage = "البريد وكلمة المرور مطلوبان";
            return;
        }

        IsBusy = true;
        ErrorMessage = null;
        try
        {
            var result = await _auth.SignInAsync(Email, Password);
            if (result.Success)
                await _navigation.GoToAsync("//main");
            else
                ErrorMessage = result.ErrorMessage;
        }
        finally { IsBusy = false; }
    }
}

الآن الاختبار. لاحظ كيف يفحص ثلاث حالات منفصلة: التحقق من المدخلات، النجاح، والفشل:

public class LoginViewModelTests
{
    private readonly Mock<IAuthService> _auth = new();
    private readonly Mock<INavigationService> _navigation = new();

    private LoginViewModel CreateSut() => new(_auth.Object, _navigation.Object);

    [Fact]
    public async Task LoginAsync_WithEmptyEmail_SetsErrorMessage()
    {
        var sut = CreateSut();
        sut.Email = "";
        sut.Password = "secret";

        await sut.LoginCommand.ExecuteAsync(null);

        sut.ErrorMessage.Should().Be("البريد وكلمة المرور مطلوبان");
        _auth.Verify(a => a.SignInAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
    }

    [Fact]
    public async Task LoginAsync_OnSuccess_NavigatesToMain()
    {
        _auth.Setup(a => a.SignInAsync("[email protected]", "p"))
             .ReturnsAsync(new AuthResult(true, null));
        var sut = CreateSut();
        sut.Email = "[email protected]";
        sut.Password = "p";

        await sut.LoginCommand.ExecuteAsync(null);

        _navigation.Verify(n => n.GoToAsync("//main"), Times.Once);
        sut.IsBusy.Should().BeFalse();
    }

    [Fact]
    public async Task LoginAsync_OnFailure_DisplaysServerError()
    {
        _auth.Setup(a => a.SignInAsync(It.IsAny<string>(), It.IsAny<string>()))
             .ReturnsAsync(new AuthResult(false, "بيانات غير صحيحة"));
        var sut = CreateSut();
        sut.Email = "[email protected]";
        sut.Password = "p";

        await sut.LoginCommand.ExecuteAsync(null);

        sut.ErrorMessage.Should().Be("بيانات غير صحيحة");
        _navigation.Verify(n => n.GoToAsync(It.IsAny<string>()), Times.Never);
    }
}

الفائدة الجوهرية هنا: لا محاكي، لا Shell.Current، لا MainThread.BeginInvokeOnMainThread، مجرد C# نقي يعمل في ٤٠ مللي ثانية. شحنت هذا النمط في ثلاثة تطبيقات إنتاجية، وكل خطأ منطقي تم اصطياده هنا وفر لنا ساعات على المحاكي.

اختبار الخدمات وطبقة البيانات

الطبقة التالية هي خدمات HTTP والذاكرة المؤقتة وقواعد البيانات. هنا تحتاج إلى اختبارات تكامل حقيقية، لأن الـ Mock للـ HttpClient سيخفي عنك مشاكل التسلسل والمهلات الفعلية. للتعمق في طبقة الشبكة، راجع دليل REST API مع HttpClient و IHttpClientFactory.

الطريقة الأفضل التي وجدتها هي استخدام WireMock.Net لمحاكاة خادم HTTP حقيقي:

public class WeatherApiClientTests : IAsyncLifetime
{
    private WireMockServer _server = null!;
    private WeatherApiClient _sut = null!;

    public Task InitializeAsync()
    {
        _server = WireMockServer.Start();
        var http = new HttpClient { BaseAddress = new Uri(_server.Url!) };
        _sut = new WeatherApiClient(http);
        return Task.CompletedTask;
    }

    public Task DisposeAsync() { _server.Stop(); return Task.CompletedTask; }

    [Fact]
    public async Task GetForecast_ReturnsParsedDays()
    {
        _server.Given(Request.Create().WithPath("/forecast").UsingGet())
               .RespondWith(Response.Create()
                    .WithStatusCode(200)
                    .WithBody("{\"days\":[{\"date\":\"2026-06-12\",\"tempC\":31}]}")
                    .WithHeader("Content-Type", "application/json"));

        var forecast = await _sut.GetForecastAsync("Dubai");

        forecast.Days.Should().HaveCount(1);
        forecast.Days[0].TempC.Should().Be(31);
    }
}

لاختبار طبقة قاعدة البيانات المحلية، استخدم SQLite في الذاكرة بدلاً من ملف فعلي. يعمل بنفس واجهة sqlite-net-pcl ولكن دون كتابة على القرص. هذا يجعل ٢٠٠ اختبار للمستودع ينتهي في ٣ ثوانٍ بدلاً من ٣٠ ثانية، وهو فرق حاسم عند التشغيل المحلي.

اختبارات الواجهة باستخدام Appium

اختبارات الواجهة في MAUI تغيرت كثيراً منذ نهاية Xamarin.UITest. الخيار الموصى به رسمياً الآن هو Appium 2.x مع .NET MAUI UITest، وهي تجريبية لكنها تعمل بشكل جيد لتغطية تدفقات end-to-end الحرجة. تحتاج إلى تثبيت Appium 2.x وبرامج التشغيل اللازمة:

npm install -g appium@next
appium driver install uiautomator2
appium driver install xcuitest
appium driver list --installed

ثم في مشروع الاختبار:

public class LoginUiTests : IDisposable
{
    private readonly AndroidDriver _driver;

    public LoginUiTests()
    {
        var options = new AppiumOptions
        {
            PlatformName = "Android",
            AutomationName = "UiAutomator2",
            App = Path.Combine(AppContext.BaseDirectory, "com.myapp.debug.apk"),
            DeviceName = "Pixel 7 API 35"
        };
        _driver = new AndroidDriver(new Uri("http://127.0.0.1:4723"), options);
        _driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(10);
    }

    [Fact]
    public void Login_WithValidCredentials_NavigatesToHome()
    {
        var email = _driver.FindElement(By.Id("EmailEntry"));
        email.SendKeys("[email protected]");
        _driver.FindElement(By.Id("PasswordEntry")).SendKeys("p@ssw0rd");
        _driver.FindElement(By.Id("LoginButton")).Click();

        var home = _driver.FindElement(By.Id("HomeTitleLabel"));
        Assert.Equal("الصفحة الرئيسية", home.Text);
    }

    public void Dispose() => _driver?.Quit();
}

المفتاح هنا هو ضبط AutomationId على كل عنصر في XAML تريد اختباره. بدونها، تضطر إلى البحث عن العناصر عبر xpath هش يكسر مع كل تغيير صغير في الواجهة. أُلزم فريقي بأن أي عنصر تفاعلي في XAML يجب أن يحمل AutomationId وإلا فلن يُقبل PR.

قياس تغطية الكود في .NET MAUI

تغطية الكود مفيدة كمؤشر، لا كهدف. استخدم coverlet.collector الذي يأتي مع قوالب xUnit:

dotnet test --collect:"XPlat Code Coverage" --results-directory ./TestResults
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" \
                -targetdir:"coverage-report" -reporttypes:Html

الخطأ الذي أراه باستمرار: فرق تستهدف ٩٠٪ تغطية للحل كله. هذا يدفع المطورين لكتابة اختبارات سخيفة لـ App.xaml.cs ومحولات XAML. الهدف الواقعي هو ٧٠-٨٠٪ لـ MyApp.Core فقط، وصفر متطلبات لمشاريع المنصات. عندما تحدد هذا في coverlet.runsettings عبر <Exclude>، يصبح الرقم ذو معنى.

أيضاً لاحظ أن تغطية فروع switch الناتجة عن سجلات C# وpartial properties (المُولّدة بـ CommunityToolkit.Mvvm) ستظهر دائماً منخفضة. هذه إيجابيات كاذبة، فاستبعدها بـ [ExcludeFromCodeCoverage] على الأصل أو بنمط استبعاد عام.

تكامل الاختبارات مع CI/CD

الاختبارات التي لا تعمل في CI ستتعطل خلال أسبوع. هذا قالب GitHub Actions يعمل عليه فريقي حالياً ويغطي Android على Ubuntu و iOS على macOS بالتوازي:

name: maui-tests
on: [push, pull_request]
jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '10.0.x' }
      - run: dotnet test MyApp.Core.Tests --collect:"XPlat Code Coverage"
      - uses: codecov/codecov-action@v5

  ui-tests-android:
    runs-on: ubuntu-latest
    needs: unit-tests
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '10.0.x' }
      - run: dotnet workload install maui-android
      - run: dotnet build MyApp -f net10.0-android -c Release
      - uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 35
          script: dotnet test MyApp.UITests --filter Platform=Android

  ui-tests-ios:
    runs-on: macos-15
    needs: unit-tests
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '10.0.x' }
      - run: dotnet workload install maui-ios
      - run: dotnet build MyApp -f net10.0-ios -c Release
      - run: dotnet test MyApp.UITests --filter Platform=iOS

ملاحظتان من التجربة: أولاً، شغّل اختبارات الوحدة أولاً كـ gate لأنها سريعة وتمسك ٩٠٪ من الأخطاء؛ لا داعي لتشغيل محاكي iOS لـ ٢٠ دقيقة إذا كان اختبار C# بسيط فاشلاً. ثانياً، احتفظ بعقود macos-15 لأقل عدد من الاختبارات لأنها أغلى بثلاث مرات من Linux في فواتير GitHub Actions.

أخطاء شائعة في اختبار MAUI

هذه قائمة من الأخطاء التي رأيتها (وارتكبتها بنفسي) في تطبيقات MAUI الإنتاجية:

  • افتراض أن MainThread.IsMainThread صحيح في الاختبار: في اختبارات xUnit، لا يوجد main thread حقيقي. لف الاستدعاء بـ IDispatcher مُحقَن واستبدله بـ Mock في الاختبارات.
  • اختبار Preferences.Set/Get مباشرة: هذه واجهة ثابتة لا يمكن استبدالها. غلّفها بـ IUserPreferences دائماً.
  • إهمال اختبارات الأداء: شحنت تطبيقاً ذات مرة بـ CollectionView يحوي ١٠ آلاف عنصر دون اختبار. للتعمق في هذا الجانب راجع دليل تحسين الأداء في .NET MAUI.
  • الاعتماد على Hot Reload للتحقق من سلوك Release: AOT يكسر التسلسل في Release بصمت أحياناً. لا تثق إلا بـ CI.
  • اختبارات هشة بسبب التوقيت: استبدل Thread.Sleep بـ explicit waits في Appium، وفي xUnit استخدم TaskCompletionSource بدلاً من await Task.Delay.

للمرجع الكامل لأنماط الاختبار في dotnet، توثيق .NET Testing الرسمي ممتاز ومحدث لإصدار 2026. ولـ Appium، وثائق Appium 2.x هي المصدر الأكثر دقة.

الأسئلة الشائعة

كيف أختبر ViewModel في .NET MAUI دون محاكي؟

أنشئ مكتبة net10.0 منفصلة لـ ViewModels والخدمات، ثم استخدم xUnit مع Moq لاستبدال أي خدمة تعتمد عليها ViewModel. إذا كانت ViewModel تستدعي MAUI مباشرة (مثل Shell.Current)، فالحل هو إخفاء ذلك خلف واجهة مثل INavigationService قابلة للاستبدال.

ما الفرق بين اختبارات الوحدة واختبارات التكامل في MAUI؟

اختبار الوحدة يعزل وحدة منطق واحدة (مثل ViewModel) ويستبدل كل تبعياتها بـ Mocks، ويعمل في مللي ثوان. اختبار التكامل يشغل عدة مكونات معاً (مثل ViewModel مع مستودع وقاعدة بيانات SQLite في الذاكرة) للتحقق من تعاونها. كلاهما يعمل دون محاكي، لكن اختبار التكامل أبطأ وأكثر هشاشة.

هل يمكن استخدام Appium لاختبار تطبيقات .NET MAUI؟

نعم، Appium 2.x مع برامج التشغيل UiAutomator2 لـ Android و XCUITest لـ iOS هي الطريقة الرسمية الموصى بها من Microsoft بعد إيقاف Xamarin.UITest. تتطلب ضبط AutomationId على عناصر XAML والوصول لـ macOS لتشغيل اختبارات iOS.

ما أفضل نسبة تغطية كود مستهدفة لتطبيق MAUI؟

اِستهدف ٧٠-٨٠٪ لمكتبة المنطق (MyApp.Core) فقط، واستبعد مشاريع المنصات و XAML من القياس. السعي لـ ٩٠٪+ على الحل كله يدفع لكتابة اختبارات منخفضة القيمة، والأخطاء الحقيقية تختبئ في تفاعلات الخدمات وليس في تغطية السطور.

كيف أتعامل مع اختبار شيفرة خاصة بمنصة معينة في MAUI؟

اعزل أي كود #if ANDROID أو #if IOS خلف واجهة في طبقة Core، ثم نفّذ هذه الواجهة في مشروع MAUI نفسه. هكذا تستطيع اختبار الواجهة بـ Mock في xUnit، وتترك التنفيذ الفعلي ليُختبر بـ Appium على الجهاز الحقيقي.

Marcus Chen
عن الكاتب Marcus Chen

Senior mobile architect with a decade of cross-platform experience. Spent the last five years going deep on .NET MAUI in production.