Testing View Models & UI

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

The main reason to adopt MVVM is that the behaviour of a screen — what is loaded, what is enabled, what happens when the user presses Delete, which errors are shown — lives in a plain class that can be created in a test without a window, a dispatcher or a mouse. This page shows how to test that class properly, how to test the pieces around it (commands, messages, validation, converters, time), and where the unit-testing approach stops and UI automation starts. It was written against .NET 10 LTS (latest patch 10.0.13) with C# 14, Visual Studio 2026 18.11.1, Windows App SDK 2.5.1 and CommunityToolkit.Mvvm 8.4.2 on Windows 10 and Windows 11. .NET 11 is at release candidate 2 (11.0.0-rc.2) and general availability is expected at .NET Conf, 10 to 13 November 2026; nothing on this page is listed as changing for it at the time of writing. WPF is the stack in focus; WinUI 3 and Windows Forms are compared in a table further down.

The running scenario is Bookshelf Desktop. The tests below exercise the canonical types of this section: the Book record, IBookRepository, IDialogService, INavigationService, BookViewModel, BooksViewModel and BookEditorViewModel. The page covers, in order:

  • the layers of a desktop test strategy and which tool belongs to which layer;

  • a runnable xUnit test project (.csproj) and the commands that run it;

  • hand-written fakes for the repository, the dialog service and the navigation service;

  • tests for AsyncRelayCommand (completion, IsRunning, cancellation, exceptions), for PropertyChanged, for messenger traffic, for ObservableValidator and ErrorsChanged, and for value converters;

  • abstractions for time (TimeProvider) and for the UI thread (a dispatcher interface);

  • the WinUI 3 unit test project and MSTest on WinUI;

  • UI automation — WinAppDriver, the Appium Windows Driver and the third-party FlaUI library — and accessibility checks in CI;

  • a GitHub Actions workflow that runs dotnet test on windows-latest.

Other pages of this section build the code that is tested here. MVVM Toolkit describes the generators, Messaging Between View Models the messenger, Navigation, Dialogs & Services in MVVM the service interfaces and Dependency Injection & the Generic Host the container; this page only repeats as much of their code as the tests need.

Test layers for a desktop MVVM app

Not everything in a desktop application is equally easy to test, and trying to test everything through the window is the usual way test suites become slow and flaky. The useful split follows the MVVM layers:

flowchart LR subgraph Fast["Fast tests - no window, no UI thread"] VM["View models: BooksViewModel, BookEditorViewModel"] CONV["Value converters"] SVC["Services and policies: LoanPolicy"] end subgraph Doubles["Test doubles"] REPO["FakeBookRepository"] DLG["FakeDialogService"] NAV["FakeNavigationService"] CLK["FakeTimeProvider"] MSG["fresh WeakReferenceMessenger"] end subgraph Slow["Slow tests - real process and window"] UIA["UI Automation client: Appium Windows Driver or FlaUI"] A11Y["Accessibility Insights and Narrator"] end VM --> REPO VM --> DLG VM --> NAV VM --> MSG SVC --> CLK UIA --> VIEW["Views in XAML"] A11Y --> VIEW VIEW -->|DataContext| VM

Microsoft’s Enterprise Application Patterns Using .NET MAUI chapter on unit testing states the principle that this page applies to Windows: testing models and view models is identical to testing any other class and uses the same tools and techniques, including mocking. Its recommendations are to test one thing per test, to use the arrange-act-assert pattern, to replace services with test doubles so that tests need no runtime platform features, web services or databases, and to avoid asserting on exception message strings. The same chapter has dedicated recipes for asynchronous initialization, INotifyPropertyChanged implementations, message-based communication, exceptions and validation, and each has a section below.

Layer What it checks Typical tools Speed and stability

Unit tests of view models, services and converters

Logic: commands, state changes, notifications, validation rules, formatting.

xUnit, MSTest or NUnit; hand-written fakes; optionally a mocking library.

Milliseconds, deterministic. Run on every commit.

Component tests with a UI thread

Code that needs real WPF or WinUI objects: a UserControl, a template, a dependency property default.

WinUI: [UITestMethod] with the WinUI unit test project. WPF: an STA thread, supplied by a third-party xUnit extension or written by hand.

Fast, but need a Windows host with a desktop session.

UI automation

The assembled application: bindings really resolve, buttons really enable, the dialog really opens.

Appium Windows Driver (with WinAppDriver), FlaUI (third party), UI Automation client API.

Seconds per test, sensitive to timing and to the desktop session. Run on a schedule or before release.

Accessibility checks

Names, roles and keyboard order in the UI Automation tree; contrast; screen reader flow.

Accessibility Insights for Windows, Narrator; UIA-tree assertions in test code.

Mostly manual, partly automatable.

The practical consequence is a rule of thumb: put every decision into a class that does not reference System.Windows.Controls or Microsoft.UI.Xaml, and the first row of the table covers almost everything that can go wrong. The Windows App SDK testing guide gives the same advice in its own words: refactor code to be tested out of the app project and into a library project that both the app and the test project reference.

A runnable xUnit test project

The test project is an ordinary SDK-style project. It targets the same Windows-specific target framework as the WPF project it tests (a plain net10.0 test project cannot reference a net10.0-windows project), enables WPF so that the converter tests can use System.Windows types, and references xUnit, the Visual Studio test adapter and the test SDK.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0-windows</TargetFramework>
    <UseWPF>true</UseWPF>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
    <RootNamespace>Bookshelf.Desktop.Tests</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
    <PackageReference Include="coverlet.collector" Version="10.1.0" />
    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
    <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.11.0" />
  </ItemGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Bookshelf.Core\Bookshelf.Core.csproj" />
    <ProjectReference Include="..\Bookshelf.Desktop\Bookshelf.Desktop.csproj" />
  </ItemGroup>

</Project>

The Microsoft tutorial lists Microsoft.NET.Test.Sdk, xunit, xunit.runner.visualstudio and coverlet.collector as the packages that dotnet new xunit adds; the package versions above were the latest stable ones on NuGet on 2026-10-23, and Microsoft.Extensions.TimeProvider.Testing and CommunityToolkit.Mvvm are the two additions for this page. The combination of xUnit 2.9.3 with xunit.runner.visualstudio 3.1.5 is a conservative choice: the NuGet description of the runner’s newer 4.0.1 release says it runs xUnit.net v1, v2 and v3 tests, so moving to the newer xunit.v3 package later does not need a different runner. The project file is a user pattern assembled from the tutorial; the official docs do not show a WPF-specific test project.

The test project needs a matching WPF project to reference. A minimal one looks like this; the application project holds the view models, the services and the converters, and Bookshelf.Core holds Book and IBookRepository.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net10.0-windows</TargetFramework>
    <UseWPF>true</UseWPF>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Bookshelf.Core\Bookshelf.Core.csproj" />
  </ItemGroup>

</Project>

Splitting the view models into their own net10.0 class library is better still: that library builds and tests on any operating system, so the bulk of the suite can run on a cheap Linux runner, and only the converter and UI tests need windows-latest. The solution layout and commands are:

dotnet new sln -n Bookshelf
dotnet new classlib -n Bookshelf.Core
dotnet new wpf -n Bookshelf.Desktop
dotnet new xunit -n Bookshelf.Desktop.Tests
dotnet sln add Bookshelf.Core Bookshelf.Desktop Bookshelf.Desktop.Tests
dotnet add Bookshelf.Desktop reference Bookshelf.Core
dotnet add Bookshelf.Desktop.Tests reference Bookshelf.Core Bookshelf.Desktop
dotnet test

After the template files are replaced by the project file above, dotnet test builds the solution, discovers the tests and prints one line per assembly with the total, passed, failed and skipped counts. In Visual Studio 2026 the same tests appear in Test Explorer.

The Bookshelf code under test

The tests need concrete types to exercise. The following listing is the canonical Bookshelf Desktop code of this section, reduced to what the tests touch; the full versions, with the details of each generator, are on the sibling pages. The messenger-aware variant of BooksViewModel is used, so the constructor takes an IMessenger in addition to the repository and the dialog service.

// Bookshelf.Core/Book.cs
namespace Bookshelf.Core;

public sealed record Book(int Id, string Title, string Author, int Year = 0, decimal Price = 0m, string? Isbn = null);

// Bookshelf.Core/IBookRepository.cs
namespace Bookshelf.Core;

public interface IBookRepository
{
    Task<IReadOnlyList<Book>> GetAllAsync(CancellationToken cancellationToken);
    Task<Book?> GetByIdAsync(int id, CancellationToken cancellationToken);
    Task<Book> AddAsync(Book book, CancellationToken cancellationToken);
    Task UpdateAsync(Book book, CancellationToken cancellationToken);
    Task DeleteAsync(int id, CancellationToken cancellationToken);
}

// Bookshelf.Core/IDialogService.cs
namespace Bookshelf.Core;

public interface IDialogService
{
    Task<bool> ConfirmAsync(string title, string message);

    /// <summary>Shows the add/edit dialog. Returns null when the user cancels.</summary>
    Task<Book?> EditBookAsync(Book? existing);
}

// Bookshelf.Core/INavigationService.cs
namespace Bookshelf.Core;

public interface INavigationService
{
    bool CanGoBack { get; }
    void NavigateTo<TViewModel>() where TViewModel : class;
    void NavigateTo<TViewModel>(object parameter) where TViewModel : class;
    void GoBack();
}

These interfaces and the record are user types of the Bookshelf application. Microsoft publishes no official repository, dialog or navigation service for WPF or the toolkit; the MAUI book’s IDialogService and INavigationService belong to its eShop sample, not to a library.

// Bookshelf.Desktop/ViewModels/BookViewModel.cs
namespace Bookshelf.Desktop.ViewModels;

