Ви дотримались 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, та покривати тестами всю бізнес-логіку.
❌ Код у 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, перевірити результат. Швидко, просто, надійно.
# Тестовий проєкт
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
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"
));
}
}
| Атрибут | Опис | Приклад |
|---|---|---|
[Fact] | Простий тест без параметрів | [Fact] public void Test() { } |
[Theory] | Параметризований тест | [Theory] [InlineData(1, 2, 3)] |
[InlineData] | Дані для Theory | [InlineData("John", "john@example.com")] |
[Skip] | Пропустити тест | [Fact(Skip = "Not implemented")] |
Формат: 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()
// Створення 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")
));
// Перевірка, що метод викликано 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.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));
[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);
}
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
}
[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);
}
[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);
}
[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"
));
}
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>());
}
[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);
}
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);
}
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);
}
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);
}
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);
}
// Повідомлення
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);
});
}
}
[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 читабельнішими та надає кращі повідомлення про помилки.
dotnet add package 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");
[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 Fixture.
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);
}
}
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);
}
}
dotnet add package coverlet.collector
# Запуск тестів з 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% |
+------------------+--------+--------+--------+
Рекомендації:
Мета: Навчитися писати прості unit-тести для ViewModel.
Завдання:
Створіть ViewModel та 5 тестів:
PersonViewModel з властивостями FirstName, LastName, AgeКритерії успіху:
Підказка:
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);
}
}
Мета: Навчитися тестувати ViewModels з залежностями через mock-об'єкти.
Завдання:
Створіть TodoListViewModel та тести:
TodoListViewModel з ITaskRepository залежністюКритерії успіху:
Підказка:
[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();
}
Мета: Навчитися тестувати складні сценарії з кількома залежностями.
Завдання:
Створіть MainViewModel з навігацією та тести:
INavigationService інтерфейс з методами NavigateToMainViewModel з 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
⚡ Async Testing
✅ Validation Testing
📨 Messenger Testing
📊 Code Coverage
Що далі?
Наступна стаття — UI Testing покаже автоматизоване тестування GUI через FlaUI.
Repository Pattern та Unit of Work
Чиста архітектура доступу до даних. IRepository<T>, GenericRepository, специфічні репозиторії. Unit of Work для координації транзакцій. User Settings через JSON.
Avalonia Headless Testing — тестування UI без вікон
Революційний підхід до тестування користувацького інтерфейсу: рендеринг UI в пам'яті, симуляція взаємодій та візуальна регресія без реальних вікон та GPU