Desktop UI

Тестування ViewModels

Unit-тести для MVVM. xUnit, NSubstitute для mock-об'єктів. Тестування Properties, Commands, Validation, Messenger. Arrange-Act-Assert pattern.

Тестування ViewModels

Ви дотримались MVVM. Відокремили бізнес-логіку від UI. Використали інтерфейси для залежностей. Тепер ваш reward — ViewModel можна тестувати без запуску GUI. Без вікон. Без кліків мишкою. Без ручного тестування кожної зміни. Просто C# код, що виконується за мілісекунди.

Уявіть: ви змінили логіку валідації у UserViewModel. Замість запуску додатку, відкриття форми, введення даних, натискання кнопки, перевірки результату — ви просто запускаєте dotnet test. За 2 секунди виконується 50 тестів, що перевіряють всі сценарії. Регресія? Тести покажуть, що саме зламалося.

MVVM робить це можливим. ViewModel — чистий C# клас без залежностей від WPF/Avalonia. Всі залежності через інтерфейси → легко замінити на mock-об'єкти. Результат — швидкі, надійні, автоматизовані тести.

У цій статті ми детально розберемо тестування ViewModels: від базових тестів Properties до складних сценаріїв з Commands, Validation, Messenger. Ви навчитесь писати тести через xUnit, створювати mock-об'єкти через NSubstitute, та покривати тестами всю бізнес-логіку.

Словник теми:Unit Test — тест окремої одиниці коду (метод, клас) в ізоляції. Mock Object — тестовий об'єкт, що імітує поведінку реального об'єкта. Arrange-Act-Assert (AAA) — структура тесту: підготовка → дія → перевірка. Test Fixture — набір тестів для одного класу. Test Runner — інструмент для виконання тестів (xUnit, NUnit). Code Coverage — відсоток коду, покритого тестами.

Чому ViewModel легко тестувати?

Порівняння: UI код vs ViewModel

❌ Код у code-behind (важко тестувати):

private void SaveButton_Click(object sender, RoutedEventArgs e)
{
    // Залежить від UI елементів
    var name = NameTextBox.Text;
    var email = EmailTextBox.Text;
    
    // Бізнес-логіка змішана з UI
    if (string.IsNullOrEmpty(name))
    {
        MessageBox.Show("Name is required");
        return;
    }
    
    // Прямий доступ до бази даних
    using var context = new AppDbContext();
    context.Users.Add(new User { Name = name, Email = email });
    context.SaveChanges();
    
    MessageBox.Show("User saved");
}

Як тестувати? Потрібно створити вікно, знайти TextBox, ввести текст, натиснути кнопку, перевірити MessageBox. Повільно, складно, brittle.

✅ Код у ViewModel (легко тестувати):

public class UserViewModel : ViewModelBase
{
    private readonly IUserRepository _userRepository;
    private string _name = string.Empty;
    private string _email = string.Empty;
    
    public UserViewModel(IUserRepository userRepository)
    {
        _userRepository = userRepository;
        SaveCommand = new AsyncRelayCommand(SaveAsync, CanSave);
    }
    
    public string Name
    {
        get => _name;
        set
        {
            if (SetProperty(ref _name, value))
                SaveCommand.NotifyCanExecuteChanged();
        }
    }
    
    public string Email
    {
        get => _email;
        set => SetProperty(ref _email, value);
    }
    
    public IAsyncRelayCommand SaveCommand { get; }
    
    private bool CanSave() => !string.IsNullOrEmpty(Name);
    
    private async Task SaveAsync()
    {
        var user = new User { Name = Name, Email = Email };
        await _userRepository.AddAsync(user);
    }
}

Як тестувати? Створити ViewModel з mock репозиторієм, встановити Name, викликати SaveCommand, перевірити результат. Швидко, просто, надійно.

Переваги тестування ViewModel

  1. Швидкість — тести виконуються за мілісекунди (без UI рендерингу)
  2. Ізоляція — тестуємо лише бізнес-логіку, без залежностей від UI
  3. Надійність — тести не залежать від змін у XAML
  4. Автоматизація — можна запускати у CI/CD pipeline
  5. Документація — тести показують, як використовувати ViewModel

xUnit: фреймворк для тестування

Встановлення пакетів

# Тестовий проєкт
dotnet new xunit -n MyApp.Tests

# Додаємо посилання на основний проєкт
dotnet add MyApp.Tests reference MyApp

# NSubstitute для mock-об'єктів
dotnet add MyApp.Tests package NSubstitute

# FluentAssertions для зручних assertions (опціонально)
dotnet add MyApp.Tests package FluentAssertions

Структура тесту: Arrange-Act-Assert

using Xunit;
using NSubstitute;