using Bookshelf.Core;
using CommunityToolkit.Mvvm.ComponentModel;

public partial class BookViewModel(Book book) : ObservableObject
{
    public Book Model { get; } = book;
    public int Id => Model.Id;
    public string Title => Model.Title;
    public string Author => Model.Author;

    [ObservableProperty]
    public partial bool IsFavorite { get; set; }
}

// Bookshelf.Desktop/ViewModels/BookDeletedMessage.cs
namespace Bookshelf.Desktop.ViewModels;

public sealed record BookDeletedMessage(int BookId);

// Bookshelf.Desktop/ViewModels/BooksViewModel.cs
namespace Bookshelf.Desktop.ViewModels;

using System.Collections.ObjectModel;
using Bookshelf.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;

public partial class BooksViewModel(IBookRepository repository, IDialogService dialogs, IMessenger messenger)
    : ObservableRecipient(messenger), IRecipient<BookDeletedMessage>
{
    public ObservableCollection<BookViewModel> Books { get; } = [];

    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(EditSelectedCommand))]
    [NotifyCanExecuteChangedFor(nameof(DeleteSelectedCommand))]
    public partial BookViewModel? SelectedBook { get; set; }

    [ObservableProperty]
    public partial string? SearchText { get; set; }

    [ObservableProperty]
    public partial string? StatusMessage { get; set; }

    private bool HasSelection => SelectedBook is not null;

    [RelayCommand(IncludeCancelCommand = true)]
    private async Task LoadAsync(CancellationToken cancellationToken)
    {
        StatusMessage = "Loading books...";
        try
        {
            var books = await repository.GetAllAsync(cancellationToken);
            Books.Clear();
            foreach (var book in books)
            {
                Books.Add(new BookViewModel(book));
            }

            StatusMessage = $"{Books.Count} books";
        }
        catch (OperationCanceledException)
        {
            StatusMessage = "Loading canceled";
        }
    }

    [RelayCommand]
    private async Task AddBookAsync()
    {
        var created = await dialogs.EditBookAsync(null);
        if (created is null)
        {
            return;
        }

        var saved = await repository.AddAsync(created, CancellationToken.None);
        Books.Add(new BookViewModel(saved));
    }

    [RelayCommand(CanExecute = nameof(HasSelection))]
    private async Task EditSelectedAsync()
    {
        if (SelectedBook is not { } selected)
        {
            return;
        }

        var edited = await dialogs.EditBookAsync(selected.Model);
        if (edited is null)
        {
            return;
        }

        await repository.UpdateAsync(edited, CancellationToken.None);
        var index = Books.IndexOf(selected);
        Books[index] = new BookViewModel(edited);
        SelectedBook = Books[index];
    }

    [RelayCommand(CanExecute = nameof(HasSelection))]
    private async Task DeleteSelectedAsync()
    {
        if (SelectedBook is not { } selected)
        {
            return;
        }

        if (!await dialogs.ConfirmAsync("Delete book", $"Delete '{selected.Title}'?"))
        {
            return;
        }

        await repository.DeleteAsync(selected.Id, CancellationToken.None);
        Books.Remove(selected);
        SelectedBook = null;
        Messenger.Send(new BookDeletedMessage(selected.Id));
    }

    [RelayCommand]
    private void ToggleFavorite(BookViewModel? book)
    {
        if (book is not null)
        {
            book.IsFavorite = !book.IsFavorite;
        }
    }

    // Another window deleted a book: keep this list consistent.
    public void Receive(BookDeletedMessage message)
    {
        var stale = Books.FirstOrDefault(b => b.Id == message.BookId);
        if (stale is not null)
        {
            Books.Remove(stale);
        }
    }
}

The listing follows the documented generator rules: the name of a generated command is the method name with the Async suffix stripped and Command appended, CanExecute can name a property, IncludeCancelCommand = true generates a LoadCancelCommand, and a CancellationToken parameter is wired to the command’s cancellation. The partial-property form of [ObservableProperty] is the one this section uses; the ObservableProperty page of the docs still shows fields only, so the release notes of the toolkit are the reference for partial properties. The string properties are declared nullable so that the generated backing fields need no initializer. The Messenger.Send call is the extension method from IMessengerExtensions.

The editor view model is an ObservableValidator, as the canonical shape requires. It is created from a Book, validates its properties when they are set, and produces a Book with ToBook(). It is a reduced form of the BookEditorViewModel(Book book) on the MVVM Toolkit (CommunityToolkit.Mvvm) page, without Price and Validate():

// Bookshelf.Desktop/ViewModels/BookEditorViewModel.cs
namespace Bookshelf.Desktop.ViewModels;

using System.ComponentModel.DataAnnotations;
using Bookshelf.Core;
using CommunityToolkit.Mvvm.ComponentModel;

public partial class BookEditorViewModel : ObservableValidator
{
    private readonly Book _original;

    public BookEditorViewModel(Book book)
    {
        _original = book;
        Title = book.Title;
        Author = book.Author;
        Year = book.Year;
        Isbn = book.Isbn;
    }

    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Required(ErrorMessage = "Title is required.")]
    [MaxLength(200, ErrorMessage = "Title is too long.")]
    public partial string? Title { get; set; }

    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Required(ErrorMessage = "Author is required.")]
    public partial string? Author { get; set; }

    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Range(0, 2100, ErrorMessage = "Year must be between 0 and 2100.")]
    public partial int Year { get; set; }

    [ObservableProperty]
    [NotifyDataErrorInfo]
    [RegularExpression(@"^(\d{10}|\d{13})$", ErrorMessage = "ISBN must have 10 or 13 digits.")]
    public partial string? Isbn { get; set; }

    public Book ToBook() => _original with
    {
        Title = Title ?? string.Empty,
        Author = Author ?? string.Empty,
        Year = Year,
        Isbn = string.IsNullOrWhiteSpace(Isbn) ? null : Isbn,
    };
}

Hand-written fakes

A test double is any object that stands in for a real dependency. The .NET unit-testing best-practices page distinguishes a stub (a controllable replacement that supplies data), a mock (a fake that the test asserts against) and the generic fake that can be either; the same page notes that other sources use these words more narrowly, and advises naming test doubles accurately because a class called a mock that is only a stub misleads the next reader. The fakes below are used both ways: they supply data, and they record how they were called so that a test can assert on it.

For view-model tests, hand-written fakes are the simplest reliable option. The three interfaces have between two and five members, a fake is a dozen lines, and it can be steered explicitly — hold a task open, throw on demand, answer "yes" or "cancel". Mocking libraries such as Moq, NSubstitute and FakeItEasy are third-party packages that generate such doubles at run time; they are a legitimate choice, especially for wide interfaces, and the C# Testing page of this site shows NSubstitute and Moq side by side (see C# Testing). Microsoft’s docs mention mocks conceptually but do not recommend a library. The rest of this page uses only fakes, so that no behaviour hides behind a library’s setup syntax.

// Bookshelf.Desktop.Tests/Fakes/FakeBookRepository.cs
namespace Bookshelf.Desktop.Tests.Fakes;

using Bookshelf.Core;

public sealed class FakeBookRepository : IBookRepository
{
    private readonly List<Book> _books;

    public FakeBookRepository(params Book[] books) => _books = [.. books];

    /// <summary>When set, GetAllAsync waits for it (and for cancellation) before returning.</summary>
    public TaskCompletionSource? Gate { get; set; }

    /// <summary>When set, GetAllAsync throws it.</summary>
    public Exception? Failure { get; set; }

    public int GetAllCalls { get; private set; }
    public List<int> DeletedIds { get; } = [];
    public IReadOnlyList<Book> Stored => _books;

    public async Task<IReadOnlyList<Book>> GetAllAsync(CancellationToken cancellationToken)
    {
        GetAllCalls++;
        if (Failure is not null)
        {
            throw Failure;
        }

        if (Gate is not null)
        {
            await Gate.Task.WaitAsync(cancellationToken);
        }

        return _books.ToList();
    }

    public Task<Book?> GetByIdAsync(int id, CancellationToken cancellationToken) =>
        Task.FromResult(_books.FirstOrDefault(b => b.Id == id));

    public Task<Book> AddAsync(Book book, CancellationToken cancellationToken)
    {
        var saved = book with { Id = _books.Count == 0 ? 1 : _books.Max(b => b.Id) + 1 };
        _books.Add(saved);
        return Task.FromResult(saved);
    }

    public Task UpdateAsync(Book book, CancellationToken cancellationToken)
    {
        var index = _books.FindIndex(b => b.Id == book.Id);
        _books[index] = book;
        return Task.CompletedTask;
    }

    public Task DeleteAsync(int id, CancellationToken cancellationToken)
    {
        _books.RemoveAll(b => b.Id == id);
        DeletedIds.Add(id);
        return Task.CompletedTask;
    }
}

// Bookshelf.Desktop.Tests/Fakes/FakeDialogService.cs
namespace Bookshelf.Desktop.Tests.Fakes;

using Bookshelf.Core;

public sealed class FakeDialogService : IDialogService
{
    /// <summary>The answer ConfirmAsync gives.</summary>
    public bool ConfirmResult { get; set; } = true;

    /// <summary>The book EditBookAsync returns; null simulates Cancel.</summary>
    public Book? EditResult { get; set; }

    public List<(string Title, string Message)> Confirmations { get; } = [];
    public List<Book?> EditRequests { get; } = [];

    public Task<bool> ConfirmAsync(string title, string message)
    {
        Confirmations.Add((title, message));
        return Task.FromResult(ConfirmResult);
    }

    public Task<Book?> EditBookAsync(Book? existing)
    {
        EditRequests.Add(existing);
        return Task.FromResult(EditResult);
    }
}

// Bookshelf.Desktop.Tests/Fakes/FakeNavigationService.cs
namespace Bookshelf.Desktop.Tests.Fakes;

