Introduction
The Microsoft Pattern and Practices Composite Application Block (CAB) and the Smart Client Software Factory (SCSF) are incredibly useful tools for developing Smart Client solutions. It is true that they take a bit of learning, but the effort is worthwhile.
The modularity of the approach means that it is easy to develop the elements of the application independently of each other. The elements can be tested using NUnit or some other unit testing framework.
I hadn't fully appreciated how easy it is to test the user interface itself using the CAB. The solution is to use a normal CAB module to run the tests (with a bit of help from the Windows API on occasions). Simply sit back and watch the application work its way though your tests!
This article outlines the approach that I have taken. I'm not the world's greatest programmer. If someone can see a simpler or better way to do it, I'd love to hear from them.
The code samples include a reusable CAB module (TestModule) and a demonstration project.
Background
The demonstration application is very simple. Download the demo and run the executable to see what the application does.
There is a main form which allows the User to select a Contact and view the Projects associated with the Contact, or to add or delete a Contact.
Adding a Contact brings up a new view:
Similarly, a Project view is displayed if Add Project is clicked.
The solution consists of 8 standard CAB modules.
In order to test the user interface, simply create a new business module.
Using the Code
Create a CAB/SCSF business module as normal. This module should be loaded of course only during testing and the way I have chosen to make sure that it happens is to check at the launch of the application by looking for a Configuration file setting.
<appSettings>
……
<add key="IsTestingUserInterface" value="true" />
……
</appSettings>
Then in the ShellApplication
, check for the config setting.
[STAThread]
static void Main()
{
string isTestingUserInterface =
System.Configuration.ConfigurationManager.AppSettings["IsTestingUserInterface"];
if ((!string.IsNullOrEmpty(isTestingUserInterface) && (isTestingUserInterface=="true")))
{
using (UITestInitialiser initialiser=new UITestInitialiser()){initialiser.Initialise();}
I have chosen to use a class to initialise the application because there may be a range of tasks to be performed before it is launched properly. In my case, I wish to set up a database for my business objects so that the UI tests run against a known configuration. This approach also allows the ProfileCatalog
to be set up correctly. So in the UITestInitialiser
class, we have code like:
string profileCatalog = Environment.CurrentDirectory +
InitialiseDBs();
CopyProfileCatalog(Environment.CurrentDirectory +
"\\ProfileCatalogUITesting.xml", profileCatalog);
Start the Test when the Shell is Loaded
Obviously the test cannot begin until the Shell has finished loading. Either raise a specific event when the Shell is loaded or subscribe to an existing event which does the same job. The event is captured in the ModuleController
.
[EventSubscription(EventTopicNames.ContactSelected, ThreadOption.Background)]
public void OnContactSelected(object sender, EventArgs eventArgs)
{
EventTopic topic = WorkItem.EventTopics.Get(EventTopicNames.ContactSelected);
topic.RemoveSubscription(this, "OnContactSelected");
IUITests uiTests = WorkItem.Services.AddNew<UITests, IUITests>();
uiTests.ExecuteTests();
}
There are a couple of points to note about this approach. Firstly, the event is invoked on a background thread. There is no way you can test the UI if you are running on the UI thread! Secondly, I have chosen to remove the event subscription after the event is fired. This may not be necessary, but if you subscribe to an existing event, you certainly don't want to begin the tests again if the event is fired.
The tests are run in a standard CAB service; this may not be necessary but it suits me and makes sure that things are disposed of correctly.
The Test Manager
The tests are run using a very simple manager.
public void ExecuteTests()
{
try
{
_result = new StringBuilder();
ExecuteTest(typeof(AddContactTest));
ExecuteTest(typeof(DeleteContactTest));
ExecuteTest(typeof(AddProjectTest));
ExecuteTest(typeof(DeleteProjectTest));
MessageBox.Show(_result.ToString(), "Test Results",
MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
MessageBox.Show("Tests failed.\r\n" + ex.ToString(),
"Tests failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void ExecuteTest(Type testType)
{
ITest test = (ITest)_workItem.Services.AddNew(testType, typeof(ITest));
string txt;
if (test.Execute())
txt = testType.Name + " completed successfully\r\n";
else
txt = testType.Name + " failed\r\n";
_result.Append(txt);
_workItem.Services.Remove(typeof(ITest));
test.Dispose();
test = null;
}
The real work is then accomplished in the individual tests. If we look at the test for adding a Contact, you will get the idea.
Test Adding a New Contact
The code is again very simple – the real work is carried out in the services which manage the user interface.
private IUIService _uiService;
[ServiceDependency]
public IUIService UIService
{ set { _uiService = value; } }
public bool Execute()
{
try
{
_uiService.ClickToolStripItem(WorkspaceNames.LayoutWorkspace,
"_mainToolStrip", "btnAddContact");
_uiService.SetControlText(WorkspaceNames.RightWorkspace,
"AddContactView", "txbFirstName", "Jim");
_uiService.SetControlText(WorkspaceNames.RightWorkspace,
"AddContactView", "txbLastName", "Smith");
_uiService.ClickButton(WorkspaceNames.RightWorkspace, "AddContactView", "btnSave");
VerifyAddContact();
return true;
}
catch (UITestException ex)
{
if (MessageBox.Show(string.Format("Test: {0} failed.\r\n{1}",
this.GetType().Name, ex.ToString()), "Test failed",
MessageBoxButtons.AbortRetryIgnore, MessageBoxIcon.Error) != DialogResult.Ignore)
throw;
else
{
return false;
}
}
}
private void VerifyAddContact()
{
XmlDocument xmldoc = new XmlDocument();
xmldoc.Load(Environment.CurrentDirectory + "\\TestData.xml");
XmlNode node = xmldoc.SelectSingleNode
("//contacts//contact[@firstName='Jim' and @lastName='Smith']");
if (node == null)
throw new UITestException("Contact not added");
}
The test makes use of the Service
, UIService
, of which more later. This service clicks the AddContact
button, inserts the contact's first name and last name into the textboxes and clicks Save. Finally, the database (or rather the XML file) is checked to make sure that the Contact
has been added.
The UIService
The UIService
provides a range of methods to help manipulate the user interface. It in turn makes use of another service UIThreadService
which ensures that all actions on the UI controls are carried out on the thread on which they were created.
There are a set of functions which look for controls in the user interface. For example:
public Control GetControl(string workSpaceName)
{
int count = 5;
Control cntrl = null;
while (cntrl == null && count > 0)
{
cntrl = (Control)_workItem.Workspaces.Get(workSpaceName);
if (cntrl == null) { Thread.Sleep(1000); }
count -= 1;
}
if (cntrl == null)
throw new UITestException(string.Format
("Not able to find Workspace: {0}", workSpaceName));
return cntrl;
}
This method looks for a specific Workspace and returns it as a control. It may be that your application is loading Workspaces and views while the test is underway. This method uses a simple but crude technique to make sure that the control is actually there, and not just missing at the instant the test looked for it! If the control is there, it is returned. If it is not, the thread waits for 1 second and then tries again. It does this five times and it is pretty safe to assume that in most applications if a control is not loaded after five seconds, it's not there at all.
There are various overloads such as:
public Control GetControl(string workSpaceName, string view, string name)
This looks for the specific control within a view in a workspace.
Having got a control, you then want to do something with it. For textboxes, you can set the Text property using
public void SetControlText(string workSpaceName, string view, string name, string value)
{
Control cntrl = GetControl(workSpaceName, view, name);
if (cntrl != null)
SetControlText(cntrl, value);
}
_uiThreadService.SetControlText(cntrl, value);
The basic SetControlText(cntrl,value)
method uses the UIThread
service to set the actual property.
The UIThread
service has to make sure that the control's property is set on the same thread it was created.
public void SetControlText(Control cntrl, string value)
{
cntrl.Invoke(new SetControlTextMethodInvoker
(SetControlTextUIThread), new object[] { cntrl, value });
}
private void SetControlTextUIThread(Control cntrl, string value)
{
cntrl.Text = value;
if (cntrl.DataBindings.Count != 0) { cntrl.DataBindings[0].WriteValue(); }
}
It simply uses the control's Invoke method to execute another method on the UI thread. As an added wrinkle, the setting method checks to see if the control is bound to a data source. If it is, the source is updated
.
Other controls are handled in the same way – TreeView
nodes or ComboBox
items can be selected. ToolStripItems
and Buttons
can be clicked to cause the user interface to progress through the various views.
Dialogs and MessageBoxes
Most of this is pretty straight forward. The background thread on which the tests are run simply has to wait for the real user interface to complete whatever tasks have been set for it. The only other issue which needs to be addressed is the case of modal forms or dialogs, and in particular the very useful MessageBox
.
When a MessageBox
is displayed, you ideally want your test application to choose the appropriate button on the form and dismiss the MessageBox
so that the application can proceed.
The trick to achieve this is again very crude but effective. When a dialog is going to be displayed, the test should execute the command on (another) background thread, suspend the test thread for a moment, and then dismiss the dialog. For example, when deleting a contact, the user is asked to confirm the delete. The Delete Contact example shows a Yes/No MessageBox
. The test then uses the Windows API FindWindow
methods to locate the handle for the MessageBox
and its buttons, and sends Windows messages to click the Yes or No button. (If you need help to find the appropriate windows, the Visual Studio Spy++ utility is very helpful.)
[DllImport("user32.dll", CharSet = CharSet.Auto)]
static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
[DllImport("user32.dll", CharSet = CharSet.Auto)]
static extern IntPtr FindWindowEx(IntPtr hwndParent,
IntPtr hwndChildAfter, string lpszClass, string lpszWindow);
[DllImport("user32.dll")]
static extern int PostMessage(IntPtr hWnd, uint Msg, int wParam, int lParam);
public IntPtr PointerToWindow(string windowCaption)
{
return FindWindow(null, windowCaption);
}
public IntPtr PointerToButton(IntPtr ptrToWindow, string buttonCaption)
{
IntPtr ptrToButton= FindWindowEx(ptrToWindow, IntPtr.Zero, null, buttonCaption);
return ptrToButton;
}
public void ClickButton(IntPtr ptrToButton)
{
uint WM_LBUTTONDOWN = 0x0201;
uint WM_LBUTTONUP = 0x0202;
PostMessage(ptrToButton, WM_LBUTTONDOWN, 0, 0);
PostMessage(ptrToButton, WM_LBUTTONUP, 0, 0);
Application.DoEvents();
}
Points of Interest
My language of choice has been VB.NET. At the present time, the SCSF does not support VB.NET. Given that I wanted to get the demo up and running, I used the SCSF to generate the demo application, and I had it up and running in a couple of hours. Rather than creating the TestModule as a VB.NET project, I decided to stick with C# and, blow me down, I eventually got used to the curly brackets and semi colons. And I actually enjoyed it! Next stop – C++!
The next step of course is to create some GAT recipes so that the TestModule and the test classes can be generated automatically. Should be fun!
In the meantime, the main application I am working on www.straitonsoftware.com has historically not had the benefit of proper user interface testing. Well it does now!
History