WpfTestKit.Xunit
0.1.1
dotnet add package WpfTestKit.Xunit --version 0.1.1
NuGet\Install-Package WpfTestKit.Xunit -Version 0.1.1
<PackageReference Include="WpfTestKit.Xunit" Version="0.1.1" />
<PackageVersion Include="WpfTestKit.Xunit" Version="0.1.1" />
<PackageReference Include="WpfTestKit.Xunit" />
paket add WpfTestKit.Xunit --version 0.1.1
#r "nuget: WpfTestKit.Xunit, 0.1.1"
#:package WpfTestKit.Xunit@0.1.1
#addin nuget:?package=WpfTestKit.Xunit&version=0.1.1
#tool nuget:?package=WpfTestKit.Xunit&version=0.1.1
WpfTestKit
Reusable WPF testability infrastructure. Drops into any WPF app to give it a trace log, hidden UIA observability surface, DI-friendly service contracts, optional batch-job plumbing, and a FlaUI-based out-of-process click-test harness with auto screenshot + UIA-tree capture on failure.
The kit ships in two packages:
WpfTestKit— diagnostics, services, batch, FlaUI helpers. Reference from your WPF app.WpfTestKit.Xunit—FlaUiTestFixturebase class. Reference from your test project.
What your WPF app must do to use the kit
- Put
<diagnostics:DiagnosticsUiaSurface />somewhere in MainWindow. Without it, FlaUI can't read the trace log out of the running process. - Put
AutomationProperties.AutomationIdon every element a test will touch.
That's the hard contract. Everything else is recommended but optional:
- DI is recommended but not required.
AddWpfTestKit*extensions are sugar;new DiagnosticsService()works fine. - CommunityToolkit.Mvvm is not a kit dependency. The kit uses its own hand-rolled
INotifyPropertyChanged. Your VMs are free to use CTK, ReactiveUI, or nothing; the kit doesn't care and won't drag CTK into consumers. - Your async commands don't have to use
IBackgroundJobService. It's a testability tool for VM unit tests; FlaUI tests work regardless.
Consumer-side startup wiring is under 50 lines — see the example at the end of this file.
Where to go next
- Writing more than a handful of tests? Read the Testing patterns section below.
- Looking up a specific capability? Skip to the feature matrix at the bottom.
Testing patterns
The kit's primitives (full reference in the feature matrix below) compose into patterns that keep tests fast, deterministic, and maintainable. Pick the ones that fit.
Structuring tests
Arrange / Act / Assert. Launch → drive → assert. Dispose is automatic via FlaUiTestFixture.
[Fact]
public void IncrementButton_ClickedThrice_CounterReadsThree()
{
// Arrange
var app = MyAppTestApp.Launch();
SetCurrentApp(app);
// Act
for (int i = 0; i < 3; i++) { app.InvokeById("IncrementButton"); }
// Assert
Assert.Equal("3", app.WaitForText("CounterText", "3", timeoutSeconds: 3));
app.AssertNoDiagnosticsErrors(Output.WriteLine);
}
TestApp wrapper per app. Don't repeat Launch(exePath, args) in every test. One helper per app keeps scenario tests concise and lets you push common startup args (test-mode flags, stubbed services) into one place.
internal static class MyAppTestApp
{
public static TestApp Launch(string? arguments = null)
=> TestApp.Launch(TestApp.LocateExeRelativeToSolution("MyApp"), arguments);
// Common test-mode variants — adopt as needs surface:
public static TestApp LaunchWithStubbedFileDialog(string? pathToOpen)
=> Launch($"--test-file-to-open={pathToOpen ?? "CANCEL"}");
}
Screen-object helpers for repeated flows. When a five-line login/save/load sequence appears in eight tests, extract a static helper that takes a TestApp. Keep it thin — it's a named composition, not an abstraction layer.
internal static class LoginFlow
{
public static void SignInAs(TestApp app, string user, string pw)
{
app.TypeInto("UsernameInput", user);
app.TypeInto("PasswordInput", pw);
app.InvokeById("SignInButton");
app.WaitForText("WelcomeLabel", $"Welcome, {user}", timeoutSeconds: 5);
}
}
Extending FlaUiTestFixture. If you want custom capture artifacts, startup checks, or per-suite diagnostics, override DisposeCore — don't subclass Dispose directly. The base already handles app disposal + artifact capture policy. See ArtifactCapturePolicy for Always / OnFailure / Never.
Element access
Prefer InvokeById / TypeInto over raw FindById. The one-shot helpers auto-retry until the element exists (default 3s). FindById returns AutomationElement? immediately — you lose auto-retry and have to handle null yourself. Use FindById only when you genuinely need a "is it there right now?" predicate.
Use Locator when touching the same element more than once. Locator re-queries UIA on every action, so it survives layout rebuilds (tab switches, collection changes, anything that invalidates the previous element reference).
var save = app.Locator("SaveButton");
save.Click(); // first interaction: fresh query
// ...UI may rebuild...
save.Click(); // re-queries; safe even after layout changes
var text = save.Read(); // same handle, different action
Use LocatorIn for modals and secondary windows. Same stale-proof behavior, but rooted at a given AutomationElement instead of MainWindow. Essential when InvokeAndWaitForModal returns a modal you want to drive with the same ergonomic API.
var modal = app.InvokeAndWaitForModal("DeleteButton", "Confirm", timeoutMs: 3000);
app.LocatorIn(modal!, "DialogOkButton").Click();
Waiting and synchronization
Never Thread.Sleep to wait for state. The kit ships four waiters; one of them fits every real case.
| You want to wait for… | Use |
|---|---|
| A label to contain a substring | app.WaitForText(id, "done", timeoutSeconds: N) |
| An element to appear in the UIA tree | app.WaitForElement(id, timeoutMs: N) |
| A button to become enabled | app.WaitForButtonEnabled(id, timeoutMs: N) |
| Anything else (custom predicate) | app.WaitFor(() => predicate) or app.WaitForOrThrow(() => predicate, "description") |
Thread.Sleep is only acceptable for documented UIA-level races (e.g. the 150ms settle after SelectTabItem, already in the kit). Your test code shouldn't need it.
Don't add manual retry around kit calls. Auto-retry is already there. InvokeById("Foo") retries until DefaultActionTimeoutMs (3s). If you need a longer budget, pass timeoutMs: explicitly — don't wrap in a loop.
Use Retry(action, maxAttempts, delayMs) only for genuinely flaky scenarios that auto-retry can't fix (e.g. a background service that only settles after an external event). Document why — future-you will thank you.
Async commands and background work
Trigger the command, then WaitForText the observable result. Don't poll internal VM state from a FlaUI test — you can't see it anyway, and the label is the user-visible contract.
app.InvokeById("StartSlowJobButton");
var status = app.WaitForText("JobStatusLabel", "Completed", timeoutSeconds: 30);
Assert.Equal("Completed", status);
For the VM-level unit tests you should write separately from FlaUI tests, swap the production IBackgroundJobService for InlineBackgroundJobService (runs synchronously) or GatedBackgroundJobService (blocks until you Release()) so you can observe mid-flight state without races.
Modals
Expected modal → InvokeAndWaitForModal. Fires the trigger on a background task so the modal's nested message loop can't block the test thread. Returns the modal window; drive it with LocatorIn or InvokeButtonByName / InvokeButtonById.
Unexpected modals → StartModalWatchdog. Stands up a background poller that auto-dismisses pop-ups using a safe default button order (Cancel/No/Close/OK/Yes). Every test that doesn't expect a modal should have one.
using var watchdog = app.StartModalWatchdog();
// ...exercise flow that shouldn't open anything...
Assert.Equal(0, watchdog.DismissedCount); // hard failure if it did
Combine both patterns: one test expects "Confirm deletion", every other test uses the watchdog as a safety net.
Assert on the modal's content, not just that it appeared.
app.AssertContainsText(modal!, "Proceed with delete?");
Assert.True(app.HasButton(modal!, "Yes"));
Assert.True(app.HasButton(modal!, "No"));
Keyboard
Pick the right layer for what you're actually testing:
| Goal | Use |
|---|---|
| Set a TextBox's value (bypass keyboard entirely) | TypeInto(id, text) — ValuePattern, no focus needed |
Exercise a KeyDown handler or IME |
SendKeys(text) — real synthesized keystrokes |
Invoke a Ctrl+S-style shortcut |
PressShortcut("Ctrl+S") |
Chord shortcut (Ctrl+K, Ctrl+E) or Alt-mnemonic (Alt+F, N) |
PressChord("Ctrl+K", "Ctrl+E") |
Single key (Enter, Escape, F2) |
PressKey("Enter") |
Reach for SendKeys / PressShortcut only when the keystroke handler is what's under test. For state setup, TypeInto is faster and doesn't require window focus.
Assertions
Assert against durable observable state. In order of preference:
- UIA property —
ReadText,IsToggled,GetSelectedComboBoxText,CheckReachability. These are what the user experiences. - Diagnostics —
AssertNoDiagnosticsErrors(Output.WriteLine)at the end of every test. Catches silent exceptions, data-binding errors, and anything routed throughIDiagnosticsService. - Visual regression —
AssertScreenshotMatches(baselineName, baselineDir, diffTolerancePct, perPixelTolerance)for layout-sensitive screens. First run establishes the baseline; subsequent runs diff. AdjustperPixelTolerancefor ClearType anti-aliasing drift. - Contrast —
AssertContrastRatio(id, minimumRatio: 4.5)for WCAG AA on text elements that should be readable.
Don't assert on things the user can't see (internal VM state, timing, exact pixel counts). If your test breaks on an innocuous refactor, the assertion was too narrow.
Reachability — guarding before you interact
For tests that care about layout (resizing, tab-content, scroll containers), assert reachability before the action:
app.ResizeMainWindow(400, 300);
app.AssertReachable("SaveButton"); // throws with reason if clipped, covered, off-screen
app.InvokeById("SaveButton");
Or for a whole-screen hygiene check:
var audit = app.AuditAutomationIds();
Assert.True(audit.IsClean,
$"{audit.MissingOnInteractiveElements.Count} missing, {audit.DuplicateIds.Count} duplicate");
Reachability distinguishes NotFound / HitTestBlocked / ScrolledOutOfView / OutsideWindow / PartiallyClipped / ZeroSized / OffscreenViaUia with a reason string — failure messages tell you why it wasn't clickable, not just that it wasn't.
Diagnostics and triage
FlaUiTestFixture captures two artifacts on every failure by default — screenshot + UIA tree dump to %TEMP%\WpfTestKit\. Paths are echoed to test output.
Triage order when a test fails:
- Read the error message.
IdSuggesteralready suffixes lookup failures with "Did you mean: 'XxxButton'?" — most typos resolve here. - Open the screenshot. Is the app in the state you expected? Wrong tab, wrong modal, crashed?
- Open the UIA tree dump. Does the element you named actually exist? With that AutomationId? Under the expected parent?
- Re-run with
TraceRecorderfor step-level timing. Attach viaapp.Trace = new TraceRecorder(); dump HTML at end of test. Most useful for "it works sometimes" bugs. - Mid-test
app.Pause("why")to freeze the running app and inspect interactively. No-op whenWPFTESTKIT_HEADLESS=1— safe to leave committed.
Flakiness mitigation
The kit's auto-retry eliminates most manual flakiness control. The remaining levers:
- Pick semantic waits over timeouts.
WaitForText(id, "Done", seconds: 30)is far more robust thanThread.Sleep(30_000)— it returns as soon as state changes. - Isolate one app per test. The default
FlaUiTestFixturegives you that. Sharing an app across tests to save launch time is almost never worth the cross-test contamination. - Use
AssertNoDiagnosticsErrorsin every test. Catches errors that would otherwise present as unrelated downstream flakes. - For known races,
WaitFor(() => predicate)notThread.Sleep. Returns on first success. - Screenshot diffing with tolerance.
diffTolerancePct: 0.5, perPixelTolerance: 10handles ClearType subpixel drift; tune higher if your test machine uses a different DPI than the baseline. - If a specific test is still flaky despite the above, wrap it with
app.Retry(action, maxAttempts: 3)and leave a comment explaining the external cause. Don't retry as a blanket policy.
Recording new tests
Covered fully in User guide §6. Summary: when starting from scratch on an app, launch the recorder, drive the flow manually, copy generated [Fact] code, add assertions where the // TODO lands.
Anti-patterns
Things that look reasonable but cause flakes, false passes, or maintenance debt:
Thread.Sleepinstead of a waiter. Auto-retry andWaitForText/WaitForcover every real case. If you're tempted, re-read §Waiting.- Hardcoded paths to
MyApp.exe. UseTestApp.LocateExeRelativeToSolution— tests run on any dev machine without path edits. - Asserting on timing (
elapsed < 100ms). Test machines vary by 10×. Assert on behavior; measure timing separately if you care about performance. - Sharing a
TestAppinstance across tests. Cross-test state contamination is the #1 source of "passes alone, fails in suite" flakes. Default is one app per test — stick with it. - Skipping
AssertNoDiagnosticsErrors. Binding errors, unhandled exceptions in command handlers, andasync voidthrows all flow through it. Opting out means those bugs become next month's flake. - Using
FindById+ null check whereInvokeByIdwould do. You lose auto-retry and write more code. Only meaningful when you specifically want "is this visible right now?". - Asserting on
AutomationElement.Nameinstead ofAutomationId.Nameis localized and changes with the user's OS language;AutomationIddoesn't. - Pausing in CI. Set
WPFTESTKIT_HEADLESS=1—Pause()becomes a no-op. Never leave aPause()call blocking CI. - Relying on focus for assertions. Focus state is fragile (foreground stealing, alt-tab). Use UIA property reads (
IsToggled,ReadText) which work regardless of focus. - Adding a screenshot assertion to a fast-changing UI. Visual regression is for stable layouts (dialog chrome, splash screens). Scenery that shifts every sprint will just rot the baseline.
- Testing implementation details (command names, VM property order). Test what the user sees through UIA. If you need VM-level coverage, write separate unit tests against the VM directly.
Feature × kit capability — extensive matrix
Legend: ✅ shipped and exercised by an end-to-end test · 🔧 shipped in kit, unit-tested only · 🚧 planned (design sketched) · 💭 idea (wanted; needs design)
Basic interaction
| Feature | Kit capability | Status |
|---|---|---|
| Click a button by AutomationId (UIA Invoke pattern, no mouse movement) | TestApp.InvokeById(id, timeoutMs?) — auto-retries until the element exists (default 3s); works even when window is not focused, blocked, or over RDP |
✅ CounterTests, AutoRetryTests |
| Real synthesized mouse click (cursor actually moves) | TestApp.MouseClickById(id, timeoutMs?) — also auto-retries |
✅ TextInputTests |
| Reusable chainable handle for an element | TestApp.Locator(id).Click().Type(text).Read() — re-queries UIA on every call; stale-proof across layout rebuilds |
✅ LocatorTests |
| Wait for a label to show a substring | TestApp.WaitForText(id, needle, timeoutSeconds, ct) (CT-aware) |
✅ CounterTests, SlowJobTests |
| Wait for an element to appear in the UIA tree | TestApp.WaitForElement(id, timeoutMs, ct) (CT-aware) |
✅ SecondaryTabTests |
| Wait for a button to become enabled | TestApp.WaitForButtonEnabled(id, timeoutMs, ct) (CT-aware) |
🔧 |
| Wait for an arbitrary predicate | TestApp.WaitFor(() => predicate) / WaitForOrThrow(() => predicate, "description") — uses WaitUtil.Until internally |
✅ AutoRetryTests + WaitUtilTests |
| Get an element without waiting | TestApp.FindById(id) → AutomationElement? |
✅ SecondaryTabTests |
| Set a TextBox's content (UIA ValuePattern, no keystrokes) | TestApp.TypeInto(id, text) |
✅ TextInputTests |
| Read a TextBox's current content | TestApp.ReadText(id) — falls back to label text for non-Value elements |
✅ TextInputTests |
| Synthesized keystroke typing for shortcuts / IME | TestApp.SendKeys(text) |
✅ KeyboardTests |
| Select a ComboBox item by visible text or index | TestApp.SelectComboBoxItem(id, itemText) / SelectComboBoxIndex(id, i) — expands via ExpandCollapse, finds the item, invokes SelectionItem. Handles the virtualization trap. |
✅ ComboBoxAndToggleTests |
| Read a ComboBox's currently selected item | TestApp.GetSelectedComboBoxText(id) |
✅ ComboBoxAndToggleTests |
| Toggle a CheckBox / RadioButton | TestApp.SetToggle(id, bool) / IsToggled(id) — Toggle pattern or SelectionItem pattern; handles both WPF CheckBox and RadioButton |
✅ ComboBoxAndToggleTests |
Navigate nested menus (File → Recent → mydoc.house) |
TestApp.InvokeMenuPath("File", "Recent", "mydoc.house") — walks the path, expanding each MenuItem before descending. Error includes path context on failure. |
✅ NestedMenuTests |
| Assert a menu item exists without opening it | TestApp.HasMenuItem(path...) — walks the structure, returns bool |
✅ NestedMenuTests |
| Drag from one element's center to another's | TestApp.DragFromTo(sourceId, targetId) — real synthesized mouse drag. Requires foreground. Tests assert cursor-final-position rather than WPF MouseEnter (capture semantics vary). |
✅ DragAndDropTests |
| Drive a secondary window (modal or not) with full Locator API | TestApp.LocatorIn(root, id) — creates a Locator scoped to any AutomationElement root (e.g., a modal returned by WaitForModalWindow) |
✅ LocatorInTests |
| Retry a flaky action up to N times | TestApp.Retry(action, maxAttempts, delayMs) / generic T Retry<T>(Func<T>, ...) — returns on first success, throws AggregateException with all errors if every attempt fails |
✅ RetryUtilTests |
| Auto-record every kit action to a trace | app.Trace = new TraceRecorder(); — every subsequent InvokeById / TypeInto / PressShortcut / etc. adds a step with timing. Combine with TraceRecorder.WriteHtmlReport for self-contained HTML on failure. |
✅ TraceRecorderIntegrationTests |
| Measure WCAG contrast ratio of an element | TestApp.EstimateContrastRatio(id) returns a 1.0–21.0 ratio; AssertContrastRatio(id, minimumRatio) throws below threshold. Heuristic: finds the pair of most-common colors inside the element with the highest luminance gap — handles anti-aliased text reliably. |
✅ ContrastTests |
| Suggest similar AutomationIds when a lookup fails | Every lookup-failure exception from InvokeById / DragFromTo etc. includes a " Did you mean: 'XxxButton', 'YyyLabel'?" suffix. Uses substring-match + Levenshtein distance. |
✅ IdSuggestionTests |
Tabs, dialogs, and secondary windows
| Feature | Kit capability | Status |
|---|---|---|
| Select a TabItem (lazy-render trap) | TestApp.SelectTabItem(tabId) (SelectionItem pattern + 150ms settle) |
✅ SecondaryTabTests |
| Assert content invisible until tab selected | FindById returns null before SelectTabItem |
✅ SecondaryTabTests |
Dismiss an expected modal dialog (MessageBox / Window.ShowDialog) |
TestApp.InvokeAndWaitForModal(triggerId, titleSubstring, timeoutMs) — fires the trigger on a background task (so the modal's nested message loop can't block the test thread) and polls for the modal. Searches desktop children AND MainWindow's owned descendants (WPF's MessageBox is an HWND-owned child of MainWindow, not a top-level window). |
✅ ModalTests |
| Find a modal (non-blocking) | TestApp.FindModalWindow(titleSubstring) |
✅ ModalTests |
| Dismiss modal by button name (text) | TestApp.InvokeButtonByName(modal, "Yes") |
✅ ModalTests |
| Dismiss modal by AutomationId | TestApp.InvokeButtonById(modal, "DialogOkButton") |
✅ ModalTests |
| Assert modal's message body contains a substring | TestApp.AssertContainsText(modal, "Proceed") / ContainsText(...) returns bool |
✅ ModalTests |
| Assert modal has specific buttons (by Name or AutomationId) | TestApp.HasButton(modal, "Yes") / HasButton(modal, "DialogOkButton") |
✅ ModalTests |
| Diagnose "why didn't my modal show up?" | TestApp.DescribeAllTopLevelWindows() returns a printable list |
🔧 |
| Auto-dismiss unexpected modals + record what appeared | using var watchdog = app.StartModalWatchdog(); — background poller. Default button-press order is Cancel/No/Close/OK/Yes (safe defaults: prefer not to approve unknown dialogs). watchdog.DismissedCount and watchdog.DismissedTitles let tests assert "no unexpected modals" or "the expected error dialog fired". Custom shouldDismiss predicate lets tests ignore specific windows. |
✅ ModalWatchdogTests |
| Drive a second non-modal top-level window (e.g. wizard step) | TestApp.FindTopLevelWindowByTitle(...) helper |
💭 |
Async commands & concurrency
| Feature | Kit capability | Status |
|---|---|---|
| Run work off the UI thread from a VM command | IBackgroundJobService.RunAsync(...) (Task.Run wrapper) |
✅ SlowJobTests |
| Unit-test async commands synchronously | InlineBackgroundJobService (returns Task.FromResult) |
🔧 |
| Observe VM state mid-flight in a unit test | GatedBackgroundJobService (blocks on TaskCompletionSource until Release()) |
🔧 |
Progress reporting through IProgress<double> |
RunAsync overload with IProgress<double> |
🔧 |
| Serialize work triggered from multiple sources (UI + CLI + script) | R1.c via BatchJobRunner + ChannelBatchJobQueue + IBatchJobDispatcher |
✅ BatchTests |
| Run a batch of jobs without managing the queue yourself | BatchJobRunner.RunAsync(IEnumerable<BatchJob>) — builds a fresh channel internally each call |
✅ BatchTests |
| Single-UI-control reentry guard | R1.a — AsyncRelayCommand.IsRunning + CanExecute from CommunityToolkit.Mvvm (not the kit's code) |
companion |
| Per-instance lock around native/shared state | R1.b — app-specific; kit does not ship a base class | not owned |
Failure observability
| Feature | Kit capability | Status |
|---|---|---|
DispatcherUnhandledException → trace |
DiagnosticsExceptionSinks.AttachTo(app, diag) returns IDisposable (unsubscribes all 3 handlers on Dispose) |
✅ ExceptionSinkTests (ThrowSync) |
async void throw after await → trace |
Same sink (SyncContext marshals to dispatcher) | ✅ ExceptionSinkTests (ThrowAfterAwait) |
TaskScheduler.UnobservedTaskException → trace |
Sink, also sets SetObserved() |
🔧 |
AppDomain.UnhandledException → trace |
Sink, best-effort | 🔧 |
| Thread-safe ring-buffer trace log (8192 entries) | DiagnosticsService |
🔧 DiagnosticsServiceTests |
| Error-count accounting with buffer eviction | Evict oldest error → decrement count | 🔧 DiagnosticsServiceTests |
Lazy-cached FullText / Entries (don't rebuild on every read) |
Cache invalidates on write; rebuild on next read only | 🔧 |
INotifyPropertyChanged for live UIA updates |
ObservableObject base raises FullText / ErrorCount on every write |
🔧 |
| Read trace/error-count from a running process | TestApp.GetDiagnosticsTrace() + GetDiagnosticsErrorCount() |
✅ ExceptionSinkTests |
| Fail a test if any error was logged | TestApp.AssertNoDiagnosticsErrors(writeLine) |
✅ CounterTests, SlowJobTests, SecondaryTabTests, ReachabilityTests |
| Throw if diagnostics UserControl is missing from the window | TestApp.VerifyDiagnosticsSurface() / probe inside AssertNoDiagnosticsErrors |
🔧 |
| WPF data-binding errors → trace | Hook PresentationTraceSources.DataBindingSource into IDiagnosticsService |
💭 |
| XAML parser warnings → trace | Similar hook, if feasible | 💭 |
Reachability & layout (NEW — the point of WpfTestKit that nobody else solves)
| Feature | Kit capability | Status |
|---|---|---|
| Element present in UIA tree, not clipped, not zero-sized | TestApp.CheckReachability(id) → Reachability record, AssertReachable(id) |
✅ ReachabilityTests |
| Detect element outside window bounds (no ScrollViewer → off-canvas) | ReachabilityStatus.OutsideWindow |
✅ ReachabilityTests |
| Detect element partially clipped by window edge | ReachabilityStatus.PartiallyClipped |
✅ ReachabilityTests |
| Detect element with zero width or height | ReachabilityStatus.ZeroSized |
🔧 |
Honor UIA's own IsOffscreen property |
ReachabilityStatus.OffscreenViaUia |
🔧 |
| Resize the main window from a test | TestApp.ResizeMainWindow(w, h) via Transform pattern |
✅ ReachabilityTests |
| Detect element covered by another UIA-visible element (z-order) | ReachabilityStatus.HitTestBlocked — Automation.FromPoint(center) returns something other than the target or its descendant; reason string names the blocking element |
✅ VisibilityReachabilityTests |
| Detect Visibility=Hidden | ReachabilityStatus.NotFound — WPF removes Visibility=Hidden elements from the UIA tree entirely |
✅ VisibilityReachabilityTests |
| Detect Opacity=0 / IsHitTestVisible=false | Known limitation — UIA treats both as hit-testable from out-of-process. Pinned as documented behavior in VisibilityReachabilityTests. Consumers who care about visual-opacity must validate via screenshots. |
⚠️ documented |
| Detect element behind a ScrollViewer not scrolled into view | ReachabilityStatus.ScrolledOutOfView when an ancestor has ScrollPattern and the element is outside the client area |
🔧 |
| Compare against window client area (not outer rect with chrome) | GetClientRect + ClientToScreen P/Invoke, fall back to outer rect on failure |
🔧 |
Audit every AutomationId'd element for reachability at a given size |
TestApp.AuditReachability() → list of failures |
💭 |
Audit every interactive element has an AutomationId |
Scan UIA tree for clickable/editable elements missing IDs | 💭 |
OS file dialogs (preferred: stub via DI; fallback: watchdog)
| Feature | Kit capability | Status |
|---|---|---|
| Abstraction for OS file-picker dialogs | IFileDialogService — PickFileToOpen / PickPathToSave |
🔧 |
| Production implementation wrapping WPF/Win32 dialogs | FileDialogService (uses Microsoft.Win32.OpenFileDialog / SaveFileDialog) |
🔧 |
| Test-mode stub with queued results + call recording | StubFileDialogService — QueueOpenResult(path), QueueSaveResult(path), OpenCalls / SaveCalls history |
✅ FileDialogTests |
| DI registration for production | services.AddWpfTestKitFileDialogs() |
🔧 |
| Consumer-side startup-arg swap (test → stub) | Pattern shown in WpfDemo/App.xaml.cs: --test-file-to-open=<path> (or CANCEL) triggers the stub swap before BuildServiceProvider. Consumer owns the flag name and wiring. |
✅ FileDialogTests |
| Fallback for tests that can't stub: auto-dismiss the real shell dialog | StartModalWatchdog() detects the OS dialog as a foreign window and clicks Cancel. Fragile — shell UIA tree varies across Windows versions. Use only when stubbing is impossible. |
⚠️ FileDialogTests (the fallback test is tagged as known-flaky) |
Keyboard & input
| Feature | Kit capability | Status |
|---|---|---|
Press a keyboard shortcut (Ctrl+S, Ctrl+Shift+E) |
TestApp.PressShortcut("Ctrl+Shift+E") — parses string, calls Keyboard.TypeSimultaneously. Requires window foreground (handled via EnsureForeground). |
✅ KeyboardTests |
Press a single key (Enter, Escape, F2) |
TestApp.PressKey("Enter") — supports A-Z, 0-9, F1-F24, arrows, Enter, Escape, Tab, Space, Backspace, Delete, Home, End |
🔧 |
Press a chord sequence (Ctrl+K, Ctrl+S / Alt+F, N) |
TestApp.PressChord("Ctrl+K", "Ctrl+E") — fires each combo in sequence with an 80ms settle between. Single-key elements are supported so PressChord("Alt+F", "N") works for menu mnemonics. |
✅ KeyboardTests (chord + mnemonic) |
Access keys / underlined mnemonics (_File) |
Same PressChord helper — PressChord("Alt+F", "N") opens File menu then invokes _New |
✅ KeyboardTests |
| Type plaintext into currently focused element via real keystrokes | TestApp.SendKeys("text") wraps FlaUI's Keyboard.Type. Use this when you need to exercise real KeyDown handlers — for just setting a TextBox's value, prefer TypeInto (ValuePattern, no focus required). |
✅ KeyboardTests |
| Bring window to foreground without stealing inner focus | TestApp.EnsureForeground() — calls SetForeground only; does NOT call Focus() (which would override an earlier element.Focus call) |
🔧 |
| Assert focus is on a specific element | TestApp.AssertFocused(id) / GetFocusedAutomationId() |
✅ KeyboardTests |
| Tab-order traversal to verify focus chain | TestApp.PressKey("Tab") + AssertFocused — compose as needed |
🔧 |
| Drag-and-drop | Synthesized mouse move/down/up sequence | 💭 |
Data editing
| Feature | Kit capability | Status |
|---|---|---|
| Set a DataGrid cell value by header + row | Playbook keystroke pattern (F2 → clear → type → Tab) |
🚧 |
| Read a DataGrid cell value | TestApp.GetDataGridCellValue(...) |
🚧 |
| Filter/scroll DataGrid before editing (virtualization trap) | Not ours to own; consumer drives; kit can offer row-wait helper | 💭 |
Evidence capture on failure
| Feature | Kit capability | Status |
|---|---|---|
| Screenshot the main window to PNG | ScreenshotHelper.CaptureToFile, TestApp.TakeScreenshot(label) |
🔧 |
| Dump UIA subtree to text | UiaTreeDumper.DumpToFile, TestApp.DumpUiaTree(label) |
🔧 |
| Auto-capture both on test dispose | FlaUiTestFixture base class (in WpfTestKit.Xunit — separate assembly so the kit itself has no xUnit dep) |
✅ all WpfDemo tests |
| Capture-on-failure policy (opt-in to capture only when assertions fail) | ArtifactCapturePolicy.OnFailure (default) / Always / Never — uses AppDomain.FirstChanceException + Xunit.Sdk.XunitException detection (by name, no xunit-core dep) |
🔧 |
| Pixel-diff visual regression vs a baseline PNG | TestApp.AssertScreenshotMatches(baselineName, baselineDir, diffTolerancePct, perPixelTolerance) — first run saves baseline and throws "baseline established"; subsequent runs compare and write a diff image to temp on failure |
✅ ScreenshotBaselineTests |
| Low-level pixel diff primitive | ImageDiff.Compare(baseline, actual, perPixelTolerance) → DiffResult(DiffPercentage, DifferentPixels, TotalPixels, DiffImage) |
🔧 ImageDiffTests |
| Record a trace of test steps with screenshots + HTML report | var trace = new TraceRecorder(); trace.Record("label", () => { ... }); trace.WriteHtmlReport("title", path) — self-contained HTML with base64-embedded screenshots |
🔧 TraceRecorderTests |
| WCAG contrast check on visible text | Sample FG/BG per TextBlock, compute ratio | 💭 |
Debugging aids (mid-test inspection)
| Feature | Kit capability | Status |
|---|---|---|
| Pause mid-test for human inspection | app.Pause("why") opens a small floating "Resume" window on the STA thread and blocks the test until click or timeout. Set env var WPFTESTKIT_HEADLESS=1 in CI to make it a no-op — safe to leave Pause() calls in tests. |
✅ PauseTests |
| Time-travel trace report (Playwright-style) | TraceRecorder wraps test actions with timing + optional screenshots; WriteHtmlReport produces a self-contained HTML |
🔧 TraceRecorderTests |
Integration wiring
| Feature | Kit capability | Status |
|---|---|---|
| Register diagnostics + background-job as singletons | services.AddWpfTestKitDiagnostics() |
🔧 ServiceCollectionExtensionsTests |
| Register batch queue + runner + consumer dispatcher | services.AddWpfTestKitBatch<TDispatcher>() |
🔧 ServiceCollectionExtensionsTests |
| Wire all three global exception sinks in one line | DiagnosticsExceptionSinks.AttachTo(this, diagnostics) |
✅ (implicit in every WpfDemo test that asserts zero errors) |
| Publish diagnostics to UIA via drop-in UserControl | <diagnostics:DiagnosticsUiaSurface /> |
✅ (used by WpfDemo) |
| Override UIA surface on a per-instance basis (rare) | DiagnosticsUiaSurface.Diagnostics property |
🔧 |
Parse --flag value startup args for test-mode swaps |
string[].ExtractFlagValue(name) |
🔧 |
| Check presence of a boolean switch in startup args | string[].HasFlag(name) |
🔧 StartupArgsExtensionsTests |
Launch the consumer .exe and locate its main window |
TestApp.Launch(exePath, args, timeoutMs) |
✅ (every WpfDemo test) |
Locate the WPF project's .exe from a sibling test project |
TestApp.LocateExeRelativeToSolution("WpfDemo") — walks up, probes bin/**/WpfDemo.exe, picks most recently built |
✅ (WpfDemoTestApp uses it) |
Resource hygiene & teardown
| Feature | Kit capability | Status |
|---|---|---|
| Clean app shutdown on test teardown | FlaUiTestFixture.Dispose disposes TestApp |
✅ (every WpfDemo test) |
TestApp.Dispose closes app + disposes automation |
Application.Close() + Application.Dispose() + UIA3Automation.Dispose() |
🔧 |
| Detect leaked WPF window references after teardown | WeakReference + GC.Collect + final assertion |
💭 |
| Detect processes surviving teardown | Process-tree cleanup assertion | 💭 |
Audits & hardening (no consumer trigger; run once per suite)
| Feature | Kit capability | Status |
|---|---|---|
All interactive UIA elements have an AutomationId |
app.AuditAutomationIds().MissingOnInteractiveElements — scans the subtree for Button / CheckBox / ComboBox / Edit / MenuItem / RadioButton / etc. that lack an AutomationId. Each entry includes ControlType, Name, and bounds. |
✅ AutomationIdAuditTests |
All AutomationIds are unique |
app.AuditAutomationIds().DuplicateIds — reports each ID used by more than one element with all occurrences |
✅ AutomationIdAuditTests |
| Single "is my audit clean" check | app.AuditAutomationIds().IsClean |
✅ AutomationIdAuditTests |
All AutomationIds referenced by tests exist |
Static analysis or runtime "this test mentioned FooButton but tree has no such id" warning |
💭 |
| Tests don't leak UIA handles | Track FindFirstDescendant results across tests |
💭 |
| Retry decorator for known-flaky scenarios | TestApp.Retry(action, maxAttempts, backoff) |
💭 |
Consumer-side wiring (App.xaml.cs)
public partial class App : Application
{
private IServiceProvider? _services;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var services = new ServiceCollection();
services.AddWpfTestKitDiagnostics();
services.AddWpfTestKitFileDialogs();
services.AddSingleton<MainViewModel>();
_services = services.BuildServiceProvider();
var diagnostics = _services.GetRequiredService<IDiagnosticsService>();
DiagnosticsExceptionSinks.AttachTo(this, diagnostics);
DiagnosticsUiaSurface.DefaultDiagnostics = diagnostics;
new MainWindow(_services.GetRequiredService<MainViewModel>()).Show();
}
}
And in MainWindow.xaml:
<Window xmlns:diagnostics="clr-namespace:WpfTestKit.Diagnostics;assembly=WpfTestKit" ...>
<Grid>
<diagnostics:DiagnosticsUiaSurface />
</Grid>
</Window>
That's enough for FlaUI tests to find the diagnostics surface and assert on errors.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0-windows7.0 is compatible. net9.0-windows was computed. net10.0-windows was computed. |
-
net8.0-windows7.0
- WpfTestKit (>= 0.1.1)
- xunit.abstractions (>= 2.0.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.1 | 96 | 5/10/2026 |