using Bookshelf.Core;

public sealed class FakeNavigationService : INavigationService
{
    private readonly Stack<Type> _history = new();

    public List<(Type ViewModel, object? Parameter)> Requests { get; } = [];
    public int BackRequests { get; private set; }

    public bool CanGoBack => _history.Count > 1;

    public void NavigateTo<TViewModel>() where TViewModel : class => Push(typeof(TViewModel), null);

    public void NavigateTo<TViewModel>(object parameter) where TViewModel : class => Push(typeof(TViewModel), parameter);

    public void GoBack()
    {
        BackRequests++;
        if (CanGoBack)
        {
            _history.Pop();
        }
    }

    private void Push(Type viewModel, object? parameter)
    {
        Requests.Add((viewModel, parameter));
        _history.Push(viewModel);
    }
}

The fakes are a user pattern: neither the MVVM Toolkit nor the Windows docs ship test doubles for repositories, dialogs or navigation, and there is no official IDialogService or INavigationService to fake — both are interfaces of this application. The MAUI book uses the same idea with OrderMockService, a hand-written mock of an IOrderService, passed into the view model’s constructor. Three design points matter:

  • The fakes expose behaviour switches (Gate, Failure, ConfirmResult, EditResult) and records (DeletedIds, Confirmations, Requests). A test sets the switches in its arrange step and reads the records in its assert step.

  • Gate is a TaskCompletionSource. A repository whose GetAllAsync is held open is the standard way to observe a command while it is running without any Task.Delay or sleeping.

  • The fakes contain no logic of their own beyond the minimum to behave like the real thing, so a failing test points at the view model and not at the fake.

A small helper builds a loaded view model so that each test starts from the same state. It uses a new WeakReferenceMessenger per test, for a reason explained in the messenger section:

// Bookshelf.Desktop.Tests/Scenario.cs
namespace Bookshelf.Desktop.Tests;

using Bookshelf.Core;
using Bookshelf.Desktop.Tests.Fakes;
using Bookshelf.Desktop.ViewModels;
using CommunityToolkit.Mvvm.Messaging;

internal sealed record Scenario(
    BooksViewModel ViewModel,
    FakeBookRepository Repository,
    FakeDialogService Dialogs,
    WeakReferenceMessenger Messenger)
{
    public static readonly Book Dune = new(1, "Dune", "Frank Herbert", 1965, 9.99m, "9780441013593");
    public static readonly Book Emma = new(2, "Emma", "Jane Austen", 1815);

    public static Scenario Create(params Book[] books)
    {
        var repository = new FakeBookRepository(books);
        var dialogs = new FakeDialogService();
        var messenger = new WeakReferenceMessenger();
        return new Scenario(new BooksViewModel(repository, dialogs, messenger), repository, dialogs, messenger);
    }

    public static async Task<Scenario> LoadedAsync(params Book[] books)
    {
        var scenario = Create(books);
        await scenario.ViewModel.LoadCommand.ExecuteAsync(null);
        return scenario;
    }
}

Testing commands and AsyncRelayCommand

A generated [RelayCommand] on an async Task method is an AsyncRelayCommand, exposed as IAsyncRelayCommand. Tests should call the command, not the private method, because the command is what the view binds to and what adds the behaviour worth testing: concurrency control, cancellation and the running state. The members used in tests are:

Member Meaning How a test uses it

ExecuteAsync(object?)

Starts the command and returns the Task of that execution.

await it to run to completion; keep the task without awaiting to observe a command that is still running.

IsRunning

True while the last execution has not completed.

Assert true while a fake holds the work open, false after the task completes.

ExecutionTask

The task of the most recent execution; null before the first one.

Assert its status, or IsFaulted after a failure.

CanBeCanceled, IsCancellationRequested, Cancel()

Cancellation support for methods that take a CancellationToken.

Call Cancel() while the work is held open; assert the flags and the outcome.

CanExecute(object?) and CanExecuteChanged

Whether the command is available, and the event that tells a button to re-ask.

Assert the value before and after a state change; count the events.

AllowConcurrentExecutions

Option on [RelayCommand]; default false.

With the default, CanExecute is false while the command runs.

FlowExceptionsToTaskScheduler

Option on [RelayCommand]; default false.

Decides whether an exception crashes the caller (default) or only faults ExecutionTask.

Completion and IsRunning

// Bookshelf.Desktop.Tests/BooksViewModelLoadTests.cs
namespace Bookshelf.Desktop.Tests;

public class BooksViewModelLoadTests
{
    [Fact]
    public async Task LoadCommand_WithTwoBooks_FillsTheCollectionAndStatus()
    {
        var scenario = Scenario.Create(Scenario.Dune, Scenario.Emma);

        await scenario.ViewModel.LoadCommand.ExecuteAsync(null);

        Assert.Equal(2, scenario.ViewModel.Books.Count);
        Assert.Equal("2 books", scenario.ViewModel.StatusMessage);
        Assert.Equal(1, scenario.Repository.GetAllCalls);
    }

    [Fact]
    public async Task LoadCommand_WhileRunning_ReportsIsRunningAndCannotExecuteAgain()
    {
        var gate = new TaskCompletionSource();
        var scenario = Scenario.Create(Scenario.Dune);
        scenario.Repository.Gate = gate;

        Task load = scenario.ViewModel.LoadCommand.ExecuteAsync(null);

        Assert.True(scenario.ViewModel.LoadCommand.IsRunning);
        Assert.False(scenario.ViewModel.LoadCommand.CanExecute(null));
        Assert.Equal("Loading books...", scenario.ViewModel.StatusMessage);

        gate.SetResult();
        await load;

        Assert.False(scenario.ViewModel.LoadCommand.IsRunning);
        Assert.True(scenario.ViewModel.LoadCommand.CanExecute(null));
        Assert.Single(scenario.ViewModel.Books);
    }

    [Fact]
    public async Task LoadCommand_WhenTheRepositoryFails_FaultsTheExecutionTask()
    {
        var scenario = Scenario.Create();
        scenario.Repository.Failure = new InvalidOperationException("database offline");

        await Assert.ThrowsAsync<InvalidOperationException>(() => scenario.ViewModel.LoadCommand.ExecuteAsync(null));

        Assert.True(scenario.ViewModel.LoadCommand.ExecutionTask!.IsFaulted);
        Assert.False(scenario.ViewModel.LoadCommand.IsRunning);
    }
}

The pattern in the second test is the important one: call ExecuteAsync without await, assert on the running state, release the gate, then await the saved task. Everything before the first suspension point of LoadAsync — here the assignment of StatusMessage — has already run when ExecuteAsync returns, so the intermediate state is observable deterministically. The third test relies on the documented default: the command awaits the execution and lets exceptions propagate, so awaiting ExecuteAsync rethrows. A command configured with FlowExceptionsToTaskScheduler = true would instead only fault ExecutionTask, and the same test would then assert on the task and not expect the throw. The toolkit’s own test suite (Test_AsyncRelayCommand.cs in the CommunityToolkit/dotnet repository) uses these same assertions: IsRunning false before, true right after ExecuteAsync and false after the await, and ExecutionTask equal to the task ExecuteAsync returned.

Cancellation

The view model’s LoadAsync takes a CancellationToken, so the generator wires the command’s cancellation into it, and IncludeCancelCommand = true adds LoadCancelCommand for a Cancel button. The test cancels while the repository fake is held open. The tests are two more members of the BooksViewModelLoadTests class:

    [Fact]
    public async Task LoadCancelCommand_WhileLoading_StopsTheLoadAndReportsIt()
    {
        var scenario = Scenario.Create(Scenario.Dune);
        scenario.Repository.Gate = new TaskCompletionSource();

        Task load = scenario.ViewModel.LoadCommand.ExecuteAsync(null);
        Assert.True(scenario.ViewModel.LoadCommand.CanBeCanceled);
        Assert.True(scenario.ViewModel.LoadCancelCommand.CanExecute(null));

        scenario.ViewModel.LoadCancelCommand.Execute(null);
        await load;

        Assert.True(scenario.ViewModel.LoadCommand.IsCancellationRequested);
        Assert.False(scenario.ViewModel.LoadCommand.IsRunning);
        Assert.Empty(scenario.ViewModel.Books);
        Assert.Equal("Loading canceled", scenario.ViewModel.StatusMessage);
    }

    [Fact]
    public async Task LoadCancelCommand_WhenNothingIsRunning_CannotExecute()
    {
        var scenario = await Scenario.LoadedAsync(Scenario.Dune);

        Assert.False(scenario.ViewModel.LoadCancelCommand.CanExecute(null));
    }

The view model catches OperationCanceledException itself, which is the pattern the RelayCommand page shows, so the task completes normally and the test can simply await it. If the view model let the exception escape, the awaited task would be canceled and the test would assert Assert.ThrowsAnyAsync<OperationCanceledException> instead. The toolkit’s tests record one detail worth knowing: IsCancellationRequested stays true after the operation finishes, and is reset only when the command runs again, so a test must not expect it to flip back by itself.

CanExecute and CanExecuteChanged

[NotifyCanExecuteChangedFor] makes the generated setter of SelectedBook call NotifyCanExecuteChanged() on the named commands, but the commands do not otherwise know that the value behind HasSelection changed. That relationship is exactly what a test should pin down:

// Bookshelf.Desktop.Tests/BooksViewModelCommandTests.cs
namespace Bookshelf.Desktop.Tests;

using Bookshelf.Core;