public class UserViewModelTests
{
    [Fact]
    public async Task SaveCommand_WithValidData_ShouldAddUser()
    {
        // Arrange (підготовка)
        var mockRepository = Substitute.For<IUserRepository>();
        var viewModel = new UserViewModel(mockRepository);
        viewModel.Name = "John Doe";
        viewModel.Email = "john@example.com";
        
        // Act (дія)
        await viewModel.SaveCommand.ExecuteAsync(null);
        
        // Assert (перевірка)
        await mockRepository.Received(1).AddAsync(Arg.Is<User>(u => 
            u.Name == "John Doe" && 
            u.Email == "john@example.com"
        ));
    }
}

Атрибути xUnit

АтрибутОписПриклад
[Fact]Простий тест без параметрів[Fact] public void Test() { }
[Theory]Параметризований тест[Theory] [InlineData(1, 2, 3)]
[InlineData]Дані для Theory[InlineData("John", "john@example.com")]
[Skip]Пропустити тест[Fact(Skip = "Not implemented")]

Naming Convention

Формат: MethodName_Scenario_ExpectedResult

// ✅ Добре
[Fact]
public void SetName_WithEmptyString_ShouldNotifyPropertyChanged()

[Fact]
public async Task LoadUsers_WhenRepositoryThrows_ShouldSetErrorMessage()

[Fact]
public void SaveCommand_WithEmptyName_ShouldReturnFalseForCanExecute()

// ❌ Погано
[Fact]
public void Test1()

[Fact]
public void TestSave()

NSubstitute: мокування залежностей

Створення mock-об'єкта

// Створення mock
var mockRepository = Substitute.For<IUserRepository>();

// Setup: що повертати при виклику методу
mockRepository.GetByIdAsync(1).Returns(new User { Id = 1, Name = "John" });

// Setup: повернути список
mockRepository.GetAllAsync().Returns(new List<User>
{
    new User { Id = 1, Name = "John" },
    new User { Id = 2, Name = "Jane" }
});

// Setup: викинути виняток
mockRepository.GetByIdAsync(999).Returns(Task.FromException<User?>(
    new NotFoundException("User not found")
));

Verify: перевірка викликів

// Перевірка, що метод викликано 1 раз
await mockRepository.Received(1).AddAsync(Arg.Any<User>());

// Перевірка, що метод НЕ викликано
await mockRepository.DidNotReceive().DeleteAsync(Arg.Any<int>());

// Перевірка з конкретними аргументами
await mockRepository.Received().AddAsync(Arg.Is<User>(u => u.Name == "John"));

// Перевірка кількості викликів
await mockRepository.Received(3).GetByIdAsync(Arg.Any<int>());

Arg.Is та Arg.Any

// Arg.Any — будь-яке значення
await mockRepository.Received().AddAsync(Arg.Any<User>());

// Arg.Is — з умовою
await mockRepository.Received().AddAsync(Arg.Is<User>(u => u.Email.Contains("@")));

// Arg.Is з конкретним значенням
await mockRepository.Received().GetByIdAsync(Arg.Is<int>(id => id == 1));

Тестування Properties

Тест 1: PropertyChanged notification

[Fact]
public void SetName_ShouldRaisePropertyChanged()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserViewModel(mockRepository);
    var propertyChangedRaised = false;
    
    viewModel.PropertyChanged += (sender, args) =>
    {
        if (args.PropertyName == nameof(UserViewModel.Name))
            propertyChangedRaised = true;
    };
    
    // Act
    viewModel.Name = "John Doe";
    
    // Assert
    Assert.True(propertyChangedRaised);
}

Тест 2: Computed property

public class UserViewModel : ViewModelBase
{
    private string _firstName = string.Empty;
    private string _lastName = string.Empty;
    
    public string FirstName
    {
        get => _firstName;
        set
        {
            if (SetProperty(ref _firstName, value))
                OnPropertyChanged(nameof(FullName)); // Оновлюємо FullName
        }
    }
    
    public string LastName
    {
        get => _lastName;
        set
        {
            if (SetProperty(ref _lastName, value))
                OnPropertyChanged(nameof(FullName));
        }
    }
    
    public string FullName => $"{FirstName} {LastName}".Trim();
}

// Тест
[Fact]
public void SetFirstName_ShouldUpdateFullName()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserViewModel(mockRepository);
    var fullNameChangedCount = 0;
    
    viewModel.PropertyChanged += (sender, args) =>
    {
        if (args.PropertyName == nameof(UserViewModel.FullName))
            fullNameChangedCount++;
    };
    
    // Act
    viewModel.FirstName = "John";
    viewModel.LastName = "Doe";
    
    // Assert
    Assert.Equal("John Doe", viewModel.FullName);
    Assert.Equal(2, fullNameChangedCount); // 2 рази: при FirstName та LastName
}

