WinUI and WPF Testing: Unit Testing Windows Desktop Applications

WinUI and WPF Testing: Unit Testing Windows Desktop Applications

Windows desktop apps built with WPF or WinUI 3 present a familiar testing challenge: UI frameworks couple logic to platform types that are hard to instantiate in tests. The solution for both frameworks is the same—MVVM architecture that concentrates testable logic in ViewModels, with UI tests reserved for integration paths.

This guide covers unit testing WPF and WinUI 3 applications using xUnit, Moq, and the Microsoft.UI.Testing library, plus end-to-end automation with WinAppDriver.

Testing Strategy for Windows Desktop Apps

Layer What to Test Tool
ViewModel Commands, properties, validation xUnit + Moq
Services Business logic, data access xUnit + Moq
Navigation Route state, parameters xUnit
UI integration Click-through scenarios WinAppDriver

The MVVM pattern is essential here. ViewModels should have no direct dependencies on WPF/WinUI types—only on interfaces and models that you can test in isolation.

Project Setup

Create a separate test project that references your main project's shared logic:

<!-- MyApp.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="xunit" Version="2.9.0" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.0" />
    <PackageReference Include="Moq" Version="4.20.0" />
    <PackageReference Include="FluentAssertions" Version="6.12.0" />
    <ProjectReference Include="../MyApp.Core/MyApp.Core.csproj" />
  </ItemGroup>
</Project>

Keep MyApp.Core as a class library with no WPF/WinUI dependency. MyApp.WinUI (or .WPF) references Core and adds the UI layer. Tests reference Core directly.

Testing ViewModels

A well-structured ViewModel is easy to test:

// MyApp.Core/ViewModels/LoginViewModel.cs
public class LoginViewModel : ObservableObject {
    private readonly IAuthService _authService;
    private readonly INavigationService _navigation;
    private string _username = string.Empty;
    private string _password = string.Empty;
    private string _errorMessage = string.Empty;
    private bool _isLoading;

    public string Username {
        get => _username;
        set => SetProperty(ref _username, value);
    }

    public string Password {
        get => _password;
        set => SetProperty(ref _password, value);
    }

    public string ErrorMessage {
        get => _errorMessage;
        set => SetProperty(ref _errorMessage, value);
    }

    public bool IsLoading {
        get => _isLoading;
        set => SetProperty(ref _isLoading, value);
    }

    public IAsyncRelayCommand LoginCommand { get; }

    public LoginViewModel(IAuthService authService, INavigationService navigation) {
        _authService = authService;
        _navigation = navigation;
        LoginCommand = new AsyncRelayCommand(LoginAsync, CanLogin);
    }

    private bool CanLogin() =>
        !string.IsNullOrWhiteSpace(Username) &&
        !string.IsNullOrWhiteSpace(Password) &&
        !IsLoading;

    private async Task LoginAsync() {
        IsLoading = true;
        ErrorMessage = string.Empty;

        try {
            var result = await _authService.LoginAsync(Username, Password);
            if (result.Success) {
                _navigation.NavigateTo<DashboardViewModel>();
            } else {
                ErrorMessage = result.Error ?? "Login failed";
            }
        } finally {
            IsLoading = false;
        }
    }
}

Test it:

// MyApp.Tests/ViewModels/LoginViewModelTests.cs
public class LoginViewModelTests {
    private readonly Mock<IAuthService> _authService;
    private readonly Mock<INavigationService> _navigation;
    private readonly LoginViewModel _sut;

    public LoginViewModelTests() {
        _authService = new Mock<IAuthService>();
        _navigation = new Mock<INavigationService>();
        _sut = new LoginViewModel(_authService.Object, _navigation.Object);
    }

    [Fact]
    public void LoginCommand_CannotExecute_WhenCredentialsEmpty() {
        _sut.Username = string.Empty;
        _sut.Password = string.Empty;

        Assert.False(_sut.LoginCommand.CanExecute(null));
    }

    [Fact]
    public void LoginCommand_CanExecute_WhenCredentialsFilled() {
        _sut.Username = "admin";
        _sut.Password = "secret";

        Assert.True(_sut.LoginCommand.CanExecute(null));
    }

    [Fact]
    public async Task LoginAsync_NavigatesToDashboard_OnSuccess() {
        _sut.Username = "admin";
        _sut.Password = "password";
        _authService
            .Setup(s => s.LoginAsync("admin", "password"))
            .ReturnsAsync(AuthResult.Ok());

        await _sut.LoginCommand.ExecuteAsync(null);

        _navigation.Verify(n => n.NavigateTo<DashboardViewModel>(), Times.Once);
        Assert.Empty(_sut.ErrorMessage);
    }

