בדיקות יחידה ואינטגרציה ב-.NET MAUI 10: מדריך מעשי עם xUnit ו-Moq

מדריך מעשי לבדיקות יחידה ואינטגרציה ב-.NET MAUI 10 — דוגמאות קוד עובדות עם xUnit, Moq ו-FluentAssertions, בדיקות ל-ViewModels ו-Services, בדיקות UI עם Appium וטיפים מהשטח.

למה בדיקות הן לא מותרות — הן חובה

אם עקבתם אחרי המדריך הקודם שלנו על בניית אפליקציית MAUI עם MVVM, בטח שמתם לב שהשקענו אנרגיה לא מעטה בארכיטקטורה נקייה — הפרדה בין שכבות, הזרקת תלויות, ו-ViewModels עם CommunityToolkit.Mvvm. כל זה נהדר, אבל בלי בדיקות? זה קצת כמו לבנות בית מפואר בלי ביטוח. הכל עובד מעולה — עד שזה לא.

בואו נהיה כנים לרגע. רוב המפתחים שאני מכיר לא אוהבים לכתוב בדיקות. זה מרגיש כמו "עבודה כפולה". אבל אחרי שבאג קריטי מגיע לפרודקשן ביום שישי בצהריים — פתאום בדיקות נשמעות כמו רעיון מצוין.

ב-.NET MAUI 10, מיקרוסופט שמה דגש חזק על איכות קוד. המערכת החדשה של Diagnostics ו-Metrics (עם ActivitySource ו-Microsoft.Maui Meters) נותנת לנו כלים מובנים לעקוב אחרי ביצועים. אבל הכלי הכי חשוב לאיכות קוד הוא ותמיד יהיה — בדיקות אוטומטיות.

במדריך הזה נבנה תשתית בדיקות מלאה לאפליקציית .NET MAUI 10. נתחיל מבדיקות יחידה ל-ViewModels, נעבור לבדיקות שירותים עם מוקים, ונסיים עם בדיקות אינטגרציה ו-UI. כל דוגמת קוד כאן עובדת — אפשר פשוט להעתיק, להדביק ולהריץ.

הגדרת פרויקט הבדיקות

יצירת פרויקט xUnit

xUnit היא ספריית הבדיקות הפופולרית ביותר בעולם .NET, וזה לא מקרי. היא מודרנית, מהירה, ומשתלבת מצוין עם כלי ה-CI/CD. אז בואו נתחיל ביצירת פרויקט בדיקות:

dotnet new xunit -n TaskMaster.Tests
cd TaskMaster.Tests
dotnet add reference ../TaskMaster/TaskMaster.csproj

עכשיו נתקין את כל החבילות שנצטרך:

dotnet add package Moq
dotnet add package FluentAssertions
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package CommunityToolkit.Mvvm
dotnet add package sqlite-net-pcl

Moq היא ספריית Mocking שמאפשרת לנו ליצור אובייקטים מזויפים במקום השירותים האמיתיים. FluentAssertions הופכת את האסרציות לקריאות הרבה יותר — במקום Assert.Equal נכתוב result.Should().Be(expected). בכנות, אחרי שמתרגלים ל-FluentAssertions קשה לחזור אחורה.

מבנה תיקיות מומלץ

כמו שמבנה טוב חשוב בפרויקט הראשי, כך גם בפרויקט הבדיקות. הנה המבנה שאני ממליץ עליו:

TaskMaster.Tests/
├── ViewModels/
│   ├── TaskListViewModelTests.cs
│   ├── TaskDetailViewModelTests.cs
│   └── AddTaskViewModelTests.cs
├── Services/
│   ├── TaskServiceTests.cs
│   └── DatabaseServiceTests.cs
├── Integration/
│   ├── NavigationTests.cs
│   └── DependencyInjectionTests.cs
├── Helpers/
│   ├── MockFactory.cs
│   └── TestDataBuilder.cs
└── GlobalUsings.cs

הקובץ GlobalUsings.cs חוסך הרבה שורות חוזרות בכל קובץ בדיקה. טיפ קטן שעושה הבדל גדול:

