Acceptance Tests for a WPF Application

Introduction

Creating automated acceptance tests for a Windows application can be a daunting task, especially if you’re not familiar with the Windows UI Automation API. Add to that the fact that your window will usually be running on a thread seperate to your tests (it certainly will if you use the code in this post), and you have a recipe for some horrible looking test code. However, you can hide away some of the horribleness if you can identify common tasks and model them as operations in functions or classes.

Operation 1 is starting up your window and obtaining a reference to it. Unfortunately, doing so for a WPF Window is a little trickier than it at first seems. However, I’ve coded up a couple of classes that may be of use for people getting started in this area.

UiOperation

First up is UiOperation. This class basically uses a few delegates and timeout values in order to try to perform an operation over and over until it’s either successful, or it times out. The class is fairly generic, so its purpose it not at first obvious and perhaps requires some explanation.

Imagine loading a window on another thread. You start the loading of the window, and at some point in the future it will appear. You basically want to keep trying to get a reference to that window for a while. If the window doesn’t appear after a while, then you timeout and fail. This class encapsulates that try, try, try, fail behaviour in a verstaile way which can be applied to a myriad of UI operations.

  1. public class UiOperation<T>
  2. {
  3.     private const Int32 OPERATION_TIMEOUT = 5000;
  4.  
  5.     private const Int32 OPERATION_RETRY_DELAY = 150;
  6.  
  7.     private readonly Int32 operationTimeout;
  8.  
  9.     private readonly Int32 retryDelay;
  10.  
  11.     private readonly T initialValue;
  12.  
  13.     private readonly Func<T> getValue;
  14.  
  15.     private readonly Func<T, Boolean> successCondition;
  16.  
  17.     public UiOperation(Func<T> getValue)
  18.         : this(default(T), getValue, v => v != null && !v.Equals(default(T))) { }
  19.  
  20.     public UiOperation(T initialValue, Func<T> getValue,
  21.         Func<T, Boolean> successCondition)
  22.         : this(initialValue, getValue, successCondition,
  23.             OPERATION_TIMEOUT, OPERATION_RETRY_DELAY) { }
  24.  
  25.     public UiOperation(T initialValue, Func<T> getValue,
  26.         Func<T, Boolean> successCondition,
  27.         Int32 operationTimeout, Int32 retryDelay)
  28.     {
  29.         if (getValue == null)
  30.         {
  31.             throw new ArgumentNullException("getValue");
  32.         }
  33.         if (successCondition == null)
  34.         {
  35.             throw new ArgumentNullException("successCondition");
  36.         }
  37.  
  38.         this.initialValue = initialValue;
  39.         this.getValue = getValue;
  40.         this.successCondition = successCondition;
  41.         this.operationTimeout = operationTimeout;
  42.         this.retryDelay = retryDelay;
  43.     }
  44.  
  45.     public T Invoke()
  46.     {
  47.         var value = initialValue;
  48.         var started = DateTime.Now;
  49.  
  50.         do
  51.         {
  52.             value = getValue();
  53.  
  54.             if (successCondition(value))
  55.             {
  56.                 break;
  57.             }
  58.  
  59.             Thread.Sleep(retryDelay);
  60.         } while ((DateTime.Now started).TotalMilliseconds <= operationTimeout);
  61.         return value;
  62.     }
  63. }

WindowRunner