Тест 3: Property validation

[Theory]
[InlineData("", false)] // Порожній рядок — невалідний
[InlineData("   ", false)] // Пробіли — невалідний
[InlineData("John", true)] // Нормальне ім'я — валідний
public void SetName_ShouldValidateCorrectly(string name, bool expectedIsValid)
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserViewModel(mockRepository);
    
    // Act
    viewModel.Name = name;
    
    // Assert
    Assert.Equal(expectedIsValid, !viewModel.HasErrors);
}

Тестування Commands

Тест 1: CanExecute

[Fact]
public void SaveCommand_WithEmptyName_ShouldReturnFalseForCanExecute()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserViewModel(mockRepository);
    viewModel.Name = "";
    
    // Act
    var canExecute = viewModel.SaveCommand.CanExecute(null);
    
    // Assert
    Assert.False(canExecute);
}

[Fact]
public void SaveCommand_WithValidName_ShouldReturnTrueForCanExecute()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserViewModel(mockRepository);
    viewModel.Name = "John Doe";
    
    // Act
    var canExecute = viewModel.SaveCommand.CanExecute(null);
    
    // Assert
    Assert.True(canExecute);
}

Тест 2: Execute викликає репозиторій

[Fact]
public async Task SaveCommand_Execute_ShouldCallRepositoryAddAsync()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserViewModel(mockRepository);
    viewModel.Name = "John Doe";
    viewModel.Email = "john@example.com";
    
    // Act
    await viewModel.SaveCommand.ExecuteAsync(null);
    
    // Assert
    await mockRepository.Received(1).AddAsync(Arg.Is<User>(u =>
        u.Name == "John Doe" &&
        u.Email == "john@example.com"
    ));
}

Тест 3: Command з параметром

public class UserListViewModel : ViewModelBase
{
    private readonly IUserRepository _userRepository;
    
    public UserListViewModel(IUserRepository userRepository)
    {
        _userRepository = userRepository;
        DeleteUserCommand = new AsyncRelayCommand<User>(DeleteUserAsync);
    }
    
    public IAsyncRelayCommand<User> DeleteUserCommand { get; }
    
    private async Task DeleteUserAsync(User? user)
    {
        if (user == null) return;
        await _userRepository.DeleteAsync(user.Id);
    }
}

// Тест
[Fact]
public async Task DeleteUserCommand_WithUser_ShouldCallRepositoryDelete()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserListViewModel(mockRepository);
    var user = new User { Id = 1, Name = "John" };
    
    // Act
    await viewModel.DeleteUserCommand.ExecuteAsync(user);
    
    // Assert
    await mockRepository.Received(1).DeleteAsync(1);
}

[Fact]
public async Task DeleteUserCommand_WithNull_ShouldNotCallRepository()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserListViewModel(mockRepository);
    
    // Act
    await viewModel.DeleteUserCommand.ExecuteAsync(null);
    
    // Assert
    await mockRepository.DidNotReceive().DeleteAsync(Arg.Any<int>());
}

Тест 4: NotifyCanExecuteChanged

[Fact]
public void SetName_ShouldNotifyCanExecuteChanged()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserViewModel(mockRepository);
    var canExecuteChangedRaised = false;
    
    viewModel.SaveCommand.CanExecuteChanged += (sender, args) =>
    {
        canExecuteChangedRaised = true;
    };
    
    // Act
    viewModel.Name = "John Doe";
    
    // Assert
    Assert.True(canExecuteChangedRaised);
}

Тестування асинхронних операцій

Тест 1: LoadCommand завантажує дані

public class UserListViewModel : ViewModelBase
{
    private readonly IUserRepository _userRepository;
    private ObservableCollection<User> _users = new();
    
    public UserListViewModel(IUserRepository userRepository)
    {
        _userRepository = userRepository;
        LoadUsersCommand = new AsyncRelayCommand(LoadUsersAsync);
    }
    
    public ObservableCollection<User> Users
    {
        get => _users;
        set => SetProperty(ref _users, value);
    }
    
    public IAsyncRelayCommand LoadUsersCommand { get; }
    
    private async Task LoadUsersAsync()
    {
        var users = await _userRepository.GetAllAsync();
        Users = new ObservableCollection<User>(users);
    }
}

