Dependency Injection у WPF та Avalonia
Dependency Injection у WPF та Avalonia
Уявіть: ваш MainViewModel створює SettingsViewModel через new, SettingsViewModel створює SettingsService через new, SettingsService створює FileRepository через new. Все працює... поки не з'являється 20 залежностей. Поки не потрібно використати той самий UserService у 5 різних ViewModels. Поки не потрібно замінити реальний DatabaseService на mock для тестування.
Ручне створення залежностей (Poor Man's DI) швидко перетворюється на кошмар підтримки. Composition Root розмивається по всьому коду, тестування стає неможливим, зміна однієї залежності вимагає правок у десятках місць.
Рішення — Dependency Injection Container. У .NET екосистемі це Microsoft.Extensions.DependencyInjection — той самий DI, що використовується в ASP.NET Core. Ви реєструєте всі залежності в одному місці (Composition Root), а контейнер автоматично створює об'єкти з усіма їх залежностями. Змінити реалізацію? Один рядок коду. Замінити на mock? Один рядок коду. Додати нову залежність? Просто додайте параметр у конструктор.
У цій статті ми детально розберемо інтеграцію DI у WPF та Avalonia: від налаштування IServiceProvider до реєстрації ViewModels, від lifecycles (Transient/Singleton/Scoped) до тестування з mock-об'єктами.
Проблема: Poor Man's DI
Розглянемо типовий MVVM-додаток без DI:
public class MainViewModel
{
private readonly SettingsViewModel _settingsViewModel;
private readonly UserService _userService;
public MainViewModel()
{
// Ручне створення залежностей
var fileRepository = new FileRepository();
var settingsService = new SettingsService(fileRepository);
_settingsViewModel = new SettingsViewModel(settingsService);
var apiClient = new ApiClient();
_userService = new UserService(apiClient);
}
}
Проблеми:
- Жорстка прив'язка — MainViewModel знає про конкретні реалізації (FileRepository, ApiClient)
- Дублювання коду — якщо UserService потрібен у 5 ViewModels, код створення дублюється 5 разів
- Неможливість тестування — не можна замінити ApiClient на mock
- Порушення Single Responsibility — MainViewModel відповідає за створення залежностей
- Складність зміни — щоб змінити FileRepository на DatabaseRepository, потрібно правити всі місця створення
Приклад: каскадні залежності
// UserService залежить від ApiClient та Logger
public class UserService
{
public UserService(IApiClient apiClient, ILogger logger) { }
}
// MainViewModel створює UserService
public class MainViewModel
{
public MainViewModel()
{
var logger = new ConsoleLogger();
var apiClient = new ApiClient(logger); // ApiClient теж потребує Logger!
var userService = new UserService(apiClient, logger);
// Що якщо ApiClient потребує ще HttpClient?
// Що якщо Logger потребує FileWriter?
// Граф залежностей росте експоненційно...
}
}
Рішення: Dependency Injection Container автоматично резолвить весь граф залежностей.
🔵 Recap: Що таке Dependency Injection?
Якщо ви не знайомі з DI або хочете освіжити знання, ось короткий огляд.
Принцип Dependency Injection
Без DI (залежність створюється всередині):
public class OrderService
{
private readonly EmailSender _emailSender;
public OrderService()
{
_emailSender = new EmailSender(); // Жорстка прив'язка
}
}
З DI (залежність передається ззовні):
public class OrderService
{
private readonly IEmailSender _emailSender;
public OrderService(IEmailSender emailSender) // Ін'єкція через конструктор
{
_emailSender = emailSender;
}
}
Переваги:
- Можна передати будь-яку реалізацію
IEmailSender(EmailSender, MockEmailSender, SmsEmailSender) - Легко тестувати (передати mock)
- Легко змінити реалізацію (один рядок коду)
IoC Container: автоматичне створення
Замість ручного створення:
var emailSender = new EmailSender();
var orderService = new OrderService(emailSender);
Використовуємо контейнер:
// Реєстрація (один раз при старті)
services.AddSingleton<IEmailSender, EmailSender>();
services.AddTransient<OrderService>();
// Резолвінг (коли потрібно)
var orderService = serviceProvider.GetRequiredService<OrderService>();
// Контейнер автоматично створить EmailSender і передасть у OrderService
Lifecycles: Transient, Singleton, Scoped
| Lifecycle | Опис | Коли використовувати |
|---|---|---|
| Transient | Новий екземпляр кожен раз | Легковагові об'єкти без стану (ViewModels, Commands) |
| Singleton | Один екземпляр на весь додаток | Сервіси зі станом, що має бути спільним (UserService, SettingsService) |
| Scoped | Один екземпляр на scope | У десктопних додатках рідко використовується (більше для веб) |
// Transient — новий кожен раз
services.AddTransient<MainViewModel>();
// Singleton — один на весь додаток
services.AddSingleton<IUserService, UserService>();
// Scoped — один на scope (рідко в десктопі)
services.AddScoped<IDatabaseContext, DatabaseContext>();
Інтеграція DI у WPF
Крок 1: Встановлення пакетів
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Hosting
Microsoft.Extensions.Hosting надає зручні методи для налаштування DI та конфігурації.
Крок 2: Налаштування в App.xaml.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Windows;
public partial class App : Application
{
private IHost? _host;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// Створення Host з DI
_host = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
// Реєстрація сервісів
ConfigureServices(services);
})
.Build();
// Отримання MainWindow через DI
var mainWindow = _host.Services.GetRequiredService<MainWindow>();
mainWindow.Show();
}
private void ConfigureServices(IServiceCollection services)
{
// ViewModels (Transient — новий кожен раз)
services.AddTransient<MainViewModel>();
services.AddTransient<SettingsViewModel>();
services.AddTransient<UserListViewModel>();
// Services (Singleton — один на додаток)
services.AddSingleton<IUserService, UserService>();
services.AddSingleton<ISettingsService, SettingsService>();
services.AddSingleton<IApiClient, ApiClient>();
// Repositories
services.AddSingleton<IUserRepository, UserRepository>();
// Windows (Transient)
services.AddTransient<MainWindow>();
}
protected override void OnExit(ExitEventArgs e)
{
_host?.Dispose();
base.OnExit(e);
}
}
Крок 3: Видалення StartupUri з App.xaml
<!-- Видаліть StartupUri, бо ми створюємо MainWindow через DI -->
<Application x:Class="MyApp.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<!-- Resources -->
</Application.Resources>
</Application>
Крок 4: Ін'єкція ViewModel у Window
public partial class MainWindow : Window
{
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
<!-- MainWindow.xaml -->
<Window x:Class="MyApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
Title="My App" Width="800" Height="600">
<Grid>
<TextBlock Text="{Binding Title}" FontSize="24"/>
<!-- UI прив'язується до ViewModel через Binding -->
</Grid>
</Window>
Крок 5: ViewModel з залежностями
public class MainViewModel : ViewModelBase
{
private readonly IUserService _userService;
private readonly ISettingsService _settingsService;
public MainViewModel(IUserService userService, ISettingsService settingsService)
{
_userService = userService;
_settingsService = settingsService;
LoadDataCommand = new RelayCommand(LoadData);
}
public string Title => "Main Window";
public ICommand LoadDataCommand { get; }
private async void LoadData()
{
var users = await _userService.GetUsersAsync();
// ...
}
}
Що відбувається:
App.OnStartup()створюєIHostз DI- Реєструємо всі сервіси та ViewModels у
ConfigureServices() - Отримуємо
MainWindowчерезGetRequiredService<MainWindow>() - DI бачить, що
MainWindowпотребуєMainViewModel - DI бачить, що
MainViewModelпотребуєIUserServiceтаISettingsService - DI автоматично створює весь граф залежностей і передає у конструктори
Static Service Locator: доступ до DI з будь-якого місця
Іноді потрібен доступ до DI з місць, де ін'єкція через конструктор неможлива (наприклад, у Converter або Markup Extension).
Варіант 1: App.Current.Services
public partial class App : Application
{
public static IServiceProvider Services { get; private set; } = null!;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
_host = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) => ConfigureServices(services))
.Build();
Services = _host.Services; // Зберігаємо для глобального доступу
var mainWindow = Services.GetRequiredService<MainWindow>();
mainWindow.Show();
}
}
Використання:
// З будь-якого місця в коді
var userService = App.Services.GetRequiredService<IUserService>();
Варіант 2: ServiceLocator клас
public static class ServiceLocator
{
private static IServiceProvider? _serviceProvider;
public static void Initialize(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public static T GetService<T>() where T : notnull
{
if (_serviceProvider == null)
throw new InvalidOperationException("ServiceLocator not initialized");
return _serviceProvider.GetRequiredService<T>();
}
}
// В App.xaml.cs
protected override void OnStartup(StartupEventArgs e)
{
// ...
ServiceLocator.Initialize(_host.Services);
// ...
}
Інтеграція DI у Avalonia
Avalonia має трохи інший підхід до життєвого циклу додатку.
Крок 1: Встановлення пакетів
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Hosting
Крок 2: Налаштування в Program.cs
using Avalonia;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
public class Program
{
public static IHost? AppHost { get; private set; }
[STAThread]
public static void Main(string[] args)
{
// Створення Host з DI
AppHost = Host.CreateDefaultBuilder(args)
.ConfigureServices((context, services) =>
{
ConfigureServices(services);
})
.Build();
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.LogToTrace();
private static void ConfigureServices(IServiceCollection services)
{
// ViewModels
services.AddTransient<MainViewModel>();
services.AddTransient<SettingsViewModel>();
// Services
services.AddSingleton<IUserService, UserService>();
services.AddSingleton<ISettingsService, SettingsService>();
// Views
services.AddTransient<MainWindow>();
}
}
Крок 3: App.axaml.cs
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Microsoft.Extensions.DependencyInjection;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Отримання MainWindow через DI
desktop.MainWindow = Program.AppHost!.Services.GetRequiredService<MainWindow>();
}
base.OnFrameworkInitializationCompleted();
}
}
Крок 4: MainWindow з ViewModel
public partial class MainWindow : Window
{
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
Різниця з WPF:
- У WPF DI налаштовується в
App.OnStartup() - В Avalonia DI налаштовується в
Program.Main(), а MainWindow створюється вApp.OnFrameworkInitializationCompleted()
Lifecycles: Transient vs Singleton vs Scoped
Transient: новий екземпляр кожен раз
services.AddTransient<MainViewModel>();
// Кожен виклик створює новий екземпляр
var vm1 = serviceProvider.GetRequiredService<MainViewModel>();
var vm2 = serviceProvider.GetRequiredService<MainViewModel>();
// vm1 != vm2
Коли використовувати:
- ViewModels (кожне вікно має свій ViewModel)
- Commands (якщо вони stateless)
- Легковагові об'єкти без стану
Singleton: один екземпляр на додаток
services.AddSingleton<IUserService, UserService>();
// Завжди повертається той самий екземпляр
var service1 = serviceProvider.GetRequiredService<IUserService>();
var service2 = serviceProvider.GetRequiredService<IUserService>();
// service1 == service2
Коли використовувати:
- Сервіси зі станом, що має бути спільним (UserService, SettingsService)
- Кеші (CacheService)
- Логери (ILogger)
- API клієнти (HttpClient wrapper)
Scoped: один екземпляр на scope
services.AddScoped<IDatabaseContext, DatabaseContext>();
using (var scope = serviceProvider.CreateScope())
{
var db1 = scope.ServiceProvider.GetRequiredService<IDatabaseContext>();
var db2 = scope.ServiceProvider.GetRequiredService<IDatabaseContext>();
// db1 == db2 (в межах одного scope)
}
using (var scope = serviceProvider.CreateScope())
{
var db3 = scope.ServiceProvider.GetRequiredService<IDatabaseContext>();
// db3 != db1 (новий scope — новий екземпляр)
}
Коли використовувати в десктопі:
- Database contexts (EF Core DbContext)
- Unit of Work pattern
- Транзакційні операції
Приклад: Scoped для діалогових вікон
private async void OpenDialog()
{
using var scope = App.Services.CreateScope();
var dialogViewModel = scope.ServiceProvider.GetRequiredService<DialogViewModel>();
var dialogWindow = new DialogWindow { DataContext = dialogViewModel };
dialogWindow.ShowDialog();
// Після закриття діалогу scope dispose, і всі Scoped сервіси очищаються
}
ViewModelLocator: автоматичне прив'язування ViewModel
Замість ручного встановлення DataContext у code-behind, можна створити ViewModelLocator для автоматичного прив'язування.
Варіант 1: Attached Property
public static class ViewModelLocator
{
public static readonly DependencyProperty AutoWireViewModelProperty =
DependencyProperty.RegisterAttached(
"AutoWireViewModel",
typeof(bool),
typeof(ViewModelLocator),
new PropertyMetadata(false, AutoWireViewModelChanged));
public static bool GetAutoWireViewModel(DependencyObject obj)
=> (bool)obj.GetValue(AutoWireViewModelProperty);
public static void SetAutoWireViewModel(DependencyObject obj, bool value)
=> obj.SetValue(AutoWireViewModelProperty, value);
private static void AutoWireViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if ((bool)e.NewValue && d is FrameworkElement element)
{
// Отримуємо тип ViewModel на основі імені View
var viewType = element.GetType();
var viewModelTypeName = viewType.FullName!.Replace("View", "ViewModel");
var viewModelType = viewType.Assembly.GetType(viewModelTypeName);
if (viewModelType != null)
{
var viewModel = App.Services.GetRequiredService(viewModelType);
element.DataContext = viewModel;
}
}
}
}
Використання:
<Window x:Class="MyApp.Views.MainWindow"
xmlns:local="clr-namespace:MyApp"
local:ViewModelLocator.AutoWireViewModel="True">
<!-- ViewModel автоматично прив'язується -->
</Window>
Варіант 2: Convention-based через Markup Extension
public class ViewModelExtension : MarkupExtension
{
public Type? ViewModelType { get; set; }
public override object ProvideValue(IServiceProvider serviceProvider)
{
if (ViewModelType == null)
{
// Визначаємо тип ViewModel на основі View
var target = (IProvideValueTarget)serviceProvider.GetService(typeof(IProvideValueTarget))!;
var targetType = target.TargetObject.GetType();
var viewModelTypeName = targetType.FullName!.Replace("View", "ViewModel");
ViewModelType = targetType.Assembly.GetType(viewModelTypeName);
}
return App.Services.GetRequiredService(ViewModelType!);
}
}
Використання:
<Window x:Class="MyApp.Views.MainWindow"
DataContext="{local:ViewModel}">
<!-- ViewModel автоматично резолвиться через DI -->
</Window>
Тестування з DI: Mock-об'єкти
Одна з головних переваг DI — легке тестування через підміну реалізацій.
Приклад: Unit-тест для ViewModel
// Інтерфейс сервісу
public interface IUserService
{
Task<List<User>> GetUsersAsync();
}
// Реальна реалізація
public class UserService : IUserService
{
private readonly IApiClient _apiClient;
public UserService(IApiClient apiClient)
{
_apiClient = apiClient;
}
public async Task<List<User>> GetUsersAsync()
{
return await _apiClient.GetAsync<List<User>>("/users");
}
}
// ViewModel
public class UserListViewModel : ViewModelBase
{
private readonly IUserService _userService;
private ObservableCollection<User> _users = new();
public UserListViewModel(IUserService userService)
{
_userService = userService;
LoadUsersCommand = new AsyncRelayCommand(LoadUsers);
}
public ObservableCollection<User> Users
{
get => _users;
set => SetProperty(ref _users, value);
}
public IAsyncRelayCommand LoadUsersCommand { get; }
private async Task LoadUsers()
{
var users = await _userService.GetUsersAsync();
Users = new ObservableCollection<User>(users);
}
}
Unit-тест з Mock
using Moq;
using Xunit;
public class UserListViewModelTests
{
[Fact]
public async Task LoadUsers_ShouldPopulateUsersCollection()
{
// Arrange
var mockUserService = new Mock<IUserService>();
mockUserService
.Setup(s => s.GetUsersAsync())
.ReturnsAsync(new List<User>
{
new User { Id = 1, Name = "Alice" },
new User { Id = 2, Name = "Bob" }
});
var viewModel = new UserListViewModel(mockUserService.Object);
// Act
await viewModel.LoadUsersCommand.ExecuteAsync(null);
// Assert
Assert.Equal(2, viewModel.Users.Count);
Assert.Equal("Alice", viewModel.Users[0].Name);
Assert.Equal("Bob", viewModel.Users[1].Name);
}
[Fact]
public async Task LoadUsers_ShouldCallServiceOnce()
{
// Arrange
var mockUserService = new Mock<IUserService>();
mockUserService
.Setup(s => s.GetUsersAsync())
.ReturnsAsync(new List<User>());
var viewModel = new UserListViewModel(mockUserService.Object);
// Act
await viewModel.LoadUsersCommand.ExecuteAsync(null);
// Assert
mockUserService.Verify(s => s.GetUsersAsync(), Times.Once);
}
}
Без DI це було б неможливо — ViewModel створював би UserService через new, і не було б способу підмінити його на mock.
Реєстрація за конвенцією: автоматична реєстрація
Для великих проєктів ручна реєстрація кожного сервісу стає громіздкою. Можна автоматизувати через рефлексію.
Приклад: реєстрація всіх ViewModels
private void ConfigureServices(IServiceCollection services)
{
// Автоматична реєстрація всіх ViewModels
var assembly = Assembly.GetExecutingAssembly();
var viewModelTypes = assembly.GetTypes()
.Where(t => t.Name.EndsWith("ViewModel") && !t.IsAbstract && !t.IsInterface);
foreach (var type in viewModelTypes)
{
services.AddTransient(type);
}
// Автоматична реєстрація всіх сервісів
var serviceTypes = assembly.GetTypes()
.Where(t => t.Name.EndsWith("Service") && !t.IsAbstract && !t.IsInterface);
foreach (var type in serviceTypes)
{
var interfaceType = type.GetInterfaces()
.FirstOrDefault(i => i.Name == $"I{type.Name}");
if (interfaceType != null)
{
services.AddSingleton(interfaceType, type);
}
else
{
services.AddSingleton(type);
}
}
}
Приклад: реєстрація через атрибути
// Атрибут для позначення lifecycle
[AttributeUsage(AttributeTargets.Class)]
public class ServiceAttribute : Attribute
{
public ServiceLifetime Lifetime { get; }
public ServiceAttribute(ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Lifetime = lifetime;
}
}
// Використання
[Service(ServiceLifetime.Singleton)]
public class UserService : IUserService
{
// ...
}
[Service(ServiceLifetime.Transient)]
public class MainViewModel : ViewModelBase
{
// ...
}
// Автоматична реєстрація
private void ConfigureServices(IServiceCollection services)
{
var assembly = Assembly.GetExecutingAssembly();
var typesWithAttribute = assembly.GetTypes()
.Where(t => t.GetCustomAttribute<ServiceAttribute>() != null);
foreach (var type in typesWithAttribute)
{
var attribute = type.GetCustomAttribute<ServiceAttribute>()!;
var interfaceType = type.GetInterfaces().FirstOrDefault();
if (interfaceType != null)
{
services.Add(new ServiceDescriptor(interfaceType, type, attribute.Lifetime));
}
else
{
services.Add(new ServiceDescriptor(type, type, attribute.Lifetime));
}
}
}
Практичні завдання
Рівень 1: Базова інтеграція DI
Мета: Навчитися налаштовувати DI у WPF/Avalonia проєкті.
Завдання:
Створіть простий додаток з DI:
- Структура:
MainWindowзMainViewModelIUserServiceінтерфейс таUserServiceреалізаціяUserмодель
- Функціональність:
- MainViewModel має кнопку "Load Users"
- При натисканні викликається
IUserService.GetUsersAsync() - Результат відображається в ListBox
- DI налаштування:
- Встановіть
Microsoft.Extensions.DependencyInjection - Налаштуйте DI в
App.xaml.cs(WPF) абоProgram.cs(Avalonia) - Зареєструйте MainViewModel як Transient
- Зареєструйте IUserService як Singleton
- Ін'єкція ViewModel у MainWindow через конструктор
- Встановіть
Критерії успіху:
- DI налаштовано правильно
- MainWindow отримує ViewModel через DI
- ViewModel отримує IUserService через DI
- Додаток працює без
newдля створення залежностей
Підказка (App.xaml.cs для WPF):
public partial class App : Application
{
private IHost? _host;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
_host = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
services.AddTransient<MainViewModel>();
services.AddSingleton<IUserService, UserService>();
services.AddTransient<MainWindow>();
})
.Build();
var mainWindow = _host.Services.GetRequiredService<MainWindow>();
mainWindow.Show();
}
}
Рівень 2: Scoped lifecycle для діалогів
Мета: Навчитися використовувати Scoped lifecycle для діалогових вікон.
Завдання:
Створіть додаток з діалоговим вікном:
- Структура:
- MainWindow з кнопкою "Open Dialog"
- DialogWindow з DialogViewModel
- IDatabaseService (Scoped) для роботи з даними
- Функціональність:
- При натисканні "Open Dialog" відкривається діалог
- Діалог має форму для редагування даних
- При збереженні дані зберігаються через IDatabaseService
- Після закриття діалогу Scoped сервіси очищаються
- DI налаштування:
- Зареєструйте IDatabaseService як Scoped
- Створіть scope при відкритті діалогу
- Передайте scope.ServiceProvider у DialogViewModel
Критерії успіху:
- Scoped lifecycle працює правильно
- Кожен діалог має свій екземпляр IDatabaseService
- Після закриття діалогу сервіси очищаються (Dispose викликається)
- Головне вікно не залежить від діалогу
Підказка:
private async void OpenDialog_Click(object sender, RoutedEventArgs e)
{
using var scope = App.Services.CreateScope();
var dialogViewModel = scope.ServiceProvider.GetRequiredService<DialogViewModel>();
var dialogWindow = new DialogWindow { DataContext = dialogViewModel };
var result = dialogWindow.ShowDialog();
if (result == true)
{
// Дані збережено
RefreshData();
}
// Scope dispose — IDatabaseService.Dispose() викликається автоматично
}
Рівень 3: Unit-тести з Mock
Мета: Навчитися тестувати ViewModels з mock-об'єктами.
Завдання:
Створіть unit-тести для ViewModel:
- Структура:
- UserListViewModel з IUserService залежністю
- LoadUsersCommand для завантаження даних
- Users ObservableCollection для відображення
- Тести:
- Тест 1: LoadUsers повинен заповнити Users колекцію
- Тест 2: LoadUsers повинен викликати GetUsersAsync один раз
- Тест 3: При помилці сервісу повинно встановлюватися ErrorMessage
- Mock:
- Використайте Moq для створення mock IUserService
- Setup різні сценарії (успіх, помилка)
- Verify виклики методів
Критерії успіху:
- Всі тести проходять
- Використано Moq для mock-об'єктів
- Тести не залежать від реальної реалізації IUserService
- Покриття коду > 80%
Підказка:
[Fact]
public async Task LoadUsers_WhenServiceThrows_ShouldSetErrorMessage()
{
// Arrange
var mockUserService = new Mock<IUserService>();
mockUserService
.Setup(s => s.GetUsersAsync())
.ThrowsAsync(new Exception("Network error"));
var viewModel = new UserListViewModel(mockUserService.Object);
// Act
await viewModel.LoadUsersCommand.ExecuteAsync(null);
// Assert
Assert.NotNull(viewModel.ErrorMessage);
Assert.Contains("Network error", viewModel.ErrorMessage);
}
Підсумок
Dependency Injection трансформує архітектуру десктопних додатків, роблячи код тестованим, підтримуваним та гнучким. Microsoft.Extensions.DependencyInjection інтегрується в WPF та Avalonia природно, використовуючи ті ж принципи, що й ASP.NET Core.
Ключові висновки:
🎯 Composition Root
⚡ Lifecycles
🧪 Тестування
🔌 Гнучкість
Що далі?
Наступна стаття — SQLite та EF Core покаже, як зберігати дані локально через Entity Framework Core.
Словник термінів
Додаткові ресурси
2D Графіка та Мультимедіа у WPF
Векторна графіка через Shapes та Path. Градієнти, геометрії, трансформації. MediaElement для відео та аудіо. Створення складних візуальних ефектів.
SQLite та EF Core у десктопних додатках
Локальне зберігання даних через SQLite. DbContext, міграції, seeding. Інтеграція з DI. IDbContextFactory для багатопоточності.