public class BooksViewModelCommandTests
{
    [Fact]
    public async Task DeleteSelectedCommand_TracksTheSelection()
    {
        var scenario = await Scenario.LoadedAsync(Scenario.Dune, Scenario.Emma);
        var raised = 0;
        scenario.ViewModel.DeleteSelectedCommand.CanExecuteChanged += (_, _) => raised++;

        Assert.False(scenario.ViewModel.DeleteSelectedCommand.CanExecute(null));

        scenario.ViewModel.SelectedBook = scenario.ViewModel.Books[0];

        Assert.True(scenario.ViewModel.DeleteSelectedCommand.CanExecute(null));
        Assert.Equal(1, raised);
    }

    [Fact]
    public async Task DeleteSelectedCommand_WhenConfirmed_DeletesFromTheRepositoryAndTheList()
    {
        var scenario = await Scenario.LoadedAsync(Scenario.Dune, Scenario.Emma);
        scenario.ViewModel.SelectedBook = scenario.ViewModel.Books[0];

        await scenario.ViewModel.DeleteSelectedCommand.ExecuteAsync(null);

        Assert.Equal(new[] { Scenario.Dune.Id }, scenario.Repository.DeletedIds);
        Assert.Equal("Emma", Assert.Single(scenario.ViewModel.Books).Title);
        Assert.Null(scenario.ViewModel.SelectedBook);
        Assert.Equal("Delete 'Dune'?", scenario.Dialogs.Confirmations[0].Message);
    }

    [Fact]
    public async Task DeleteSelectedCommand_WhenTheUserDeclines_ChangesNothing()
    {
        var scenario = await Scenario.LoadedAsync(Scenario.Dune);
        scenario.Dialogs.ConfirmResult = false;
        scenario.ViewModel.SelectedBook = scenario.ViewModel.Books[0];

        await scenario.ViewModel.DeleteSelectedCommand.ExecuteAsync(null);

        Assert.Empty(scenario.Repository.DeletedIds);
        Assert.Single(scenario.ViewModel.Books);
    }

    [Fact]
    public async Task AddBookCommand_WhenTheDialogIsCanceled_AddsNothing()
    {
        var scenario = await Scenario.LoadedAsync(Scenario.Dune);
        scenario.Dialogs.EditResult = null;

        await scenario.ViewModel.AddBookCommand.ExecuteAsync(null);

        Assert.Single(scenario.ViewModel.Books);
        Assert.Single(scenario.Dialogs.EditRequests);
        Assert.Null(scenario.Dialogs.EditRequests[0]);
    }

    [Fact]
    public async Task AddBookCommand_WhenTheDialogReturnsABook_SavesItAndShowsIt()
    {
        var scenario = await Scenario.LoadedAsync(Scenario.Dune);
        scenario.Dialogs.EditResult = new Book(0, "Neuromancer", "William Gibson", 1984);

        await scenario.ViewModel.AddBookCommand.ExecuteAsync(null);

        Assert.Equal(2, scenario.ViewModel.Books.Count);
        Assert.Equal(2, scenario.ViewModel.Books[1].Id);
        Assert.Equal(2, scenario.Repository.Stored.Count);
    }

    [Fact]
    public async Task ToggleFavoriteCommand_FlipsTheFlagOnTheGivenBook()
    {
        var scenario = await Scenario.LoadedAsync(Scenario.Dune);
        var book = scenario.ViewModel.Books[0];

        scenario.ViewModel.ToggleFavoriteCommand.Execute(book);

        Assert.True(book.IsFavorite);
    }
}

Each test follows the naming scheme recommended on the best-practices page — the member under test, the scenario and the expected behaviour — and checks one concern. The dialog is never shown: FakeDialogService returns the book or the cancellation that the arrange step chose, and the test also reads Confirmations and EditRequests to prove that the view model asked the right question.

Asserting PropertyChanged

Anything bound in XAML depends on PropertyChanged being raised with the right name at the right time, so notification behaviour is worth a test even when the toolkit generates it. The MAUI chapter’s recipe is to attach a handler, perform the action, and assert that the event fired; a small recorder makes the intent readable and works for any INotifyPropertyChanged, including commands.

// Bookshelf.Desktop.Tests/PropertyChangedRecorder.cs
namespace Bookshelf.Desktop.Tests;

using System.ComponentModel;

internal sealed class PropertyChangedRecorder
{
    public PropertyChangedRecorder(INotifyPropertyChanged source) =>
        source.PropertyChanged += (_, e) => Names.Add(e.PropertyName);

    public List<string?> Names { get; } = [];
}

// Bookshelf.Desktop.Tests/NotificationTests.cs
namespace Bookshelf.Desktop.Tests;

using System.ComponentModel;

public class NotificationTests
{
    [Fact]
    public void SearchText_WhenChanged_RaisesPropertyChangedOnce()
    {
        var viewModel = Scenario.Create().ViewModel;
        var recorder = new PropertyChangedRecorder(viewModel);

        viewModel.SearchText = "dune";

        Assert.Equal(nameof(viewModel.SearchText), Assert.Single(recorder.Names));
    }

    [Fact]
    public void SearchText_WhenSetToTheSameValue_RaisesNothing()
    {
        var viewModel = Scenario.Create().ViewModel;
        viewModel.SearchText = "dune";
        var recorder = new PropertyChangedRecorder(viewModel);

        viewModel.SearchText = "dune";

        Assert.Empty(recorder.Names);
    }

    [Fact]
    public async Task LoadCommand_RaisesIsRunningNotifications()
    {
        var scenario = Scenario.Create(Scenario.Dune);
        var recorder = new PropertyChangedRecorder((INotifyPropertyChanged)scenario.ViewModel.LoadCommand);

        await scenario.ViewModel.LoadCommand.ExecuteAsync(null);

        Assert.Contains("IsRunning", recorder.Names);
        Assert.Contains("ExecutionTask", recorder.Names);
    }
}

The generated setter of an [ObservableProperty] raises PropertyChanged only when the value actually changes, which the second test pins down; an assertion for "no notification on an equal value" is worth keeping, because binding loops start when a property raises on every set. Generated hooks are tested through their effects, not directly:

Toolkit feature

Observable effect

What to assert

[ObservableProperty]

PropertyChanged with the property name; PropertyChanging before it.

The recorded names, in order when order matters.

[NotifyPropertyChangedFor(nameof(X))]

A second PropertyChanged for the dependent property.

Names contains the dependent name.

[NotifyCanExecuteChangedFor(nameof(XCommand))]

CanExecuteChanged on the command.

A counter on the command event, as in the delete-command test.

OnXChanged partial methods

Whatever the method does, for example loading details.

The resulting state, never the method itself (it is private).

[NotifyPropertyChangedRecipients] (on an ObservableRecipient)

A PropertyChangedMessage<T> on the messenger.

A probe recipient on a fresh messenger, as below.

Testing messenger traffic

The MAUI chapter tests message-based communication by subscribing to the message in the test and checking that the callback ran. The MVVM Toolkit equivalent is a recipient registered on the same IMessenger that the view model uses. The decisive rule is to give each test its own messenger instance:

  • WeakReferenceMessenger.Default and StrongReferenceMessenger.Default are process-wide singletons. xUnit runs the tests of different classes in parallel by default, so tests that share Default can receive each other’s messages, and a recipient left registered by one test stays registered for the next.

  • The toolkit documents that creating multiple messenger instances is supported (for instance one per window), and WeakReferenceMessenger has a public parameterless constructor. new WeakReferenceMessenger() is therefore the correct per-test seam, provided the code under test receives the messenger through its constructor — the reason BooksViewModel takes an IMessenger. A view model that calls WeakReferenceMessenger.Default directly cannot be isolated this way.

  • If legacy code cannot be changed, call Reset() on the default messenger in the test’s cleanup. IMessenger.Reset() is documented as resetting the instance and unregistering all existing recipients. Treat it as a workaround: it cannot make tests in parallel classes independent of each other.

// Bookshelf.Desktop.Tests/MessageProbe.cs
namespace Bookshelf.Desktop.Tests;

using CommunityToolkit.Mvvm.Messaging;

internal sealed class MessageProbe<TMessage> : IRecipient<TMessage>
    where TMessage : class
{
    public MessageProbe(IMessenger messenger) => messenger.Register<TMessage>(this);

    public List<TMessage> Received { get; } = [];

    public void Receive(TMessage message) => Received.Add(message);
}

// Bookshelf.Desktop.Tests/BooksViewModelMessagingTests.cs
namespace Bookshelf.Desktop.Tests;

using Bookshelf.Desktop.ViewModels;
using CommunityToolkit.Mvvm.Messaging;

public class BooksViewModelMessagingTests
{
    [Fact]
    public async Task DeleteSelectedCommand_WhenConfirmed_SendsBookDeletedMessage()
    {
        var scenario = await Scenario.LoadedAsync(Scenario.Dune, Scenario.Emma);
        var probe = new MessageProbe<BookDeletedMessage>(scenario.Messenger);
        scenario.ViewModel.SelectedBook = scenario.ViewModel.Books[1];

        await scenario.ViewModel.DeleteSelectedCommand.ExecuteAsync(null);

        Assert.Equal(new BookDeletedMessage(Scenario.Emma.Id), Assert.Single(probe.Received));
    }

    [Fact]
    public async Task DeleteSelectedCommand_WhenDeclined_SendsNothing()
    {
        var scenario = await Scenario.LoadedAsync(Scenario.Dune);
        scenario.Dialogs.ConfirmResult = false;
        var probe = new MessageProbe<BookDeletedMessage>(scenario.Messenger);
        scenario.ViewModel.SelectedBook = scenario.ViewModel.Books[0];

        await scenario.ViewModel.DeleteSelectedCommand.ExecuteAsync(null);

        Assert.Empty(probe.Received);
    }

    [Fact]
    public async Task WhenActive_RemovesABookDeletedElsewhere()
    {
        var scenario = await Scenario.LoadedAsync(Scenario.Dune, Scenario.Emma);
        scenario.ViewModel.IsActive = true;

        scenario.Messenger.Send(new BookDeletedMessage(Scenario.Dune.Id));

        Assert.Equal("Emma", Assert.Single(scenario.ViewModel.Books).Title);
    }