// GlobalUsings.cs
global using Xunit;
global using Moq;
global using FluentAssertions;
global using TaskMaster.Models;
global using TaskMaster.Services;
global using TaskMaster.ViewModels;

בדיקות יחידה ל-ViewModels

ה-ViewModels הם הלב של האפליקציה. הם מכילים את הלוגיקה העסקית, מנהלים את מצב הממשק, ומטפלים באינטראקציות של המשתמש. אם הם עובדים נכון — רוב האפליקציה עובדת נכון.

בדיקת טעינת משימות

נתחיל עם בדיקה בסיסית: האם ה-ViewModel טוען משימות בהצלחה? שימו לב איך אנחנו משתמשים ב-Mock של ITaskService כדי לבודד את הבדיקה לחלוטין מהמסד נתונים:

// ViewModels/TaskListViewModelTests.cs
namespace TaskMaster.Tests.ViewModels;

public class TaskListViewModelTests
{
    private readonly Mock<ITaskService> _mockTaskService;
    private readonly TaskListViewModel _viewModel;

    public TaskListViewModelTests()
    {
        _mockTaskService = new Mock<ITaskService>();
        _viewModel = new TaskListViewModel(_mockTaskService.Object);
    }

    [Fact]
    public async Task LoadTasks_ShouldPopulateTasksList_WhenServiceReturnsData()
    {
        // Arrange
        var expectedTasks = new List<TaskItem>
        {
            new() { Id = 1, Title = "משימה ראשונה", IsCompleted = false },
            new() { Id = 2, Title = "משימה שנייה", IsCompleted = true },
            new() { Id = 3, Title = "משימה שלישית", IsCompleted = false }
        };

        _mockTaskService
            .Setup(s => s.GetAllTasksAsync())
            .ReturnsAsync(expectedTasks);

        // Act
        await _viewModel.LoadTasksCommand.ExecuteAsync(null);

        // Assert
        _viewModel.Tasks.Should().HaveCount(3);
        _viewModel.Tasks.First().Title.Should().Be("משימה ראשונה");
        _viewModel.IsEmpty.Should().BeFalse();
        _viewModel.IsLoading.Should().BeFalse();
    }

    [Fact]
    public async Task LoadTasks_ShouldSetIsEmpty_WhenNoTasksExist()
    {
        // Arrange
        _mockTaskService
            .Setup(s => s.GetAllTasksAsync())
            .ReturnsAsync(new List<TaskItem>());

        // Act
        await _viewModel.LoadTasksCommand.ExecuteAsync(null);

        // Assert
        _viewModel.Tasks.Should().BeEmpty();
        _viewModel.IsEmpty.Should().BeTrue();
    }
}

שימו לב לדפוס Arrange-Act-Assert (או בקיצור AAA). כל בדיקה מחולקת לשלושה חלקים ברורים: הכנה, פעולה, ובדיקת תוצאה. זה הופך את הבדיקות לקריאות וקלות לתחזוקה — מה שנשמע כמו פרט קטן, אבל עושה הבדל ענק כשיש לכם 200 בדיקות בפרויקט.

בדיקת פקודות (RelayCommand)

אחד הדברים הטובים ב-CommunityToolkit.Mvvm הוא שהפקודות שנוצרות עם [RelayCommand] הן ישויות שאפשר להפעיל ולבדוק ישירות. נבדוק את פעולת המחיקה:

[Fact]
public async Task DeleteTask_ShouldRemoveFromList_WhenConfirmed()
{
    // Arrange
    var taskToDelete = new TaskItem { Id = 1, Title = "משימה למחיקה" };
    var tasks = new List<TaskItem> { taskToDelete };

    _mockTaskService
        .Setup(s => s.GetAllTasksAsync())
        .ReturnsAsync(tasks);

    _mockTaskService
        .Setup(s => s.DeleteTaskAsync(1))
        .Returns(Task.CompletedTask);

    await _viewModel.LoadTasksCommand.ExecuteAsync(null);
    _viewModel.Tasks.Should().HaveCount(1);

    // Act
    await _viewModel.DeleteTaskCommand.ExecuteAsync(taskToDelete);

    // Assert
    _mockTaskService.Verify(s => s.DeleteTaskAsync(1), Times.Once);
}

