Desktop Application Testing Strategies: WinForms, WPF, and Electron Apps
Desktop application testing sits in an awkward position in most organizations: less investment than web testing, harder to automate, and often maintained by teams that haven't had to think about desktop UI in years. Yet desktop applications — enterprise tools, developer utilities, internal software — remain critical for many businesses.
This guide covers strategies, tools, and practical approaches for testing WinForms, WPF, Win32, and Electron desktop applications.
The Desktop Testing Landscape
Desktop testing is fundamentally different from web testing:
- No browser DevTools — element inspection requires specialized tools
- Native controls — buttons, grids, and dialogs are OS-level components, not HTML
- Window management — multi-window workflows, modal dialogs, system dialogs
- Platform coupling — Windows applications run on Windows (mostly); Mac apps on Mac
- Screen-dependent — tests fail if screen resolution or DPI doesn't match expectations
The good news: the tooling has improved significantly. Modern frameworks for WPF and Electron have testing built in.
Testing WinForms Applications
WinForms (Windows Forms) is .NET's original UI framework, introduced in 2001. Millions of business applications still run on it.
Accessibility API Testing (Recommended)
WinForms exposes the UI Automation API (UIA), which is the right way to automate and test it. UIA exposes controls as a tree of accessible elements — buttons, text boxes, data grids — that you can interact with programmatically.
FlaUI — open-source .NET library for UI Automation testing:
using FlaUI.Core.AutomationElements;
using FlaUI.UIA3;
[TestClass]
public class CustomerFormTests
{
private Application _app;
private UIA3Automation _automation;
[TestInitialize]
public void Setup()
{
_automation = new UIA3Automation();
_app = Application.Launch("CustomerApp.exe");
_app.WaitWhileMainHandleIsMissing();
}
[TestCleanup]
public void Teardown()
{
_app.Close();
_automation.Dispose();
}
[TestMethod]
public void AddNewCustomer_SavesSuccessfully()
{
var mainWindow = _app.GetMainWindow(_automation);
// Click New Customer button
mainWindow.FindFirstDescendant(
cf => cf.ByName("New Customer")
.And(cf.ByControlType(ControlType.Button))
).AsButton().Click();
// Fill the form
var dialog = mainWindow.FindFirstDescendant(
cf => cf.ByClassName("CustomerDialog")
).AsWindow();
dialog.FindFirstDescendant(
cf => cf.ByAutomationId("txtFirstName")
).AsTextBox().Enter("John");
dialog.FindFirstDescendant(
cf => cf.ByAutomationId("txtLastName")
).AsTextBox().Enter("Doe");
// Save
dialog.FindFirstDescendant(
cf => cf.ByName("Save").And(cf.ByControlType(ControlType.Button))
).AsButton().Click();
// Verify in the grid
var grid = mainWindow.FindFirstDescendant(
cf => cf.ByAutomationId("customerGrid")
).AsDataGridView();
var lastRow = grid.Rows.Last();
Assert.AreEqual("John Doe", lastRow.Cells[0].Value.ToString());
}
}Accessibility IDs are key. Work with developers to add AccessibleName and AutomationId properties to all significant controls. Tests that rely on element text or position are fragile; tests using AutomationId are stable.
WinAppDriver (Appium for Windows)
Microsoft's WinAppDriver uses the Appium protocol to control Windows applications. Compatible with any Appium client library:
from appium import webdriver
from appium.options import AppiumOptions
options = AppiumOptions()
options.platform_name = "Windows"
options.app = r"C:\Program Files\CustomerApp\CustomerApp.exe"
driver = webdriver.Remote(
command_executor="http://127.0.0.1:4723",
options=options
)
# Find elements by accessibility ID
first_name = driver.find_element("accessibility id", "txtFirstName")
first_name.send_keys("John")
last_name = driver.find_element("accessibility id", "txtLastName")
last_name.send_keys("Doe")
save_btn = driver.find_element("name", "Save")
save_btn.click()
# Verify
grid = driver.find_element("accessibility id", "customerGrid")
assert "John Doe" in grid.textWinAppDriver requires WinAppDriver server running on the test machine and Desktop Bridge enabled in Windows.
Testing Strategies for WinForms
Unit test the business logic separately. WinForms apps often have logic mixed into event handlers. Refactor with a separation pattern (MVP, MVVM-lite) so business logic is testable without the UI.
Focus automation on critical workflows. Full UI automation coverage of WinForms is expensive to maintain. Automate the 20% of workflows that represent 80% of usage.
Test data setup/teardown. Desktop apps often use local SQLite or SQL Server databases. Set up known test data before each test run; clean up after.
Testing WPF Applications
WPF (Windows Presentation Foundation) is .NET's XAML-based UI framework. It has better testing support than WinForms because MVVM (Model-View-ViewModel) is the standard pattern.
MVVM Makes Testing Easier
With MVVM, you test the ViewModel directly without touching the UI:
[TestClass]
public class CustomerViewModelTests
{
[TestMethod]
public void SaveCommand_WithValidData_AddsCustomerToList()
{
// Arrange
var mockRepo = new Mock<ICustomerRepository>();
mockRepo.Setup(r => r.Save(It.IsAny<Customer>())).Returns(true);
var vm = new CustomerViewModel(mockRepo.Object);
vm.FirstName = "John";
vm.LastName = "Doe";
vm.Email = "john@example.com";
// Act
vm.SaveCommand.Execute(null);
// Assert
mockRepo.Verify(r => r.Save(It.Is<Customer>(
c => c.FirstName == "John" && c.LastName == "Doe"
)), Times.Once);
Assert.IsTrue(vm.SaveSuccessful);
Assert.AreEqual(string.Empty, vm.ValidationError);
}
[TestMethod]
public void SaveCommand_WithEmptyFirstName_ShowsValidationError()
{
var vm = new CustomerViewModel(new Mock<ICustomerRepository>().Object);
vm.FirstName = "";
vm.LastName = "Doe";
vm.SaveCommand.Execute(null);
Assert.AreEqual("First name is required", vm.ValidationError);
}
}These tests run in milliseconds and don't require the UI to be visible. This is the most maintainable form of WPF testing.
WPF UI Automation with TestStack.White or FlaUI
For scenarios requiring actual UI interaction:
using FlaUI.Core.AutomationElements;
using FlaUI.UIA3;
[TestMethod]
public void CustomerForm_UIFlow_EntersAndSavesData()
{
using var app = Application.Launch("WpfApp.exe");
using var automation = new UIA3Automation();
var window = app.GetMainWindow(automation);
// WPF exposes AutomationId from x:Name properties
var firstNameField = window.FindFirstDescendant(
cf => cf.ByAutomationId("FirstNameTextBox")
).AsTextBox();
firstNameField.Enter("Jane");
window.FindFirstDescendant(
cf => cf.ByAutomationId("SaveButton")
).AsButton().Click();
var statusLabel = window.FindFirstDescendant(
cf => cf.ByAutomationId("StatusLabel")
).AsLabel();
Assert.AreEqual("Customer saved successfully", statusLabel.Text);
}Tip for WPF developers: Set AutomationProperties.AutomationId in XAML for all testable controls:
<TextBox x:Name="FirstNameTextBox"
AutomationProperties.AutomationId="FirstNameTextBox"
AutomationProperties.Name="First Name" />This makes UI automation reliable. Without it, tests depend on visual position or text content.
Testing Electron Applications
Electron applications (VS Code, Slack, Discord, Figma) are HTML/CSS/JavaScript applications packaged as desktop apps. This means web testing tools apply — but with some differences.
Playwright for Electron
Playwright has native Electron support:
import { test, expect, _electron as electron } from '@playwright/test';
test('Electron app opens correctly', async () => {
const electronApp = await electron.launch({
args: ['main.js']
});
const window = await electronApp.firstWindow();
// Standard Playwright assertions work
await expect(window).toHaveTitle('My Electron App');
const newItemButton = window.locator('[data-testid="new-item-btn"]');
await expect(newItemButton).toBeVisible();
await newItemButton.click();
const input = window.locator('[data-testid="item-name-input"]');
await input.fill('Test Item');
await window.locator('[data-testid="save-btn"]').click();
const itemList = window.locator('[data-testid="item-list"]');
await expect(itemList).toContainText('Test Item');
await electronApp.close();
});This is the recommended approach for modern Electron apps. Because Electron is Chromium under the hood, all the CSS selectors, locators, and assertions from web testing apply directly.
Testing Electron's Main Process
Electron has two processes: renderer (the web UI) and main (Node.js, file system, OS integration). Test them separately:
// Test main process IPC
test('File save IPC works correctly', async () => {
const electronApp = await electron.launch({ args: ['main.js'] });
// Test IPC communication
const result = await electronApp.evaluate(async ({ ipcMain }) => {
return new Promise(resolve => {
ipcMain.handleOnce('save-file', async (event, content) => {
resolve({ received: content });
});
});
});
const window = await electronApp.firstWindow();
await window.evaluate(() => {
window.electron.ipcRenderer.invoke('save-file', 'test content');
});
expect(result.received).toBe('test content');
await electronApp.close();
});Spectron (Legacy)
Spectron was the previous Electron testing framework (built on Appium). It's deprecated. Migrate to Playwright for Electron instead.
CI/CD Integration for Desktop Testing
Desktop testing on CI has historically required physical Windows machines. Options in 2026:
GitHub Actions (Windows Runner)
jobs:
desktop-tests:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0'
- name: Run WPF ViewModel tests
run: dotnet test tests/CustomerApp.Tests/
- name: Start WinAppDriver
run: |
choco install winappdriver
Start-Process "C:\Program Files (x86)\Windows Application Driver\WinAppDriver.exe"
Start-Sleep 3
- name: Run UI automation tests
run: dotnet test tests/CustomerApp.UITests/
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results
path: '**/TestResults/**'Virtual Displays for Headless
Desktop tests usually require a display. On Linux CI (for Electron):
- name: Install virtual display
run: sudo apt-get install -y xvfb
- name: Run Electron tests with virtual display
run: xvfb-run --auto-servernum npm testWindows GitHub Actions runners have a display by default (virtual desktop).
Testing Strategy by Application Type
| App Type | Unit/VM Testing | UI Automation | Recommended Tools |
|---|---|---|---|
| WPF + MVVM | High — ViewModel tests | Selective | xUnit/MSTest + FlaUI |
| WinForms | Medium — extract logic | Selective | FlaUI or WinAppDriver |
| Electron | High — renderer unit tests | Selective | Playwright for Electron |
| Win32/MFC | Low (legacy) | Required | UFT or Ranorex |
| UWP | Medium | Limited | WinAppDriver |
Common Pitfalls
Testing with hardcoded window size/position: Tests that assume window dimensions fail when run on different resolution machines. Use relative element locators, not coordinate-based clicks.
Not setting AutomationId: Without explicit IDs, tests rely on element text or index — both fragile. Add AutomationId to all testable controls during development.
Modal dialog handling: Unexpected modal dialogs (update notices, error messages) break test flows. Add explicit handling for known modals.
File system side effects: Desktop apps write to disk (config files, databases, logs). Tests should use isolated directories and clean up after.
Screen resolution differences: CI machines often run at different resolutions. Tests that depend on visibility of off-screen elements fail. Maximize the window in test setup.
Summary
Desktop application testing in 2026 has better tooling than ever — FlaUI and WinAppDriver for Windows native apps, Playwright for Electron, and the testability benefits of MVVM for WPF. The key principles:
- Test business logic without the UI — WPF ViewModel tests and WinForms unit tests are fast and stable
- Use AutomationId — add them during development, not after
- Automate selectively — focus on critical paths, not comprehensive coverage
- Separate process testing for Electron — test main process and renderer independently
- Windows CI agents — plan for this in your infrastructure; desktop tests can't run on Linux for Windows-native apps
The goal isn't 100% UI automation coverage — it's enough automation to catch regressions in the workflows that matter most.