    [Fact]
    public async Task WhenNotActive_IgnoresMessages()
    {
        var scenario = await Scenario.LoadedAsync(Scenario.Dune);

        scenario.Messenger.Send(new BookDeletedMessage(Scenario.Dune.Id));

        Assert.Single(scenario.ViewModel.Books);
    }

    [Fact]
    public async Task WhenDeactivated_StopsReceiving()
    {
        var scenario = await Scenario.LoadedAsync(Scenario.Dune);
        scenario.ViewModel.IsActive = true;
        scenario.ViewModel.IsActive = false;

        scenario.Messenger.Send(new BookDeletedMessage(Scenario.Dune.Id));

        Assert.Single(scenario.ViewModel.Books);
    }
}

Source: Messenger

ObservableRecipient registers the declared IRecipient<TMessage> handlers when IsActive is set to true and removes them when it is set to false, which is why the receiving tests set IsActive explicitly. A view model that is never activated receives nothing, and that is itself a behaviour worth pinning with the "not active" test. Two further points:

  • Keep a reference to the probe for the whole test (the assertion at the end does that). WeakReferenceMessenger holds its recipients weakly, so a probe that nothing references can be collected before the message is sent, and the test would pass or fail depending on garbage collection. A lambda registered with Register<TRecipient, TMessage> against an object that is still referenced is safe.

  • Messages are dispatched synchronously on the calling thread, so no waiting is needed in these tests. Request messages (RequestMessage<T>, AsyncRequestMessage<T>) are tested the same way: register a recipient that calls Reply, call Send<TRequest>(), and assert on the value. The toolkit documents that a RequestMessage<T> converted to its value throws if nobody replied, which gives a natural negative test.

Testing ObservableValidator and ErrorsChanged

ObservableValidator implements INotifyDataErrorInfo: it exposes HasErrors, GetErrors(propertyName) and the ErrorsChanged event. The MAUI chapter tests its own ValidatableObject<T> by asserting IsValid, Errors and the overall Validate result; the Toolkit equivalent asserts HasErrors, the error messages and the events.

// Bookshelf.Desktop.Tests/BookEditorViewModelTests.cs
namespace Bookshelf.Desktop.Tests;

using System.ComponentModel.DataAnnotations;
using Bookshelf.Desktop.ViewModels;

public class BookEditorViewModelTests
{
    private static BookEditorViewModel CreateEditor() => new(Scenario.Dune);

    private static List<string> Messages(BookEditorViewModel editor, string property) =>
        editor.GetErrors(property).Cast<ValidationResult>().Select(r => r.ErrorMessage ?? string.Empty).ToList();

    [Fact]
    public void NewEditor_FromAValidBook_HasNoErrors()
    {
        var editor = CreateEditor();

        Assert.False(editor.HasErrors);
    }

    [Fact]
    public void Title_WhenCleared_ReportsRequiredAndRaisesErrorsChanged()
    {
        var editor = CreateEditor();
        var changedProperties = new List<string?>();
        editor.ErrorsChanged += (_, e) => changedProperties.Add(e.PropertyName);

        editor.Title = string.Empty;

        Assert.True(editor.HasErrors);
        Assert.Contains(nameof(BookEditorViewModel.Title), changedProperties);
        Assert.Equal(new[] { "Title is required." }, Messages(editor, nameof(BookEditorViewModel.Title)));
    }

    [Fact]
    public void Title_WhenFixedAgain_ClearsTheError()
    {
        var editor = CreateEditor();
        editor.Title = string.Empty;

        editor.Title = "Dune Messiah";

        Assert.False(editor.HasErrors);
        Assert.Empty(Messages(editor, nameof(BookEditorViewModel.Title)));
    }

    [Theory]
    [InlineData("0441013597", false)]
    [InlineData("9780441013593", false)]
    [InlineData("123", true)]
    [InlineData("97804410135931", true)]
    [InlineData("97804410135X3", true)]
    public void Isbn_Validation_FollowsTheTenOrThirteenDigitRule(string isbn, bool expectedHasErrors)
    {
        var editor = CreateEditor();

        editor.Isbn = isbn;

        Assert.Equal(expectedHasErrors, editor.HasErrors);
    }

    [Fact]
    public void ValidateAllProperties_ReportsEveryInvalidPropertyAtOnce()
    {
        var editor = CreateEditor();
        editor.Title = string.Empty;
        editor.Year = 3000;

        editor.ValidateAllProperties();

        Assert.NotEmpty(Messages(editor, nameof(BookEditorViewModel.Title)));
        Assert.NotEmpty(Messages(editor, nameof(BookEditorViewModel.Year)));
        Assert.Empty(Messages(editor, nameof(BookEditorViewModel.Author)));
    }

    [Fact]
    public void ToBook_KeepsTheIdentityAndAppliesTheEdits()
    {
        var editor = CreateEditor();
        editor.Title = "Dune Messiah";
        editor.Isbn = "  ";

        var book = editor.ToBook();

        Assert.Equal(Scenario.Dune with { Title = "Dune Messiah", Isbn = null }, book);
    }
}

Test the rules (which inputs fail), the messages only where the message is part of the contract, and the signalling (ErrorsChanged carries the property name). The tests assert with Contains for the events because the toolkit raises ErrorsChanged when errors are cleared as well as when they are added, so the exact number of events is an implementation detail. Do not assert on the text of framework-generated messages — the MAUI chapter warns that message strings change and make tests brittle — which is why the [Required] and [Range] attributes above carry an explicit ErrorMessage that belongs to the application.

Testing a navigation-driven shell

A shell or main view model that drives INavigationService is tested with FakeNavigationService: the test calls the command and asserts on Requests. The view model below is the smallest example; the sibling page Navigation, Dialogs & Services in MVVM covers the real service.

// Bookshelf.Desktop/ViewModels/ShellViewModel.cs
namespace Bookshelf.Desktop.ViewModels;

using Bookshelf.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

public partial class ShellViewModel(INavigationService navigation) : ObservableObject
{
    private bool CanGoBackNow => navigation.CanGoBack;

    [RelayCommand]
    private void ShowBooks() => navigation.NavigateTo<BooksViewModel>();

    [RelayCommand]
    private void OpenBook(int id) => navigation.NavigateTo<BookEditorViewModel>(id);

    [RelayCommand(CanExecute = nameof(CanGoBackNow))]
    private void GoBack() => navigation.GoBack();
}

// Bookshelf.Desktop.Tests/ShellViewModelTests.cs
namespace Bookshelf.Desktop.Tests;

using Bookshelf.Desktop.Tests.Fakes;
using Bookshelf.Desktop.ViewModels;

public class ShellViewModelTests
{
    [Fact]
    public void ShowBooksCommand_NavigatesToTheBooksViewModel()
    {
        var navigation = new FakeNavigationService();
        var shell = new ShellViewModel(navigation);

        shell.ShowBooksCommand.Execute(null);

        Assert.Equal((typeof(BooksViewModel), (object?)null), Assert.Single(navigation.Requests));
    }

    [Fact]
    public void OpenBookCommand_PassesTheIdAsParameter()
    {
        var navigation = new FakeNavigationService();
        var shell = new ShellViewModel(navigation);

        shell.OpenBookCommand.Execute(42);

        var request = Assert.Single(navigation.Requests);
        Assert.Equal(typeof(BookEditorViewModel), request.ViewModel);
        Assert.Equal(42, request.Parameter);
    }

    [Fact]
    public void GoBackCommand_IsOnlyAvailableAfterTwoNavigations()
    {
        var navigation = new FakeNavigationService();
        var shell = new ShellViewModel(navigation);
        Assert.False(shell.GoBackCommand.CanExecute(null));

        shell.ShowBooksCommand.Execute(null);
        shell.OpenBookCommand.Execute(1);

        Assert.True(shell.GoBackCommand.CanExecute(null));
    }
}

The last test reads CanExecute directly after each navigation. A real button would only re-evaluate after CanExecuteChanged, and the shell does not raise it when the navigation history changes — the RelayCommand page states that the command is not automatically aware of such changes. A production shell would call GoBackCommand.NotifyCanExecuteChanged() from a navigation event, and the test for that would count CanExecuteChanged as in the delete-command test.

Testing value converters

A value converter is a pure function with a culture parameter, which makes it the easiest class in the application to test — and the one most often left untested. Its test needs no UI thread, because IValueConverter and DependencyProperty.UnsetValue are ordinary types; what needs a UI thread are real controls and DependencyObject instances. The converter below formats a price for display and parses it back:

// Bookshelf.Desktop/Converters/PriceConverter.cs
namespace Bookshelf.Desktop.Converters;

using System.Globalization;
using System.Windows;
using System.Windows.Data;

public sealed class PriceConverter : IValueConverter
{
    public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
        value is decimal price ? price.ToString("C", culture) : DependencyProperty.UnsetValue;

    public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
        value is string text && decimal.TryParse(text, NumberStyles.Currency, culture, out var price)
            ? price
            : Binding.DoNothing;
}

// Bookshelf.Desktop.Tests/PriceConverterTests.cs
namespace Bookshelf.Desktop.Tests;

using System.Globalization;
using System.Windows;
using System.Windows.Data;
using Bookshelf.Desktop.Converters;

public class PriceConverterTests
{
    private static readonly CultureInfo English = CultureInfo.GetCultureInfo("en-US");
    private static readonly CultureInfo Spanish = CultureInfo.GetCultureInfo("es-ES");
    private readonly PriceConverter _converter = new();

    [Fact]
    public void Convert_UsesTheCurrencyFormatOfTheGivenCulture()
    {
        var english = (string)_converter.Convert(12.5m, typeof(string), null, English);
        var spanish = (string)_converter.Convert(12.5m, typeof(string), null, Spanish);

        Assert.StartsWith("$12.50", english);
        Assert.NotEqual(english, spanish);
    }