// Тест
[Fact]
public async Task LoadUsersCommand_ShouldPopulateUsersCollection()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var testUsers = new List<User>
    {
        new User { Id = 1, Name = "John" },
        new User { Id = 2, Name = "Jane" }
    };
    mockRepository.GetAllAsync().Returns(testUsers);
    
    var viewModel = new UserListViewModel(mockRepository);
    
    // Act
    await viewModel.LoadUsersCommand.ExecuteAsync(null);
    
    // Assert
    Assert.Equal(2, viewModel.Users.Count);
    Assert.Equal("John", viewModel.Users[0].Name);
    Assert.Equal("Jane", viewModel.Users[1].Name);
}

Тест 2: Обробка помилок

public class UserListViewModel : ViewModelBase
{
    private string? _errorMessage;
    
    public string? ErrorMessage
    {
        get => _errorMessage;
        set => SetProperty(ref _errorMessage, value);
    }
    
    private async Task LoadUsersAsync()
    {
        try
        {
            var users = await _userRepository.GetAllAsync();
            Users = new ObservableCollection<User>(users);
            ErrorMessage = null;
        }
        catch (Exception ex)
        {
            ErrorMessage = $"Failed to load users: {ex.Message}";
        }
    }
}

// Тест
[Fact]
public async Task LoadUsersCommand_WhenRepositoryThrows_ShouldSetErrorMessage()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    mockRepository.GetAllAsync().Returns(Task.FromException<IEnumerable<User>>(
        new Exception("Database connection failed")
    ));
    
    var viewModel = new UserListViewModel(mockRepository);
    
    // Act
    await viewModel.LoadUsersCommand.ExecuteAsync(null);
    
    // Assert
    Assert.NotNull(viewModel.ErrorMessage);
    Assert.Contains("Database connection failed", viewModel.ErrorMessage);
}

Тест 3: IsLoading indicator

public class UserListViewModel : ViewModelBase
{
    private bool _isLoading;
    
    public bool IsLoading
    {
        get => _isLoading;
        set => SetProperty(ref _isLoading, value);
    }
    
    private async Task LoadUsersAsync()
    {
        IsLoading = true;
        
        try
        {
            var users = await _userRepository.GetAllAsync();
            Users = new ObservableCollection<User>(users);
        }
        finally
        {
            IsLoading = false;
        }
    }
}

// Тест
[Fact]
public async Task LoadUsersCommand_ShouldSetIsLoadingDuringExecution()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var tcs = new TaskCompletionSource<IEnumerable<User>>();
    mockRepository.GetAllAsync().Returns(tcs.Task);
    
    var viewModel = new UserListViewModel(mockRepository);
    
    // Act
    var loadTask = viewModel.LoadUsersCommand.ExecuteAsync(null);
    
    // Assert — IsLoading має бути true під час виконання
    Assert.True(viewModel.IsLoading);
    
    // Завершуємо операцію
    tcs.SetResult(new List<User>());
    await loadTask;
    
    // Assert — IsLoading має бути false після завершення
    Assert.False(viewModel.IsLoading);
}

Тестування Validation

Приклад ViewModel з валідацією

public class UserViewModel : ViewModelBase, INotifyDataErrorInfo
{
    private readonly Dictionary<string, List<string>> _errors = new();
    private string _name = string.Empty;
    private string _email = string.Empty;
    
    public string Name
    {
        get => _name;
        set
        {
            if (SetProperty(ref _name, value))
            {
                ValidateName();
                SaveCommand.NotifyCanExecuteChanged();
            }
        }
    }
    
    public string Email
    {
        get => _email;
        set
        {
            if (SetProperty(ref _email, value))
            {
                ValidateEmail();
                SaveCommand.NotifyCanExecuteChanged();
            }
        }
    }
    
    public bool HasErrors => _errors.Any();
    
    public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
    
    public IEnumerable GetErrors(string? propertyName)
    {
        if (string.IsNullOrEmpty(propertyName))
            return _errors.Values.SelectMany(e => e);
        
        return _errors.TryGetValue(propertyName, out var errors) ? errors : Enumerable.Empty<string>();
    }
    
    private void ValidateName()
    {
        ClearErrors(nameof(Name));
        
        if (string.IsNullOrWhiteSpace(Name))
            AddError(nameof(Name), "Name is required");
        else if (Name.Length < 2)
            AddError(nameof(Name), "Name must be at least 2 characters");
    }
    
    private void ValidateEmail()
    {
        ClearErrors(nameof(Email));
        
        if (string.IsNullOrWhiteSpace(Email))
            AddError(nameof(Email), "Email is required");
        else if (!Email.Contains("@"))
            AddError(nameof(Email), "Email must contain @");
    }
    
    private void AddError(string propertyName, string error)
    {
        if (!_errors.ContainsKey(propertyName))
            _errors[propertyName] = new List<string>();
        
        _errors[propertyName].Add(error);
        OnErrorsChanged(propertyName);
    }
    