[Fact]
public async Task DeleteTask_ShouldUpdateIsEmpty_WhenLastTaskDeleted()
{
    // Arrange
    var lastTask = new TaskItem { Id = 1, Title = "משימה אחרונה" };
    _mockTaskService
        .Setup(s => s.GetAllTasksAsync())
        .ReturnsAsync(new List<TaskItem> { lastTask });
    _mockTaskService
        .Setup(s => s.DeleteTaskAsync(1))
        .Returns(Task.CompletedTask);

    await _viewModel.LoadTasksCommand.ExecuteAsync(null);

    // Act
    await _viewModel.DeleteTaskCommand.ExecuteAsync(lastTask);

    // Assert
    _viewModel.Tasks.Should().BeEmpty();
    _viewModel.IsEmpty.Should().BeTrue();
}

בדיקת סינון וחיפוש

סינון הוא לוגיקה עסקית שממש חשוב לכסות בבדיקות. נבדוק שהחיפוש עובד כמו שצריך:

[Theory]
[InlineData("קניות", 1)]
[InlineData("משימה", 3)]
[InlineData("לא קיים", 0)]
[InlineData("", 3)]
public async Task FilterTasks_ShouldReturnMatchingTasks(
    string searchText, int expectedCount)
{
    // Arrange
    var allTasks = new List<TaskItem>
    {
        new() { Id = 1, Title = "קניות בסופר", Description = "חלב, לחם, ביצים" },
        new() { Id = 2, Title = "משימה בעבודה", Description = "לסיים דוח" },
        new() { Id = 3, Title = "משימה ביתית", Description = "לנקות משימה" }
    };

    _mockTaskService
        .Setup(s => s.GetAllTasksAsync())
        .ReturnsAsync(allTasks);

    await _viewModel.LoadTasksCommand.ExecuteAsync(null);

    // Act
    _viewModel.SearchText = searchText;

    // המתנה קצרה לסינון אסינכרוני
    await Task.Delay(100);

    // Assert
    _viewModel.Tasks.Should().HaveCount(expectedCount);
}

שימו לב לשימוש ב-[Theory] עם [InlineData] — זה מאפשר לנו להריץ את אותה בדיקה עם ערכים שונים בלי לשכפל קוד. ב-xUnit, [Fact] הוא בדיקה בודדת ו-[Theory] הוא בדיקה פרמטרית. פיצ'ר קטן ושימושי מאוד.

בדיקת שינויי מצב (INotifyPropertyChanged)

אחד הדברים הקריטיים ב-MVVM הוא שהממשק מתעדכן כשהמצב משתנה. בואו נוודא שהאירועים באמת נשלחים כשצריך:

[Fact]
public async Task LoadTasks_ShouldNotifyPropertyChanged_ForIsLoading()
{
    // Arrange
    var propertyChanges = new List<string>();
    _viewModel.PropertyChanged += (_, e) =>
        propertyChanges.Add(e.PropertyName!);

    _mockTaskService
        .Setup(s => s.GetAllTasksAsync())
        .ReturnsAsync(new List<TaskItem>());

    // Act
    await _viewModel.LoadTasksCommand.ExecuteAsync(null);

    // Assert
    propertyChanges.Should().Contain("IsLoading");
    propertyChanges.Should().Contain("Tasks");
    propertyChanges.Should().Contain("IsEmpty");
}

[Fact]
public void SearchText_ShouldNotifyPropertyChanged_WhenSet()
{
    // Arrange
    var raised = false;
    _viewModel.PropertyChanged += (_, e) =>
    {
        if (e.PropertyName == "SearchText") raised = true;
    };

    // Act
    _viewModel.SearchText = "חיפוש חדש";

    // Assert
    raised.Should().BeTrue();
    _viewModel.SearchText.Should().Be("חיפוש חדש");
}

Mocking שירותים עם Moq

Mocking הוא הטכניקה שמאפשרת לנו לבדוק רכיב אחד בבידוד מוחלט. במקום להתחבר למסד נתונים אמיתי או לשלוח בקשות רשת — אנחנו יוצרים "שירות מזויף" שמחזיר בדיוק מה שאנחנו רוצים.

