WPF performance and testing MVVM applications
Binding and layout cost, virtualization and frozen resources, profiling the UI thread, testing view models without a window, and when UI automation earns its cost.
Where the time goes
| Cost | Typical cause | Fix |
|---|---|---|
| Layout thrash | Changing Width, Margin or Visibility in a loop | Batch changes, use RenderTransform |
| Binding overhead | Hundreds of bindings on dynamic data | Bind to a stable snapshot; avoid deep paths |
| Container creation | Virtualization disabled | Turn on item-based scrolling and recycling |
| Freezable churn | A new brush or geometry per item | Freeze one instance and share it |
| Dispatcher saturation | Too many BeginInvoke calls | Coalesce and use Background priority |
| Template explosion | A heavy DataTemplate per row | Simplify the template; show detail on demand |
// a frozen, shared brush has no change tracking and can be used everywhere
public static class Palette
{
public static readonly SolidColorBrush Accent = Create("#2F6FED");
public static readonly SolidColorBrush Muted = Create("#6B7280");
private static SolidColorBrush Create(string hex)
{
var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex));
brush.Freeze(); // immutable and shareable
return brush;
}
}- Turn on binding trace output in the debugger. A binding that fails on every render is invisible in behaviour and very visible in a profile.
PresentationTraceSources.TraceLevel=Highon a single binding answers "why is this value wrong" without guesswork.- Profile the UI thread and the render thread separately; a smooth UI thread with a saturated render thread means too much visual complexity, not too much code.
Testing view models
[Fact]
public async Task LoadAsync_populates_orders_and_clears_busy_flag()
{
var api = Substitute.For<IOrderApi>();
api.GetOrdersAsync().Returns(Task.FromResult<IReadOnlyList<Order>>(
new[] { new Order("A-1", "Acme", 120m) }));
var vm = new OrdersViewModel(api, Substitute.For<IDialogService>());
await vm.LoadAsync();
Assert.Single(vm.Orders);
Assert.False(vm.IsBusy);
Assert.Null(vm.Error);
}
[Fact]
public async Task LoadAsync_surfaces_an_api_failure()
{
var api = Substitute.For<IOrderApi>();
api.GetOrdersAsync().Returns(Task.FromException<IReadOnlyList<Order>>(new HttpRequestException("503")));
var vm = new OrdersViewModel(api, Substitute.For<IDialogService>());
await vm.LoadAsync();
Assert.Contains("503", vm.Error);
Assert.False(vm.IsBusy);
}A view model built on interfaces can be tested in milliseconds with no window, no dispatcher and no application instance. That is the practical payoff of MVVM: the interesting logic is the part that does not need a screen.
// anything that touches the dispatcher needs it while testing
[Fact]
public void Save_command_raises_property_changed()
{
var raised = new List<string>();
var vm = new EditorViewModel();
vm.PropertyChanged += (s, e) => raised.Add(e.PropertyName);
vm.SaveCommand.Execute(null);
Assert.Contains(nameof(EditorViewModel.IsDirty), raised);
}When to add UI automation
- Automate the flows that genuinely break: start-up, navigation to a key screen, and one full save round-trip per feature area.
- Select elements with
AutomationProperties.AutomationId. Locating by visible text makes the test fail every time the copy changes. - Run UI tests on a machine with a real desktop session; headless agents without a session cannot drive WPF reliably.
- Treat a flaky UI test as a bug in the test. Retrying a flaky test hides the real problem and erodes trust in the suite.
- Keep the UI suite small and fast. Twenty reliable tests that run in a minute beat two hundred that run for an hour.
<Button Content="Save"
AutomationProperties.AutomationId="SaveButton"
AutomationProperties.Name="Save the order"
Command="{Binding SaveCommand}" />⚠️
Do not measure performance from a debug build, and do not measure it on a warm run only. First-start cost includes XAML parsing, JIT and resource loading; a second launch can be twice as fast and hide a genuine regression.
FAQ
How many bindings are too many?
There is no fixed number - a grid with a few thousand live bindings can be perfectly smooth once virtualization is on. Measure with the visual tree tool and a UI-thread profile rather than counting.
Should I test the XAML?
Test the view models thoroughly and automate only the handful of end-to-end flows you would otherwise release broken. Asserting on visual details tests the theme, not the behaviour.
Related
Items controls, collections and virtualization Custom controls and user controls
Last refreshed 2026-09-18.