    private void ClearErrors(string propertyName)
    {
        if (_errors.Remove(propertyName))
            OnErrorsChanged(propertyName);
    }
    
    private void OnErrorsChanged(string propertyName)
    {
        ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
    }
    
    private bool CanSave() => !HasErrors && !string.IsNullOrEmpty(Name);
}

Тести для валідації

[Fact]
public void SetName_WithEmptyString_ShouldHaveError()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserViewModel(mockRepository);
    
    // Act
    viewModel.Name = "";
    
    // Assert
    Assert.True(viewModel.HasErrors);
    var errors = viewModel.GetErrors(nameof(UserViewModel.Name)).Cast<string>();
    Assert.Contains("Name is required", errors);
}

[Fact]
public void SetName_WithShortString_ShouldHaveError()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserViewModel(mockRepository);
    
    // Act
    viewModel.Name = "J";
    
    // Assert
    Assert.True(viewModel.HasErrors);
    var errors = viewModel.GetErrors(nameof(UserViewModel.Name)).Cast<string>();
    Assert.Contains("Name must be at least 2 characters", errors);
}

[Fact]
public void SetEmail_WithoutAtSign_ShouldHaveError()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserViewModel(mockRepository);
    
    // Act
    viewModel.Email = "invalidemail";
    
    // Assert
    Assert.True(viewModel.HasErrors);
    var errors = viewModel.GetErrors(nameof(UserViewModel.Email)).Cast<string>();
    Assert.Contains("Email must contain @", errors);
}

[Fact]
public void SetValidData_ShouldNotHaveErrors()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserViewModel(mockRepository);
    
    // Act
    viewModel.Name = "John Doe";
    viewModel.Email = "john@example.com";
    
    // Assert
    Assert.False(viewModel.HasErrors);
}

[Fact]
public void ErrorsChanged_ShouldBeRaisedWhenValidationChanges()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserViewModel(mockRepository);
    var errorsChangedRaised = false;
    
    viewModel.ErrorsChanged += (sender, args) =>
    {
        if (args.PropertyName == nameof(UserViewModel.Name))
            errorsChangedRaised = true;
    };
    
    // Act
    viewModel.Name = "";
    
    // Assert
    Assert.True(errorsChangedRaised);
}

Тестування Messenger (WeakReferenceMessenger)

Приклад: повідомлення між ViewModels

// Повідомлення
public class UserDeletedMessage
{
    public int UserId { get; set; }
}

// Sender ViewModel
public class UserDetailsViewModel : ViewModelBase
{
    private readonly IUserRepository _userRepository;
    
    public IAsyncRelayCommand DeleteCommand { get; }
    
    private async Task DeleteAsync()
    {
        await _userRepository.DeleteAsync(CurrentUser.Id);
        
        // Відправка повідомлення
        WeakReferenceMessenger.Default.Send(new UserDeletedMessage
        {
            UserId = CurrentUser.Id
        });
    }
}

// Receiver ViewModel
public class UserListViewModel : ViewModelBase
{
    public UserListViewModel(IUserRepository userRepository)
    {
        _userRepository = userRepository;
        
        // Реєстрація на повідомлення
        WeakReferenceMessenger.Default.Register<UserDeletedMessage>(this, (recipient, message) =>
        {
            var user = Users.FirstOrDefault(u => u.Id == message.UserId);
            if (user != null)
                Users.Remove(user);
        });
    }
}

Тести для Messenger

[Fact]
public async Task DeleteCommand_ShouldSendUserDeletedMessage()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserDetailsViewModel(mockRepository);
    viewModel.CurrentUser = new User { Id = 1, Name = "John" };
    
    UserDeletedMessage? receivedMessage = null;
    WeakReferenceMessenger.Default.Register<UserDeletedMessage>(this, (recipient, message) =>
    {
        receivedMessage = message;
    });
    
    // Act
    await viewModel.DeleteCommand.ExecuteAsync(null);
    
    // Assert
    Assert.NotNull(receivedMessage);
    Assert.Equal(1, receivedMessage.UserId);
    
    // Cleanup
    WeakReferenceMessenger.Default.Unregister<UserDeletedMessage>(this);
}

[Fact]
public void UserListViewModel_ShouldRemoveUserWhenMessageReceived()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserListViewModel(mockRepository);
    viewModel.Users.Add(new User { Id = 1, Name = "John" });
    viewModel.Users.Add(new User { Id = 2, Name = "Jane" });
    
    // Act
    WeakReferenceMessenger.Default.Send(new UserDeletedMessage { UserId = 1 });
    
    // Assert
    Assert.Single(viewModel.Users);
    Assert.Equal(2, viewModel.Users[0].Id);
}
Важливо: Не забувайте викликати WeakReferenceMessenger.Default.Unregister() у тестах після використання, щоб уникнути витоку пам'яті та впливу на інші тести.