    [Fact]
    public async Task LoginAsync_SetsErrorMessage_OnFailure() {
        _sut.Username = "admin";
        _sut.Password = "wrong";
        _authService
            .Setup(s => s.LoginAsync(It.IsAny<string>(), It.IsAny<string>()))
            .ReturnsAsync(AuthResult.Fail("Invalid credentials"));

        await _sut.LoginCommand.ExecuteAsync(null);

        Assert.Equal("Invalid credentials", _sut.ErrorMessage);
        _navigation.Verify(n => n.NavigateTo<DashboardViewModel>(), Times.Never);
    }

    [Fact]
    public async Task LoginAsync_SetsIsLoadingDuringExecution() {
        _sut.Username = "admin";
        _sut.Password = "password";

        var tcs = new TaskCompletionSource<AuthResult>();
        _authService
            .Setup(s => s.LoginAsync(It.IsAny<string>(), It.IsAny<string>()))
            .Returns(tcs.Task);

        var loginTask = _sut.LoginCommand.ExecuteAsync(null);

        // Check loading state during execution
        Assert.True(_sut.IsLoading);

        tcs.SetResult(AuthResult.Ok());
        await loginTask;

        Assert.False(_sut.IsLoading);
    }

    [Fact]
    public async Task LoginAsync_ResetsIsLoading_OnException() {
        _sut.Username = "admin";
        _sut.Password = "password";
        _authService
            .Setup(s => s.LoginAsync(It.IsAny<string>(), It.IsAny<string>()))
            .ThrowsAsync(new HttpRequestException("Network error"));

        await Assert.ThrowsAsync<HttpRequestException>(
            () => _sut.LoginCommand.ExecuteAsync(null));

        Assert.False(_sut.IsLoading);
    }
}

Testing INotifyPropertyChanged

Verify that property changes fire notifications correctly:

[Fact]
public void Username_RaisesPropertyChanged() {
    var changes = new List<string?>();
    _sut.PropertyChanged += (s, e) => changes.Add(e.PropertyName);

    _sut.Username = "newuser";

    Assert.Contains(nameof(LoginViewModel.Username), changes);
}

[Fact]
public void IsLoading_RaisesCanExecuteChanged_OnLoginCommand() {
    bool canExecuteChanged = false;
    _sut.LoginCommand.CanExecuteChanged += (s, e) => canExecuteChanged = true;
    _sut.Username = "admin";
    _sut.Password = "secret";
    canExecuteChanged = false; // reset after setup

    _sut.IsLoading = true;

    Assert.True(canExecuteChanged);
}

Testing Services

Services contain the core business logic. Test them directly with mocked dependencies:

// MyApp.Tests/Services/FileServiceTests.cs
public class FileServiceTests {
    private readonly Mock<IFileSystem> _fileSystem;
    private readonly FileService _sut;

    public FileServiceTests() {
        _fileSystem = new Mock<IFileSystem>();
        _sut = new FileService(_fileSystem.Object);
    }

    [Fact]
    public async Task ReadFileAsync_ReturnsContent_WhenFileExists() {
        _fileSystem
            .Setup(fs => fs.FileExistsAsync("/docs/note.txt"))
            .ReturnsAsync(true);
        _fileSystem
            .Setup(fs => fs.ReadAllTextAsync("/docs/note.txt"))
            .ReturnsAsync("Hello, world!");

        var content = await _sut.ReadFileAsync("/docs/note.txt");

        Assert.Equal("Hello, world!", content);
    }

    [Fact]
    public async Task ReadFileAsync_ThrowsFileNotFoundException_WhenMissing() {
        _fileSystem
            .Setup(fs => fs.FileExistsAsync(It.IsAny<string>()))
            .ReturnsAsync(false);

        await Assert.ThrowsAsync<FileNotFoundException>(
            () => _sut.ReadFileAsync("/nonexistent.txt"));
    }

    [Theory]
    [InlineData("")]
    [InlineData("  ")]
    [InlineData(null)]
    public async Task ReadFileAsync_ThrowsArgumentException_ForInvalidPath(string path) {
        await Assert.ThrowsAsync<ArgumentException>(
            () => _sut.ReadFileAsync(path));
    }
}

WPF-Specific: Testing Data Binding

When testing binding behavior in WPF, you can instantiate Binding and verify converter logic:

// MyApp.Tests/Converters/BoolToVisibilityConverterTests.cs
public class BoolToVisibilityConverterTests {
    private readonly BoolToVisibilityConverter _converter = new();

    [Theory]
    [InlineData(true, Visibility.Visible)]
    [InlineData(false, Visibility.Collapsed)]
    public void Convert_ReturnsExpectedVisibility(bool input, Visibility expected) {
        var result = _converter.Convert(input, typeof(Visibility), null, CultureInfo.InvariantCulture);
        Assert.Equal(expected, result);
    }

    [Fact]
    public void ConvertBack_ReturnsBool() {
        var result = _converter.ConvertBack(Visibility.Visible, typeof(bool), null, CultureInfo.InvariantCulture);
        Assert.Equal(true, result);
    }
}

End-to-End Testing with WinAppDriver

WinAppDriver automates actual Windows app UI. Use it for critical integration paths:

// MyApp.UITests/LoginFlowTests.cs
using OpenQA.Selenium.Appium;
using OpenQA.Selenium.Appium.Windows;

public class LoginFlowTests : IDisposable {
    private static WindowsDriver<WindowsElement>? _driver;
    private const string AppPath = @"C:\MyApp\MyApp.exe";
    private const string WinAppDriverUrl = "http://127.0.0.1:4723";

    public LoginFlowTests() {
        var options = new AppiumOptions();
        options.AddAdditionalCapability("app", AppPath);
        options.AddAdditionalCapability("deviceName", "WindowsPC");
        _driver = new WindowsDriver<WindowsElement>(new Uri(WinAppDriverUrl), options);
        _driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(5);
    }

    [Fact]
    public void LoginWithValidCredentials_NavigatesToDashboard() {
        var usernameField = _driver!.FindElementByAccessibilityId("UsernameField");
        var passwordField = _driver.FindElementByAccessibilityId("PasswordField");
        var loginButton = _driver.FindElementByAccessibilityId("LoginButton");

        usernameField.Clear();
        usernameField.SendKeys("admin");
        passwordField.Clear();
        passwordField.SendKeys("password");
        loginButton.Click();

        var dashboard = _driver.FindElementByAccessibilityId("DashboardPanel");
        Assert.True(dashboard.Displayed);
    }

    [Fact]
    public void LoginWithInvalidCredentials_ShowsErrorMessage() {
        var usernameField = _driver!.FindElementByAccessibilityId("UsernameField");
        var passwordField = _driver.FindElementByAccessibilityId("PasswordField");
        var loginButton = _driver.FindElementByAccessibilityId("LoginButton");

        usernameField.SendKeys("wrong");
        passwordField.SendKeys("credentials");
        loginButton.Click();

        var errorLabel = _driver.FindElementByAccessibilityId("ErrorMessage");
        Assert.True(errorLabel.Displayed);
        Assert.NotEmpty(errorLabel.Text);
    }

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

For WinAppDriver to work, you must:

  1. Enable Developer Mode in Windows settings
  2. Start WinAppDriver.exe before running tests
  3. Add AutomationId to controls in your XAML: AutomationProperties.AutomationId="LoginButton"

CI Configuration

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0'
      - run: dotnet test MyApp.Tests/ --configuration Release --logger "trx" --results-directory TestResults
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: TestResults/

  ui-tests:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0'
      - name: Build app
        run: dotnet publish MyApp.WinUI/ -c Release -o publish/
      - name: Start WinAppDriver
        run: Start-Process "C:\Program Files (x86)\Windows Application Driver\WinAppDriver.exe"
        shell: pwsh
      - run: dotnet test MyApp.UITests/ --configuration Release

Common Patterns

Constructor injection for all dependencies. Never use service locators or static access in ViewModels. Constructor injection makes dependencies explicit and mockable.

One test project per concern. Separate unit tests (fast, no platform) from UI tests (slow, requires Windows). Run unit tests on every push; UI tests on merge or scheduled.

AutomationId everywhere. Set AutomationProperties.AutomationId on every interactive control in XAML. Locating by AutomationId is more stable than by text or position.

Test the CanExecute logic. ICommand.CanExecute determines whether buttons are enabled. Test it explicitly—it's often missed and creates confusing UX bugs.

Summary

WPF and WinUI 3 apps are highly testable when built with MVVM. ViewModels with interface-based dependencies can be fully unit tested with xUnit and Moq—no UI required. WinAppDriver handles the automation layer for integration paths that must exercise the real UI. The pattern is consistent: maximize fast unit tests, minimize slow UI automation.

Read more