    [Theory]
    [InlineData("en-US")]
    [InlineData("es-ES")]
    public void ConvertBack_InvertsConvert_ForTheSameCulture(string cultureName)
    {
        var culture = CultureInfo.GetCultureInfo(cultureName);
        var text = _converter.Convert(1234.5m, typeof(string), null, culture);

        var back = _converter.ConvertBack(text, typeof(decimal), null, culture);

        Assert.Equal(1234.5m, back);
    }

    [Fact]
    public void Convert_WithANonDecimal_ReturnsUnsetValue()
    {
        var result = _converter.Convert("not a number", typeof(string), null, English);

        Assert.Same(DependencyProperty.UnsetValue, result);
    }

    [Fact]
    public void ConvertBack_WithUnparseableText_ReturnsDoNothing()
    {
        var result = _converter.ConvertBack("abc", typeof(decimal), null, English);

        Assert.Same(Binding.DoNothing, result);
    }
}

Three habits keep converter tests robust. First, always pass the culture explicitly — the binding engine passes the ConverterCulture or the language of the element, and a converter that reads CultureInfo.CurrentCulture internally behaves differently on the build agent than on a developer machine. Second, assert relations (round trip, difference between cultures, prefix) instead of the full formatted string of a non-English culture, whose separators and spacing depend on the operating system’s globalization data; the tests above use the round trip for exactly this reason. Third, test the sentinel returns (UnsetValue, DoNothing) because they decide whether a binding falls back to its default or leaves the target untouched. The Converters, Collection Views & Data Templates page describes the converter contract; the Globalization & Localization page explains the culture flow.

A test that creates a Button, a UserControl or any other DependencyObject must run on a thread that has a dispatcher and the single-threaded apartment (STA) state. Plain xUnit test threads are not STA. The usual answers are a third-party extension that supplies an STA or WPF-aware fact attribute (the Xunit.StaFact package is a widely used one), or a helper in the test project that starts an STA thread, runs the action and rethrows. Neither is an official Microsoft recommendation, and the WPF documentation has no testing guide, so keep such tests few and push logic out of the code-behind instead.

Abstracting time and the UI thread

View models become hard to test when they reach for the clock or the dispatcher directly. Both are seams: static or ambient dependencies that a test cannot control. The best-practices page describes exactly this problem with DateTime.Now and shows the solution of wrapping the dependency in an interface that the code receives. .NET 8 added a built-in seam for time, System.TimeProvider, so that no custom interface is needed.

TimeProvider

TimeProvider is an abstract class with GetUtcNow(), GetLocalNow(), GetTimestamp() and CreateTimer; production code uses TimeProvider.System, and tests use FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package. FakeTimeProvider has a constructor taking a start DateTimeOffset, Advance(TimeSpan) to move time forward, SetUtcNow and AdjustTime, and AutoAdvanceAmount to make every read advance the clock. Timers created through the fake fire when the clock is advanced past their due time, and Task.Delay(TimeSpan, TimeProvider, CancellationToken) (the Task.Delay overload that takes a TimeProvider) completes the same way, so debounce logic and timeouts are tested without sleeping.

// Bookshelf.Desktop/Services/LoanPolicy.cs
namespace Bookshelf.Desktop.Services;

public sealed class LoanPolicy(TimeProvider time)
{
    public static readonly TimeSpan LoanPeriod = TimeSpan.FromDays(21);

    public bool IsOverdue(DateTimeOffset borrowedAt) => time.GetUtcNow() - borrowedAt > LoanPeriod;
}

// Bookshelf.Desktop.Tests/LoanPolicyTests.cs
namespace Bookshelf.Desktop.Tests;

using Bookshelf.Desktop.Services;
using Microsoft.Extensions.Time.Testing;

public class LoanPolicyTests
{
    private static readonly DateTimeOffset Borrowed = new(2026, 10, 1, 9, 0, 0, TimeSpan.Zero);

    [Fact]
    public void IsOverdue_OnTheLastDayOfTheLoan_IsFalse()
    {
        var clock = new FakeTimeProvider(Borrowed);
        var policy = new LoanPolicy(clock);

        clock.Advance(LoanPolicy.LoanPeriod);

        Assert.False(policy.IsOverdue(Borrowed));
    }

    [Fact]
    public void IsOverdue_AfterThePeriodElapses_IsTrue()
    {
        var clock = new FakeTimeProvider(Borrowed);
        var policy = new LoanPolicy(clock);

        clock.Advance(LoanPolicy.LoanPeriod + TimeSpan.FromMinutes(1));

        Assert.True(policy.IsOverdue(Borrowed));
    }
}

The registration is one line in the composition root, services.AddSingleton(TimeProvider.System), which the sibling page Dependency Injection & the Generic Host places with the other services. The C# Testing page of this site shows the older hand-rolled clock interface that TimeProvider replaces.

A dispatcher abstraction

WPF has Dispatcher, WinUI 3 has DispatcherQueue, and Windows Forms has Control.Invoke and SynchronizationContext; none of them implements a shared interface, and there is no official abstraction over them. Code that touches the UI thread from a background continuation therefore needs a small interface of the application’s own. This is a user pattern, not an official API. Most view-model code should not need it: after an await on a UI thread, execution resumes on that thread, and bound ObservableCollection<T> updates happen there. The interface is for the exceptions — an import that runs on a worker thread and must add to a bound collection, for example.

// Bookshelf.Desktop/Services/IUiDispatcher.cs
namespace Bookshelf.Desktop.Services;

public interface IUiDispatcher
{
    Task InvokeAsync(Action action);
}

// Bookshelf.Desktop/Services/WpfDispatcher.cs
namespace Bookshelf.Desktop.Services;

using System.Windows.Threading;

public sealed class WpfDispatcher(Dispatcher dispatcher) : IUiDispatcher
{
    public Task InvokeAsync(Action action) => dispatcher.InvokeAsync(action).Task;
}

// Bookshelf.Desktop/ViewModels/ImportViewModel.cs
namespace Bookshelf.Desktop.ViewModels;

using System.Collections.ObjectModel;
using Bookshelf.Core;
using Bookshelf.Desktop.Services;
using CommunityToolkit.Mvvm.ComponentModel;

public class ImportViewModel(IBookRepository repository, IUiDispatcher ui) : ObservableObject
{
    public ObservableCollection<BookViewModel> Imported { get; } = [];

    public async Task ImportAsync(IReadOnlyList<Book> incoming, CancellationToken cancellationToken)
    {
        foreach (var book in incoming)
        {
            var saved = await repository.AddAsync(book, cancellationToken).ConfigureAwait(false);
            await ui.InvokeAsync(() => Imported.Add(new BookViewModel(saved)));
        }
    }
}

// Bookshelf.Desktop.Tests/ImportViewModelTests.cs
namespace Bookshelf.Desktop.Tests;

using Bookshelf.Core;
using Bookshelf.Desktop.Services;
using Bookshelf.Desktop.Tests.Fakes;
using Bookshelf.Desktop.ViewModels;

public class ImportViewModelTests
{
    private sealed class ImmediateDispatcher : IUiDispatcher
    {
        public int Calls { get; private set; }

        public Task InvokeAsync(Action action)
        {
            Calls++;
            action();
            return Task.CompletedTask;
        }
    }

    [Fact]
    public async Task ImportAsync_AddsEveryBookThroughTheDispatcher()
    {
        var dispatcher = new ImmediateDispatcher();
        var viewModel = new ImportViewModel(new FakeBookRepository(), dispatcher);
        Book[] incoming = [new(0, "Dune", "Frank Herbert"), new(0, "Emma", "Jane Austen")];

        await viewModel.ImportAsync(incoming, CancellationToken.None);

        Assert.Equal(2, viewModel.Imported.Count);
        Assert.Equal(2, dispatcher.Calls);
    }
}

The fake runs the action inline, which is the correct behaviour for a unit test: the test is single-threaded and deterministic, and it still proves that the view model routes the collection change through the dispatcher. A second double that records the actions without running them lets a test verify the opposite case, that nothing touches the collection until the dispatcher runs. The threading rules themselves — which thread owns which object, ConfigureAwait, Dispatcher.InvokeAsync — are covered in Threading, Dispatcher & Async; ConfigureAwait and synchronization contexts in general are covered in Async & Await.

Choosing a framework, and MSTest on WinUI

xUnit, NUnit and MSTest all work for view-model tests, and the tests above translate one to one. Microsoft’s WinUI guidance uses MSTest because of the Visual Studio templates; the MAUI chapter’s examples use MSTest; the .NET tutorial uses xUnit; the tool you pick matters less than using one consistently.

Concept

xUnit (2.x)

MSTest

NUnit

Test method

[Fact]

[TestMethod]

[Test]

Parameterized

[Theory] with [InlineData]

[TestMethod] with [DataRow]

[TestCase]

Per-test setup

Constructor (a new class instance per test)

[TestInitialize]

[SetUp]

Async test

async Task

async Task

async Task

Expected exception

Assert.Throws or Assert.ThrowsAsync

Assert.Throws or Assert.ThrowsExactly

Assert.Throws

UI-thread test

third-party STA attributes

[UITestMethod] (WinUI test app)

third-party or [Apartment]

The best-practices page notes that the SetUp and TearDown attributes were removed from xUnit 2.x, and recommends helper methods over setup attributes because they keep the arrangement visible inside each test; the Scenario helper above is that advice applied. The mapping row for expected exceptions is deliberately general because the assertion APIs changed between framework versions; consult the version you use.

The WinUI 3 unit test project

