Какие подходы вы используете для unit-тестирования ViewModels?

Unit-тестирование ViewModel-ов в проектах на Xamarin и .NET MAUI (а также других MVVM-ориентированных решениях) является критически важной частью обеспечения качества. ViewModel представляет собой слой, содержащий бизнес-логику и взаимодействующий с моделью и пользовательским интерфейсом через биндинги. Основная цель тестирования ViewModel — изолировать её от UI и убедиться, что поведение соответствует требованиям при различных сценариях.

Ниже подробно рассмотрены подходы, инструменты, структуры и техники unit-тестирования ViewModel'ов.

1. Принципы тестируемости ViewModel

ViewModel, чтобы быть легко тестируемой:

  • Не должен напрямую взаимодействовать с UI (View).

  • Не должен содержать кода, зависящего от Device, Dispatcher, Page, NavigationPage и т.п.

  • Все внешние зависимости (например, сервисы, репозитории, навигаторы) должны быть переданы через конструктор (Dependency Injection).

  • Команды (ICommand или RelayCommand) должны быть легко вызываемы и проверяемы.

  • Observable свойства должны быть доступны для проверки.

2. Стек инструментов

Для unit-тестирования ViewModel чаще всего используется:

  • Тестовый фреймворк:

    • xUnit (рекомендуется)

    • NUnit (часто используется в Xamarin проектах)

    • MSTest (поддерживается Visual Studio)

  • Мока-фреймворк:

    • Moq — для создания поддельных (fake/mock) зависимостей

    • FakeItEasy, NSubstitute — альтернативы

  • Fluent Assertions — для читаемых и выразительных проверок

  • CommunityToolkit.MVVM — если ViewModel использует ObservableObject, RelayCommand и т.п., их поведение также легко тестировать

3. Пример структуры ViewModel

public class LoginViewModel : ObservableObject
{
private readonly IAuthService \_authService;
private readonly INavigationService \_navigationService;
public LoginViewModel(IAuthService authService, INavigationService navigationService)
{
\_authService = authService;
\_navigationService = navigationService;
LoginCommand = new AsyncRelayCommand(LoginAsync);
}
\[ObservableProperty\]
private string username;
\[ObservableProperty\]
private string password;
public IAsyncRelayCommand LoginCommand { get; }
private async Task LoginAsync()
{
var result = await \_authService.LoginAsync(Username, Password);
if (result)
{
await \_navigationService.NavigateToHomeAsync();
}
}
}

4. Тестирование этой ViewModel (пример на xUnit + Moq)

public class LoginViewModelTests
{
private readonly Mock<IAuthService> \_authServiceMock;
private readonly Mock<INavigationService> \_navigationServiceMock;
private readonly LoginViewModel \_viewModel;
public LoginViewModelTests()
{
\_authServiceMock = new Mock<IAuthService>();
\_navigationServiceMock = new Mock<INavigationService>();
\_viewModel = new LoginViewModel(
\_authServiceMock.Object,
\_navigationServiceMock.Object);
}
\[Fact\]
public void Username_SetProperty_RaisesPropertyChanged()
{
bool raised = false;
\_viewModel.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(\_viewModel.Username))
raised = true;
};
\_viewModel.Username = "user@test.com";
Assert.True(raised);
Assert.Equal("user@test.com", \_viewModel.Username);
}
\[Fact\]
public async Task LoginCommand_Success_CallsNavigateToHome()
{
// Arrange
\_viewModel.Username = "user";
\_viewModel.Password = "pass";
\_authServiceMock.Setup(x => x.LoginAsync("user", "pass")).ReturnsAsync(true);
// Act
await \_viewModel.LoginCommand.ExecuteAsync(null);
// Assert
\_authServiceMock.Verify(x => x.LoginAsync("user", "pass"), Times.Once);
\_navigationServiceMock.Verify(x => x.NavigateToHomeAsync(), Times.Once);
}
\[Fact\]
public async Task LoginCommand_Failure_DoesNotNavigate()
{
\_viewModel.Username = "baduser";
\_viewModel.Password = "wrongpass";
\_authServiceMock.Setup(x => x.LoginAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
await \_viewModel.LoginCommand.ExecuteAsync(null);
\_navigationServiceMock.Verify(x => x.NavigateToHomeAsync(), Times.Never);
}
}

5. Тестирование команд (Command)

Если используется RelayCommand или AsyncRelayCommand, их можно тестировать напрямую, вызывая метод Execute() или ExecuteAsync() и проверяя:

  • Выполнение логики

  • Условия выполнения (CanExecute)

  • Реакцию на изменения входных данных

\[Fact\]
public void LoginCommand_CanExecute_ChangesWhenFieldsChange()
{
var command = \_viewModel.LoginCommand;
Assert.True(command.CanExecute(null));
\_viewModel.Password = "";
Assert.True(command.CanExecute(null)); // зависит от реализации CanExecute
}

6. Изоляция от времени, навигации и файловой системы

Для тестирования логики, зависящей от:

  • DateTime.Now → используется IDateTimeProvider

  • INavigation → используется интерфейс INavigationService

  • IFileSystem, IDeviceInfo → абстрагируются через интерфейсы

Все эти зависимости можно подменить через моки в тестах.

7. Асинхронные ViewModel методы

Асинхронные команды и методы следует вызывать через await, и тесты тоже должны быть асинхронными (async Task вместо void).

Особое внимание следует уделять:

  • Проверке побочных эффектов (Verify)

  • Обработке исключений

  • Проверке IsBusy, IsLoading состояний

8. Тестирование PropertyChanged

Тесты могут проверять, что при установке значения свойства вызывается событие PropertyChanged, если ViewModel реализует INotifyPropertyChanged.

Фреймворки типа MvvmLight, ReactiveUI, CommunityToolkit.MVVM автоматизируют этот процесс, но он всё равно проверяется на уровне юнит-тестов.

9. Тесты с моками навигации

Если ViewModel вызывает навигацию (например, после логина или нажатия на элемент списка), этот вызов должен быть абстрагирован через интерфейс. Пример:

\_navigationServiceMock.Verify(x => x.NavigateToAsync("DetailsPage", It.IsAny<object>()), Times.Once);

10. Рекомендации и best practices

  • Покрывать тестами все публичные команды и критические свойства.

  • Писать тесты на сценарии поведения, а не на каждую строчку кода.

  • Использовать автоматическую сборку моков с помощью AutoFixture + Moq для ускорения.

  • Делить ViewModel'ы на маленькие, фокусированные части (Single Responsibility).

  • Писать тесты до реализации (TDD), особенно в новых фичах.

11. Общий шаблон архитектуры ViewModel тестов

/Tests
/ViewModels
LoginViewModelTests.cs
ProfileViewModelTests.cs
...
/Services
AuthServiceTests.cs

Каждый ViewModel тестируется отдельно, с мокаемыми зависимостями.

Юнит-тестирование ViewModel позволяет достичь высокой уверенности в корректности логики без запуска приложения или UI-тестов. При должной архитектуре MVVM, внедрении зависимостей и использовании современных инструментов, написание таких тестов становится быстрым и рутинным процессом.