אגב, זה גם מה שהופך את הבדיקות למהירות ברמות. אין IO, אין רשת, אין דיסק. הכל בזיכרון.

הגדרת Mock מתקדם

לפעמים צריך לבדוק תרחישים יותר מורכבים. הנה דוגמה למוק שמדמה שירות עם מצב פנימי (וזה כבר ממש שימושי בפרויקטים אמיתיים):

public class TaskServiceMockFactory
{
    public static Mock<ITaskService> CreateWithTasks(
        params TaskItem[] tasks)
    {
        var mock = new Mock<ITaskService>();
        var taskList = new List<TaskItem>(tasks);

        mock.Setup(s => s.GetAllTasksAsync())
            .ReturnsAsync(() => taskList.ToList());

        mock.Setup(s => s.GetTaskByIdAsync(It.IsAny<int>()))
            .ReturnsAsync((int id) =>
                taskList.FirstOrDefault(t => t.Id == id));

        mock.Setup(s => s.AddTaskAsync(It.IsAny<TaskItem>()))
            .Callback<TaskItem>(task =>
            {
                task.Id = taskList.Count + 1;
                taskList.Add(task);
            })
            .Returns(Task.CompletedTask);

        mock.Setup(s => s.DeleteTaskAsync(It.IsAny<int>()))
            .Callback<int>(id =>
                taskList.RemoveAll(t => t.Id == id))
            .Returns(Task.CompletedTask);

        return mock;
    }
}

ואז השימוש הוא פשוט:

[Fact]
public async Task AddTask_ShouldAppearInList_AfterRefresh()
{
    // Arrange
    var mock = TaskServiceMockFactory.CreateWithTasks(
        new TaskItem { Id = 1, Title = "משימה קיימת" }
    );
    var viewModel = new TaskListViewModel(mock.Object);

    // Act - הוספת משימה
    await mock.Object.AddTaskAsync(
        new TaskItem { Title = "משימה חדשה" });
    await viewModel.LoadTasksCommand.ExecuteAsync(null);

    // Assert
    viewModel.Tasks.Should().HaveCount(2);
    viewModel.Tasks.Last().Title.Should().Be("משימה חדשה");
}

בדיקת תרחישי שגיאה

וזה החלק שהרבה מפתחים מדלגים עליו (ואז מתחרטים). אל תשכחו לבדוק מה קורה כשדברים לא עובדים:

[Fact]
public async Task LoadTasks_ShouldHandleServiceException_Gracefully()
{
    // Arrange
    _mockTaskService
        .Setup(s => s.GetAllTasksAsync())
        .ThrowsAsync(new InvalidOperationException("Database connection failed"));

    // Act
    await _viewModel.LoadTasksCommand.ExecuteAsync(null);

    // Assert
    _viewModel.IsLoading.Should().BeFalse();
    _viewModel.Tasks.Should().BeEmpty();
}

[Fact]
public async Task LoadTasks_ShouldNotLoadConcurrently()
{
    // Arrange
    var tcs = new TaskCompletionSource<List<TaskItem>>();
    _mockTaskService
        .Setup(s => s.GetAllTasksAsync())
        .Returns(tcs.Task);

    // Act - קריאה ראשונה (ממתינה)
    var firstCall = _viewModel.LoadTasksCommand.ExecuteAsync(null);
    _viewModel.IsLoading.Should().BeTrue();

    // קריאה שנייה בזמן שהראשונה עדיין רצה
    var secondCall = _viewModel.LoadTasksCommand.ExecuteAsync(null);

    // שחרור הקריאה הראשונה
    tcs.SetResult(new List<TaskItem>());
    await firstCall;

    // Assert - הפונקציה נקראה רק פעם אחת
    _mockTaskService.Verify(
        s => s.GetAllTasksAsync(), Times.Once);
}

בדיקות שכבת Services

בזמן שהבדיקות של ViewModels משתמשות במוקים, בבדיקות של Services אנחנו רוצים לבדוק את הלוגיקה האמיתית. עבור שירותים שמשתמשים ב-SQLite, הטריק הוא להשתמש במסד נתונים בזיכרון — מהיר, נקי, ולא משאיר שאריות:

public class TaskServiceTests : IAsyncLifetime
{
    private SQLiteAsyncConnection _connection = null!;
    private TaskService _service = null!;

    public async Task InitializeAsync()
    {
        // שימוש ב-SQLite בזיכרון לבדיקות
        _connection = new SQLiteAsyncConnection(":memory:");
        await _connection.CreateTableAsync<TaskItem>();
        _service = new TaskService(_connection);
    }

    public async Task DisposeAsync()
    {
        await _connection.CloseAsync();
    }

    [Fact]
    public async Task AddAndRetrieveTask_ShouldWork()
    {
        // Arrange
        var newTask = new TaskItem
        {
            Title = "בדיקת אינטגרציה",
            Description = "לוודא שהכל עובד",
            Priority = 2,
            DueDate = DateTime.Now.AddDays(7)
        };

        // Act
        await _service.AddTaskAsync(newTask);
        var allTasks = await _service.GetAllTasksAsync();

        // Assert
        allTasks.Should().ContainSingle();
        allTasks.First().Title.Should().Be("בדיקת אינטגרציה");
        allTasks.First().Id.Should().BeGreaterThan(0);
    }

    [Fact]
    public async Task UpdateTask_ShouldPersistChanges()
    {
        // Arrange
        var task = new TaskItem { Title = "לפני עדכון" };
        await _service.AddTaskAsync(task);

        // Act
        task.Title = "אחרי עדכון";
        task.IsCompleted = true;
        await _service.UpdateTaskAsync(task);

        var updated = await _service.GetTaskByIdAsync(task.Id);

        // Assert
        updated!.Title.Should().Be("אחרי עדכון");
        updated.IsCompleted.Should().BeTrue();
    }

    [Fact]
    public async Task DeleteTask_ShouldRemoveFromDatabase()
    {
        // Arrange
        var task = new TaskItem { Title = "משימה למחיקה" };
        await _service.AddTaskAsync(task);
        var countBefore = (await _service.GetAllTasksAsync()).Count;

        // Act
        await _service.DeleteTaskAsync(task.Id);
        var countAfter = (await _service.GetAllTasksAsync()).Count;

        // Assert
        countBefore.Should().Be(1);
        countAfter.Should().Be(0);
    }

    [Fact]
    public async Task GetTasksByPriority_ShouldReturnSorted()
    {
        // Arrange
        await _service.AddTaskAsync(
            new TaskItem { Title = "נמוכה", Priority = 0 });
        await _service.AddTaskAsync(
            new TaskItem { Title = "גבוהה", Priority = 2 });
        await _service.AddTaskAsync(
            new TaskItem { Title = "בינונית", Priority = 1 });

        // Act
        var tasks = await _service.GetAllTasksAsync();
        var sorted = tasks.OrderByDescending(t => t.Priority).ToList();

        // Assert
        sorted.First().Title.Should().Be("גבוהה");
        sorted.Last().Title.Should().Be("נמוכה");
    }
}

שימו לב לממשק IAsyncLifetime של xUnit — הוא מאפשר לבצע אתחול ופירוק אסינכרוניים לפני ואחרי כל בדיקה. ככה כל בדיקה מתחילה עם מסד נתונים נקי לגמרי, בלי תלות בסדר הרצה.

בדיקות Dependency Injection

ב-.NET MAUI 10, הזרקת תלויות היא חלק מרכזי מהארכיטקטורה. כדאי מאוד לוודא שכל השירותים רשומים ונפתרים נכון — כי באגים ב-DI הם מהמעצבנים שיש (הם צצים רק בזמן ריצה):

public class DependencyInjectionTests
{
    private readonly IServiceProvider _serviceProvider;

    public DependencyInjectionTests()
    {
        var services = new ServiceCollection();

        // רישום כמו ב-MauiProgram.cs
        services.AddSingleton<ITaskService, TaskService>();
        services.AddTransient<TaskListViewModel>();
        services.AddTransient<TaskDetailViewModel>();
        services.AddTransient<AddTaskViewModel>();

        _serviceProvider = services.BuildServiceProvider();
    }