A WinUI 3 view model with no WinUI types in it is tested exactly like the WPF ones above, in any test project that references it. The Windows App SDK testing guide is about the other case: code that does use Microsoft.UI.Xaml types, which must run on a UI thread inside a XAML application process. For that, Visual Studio has the WinUI Unit Test App project template, whose generated tests show the key attribute:

namespace WinUITest1
{
   [TestClass]
   public class UnitTest1
   {
      [TestMethod]
      public void TestMethod1()
      {
         Assert.AreEqual(0, 0);
      }

      // Use the UITestMethod attribute for tests that need to run on the UI thread.
      [UITestMethod]
      public void TestMethod2()
      {
         var grid = new Grid();
         Assert.AreEqual(0, grid.MinWidth);
      }
   }
}

The listing is the template’s sample, reproduced because it is the official statement of the rule: [TestMethod] for tests that do not need the UI thread, [UITestMethod] for tests that create or use WinUI objects. The testing hub also recommends moving the code under test into a class library that both the app and the test project reference, and shows how to add a WinUI Class Library and a project reference from the test app. The specific "create a WinUI unit test project" article that older material links to no longer exists as a separate page; its content is now the first tutorial section of the testing hub linked above.

When the code under test has no WinUI types but the test project must reference a WinUI 3 project, the hub lists four changes to a standard MSTest, NUnit or xUnit project: set the TargetFramework to the same Windows-specific value as the WinUI project, add the matching RuntimeIdentifiers, add WindowsAppSdkBootstrapInitialize, and make sure the Windows App SDK runtime is installed on the machine that runs the tests. For a project that uses .NET 10 the same shape becomes:

<PropertyGroup>
  <TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
  <RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
  <WindowsAppSdkBootstrapInitialize>true</WindowsAppSdkBootstrapInitialize>
</PropertyGroup>

The hub’s own example uses net8.0 and net8.0-windows10.0.19041.0; the net10.0 value above is an adaptation, and the target platform version must match your WinUI project.

The .NET documentation has a newer page, Test UWP and WinUI 3 apps with MSTest and Microsoft.Testing.Platform (dated 2026-09-30 at the time of writing), that describes running MSTest tests inside the application itself, with the app acting as the test host. Its main points:

Application model

How the test host starts

Run command

Packaged WinUI 3

A full-trust sidecar registers the package and activates the app by AUMID.

dotnet run or dotnet test --project

Unpackaged WinUI 3 (WindowsPackageType set to None)

The test platform starts the executable directly.

dotnet run or dotnet test --project

WinUI 3 packagedClassicApp with TrustLevel="appContainer"

A full-trust sidecar activates the app and authorizes its package SID on the pipes.

dotnet msbuild with the InvokeTestingPlatform target

Classic or modern UWP

A sidecar registers the package and activates it by AUMID.

MSBuild with the InvokeTestingPlatform target

The page requires MSTest.Sdk 4.5 or later and the .NET SDK 10 or later, a Windows-specific target framework of 10.0.19041.0 or later for packaged activation, and Developer Mode (or a sideloading policy) so that Windows can register an unsigned build-output package. In a self-hosted test app the window publishes its dispatcher queue to the test attribute and the platform’s exit code is returned to the process:

_window = new UnitTestAppWindow();
_window.Activate();
UITestMethodAttribute.DispatcherQueue = _window.DispatcherQueue;

Environment.ExitCode = await MicrosoftTestingPlatformApplication.RunAsync(
    Environment.GetCommandLineArgs()[1..]);

The troubleshooting table on that page lists the failures to expect: a second Application.Start call when a WinUITestTarget attribute is added to a self-hosted app, a process that stays open because the window was not closed, failed tests that still return exit code 0 because the result was not assigned to Environment.ExitCode, and a missing AppxManifest.xml message when an unpackaged app is run under VSTest, which does not support unpackaged WinUI 3 test apps. The page also says that the complete Windows application support matrix needs MSTest 4.5 with Microsoft.Testing.Platform 2.5 and, until the stable versions are released, preview packages; NuGet already listed MSTest.Sdk 4.5.1 as a stable release on 2026-10-23, so check the current state before you follow the "use preview packages" wording. Do not use dotnet exec for a WinUI test app, because WinUI resolves PRI resources relative to the process path.

Comparing the three stacks

Concern WPF WinUI 3 Windows Forms

View-model tests

Plain test project; net10.0-windows only if it references WPF types.

Plain test project; same.

Plain test project; same.

Code that needs a UI object

STA thread with a dispatcher; third-party xUnit extension or a hand-written helper. No official project template.

[UITestMethod] in the WinUI Unit Test App, or the MSTest-on-WinUI host.

STA thread; controls need a handle and message loop for many behaviours.

Binding errors visible in tests

Not without a real window; run the app and watch the Output window, or use a UI test.

Same, plus compile-time checking of x:Bind.

BindingSource and Binding events can be exercised in a test with a control not shown.

Converter type

System.Windows.Data.IValueConverter, culture as CultureInfo.

Microsoft.UI.Xaml.Data.IValueConverter, language as a string.

Binding.Format and Binding.Parse events.

UI-thread seam

Dispatcher

DispatcherQueue

Control.Invoke, SynchronizationContext

UI automation

UI Automation provider built in; Appium Windows Driver or FlaUI.

UI Automation provider built in; Appium Windows Driver or FlaUI.

UI Automation through the MSAA bridge; Appium Windows Driver or FlaUI.

Official testing guidance

None specific to WPF. The MAUI chapter and the .NET testing docs apply.

Testing hub and MSTest-on-WinUI page.

None specific to Windows Forms.

The table’s WPF and Windows Forms cells for official guidance reflect an absence rather than a finding: the WPF and Windows Forms documentation sets have no page on testing view models or UI, and the converter and binding rows are the author’s summary of the platforms' APIs, not quotations from a testing guide.

UI automation

Unit tests cannot prove that a binding path is spelled correctly, that a template shows the right element or that Tab moves focus sensibly. Those need the real application, driven the way a user drives it. On Windows the mechanism underneath every tool is Microsoft UI Automation (UIA): the accessibility framework that exposes every piece of UI as an AutomationElement in a tree rooted at the desktop, with control types, properties and control patterns. UIA exists primarily so that screen readers can operate the UI, and its documentation states that it also "allows automated test scripts to interact with the UI". WPF controls have native UIA support, so no extra work is needed for standard controls, and a custom control needs an AutomationPeer (covered in Accessibility & UI Automation).

That overview page is a .NET Framework page and carries a note pointing to the Win32 UI Automation documentation for the latest information; it describes the managed client API in the System.Windows.Automation namespace, which is also available to .NET on Windows. For UI tests the most useful consequence is a design one: give every element a test could need a stable AutomationProperties.AutomationId (and a human AutomationProperties.Name), because automation tools locate elements by those properties and not by their position.

<StackPanel>
  <TextBox AutomationProperties.AutomationId="SearchBox"
           AutomationProperties.Name="Search books" />
  <ListView AutomationProperties.AutomationId="BookList"
            AutomationProperties.Name="Books" />
  <Button AutomationProperties.AutomationId="AddBookButton"
          Content="Add book" />
</StackPanel>

The tools

Tool Status Notes

WinAppDriver (Microsoft)

The GitHub repository is not archived, but Microsoft’s testing hub describes it as the original tool for Windows UI automation testing that is no longer under active development. Its latest release marked stable is v1.2.1 (2020-11-05); the newest release overall is the pre-release v1.2.99 (2021-07-01). It is not formally deprecated.

A WebDriver-protocol server that listens by default on 127.0.0.1:4723. It supports UWP, Windows Forms, WPF and Win32 apps. Developer Mode must be on, and listening on another address needs administrator rights.

Appium Windows Driver (appium-windows-driver)

Maintained under the Appium organization; version 6.3.1 was released 2026-10-08. Microsoft’s hub names it the recommended successor to WinAppDriver.

A proxy for the WinAppDriver server: its README states that the driver does not install WinAppDriver automatically since version 3.0.0, and that it needs Developer Mode and Windows 10 as host. See the caveat below.

FlaUI (third party)

An open-source .NET library over the UIA2 and UIA3 client APIs. It is not a Microsoft product and has no Microsoft documentation.

Runs in-process with the test; no server. Mentioned here as an alternative; this page does not teach it.

UI Automation client API

Part of .NET on Windows (System.Windows.Automation).

Enough for small smoke tests without any extra package, at the cost of low-level code.

Two caveats about the support picture, since the sources do not agree with each other. The testing hub says WinAppDriver is no longer under active development and names appium-windows-driver as the recommended successor, with a setup of npm install -g appium, appium driver install windows and appium. The README of appium-windows-driver itself, however, calls the driver deprecated, says it is incompatible with Appium 4 and later because the WinAppDriver server supports only the legacy JSONWP protocol, says Microsoft has not maintained WinAppDriver for years, and points to other Appium drivers (the Appium WinCore driver and the NovaWindows driver) as the way forward; it also lists UWP, WinForms, WPF and Win32 apps as supported and does not mention WinUI, whereas the hub’s setup instructions are for a WinUI 3 desktop app. In short: Microsoft has no current, maintained first-party recommendation for desktop UI automation, the Appium route works today with Appium 3 and WinAppDriver installed, and its long-term future is uncertain. Treat the choice of tool as a decision with an exit plan: keep UI tests thin and behind a page-object layer so that the driver can be replaced.

An Appium session for Bookshelf Desktop

The server setup from the testing hub, for a machine with Node.js installed (Node.js LTS is recommended there):

npm install -g appium
appium driver install windows
appium driver list
appium