FluentAssertions: зручніші assertions

FluentAssertions робить assertions читабельнішими та надає кращі повідомлення про помилки.

Встановлення

dotnet add package FluentAssertions

Порівняння: xUnit vs FluentAssertions

// xUnit
Assert.Equal(2, viewModel.Users.Count);
Assert.True(viewModel.HasErrors);
Assert.Contains("John", viewModel.Users.Select(u => u.Name));

// FluentAssertions
viewModel.Users.Should().HaveCount(2);
viewModel.HasErrors.Should().BeTrue();
viewModel.Users.Select(u => u.Name).Should().Contain("John");

Приклади з FluentAssertions

[Fact]
public async Task LoadUsersCommand_ShouldPopulateUsersCollection()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var testUsers = new List<User>
    {
        new User { Id = 1, Name = "John", Email = "john@example.com" },
        new User { Id = 2, Name = "Jane", Email = "jane@example.com" }
    };
    mockRepository.GetAllAsync().Returns(testUsers);
    
    var viewModel = new UserListViewModel(mockRepository);
    
    // Act
    await viewModel.LoadUsersCommand.ExecuteAsync(null);
    
    // Assert
    viewModel.Users.Should().HaveCount(2);
    viewModel.Users.Should().Contain(u => u.Name == "John");
    viewModel.Users.Should().Contain(u => u.Email == "jane@example.com");
    viewModel.Users.Select(u => u.Id).Should().BeEquivalentTo(new[] { 1, 2 });
}

[Fact]
public void SetName_WithEmptyString_ShouldHaveError()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new UserViewModel(mockRepository);
    
    // Act
    viewModel.Name = "";
    
    // Assert
    viewModel.HasErrors.Should().BeTrue();
    viewModel.GetErrors(nameof(UserViewModel.Name))
        .Cast<string>()
        .Should().Contain("Name is required");
}

[Fact]
public async Task SaveCommand_WhenRepositoryThrows_ShouldSetErrorMessage()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    mockRepository.AddAsync(Arg.Any<User>()).Returns(Task.FromException(
        new Exception("Database error")
    ));
    
    var viewModel = new UserViewModel(mockRepository);
    viewModel.Name = "John";
    
    // Act
    await viewModel.SaveCommand.ExecuteAsync(null);
    
    // Assert
    viewModel.ErrorMessage.Should().NotBeNullOrEmpty();
    viewModel.ErrorMessage.Should().Contain("Database error");
}

Test Fixtures: спільна підготовка для тестів

Коли кілька тестів потребують однакової підготовки, використовуйте Test Fixture.

Варіант 1: Constructor (для кожного тесту)

public class UserViewModelTests
{
    private readonly IUserRepository _mockRepository;
    private readonly UserViewModel _viewModel;
    
    public UserViewModelTests()
    {
        // Виконується перед кожним тестом
        _mockRepository = Substitute.For<IUserRepository>();
        _viewModel = new UserViewModel(_mockRepository);
    }
    
    [Fact]
    public void Test1()
    {
        // _viewModel вже створено
        _viewModel.Name = "John";
        Assert.Equal("John", _viewModel.Name);
    }
    
    [Fact]
    public void Test2()
    {
        // Новий екземпляр _viewModel для цього тесту
        _viewModel.Email = "john@example.com";
        Assert.Equal("john@example.com", _viewModel.Email);
    }
}

Варіант 2: IClassFixture (спільний для всіх тестів)

public class DatabaseFixture : IDisposable
{
    public AppDbContext Context { get; }
    
    public DatabaseFixture()
    {
        // Створення in-memory бази даних
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase("TestDatabase")
            .Options;
        
        Context = new AppDbContext(options);
        
        // Seed даних
        Context.Users.AddRange(
            new User { Id = 1, Name = "John" },
            new User { Id = 2, Name = "Jane" }
        );
        Context.SaveChanges();
    }
    
    public void Dispose()
    {
        Context.Dispose();
    }
}

public class UserRepositoryTests : IClassFixture<DatabaseFixture>
{
    private readonly DatabaseFixture _fixture;
    
    public UserRepositoryTests(DatabaseFixture fixture)
    {
        _fixture = fixture;
    }
    
    [Fact]
    public async Task GetAllAsync_ShouldReturnAllUsers()
    {
        // Arrange
        var repository = new UserRepository(_fixture.Context);
        
        // Act
        var users = await repository.GetAllAsync();
        
        // Assert
        users.Should().HaveCount(2);
    }
}

Code Coverage: вимірювання покриття тестами