WindowRunner is where we kick off a new window in a seperate AppDomain. Now, you can just kick off another thread to run the window without the new AppDomain, but if you’re using DataTemplates and coding using MVVM, you may find your templates aren’t loaded. In addition, running in a seperate AppDomain keeps failures nicely isolated so any in memory state shouldn’t persist between tests, and it’s closer to how your users will run your application.

  1. public class WindowRunner
  2. {
  3.     private const Int32 WINDOW_STARTUP_TIMEOUT = 15000;
  4.  
  5.     private readonly String windowAutomationId;
  6.  
  7.     private readonly Type windowType;
  8.  
  9.     public WindowRunner(Type windowType, String windowAutomationId)
  10.     {
  11.         if (windowType == null)
  12.         {
  13.             throw new ArgumentNullException("windowType");
  14.         }
  15.         if (windowAutomationId == null)
  16.         {
  17.             throw new ArgumentNullException("windowAutomationId");
  18.         }
  19.  
  20.         this.windowType = windowType;
  21.         this.windowAutomationId = windowAutomationId;
  22.     }
  23.  
  24.     public AutomationElement Start(params String[] arguments)
  25.     {
  26.         RunWindowAsync(arguments);
  27.         var windowElement = TryGetWindowElement();
  28.  
  29.         if (windowElement == null)
  30.         {
  31.             throw new TimeoutException(
  32.                 "Could not load application and find " + windowAutomationId +
  33.                 " within " + WINDOW_STARTUP_TIMEOUT + "ms");
  34.         }
  35.  
  36.         return windowElement;
  37.     }
  38.  
  39.     private AutomationElement TryGetWindowElement()
  40.     {
  41.         var desktop = AutomationElement.RootElement;
  42.         var condition = new PropertyCondition(AutomationElement.AutomationIdProperty,
  43.             windowAutomationId);
  44.         var getWindowElementOperation = new UiOperation<AutomationElement>(
  45.             null,
  46.             () => desktop.FindFirst(TreeScope.Children, condition),
  47.             e => e != null,
  48.             WINDOW_STARTUP_TIMEOUT,
  49.             150);
  50.         return getWindowElementOperation.Invoke();
  51.     }
  52.        
  53.     private void RunWindowAsync(params String[] arguments)
  54.     {
  55.         var thread = new Thread(() =>
  56.         {
  57.             var domain = AppDomain.CreateDomain("WindowRunnerDomain");
  58.             var assembly = Assembly.GetAssembly(windowType);
  59.             domain.ExecuteAssemblyByName(assembly.GetName(), arguments);
  60.         });
  61.         thread.SetApartmentState(ApartmentState.STA);
  62.         thread.Start();
  63.     }
  64. }

Here, we just use the type of the window to find the assembly to execute in our new AppDomain. We use the automation ID to find the window (the window’s Name property in WPF, which you need to set).

Trickiness with Receiving Command Line Arguments from WindowRunner

If you’re like me, and have added command line arguments to your WPF application, you’ll have to engage in a little friggery pokery in your WPF application so that you can implement your own static void Main(String[] args) and access the arguments passed in via AppDomain.ExecuteAssemblyByName in WindowRunner. This isn’t too difficult though:

  • Stick a new Main declaration in App.xaml.cs
  • Set App.xaml’s build action property to ‘Page’

This will prevent MSBuild from creating a default Main in the App class, and gives you direct control.

A Sample Test

A typical test would start like so, with some database connection details passed as command line arguments.

  1. [Test]
  2. public void WhenMyApplicationIsStartedICanSeeTheMainWindow()
  3. {
  4.     var windowRunner = new WindowRunner(typeof(MainWindow), "AlexApplicationWindow");
  5.     var windowElement = windowRunner.Start(TestConstants.ConnectionString);
  6. }

That test simply executes the assembly that MainWindow is contained in, and waits to find a desktop child named AlexApplicationWindow (for this to work I need to set Name=”AlexApplicationWindow” on the Window element in MainWindow.xaml). If the window appears within about 15 seconds, all is good. If not, a TimeoutException is thrown.

All my acceptance tests start like this, with each drilling down into the control hierarchy and invoking different controls to carry out use cases and check that we get the expected results.

Conclusion

Once you have your window’s AutomationElement, navigating and finding other important elements in your control hierarchy to interact with takes a fair bit of patience (you must try the Inspect Objects tool shipped with the Windows SDK – it will save you hours), but it’s worth it. As long as you keep on factoring the code so that common operations are nicely tucked away and easily invokable you can form a clean and comprehensive library of acceptance tests.

Share and Enjoy:
  • Print
  • Digg
  • StumbleUpon
  • del.icio.us
  • Facebook
  • Yahoo! Buzz
  • Twitter
  • Google Bookmarks
  • email
  • LinkedIn
  • Technorati