The hub adds that tests are written with a WebDriver client library (available for C#, Python, Java and JavaScript). The session is requested with capabilities; the Appium Windows Driver README documents platformName, appium:automationName and appium:app (a UWP app ID, or the full path of a classic app; for packaged WinUI 3 apps the hub says to use the Application User Model ID, for unpackaged apps the executable path):

{
  "platformName": "windows",
  "appium:automationName": "windows",
  "appium:app": "C:\\src\\Bookshelf\\Bookshelf.Desktop\\bin\\Release\\net10.0-windows\\Bookshelf.Desktop.exe"
}

The C# client is the Appium.WebDriver NuGet package (9.0.0 was the latest on 2026-10-23), built on Selenium. The client’s README shows no Windows example and refers to its wiki and a sample repository, and its API names changed between major versions, so this page does not reproduce client code that it could not verify against the version you install. The shape of a UI test is, however, always the same: start a session, find an element by its automation id, act on it, assert on another element, and quit the session in a finally block or in the test’s Dispose.

Keep UI tests few and valuable: one for startup and the main list, one for the add-and-edit dialog round trip, one for delete with confirmation. They are slow, they need an interactive desktop session (a locked or disconnected session does not work for UI automation on many agents), and they fail for environmental reasons more often than unit tests do. Run them on a separate schedule from the unit tests, and give them generous explicit waits on the element to appear instead of fixed sleeps.

Accessibility checks and the CI boundary

Accessibility problems are bugs in the UIA tree, so the same tree can be tested. The testing hub points to Accessibility Insights for Windows as the tool that inspects and validates the UIA tree of an app, and observes that the elements exposed through UIA are the same elements that automated test tools interact with: a well-structured automation tree improves both accessibility and testability. What can be automated in CI is limited to the structural part:

  • In unit or component tests: assert that the controls of a view have a non-empty AutomationProperties.Name, that interactive elements are keyboard focusable, and that custom controls return a peer with the expected control type. The assertions run against real controls and therefore need the STA/UI-thread setup described above.

  • In UI automation tests: assert, through the driver, that the elements located by automation id have the expected names and control types, and that Tab order visits them in the expected sequence.

  • Manually, at each release: a screen-reader pass with Narrator, high-contrast themes, text scaling and a keyboard-only walk through the main flows. These are not automatable with official tooling, and Accessibility Insights for Windows is a desktop tool rather than a CI component.

The checklist, the Narrator workflow and the AutomationPeer guidance are in Accessibility & UI Automation; this page only places those checks in the pipeline. Where a WebView2 control is part of the UI, the testing hub also names Playwright as the tool for testing the web content.

Running the tests in CI

dotnet test is the same command locally and in a pipeline. A GitHub Actions workflow for Bookshelf Desktop restores, builds and tests on windows-latest, writes TRX results, collects coverage with the coverlet.collector package that the test project references, and uploads the results even when a test fails:

name: tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --configuration Release --no-restore

      - name: Test
        run: >
          dotnet test --configuration Release --no-build
          --logger "trx;LogFileName=results.trx"
          --collect:"XPlat Code Coverage"
          --results-directory TestResults

      - name: Upload test results
        if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: TestResults

The GitHub page uses actions/setup-dotnet@v4, dotnet restore, dotnet build --no-restore, dotnet test --no-build, a --logger trx --results-directory test step and an actions/upload-artifact@v4 step with if: ${{ always() }} so that results are published when tests fail; the page’s example installs .NET 6 and uses a newer checkout action than the @v4 shown here, so update the pinned versions to the ones current for your repository. The workflow above is a user pattern based on those steps: the --collect argument and the explicit Release configuration are additions. The step is split into build and test --no-build so that a compilation failure and a test failure are reported separately.

Notes on running desktop tests on a hosted runner:

  • Unit tests, converter tests and tests with a dedicated STA thread run on windows-latest without further setup. WinUI 3 tests that use [UITestMethod] need the Windows App SDK prerequisites listed on the WinUI testing pages (and, for packaged apps, Developer Mode or a sideloading policy).

  • UI automation tests need an interactive desktop session and a running Appium server; they are usually a separate job, or a separate workflow that runs nightly. The CI/CD page of this section covers runner setup in general.

  • Add --filter (or xUnit traits) to split fast and slow tests into separate steps, so a pull request gets quick feedback and the slow suite runs on a schedule.

  • A first run on a clean runner restores NuGet packages; cache the NuGet package folder if restore time matters.

What changed across .NET versions

Version Testing-relevant change

.NET Framework 4.8

WPF view models test the same way, and SDK-style test projects can target it; TimeProvider is not part of the base class library, so a small clock interface is needed. System.Windows.Automation is the managed UI Automation client.

.NET 8

System.TimeProvider is part of the base class library, so time can be faked without a custom interface; Task.WaitAsync(CancellationToken) (used by the repository fake) is available since .NET 6.

.NET 9 (C# 13)

Partial properties exist in the language, but the toolkit documentation still shows [ObservableProperty] on fields and its analyzer message MVVMTK0042 still mentions a preview language version; check the toolkit release notes for the form your compiler supports. Test code is unaffected, because tests use the generated public property either way.

.NET 10 (C# 14)

[ObservableProperty] on partial properties is the form this section uses; WinUI test apps are run with dotnet test --project under Microsoft.Testing.Platform, which needs the .NET SDK 10.

.NET 11 (RC2)

Nothing on this page is listed as changed. General availability is expected at .NET Conf, 10 to 13 November 2026.

Test frameworks

xUnit has a v3 generation (the xunit.v3 package, 4.0.2 on NuGet on 2026-10-23) next to v2; MSTest 4.5 and Microsoft.Testing.Platform 2.5 are the versions the WinUI hosting page requires. The Visual Studio runner xunit.runner.visualstudio runs v1, v2 and v3 tests according to the NuGet description of its 4.0.1 release.

The .NET 9 row is stated cautiously: partial properties were added in C# 13, but the toolkit’s own documentation for [ObservableProperty] still shows fields, so the practical statement is the one in the row. Verify the generator support for your compiler version in the toolkit’s release notes.

What the official documentation does not provide

  • There is no official guidance on testing WPF view models. The closest sources are the MAUI book’s unit-testing chapter (written for .NET MAUI with MSTest and MessagingCenter, which this page translates to the Toolkit’s messenger), the MVVM Toolkit’s own unit tests in its GitHub repository, and the general .NET testing documentation.

  • There is no official dialog-service, navigation-service or dispatcher abstraction for WPF or the toolkit, and so no official fakes. The interfaces and fakes on this page are user patterns.

  • There is no official WPF test project template or STA test attribute. WinUI 3 has the WinUI Unit Test App template and [UITestMethod]; WPF has nothing equivalent from Microsoft.

  • Desktop UI-test automation has no current recommendation from Microsoft. The testing hub points to Appium and the Appium Windows Driver, while that driver’s README says the driver is deprecated; WinAppDriver has had no stable release since 2020.

  • The messenger page does not discuss testing. The advice to use a fresh messenger per test follows from the documented facts that Default is a shared instance and that new instances can be created; it is not written down as a test recommendation.

  • The specific WinUI unit-test-project article is gone. Its content moved into the Windows App SDK testing hub, and the MSTest-on-WinUI page covers the new Microsoft.Testing.Platform hosting model.

Pitfalls

  • Sharing WeakReferenceMessenger.Default between tests. Parallel test classes see each other’s messages, and registrations leak from one test to the next. Inject an IMessenger and give each test a new instance.

  • A probe or recipient that is garbage collected. A weak messenger does not keep recipients alive; hold the reference until the assertion has run.

  • Testing the private method behind a command. Test the generated command: that is what the view calls, and it carries the concurrency, cancellation and CanExecute behaviour.

  • Sleeping instead of gating. Task.Delay in a test or a fake makes the suite slow and flaky. Hold work open with a TaskCompletionSource and release it, or advance a FakeTimeProvider.

  • Not awaiting ExecuteAsync. An un-awaited command can finish after the test ends and report its failure on another test. Keep the task and await it before the test returns.

  • Expecting IsCancellationRequested to reset. It stays true after the work finishes and is reset when the command runs again.

  • Forgetting that CanExecute is not re-evaluated automatically. A test that reads CanExecute right after a state change can pass while the real button stays disabled, because nothing raised CanExecuteChanged. Assert the event, not only the value.

  • Asserting on the number of ErrorsChanged or PropertyChanged events when only the presence of an event matters. The toolkit raises extra notifications for clearing and for dependent properties.

  • Asserting on exception messages or on culture-specific formatted strings. Both are brittle; assert types and relations.

  • Using CultureInfo.CurrentCulture implicitly. The result depends on the agent’s regional settings. Pass the culture explicitly in converter and formatting tests.

  • Creating WPF or WinUI controls in a plain test. Without an STA thread and a dispatcher (WPF) or the WinUI test host ([UITestMethod]), creation fails or behaves unpredictably. Move the logic into a view model instead.

  • A net10.0 test project referencing a net10.0-windows project fails to restore. Match the target framework, or put the logic in a plain library.

  • Running dotnet exec on a WinUI test app. WinUI resolves PRI resources relative to the process path; use dotnet run or dotnet test --project.

  • UI tests that locate elements by position or text. Use AutomationProperties.AutomationId; text changes with localization and layout changes with the window size.

  • Running UI automation on a locked or disconnected session. Many hosted and virtual agents do not provide an interactive desktop; configure the agent to log in and stay connected.

  • Treating coverage as the goal. The best-practices page warns that a high percentage does not imply quality. Cover behaviour: commands, notifications, validation, messages.

Books used for this page

The testing chapters of two books informed the structure of this page. They are listed by name only and none of their text is reproduced:

  • Tidjani Belmansour, Building CLI Applications with C# and .NET (Packt, 2025), chapter 10 on test types and on mocking external dependencies.

  • Mark J. Price, C# 13 and .NET 9 — Modern Cross-Platform Development Fundamentals (Packt, 2024), chapter 4 on debugging, Hot Reload and unit testing with xUnit.

References