    [Fact]
    public void TaskService_ShouldResolve_AsSingleton()
    {
        // Act
        var service1 = _serviceProvider
            .GetRequiredService<ITaskService>();
        var service2 = _serviceProvider
            .GetRequiredService<ITaskService>();

        // Assert - Singleton = אותו אובייקט
        service1.Should().BeSameAs(service2);
    }

    [Fact]
    public void ViewModels_ShouldResolve_AsTransient()
    {
        // Act
        var vm1 = _serviceProvider
            .GetRequiredService<TaskListViewModel>();
        var vm2 = _serviceProvider
            .GetRequiredService<TaskListViewModel>();

        // Assert - Transient = אובייקטים שונים
        vm1.Should().NotBeSameAs(vm2);
    }

    [Fact]
    public void ViewModel_ShouldReceive_InjectedService()
    {
        // Act
        var viewModel = _serviceProvider
            .GetRequiredService<TaskListViewModel>();

        // Assert - ה-ViewModel התקבל עם Service מוזרק
        viewModel.Should().NotBeNull();
    }
}

בדיקות UI עם Appium

בדיקות UI מוודאות שהממשק באמת עובד מנקודת המבט של המשתמש. Appium הוא הכלי הסטנדרטי לבדיקות UI חוצות פלטפורמות, והוא עובד מצוין עם .NET MAUI.

נגיד את זה מראש — בדיקות UI הן איטיות יותר ושבריריות יותר מבדיקות יחידה. אל תנסו לכסות הכל עם UI tests. תשתמשו בהן בעיקר לתרחישים קריטיים.

הגדרת Appium

ראשית, התקינו את Appium ואת הדרייבר המתאים:

npm install -g appium
appium driver install uiautomator2  # Android
appium driver install xcuitest      # iOS

וצרו פרויקט בדיקות UI נפרד:

dotnet new xunit -n TaskMaster.UITests
cd TaskMaster.UITests
dotnet add package Appium.Net
dotnet add package Appium.WebDriver

כתיבת בדיקת UI בסיסית

public class TaskListPageUITests : IDisposable
{
    private readonly AndroidDriver _driver;

    public TaskListPageUITests()
    {
        var options = new AppiumOptions
        {
            PlatformName = "Android",
            AutomationName = "UiAutomator2",
            App = "/path/to/com.taskmaster.app.apk"
        };

        _driver = new AndroidDriver(
            new Uri("http://localhost:4723"), options);
        _driver.Manage().Timeouts().ImplicitWait =
            TimeSpan.FromSeconds(10);
    }

    [Fact]
    public void AddTask_ShouldAppearInList()
    {
        // Arrange - ניווט לדף הוספת משימה
        var addButton = _driver.FindElement(
            MobileBy.AccessibilityId("AddTaskButton"));
        addButton.Click();

        // Act - מילוי הטופס
        var titleEntry = _driver.FindElement(
            MobileBy.AccessibilityId("TaskTitleEntry"));
        titleEntry.SendKeys("משימה מבדיקת UI");

        var saveButton = _driver.FindElement(
            MobileBy.AccessibilityId("SaveTaskButton"));
        saveButton.Click();

        // Assert - המשימה מופיעה ברשימה
        var taskLabel = _driver.FindElement(
            MobileBy.AccessibilityId("TaskItem_משימה מבדיקת UI"));
        taskLabel.Should().NotBeNull();
        taskLabel.Displayed.Should().BeTrue();
    }

    [Fact]
    public void SwipeToDelete_ShouldRemoveTask()
    {
        // Arrange
        var taskItem = _driver.FindElement(
            MobileBy.AccessibilityId("TaskItem_0"));

        // Act - החלקה שמאלה למחיקה
        var size = taskItem.Size;
        var location = taskItem.Location;

        var touchAction = new TouchAction(_driver);
        touchAction.Press(location.X + size.Width - 10, location.Y + size.Height / 2)
                   .Wait(200)
                   .MoveTo(location.X + 10, location.Y + size.Height / 2)
                   .Release()
                   .Perform();

        // אישור מחיקה
        var confirmButton = _driver.FindElement(
            MobileBy.AccessibilityId("ConfirmDeleteButton"));
        confirmButton.Click();

        // Assert
        var elements = _driver.FindElements(
            MobileBy.AccessibilityId("TaskItem_0"));
        elements.Should().BeEmpty();
    }

