Desktop Application Testing Strategies: WinForms, WPF, and Electron Apps

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.

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.text

WinAppDriver 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 test

Windows 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.

Read more

Start now free