Встановлення coverlet

dotnet add package coverlet.collector

Запуск тестів з coverage

# Запуск тестів з coverage
dotnet test /p:CollectCoverage=true

# Генерація HTML звіту
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=html

# Відкрити звіт
# coverage/index.html

Інтерпретація результатів

+------------------+--------+--------+--------+
| Module           | Line   | Branch | Method |
+------------------+--------+--------+--------+
| MyApp            | 85.2%  | 78.5%  | 90.1%  |
| MyApp.ViewModels | 92.3%  | 85.7%  | 95.2%  |
+------------------+--------+--------+--------+

Рекомендації:

  • Line Coverage > 80% — добре
  • Branch Coverage > 70% — добре
  • Method Coverage > 90% — добре
Мета: Не 100% coverage, а покриття критичної бізнес-логіки. Тести повинні бути корисними, а не формальними.

Практичні завдання

Рівень 1: Базові тести для Properties

Мета: Навчитися писати прості unit-тести для ViewModel.

Завдання:

Створіть ViewModel та 5 тестів:

  1. ViewModel:
    • PersonViewModel з властивостями FirstName, LastName, Age
    • Computed property FullName = FirstName + LastName
    • Validation: Age має бути > 0 та < 150
  2. Тести:
    • Тест 1: SetFirstName повинен викликати PropertyChanged
    • Тест 2: SetFirstName повинен оновити FullName
    • Тест 3: SetAge з від'ємним значенням повинен встановити HasErrors = true
    • Тест 4: SetAge з валідним значенням повинен встановити HasErrors = false
    • Тест 5: FullName повинен повертати "FirstName LastName"

Критерії успіху:

  • Всі 5 тестів проходять
  • Використано xUnit та Arrange-Act-Assert
  • Naming convention дотримано

Підказка:

public class PersonViewModelTests
{
    [Fact]
    public void SetFirstName_ShouldRaisePropertyChanged()
    {
        // Arrange
        var viewModel = new PersonViewModel();
        var propertyChangedRaised = false;
        viewModel.PropertyChanged += (s, e) =>
        {
            if (e.PropertyName == nameof(PersonViewModel.FirstName))
                propertyChangedRaised = true;
        };
        
        // Act
        viewModel.FirstName = "John";
        
        // Assert
        Assert.True(propertyChangedRaised);
    }
}

Рівень 2: Тести для CRUD ViewModel з mock Repository

Мета: Навчитися тестувати ViewModels з залежностями через mock-об'єкти.

Завдання:

Створіть TodoListViewModel та тести:

  1. ViewModel:
    • TodoListViewModel з ITaskRepository залежністю
    • ObservableCollection Tasks
    • LoadTasksCommand, AddTaskCommand, DeleteTaskCommand
    • IsLoading property
  2. Тести:
    • Тест 1: LoadTasksCommand повинен заповнити Tasks колекцію
    • Тест 2: LoadTasksCommand повинен викликати GetAllAsync один раз
    • Тест 3: AddTaskCommand повинен викликати AddAsync з правильними даними
    • Тест 4: DeleteTaskCommand повинен видалити задачу з колекції
    • Тест 5: LoadTasksCommand при помилці повинен встановити ErrorMessage
    • Тест 6: IsLoading повинен бути true під час завантаження

Критерії успіху:

  • Всі 6 тестів проходять
  • Використано NSubstitute для mock Repository
  • Перевірено виклики методів через Received()

Підказка:

[Fact]
public async Task LoadTasksCommand_ShouldPopulateTasksCollection()
{
    // Arrange
    var mockRepository = Substitute.For<ITaskRepository>();
    var testTasks = new List<TodoItem>
    {
        new TodoItem { Id = 1, Title = "Task 1" },
        new TodoItem { Id = 2, Title = "Task 2" }
    };
    mockRepository.GetAllAsync().Returns(testTasks);
    
    var viewModel = new TodoListViewModel(mockRepository);
    
    // Act
    await viewModel.LoadTasksCommand.ExecuteAsync(null);
    
    // Assert
    Assert.Equal(2, viewModel.Tasks.Count);
    await mockRepository.Received(1).GetAllAsync();
}

Рівень 3: Тести для Navigation з mock INavigationService

Мета: Навчитися тестувати складні сценарії з кількома залежностями.

Завдання:

Створіть MainViewModel з навігацією та тести:

  1. Структура:
    • INavigationService інтерфейс з методами NavigateTo(), GoBack()
    • MainViewModel з INavigationService та IUserRepository
    • Commands: OpenUserDetailsCommand, OpenSettingsCommand, LogoutCommand
  2. Тести:
    • Тест 1: OpenUserDetailsCommand повинен викликати NavigateTo()
    • Тест 2: OpenSettingsCommand повинен викликати NavigateTo()
    • Тест 3: LogoutCommand повинен викликати NavigateTo()
    • Тест 4: LogoutCommand повинен очистити CurrentUser
    • Тест 5: OpenUserDetailsCommand з null user не повинен викликати NavigateTo
    • Тест 6: Перевірити, що параметри передаються правильно

Критерії успіху:

  • Всі 6 тестів проходять
  • Використано mock для INavigationService та IUserRepository
  • Перевірено передачу параметрів

Підказка:

public interface INavigationService
{
    void NavigateTo<TViewModel>() where TViewModel : ViewModelBase;
    void NavigateTo<TViewModel>(object parameter) where TViewModel : ViewModelBase;
    void GoBack();
}

[Fact]
public void OpenUserDetailsCommand_ShouldNavigateToUserDetailsViewModel()
{
    // Arrange
    var mockNavigation = Substitute.For<INavigationService>();
    var mockRepository = Substitute.For<IUserRepository>();
    var viewModel = new MainViewModel(mockNavigation, mockRepository);
    var user = new User { Id = 1, Name = "John" };
    
    // Act
    viewModel.OpenUserDetailsCommand.Execute(user);
    
    // Assert
    mockNavigation.Received(1).NavigateTo<UserDetailsViewModel>(
        Arg.Is<User>(u => u.Id == 1)
    );
}

Підсумок

Тестування ViewModels — це reward за дотримання MVVM. Чистий C# код без залежностей від UI, інтерфейси для всіх залежностей, швидкі автоматизовані тести. xUnit + NSubstitute роблять тестування простим та приємним.

Ключові висновки:

🎯 Arrange-Act-Assert

Структура кожного тесту: підготовка → дія → перевірка. Чіткий, читабельний, зрозумілий код.

🎭 Mock Objects

NSubstitute для створення mock-об'єктів. Substitute.For(), Returns(), Received(). Ізоляція від реальних залежностей.

⚡ Async Testing

async/await у тестах. TaskCompletionSource для контролю виконання. Перевірка IsLoading під час операції.

✅ Validation Testing

Тестування INotifyDataErrorInfo. Перевірка HasErrors, GetErrors(), ErrorsChanged. Покриття всіх валідаційних правил.

📨 Messenger Testing

Тестування WeakReferenceMessenger. Перевірка відправки та отримання повідомлень. Не забувайте Unregister().

📊 Code Coverage

Coverlet для вимірювання покриття. Мета > 80% для критичної логіки. Корисні тести, а не формальні.

Що далі?

Наступна стаття — UI Testing покаже автоматизоване тестування GUI через FlaUI.


Словник термінів

Unit Test — тест окремої одиниці коду (метод, клас) в ізоляції від інших компонентів.Mock Object — тестовий об'єкт, що імітує поведінку реального об'єкта для ізоляції тестів.Stub — спрощена реалізація залежності, що повертає заздалегідь визначені дані.Arrange-Act-Assert (AAA) — структура тесту: підготовка даних → виконання дії → перевірка результату.Test Fixture — набір тестів для одного класу або компонента.Test Runner — інструмент для виконання тестів (xUnit, NUnit, MSTest).Assertion — перевірка очікуваного результату (Assert.Equal, Should().Be()).Code Coverage — відсоток коду, покритого тестами (line, branch, method coverage).Fact — атрибут xUnit для простого тесту без параметрів.Theory — атрибут xUnit для параметризованого тесту з InlineData.InlineData — атрибут xUnit для передачі даних у Theory тест.Substitute.For() — створення mock-об'єкта через NSubstitute.Returns() — налаштування повернення значення для mock-методу.Received() — перевірка, що mock-метод був викликаний.DidNotReceive() — перевірка, що mock-метод НЕ був викликаний.Arg.Any() — будь-яке значення типу T для перевірки аргументів.Arg.Is() — значення типу T з умовою для перевірки аргументів.FluentAssertions — бібліотека для зручніших та читабельніших assertions.IClassFixture — xUnit механізм для спільної підготовки для всіх тестів класу.TaskCompletionSource — ручний контроль завершення Task для тестування async коду.

Додаткові ресурси

📖 xUnit Documentation

Офіційна документація xUnit.

🎭 NSubstitute Docs

Документація NSubstitute для mock-об'єктів.

✨ FluentAssertions

Документація FluentAssertions.

📊 Coverlet

Інструмент для вимірювання code coverage.

📚 Попередня стаття: Repository Pattern

Повернутися до Repository Pattern.

📚 Наступна стаття: UI Testing

Дізнатися про автоматизоване тестування GUI.