    public void Dispose()
    {
        _driver?.Quit();
    }
}

כדי שהאלמנטים יהיו נגישים לבדיקות, חשוב להגדיר AutomationId ב-XAML. זה גם עוזר ל-Accessibility, אז זה win-win:

<Button Text="הוסף משימה"
        AutomationId="AddTaskButton"
        Command="{Binding AddTaskCommand}" />

<Entry Placeholder="כותרת המשימה"
       AutomationId="TaskTitleEntry"
       Text="{Binding Title}" />

שיטות עבודה מומלצות

אחרי כמה פרויקטים עם בדיקות ב-MAUI, הנה הדברים שלמדתי (חלקם בדרך הקשה):

  • בדקו לוגיקה, לא UI: 80% מהבדיקות שלכם צריכות להיות בדיקות יחידה ל-ViewModels ו-Services. הן מהירות, יציבות וקלות לתחזוקה. רק 20% צריכות להיות בדיקות UI — ואלה רק לתרחישים הכי קריטיים.
  • שם הבדיקה חשוב: השתמשו בפורמט MethodName_Should_When. למשל: LoadTasks_ShouldSetIsEmpty_WhenNoTasksExist. זה הופך את רשימת הבדיקות לתיעוד חי של ההתנהגות הצפויה.
  • בדיקה אחת — אסרציה אחת: כל בדיקה צריכה לבדוק דבר אחד ספציפי. אם בדיקה נכשלת, צריך להיות ברור מיד מה בדיוק השתבש.
  • אל תבדקו את ה-Framework: אין צורך לבדוק ש-ObservableCollection עובד או ש-SQLite שומר נתונים. בדקו את הלוגיקה שלכם בלבד.
  • הריצו בדיקות ב-CI/CD: הגדירו Pipeline שמריץ את כל הבדיקות בכל Push. אם משהו נשבר, תדעו מיד:
# .github/workflows/test.yml
name: Run Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x
      - run: dotnet test TaskMaster.Tests/
             --configuration Release
             --logger "trx;LogFileName=results.trx"
             --collect:"XPlat Code Coverage"
      - uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: "**/*.trx"
  • כיסוי קוד (Code Coverage): שאפו ל-80% כיסוי על הלוגיקה העסקית. לא 100% (זה כמעט תמיד בזבוז זמן ומוביל לבדיקות שבריריות) ולא 50% (זה פשוט לא מספיק). הפקודה --collect:"XPlat Code Coverage" מייצרת דוחות כיסוי שאפשר לנתח.
  • השתמשו ב-Test Data Builders: במקום לשכפל יצירת אובייקטי בדיקה בכל טסט, צרו Builder. זה חוסך שכפול קוד ומקל על שינויים עתידיים:
public class TaskItemBuilder
{
    private readonly TaskItem _task = new()
    {
        Title = "משימת ברירת מחדל",
        Description = "תיאור ברירת מחדל",
        Priority = 1,
        DueDate = DateTime.Now.AddDays(7),
        CreatedAt = DateTime.Now
    };

    public TaskItemBuilder WithTitle(string title)
    {
        _task.Title = title;
        return this;
    }

    public TaskItemBuilder WithPriority(int priority)
    {
        _task.Priority = priority;
        return this;
    }

    public TaskItemBuilder Completed()
    {
        _task.IsCompleted = true;
        return this;
    }

    public TaskItemBuilder DueOn(DateTime date)
    {
        _task.DueDate = date;
        return this;
    }

    public TaskItem Build() => _task;
}

// שימוש בבדיקה:
var urgentTask = new TaskItemBuilder()
    .WithTitle("דחוף!")
    .WithPriority(2)
    .DueOn(DateTime.Now.AddHours(2))
    .Build();

כלי הדיאגנוסטיקה החדשים של .NET MAUI 10

.NET MAUI 10 הוסיף מערכת דיאגנוסטיקה מובנית שאפשר לנצל גם בבדיקות. המערכת מבוססת על ActivitySource ו-Meters של .NET, ומספקת מטריקות לביצועי Layout. זה נחמד במיוחד כשצריך לאתר בעיות ביצועים:

// בדיקת ביצועים עם Metrics של MAUI 10
[Fact]
public void MeasurePerformance_ShouldComplete_WithinThreshold()
{
    // Arrange
    var stopwatch = System.Diagnostics.Stopwatch.StartNew();
    var tasks = Enumerable.Range(1, 1000)
        .Select(i => new TaskItem
        {
            Id = i,
            Title = $"משימה {i}",
            Priority = i % 3
        })
        .ToList();

    // Act
    var sorted = tasks
        .OrderByDescending(t => t.Priority)
        .ThenBy(t => t.DueDate)
        .ToList();

    stopwatch.Stop();

    // Assert - מיון 1000 משימות צריך לקחת פחות מ-50ms
    stopwatch.ElapsedMilliseconds.Should().BeLessThan(50);
    sorted.Should().HaveCount(1000);
}

המטריקות החדשות כוללות maui.layout.measure_count, maui.layout.measure_duration, maui.layout.arrange_count ו-maui.layout.arrange_duration. שימושי מאוד לזיהוי צווארי בקבוק בביצועי Layout.

שאלות נפוצות

האם אפשר להריץ בדיקות יחידה ל-ViewModels בלי אמולטור?

בהחלט כן! זה בדיוק אחד היתרונות הגדולים של ארכיטקטורת MVVM. כל הלוגיקה העסקית נמצאת ב-ViewModels שהם מחלקות C# רגילות. פרויקט הבדיקות הוא פרויקט xUnit סטנדרטי שרץ על ה-CLR — אין צורך באמולטור או מכשיר פיזי. רק בדיקות UI עם Appium דורשות אמולטור.

מה ההבדל בין Moq ל-NSubstitute, ומה עדיף ל-MAUI?

שתיהן ספריות Mocking מצוינות. Moq היא הותיקה והפופולרית יותר, עם תחביר מבוסס Fluent API (Setup...Returns). NSubstitute יותר מינימליסטית עם תחביר פשוט יותר. לפרויקטי MAUI, שתיהן עובדות מעולה. באופן אישי, אני מעדיף Moq בגלל השליטה המדויקת יותר על Verification — אבל זו העדפה אישית ו-NSubstitute קלה יותר ללמידה למתחילים.

כמה אחוזי כיסוי קוד (Code Coverage) צריך לשאוף אליהם?

כלל האצבע שלי הוא 80% כיסוי על הלוגיקה העסקית (ViewModels ו-Services). אל תשקיעו בכיסוי של שכבת ה-UI (XAML) או קוד דבק פשוט. 100% כיסוי זו מטרה שנשמעת נחמדה אבל בפועל מובילה לבדיקות שבריריות ומעצבנות. התמקדו בנתיבי קוד קריטיים — תנאים, טיפול בשגיאות, ולוגיקה עסקית מורכבת.

איך בודקים ניווט Shell ב-.NET MAUI בלי להריץ את האפליקציה?

ניווט Shell קשה לבדיקה ישירה כי הוא תלוי בתשתית MAUI. הגישה המומלצת היא ליצור ממשק INavigationService שעוטף את Shell.Current.GoToAsync. בבדיקות, אתם פשוט יוצרים Mock של הממשק הזה ומוודאים שה-ViewModel קורא לפונקציית הניווט עם הפרמטרים הנכונים. נקי ופשוט.

האם Appium הוא הכלי היחיד לבדיקות UI ב-MAUI?

לא, אבל הוא הנפוץ ביותר. יש גם Xamarin.UITest (שעובד עם MAUI בתצורות מסוימות) ו-DeviceRunners — כלי בדיקות שרץ ישירות על המכשיר כאפליקציית MAUI. מיקרוסופט גם עובדת על שיפורים בתשתית הבדיקות המובנית. עם זאת, Appium נשאר הסטנדרט התעשייתי בגלל התמיכה הרחבה שלו במגוון פלטפורמות ושפות — וזה מה שרוב החברות משתמשות בו.

אודות הכותב Editorial Team

Our team of expert writers and editors.