Caves Travel Diving Graphics Mizar Texts Cuisine Lemkov Contact Map RSS Polski
Trybiks' Dive Texts Testing Automated GUI Testing YAC Software
  Back

List

Charsets

Charts

DBExpress

Delphi

HTML

Intraweb

MSTest

PHP

Programming

R

Rhino Mocks

Software

Testing

UI Testing

VB.NET

VCL

WPF

Automated GUI Testing
Somehow I never got to like external software for testing my GUI applications - the ones I used always had some problem with synchronizing their scripts against the behavior of the program being tested. 90% of the time these scripts ran fine, but for the other 10%... a window or a control wasn't found, the program hanged waiting for some kind of input (the application did something too fast), etc. Obviously the longer the test, the higher the probability that something will go wrong. And that's just not acceptable for any kind of serious automated testing...

On the other hand, standard unit tests can not only treat the functions/classes/whatever being tested as a black box (like the GUI testing applications do the program they're testing), but can also reference and/or check the internal state of the program at any time of the test. This can greatly enhance the effectiveness of tests IMO, though you can still keep to the black box approach if you wish to.

So, I decided to try testing my GUIs in a more unit testing fashion. That is, writing a DUnit test (for my Delphi programs) that opens the GUI, simulates user actions on it, and checks whether the GUI's behaving correctly.

The handling of forms, etc. is done just like you would program a GUI application - create the form, simulate user actions, add checks for any behavior that you want to check:
  LForm := TSomeForm.Create(...);
  try
    LForm.Show;
    // Testing code goes here.
  finally
    FreeAndNIL(LForm);
  end;
Of course, to simulate user actions, some additional code will be needed. For my purposes, I defined several procedures and functions that help in that:
  // Simulate keyboard input:
  procedure SimChar(AChar: char);
  procedure SimText(AText: string);
  
  // Simulate mouse input:
  procedure SimMouseClick(APos: TPoint); overload;
  procedure SimMouseClick(AControl: TControl); overload;
  procedure ClickCaption(AControl: TControl; ACaption: string; ACount: integer = 1);
  procedure ClickName(AControl: TControl; AName: string);
  
  // Find a control based on its caption or name:
  function FindControlByCaption(
    AControl: TControl; ACaption: string; ACount: integer = 1): TControl;
  function FindControlByName(AControl: TControl; AName: string): TControl;
The nice thing here is that we can usually reference controls by captions and/or names. And that means that the testing code is totally independent of the positions of controls, selected themes, and such like. Basically, I have no problem with moving my tests from one computer to another (or using a Virtual Machine to run them).

The implementation is pretty straightforward here.
  procedure SimChar(AChar: char);
  begin
    Application.ProcessMessages;
    if AChar = Upcase(AChar) then
    begin
      keybd_event(VK_SHIFT, 0, 0, 0);
      keybd_event(ord(Upcase(AChar)), 0, 0, 0);
      keybd_event(ord(Upcase(AChar)), 0, KEYEVENTF_KEYUP, 0);
      keybd_event(VK_SHIFT, 0, KEYEVENTF_KEYUP, 0);
    end
    else
    begin
      keybd_event(ord(Upcase(AChar)), 0, 0, 0);
      keybd_event(ord(Upcase(AChar)), 0, KEYEVENTF_KEYUP, 0);
    end;
    Application.ProcessMessages;
  end;
  
  procedure SimText(AText: string);
  var
    k: integer;
  begin
    for k := 1 to Length(AText) do
      SimChar(AText[k]);
  end;
The first procedure simulates a keyboard character; note that upper case characters need to have the SHIFT key pressed down and released. :-) The second procedure simulates entering text (into an edit line, for instance).

Now, what's with those Application.ProcessMessages calls? Well, they're needed for the GUI to handle incoming events as they come. If you have a OnChange event programmed for an edit line, for instance, you want it to fire as early as an event arrives - this simulates real world behavior (a user won't be able to type faster than the program is able to process such events).

Note that if you want to send characters to a given control, you must focus that control first.
  procedure SimMouseClick(APos: TPoint);
  begin
    Application.ProcessMessages;
    APos.x := Round(APos.x * (65535 / Screen.Width));
    APos.y := Round(APos.y * (65535 / Screen.Height));
    mouse_event(MOUSEEVENTF_ABSOLUTE or MOUSEEVENTF_MOVE, APos.x, APos.y, 0, 0);
    Application.ProcessMessages;
    mouse_event(MOUSEEVENTF_ABSOLUTE or MOUSEEVENTF_LEFTDOWN, APos.x, APos.y, 0, 0);
    Application.ProcessMessages;
    mouse_event(MOUSEEVENTF_ABSOLUTE or MOUSEEVENTF_LEFTUP, APos.x, APos.y, 0, 0);
    Application.ProcessMessages;
  end;
  
  procedure SimMouseClick(AControl: TControl);
  var
    LPos: TPoint;
  begin
    Assert((AControl <> NIL) and AControl.Visible);
    Application.ProcessMessages;
    LPos.x := AControl.Left + AControl.Width div 2;
    LPos.y := AControl.Top + AControl.Height div 2;
    if AControl.Parent <> NIL then
      LPos := AControl.Parent.ClientToScreen(LPos);
    SimMouseClick(LPos);
  end;
The first procedure simulates a mouse click at the given screen coordinates. First, it moves the mouse, then presses the key (down and up). We call ProcessMessages here quite often so that the GUI may handle any drag and dock operations correctly.

The second procedure just retrieves the control's position (in screen coordinates) and clicks in the middle of that control.
  procedure ClickCaption(AControl: TControl; ACaption: string; ACount: integer = 1);
  var
    LControl: TControl;
  begin
    LControl := FindControlByCaption(AControl, ACaption, ACount);
    Assert(LControl <> NIL,
      Format('ClickCaption - ACaption "%s" number %d not found.', [ACaption, ACount]));
    SimMouseClick(LControl);
  end;
  
  procedure ClickName(AControl: TControl; AName: string);
  var
    LControl: TControl;
  begin
    LControl := FindControlByName(AControl, AName);
    Assert(LControl <> NIL,
      Format('ClickName - AName "%s" not found.', [AName]));
    SimMouseClick(LControl);
  end;
These just wrap finding a control and clicking on it; two versions are provided - search for a control based on its caption or its name.

The first one takes an additional parameter - ACount. That's for handling the case when two controls have the same caption (and we want to click the second one). Though in such cases I prefer to reference the control by its name, so that the code is more independent of the order of controls on a form.

However, you may want to use ClickCaption if you want to check that such and such caption really exist on the form (though you can use FindControlByCaption for that).
  function DoFindControlByCaption(
    AControl: TControl; ACaption: string; var ACount: integer): TControl;
  var
    k: integer;
    LCaption, LCaptionNoAmp: string;
  begin
    Result := NIL;
    Application.ProcessMessages;
    if not AControl.Visible then
      Exit;
    LCaption := TCaptionControl(AControl).Caption;
    LCaptionNoAmp := StringReplace(LCaption, '&', '', [rfReplaceAll]);
    if (ACaption = LCaption) or (ACaption = LCaptionNoAmp) then
    begin
      if ACount > 1 then
        Dec(ACount)
      else
        Result := AControl;
    end
    else if AControl is TWinControl then
    begin
      for k := 0 to (AControl as TWinControl).ControlCount - 1 do
      begin
        Result := DoFindControlByCaption(
          (AControl as TWinControl).Controls[k], ACaption, ACount);
        if Result <> NIL then
          Exit;
      end;
    end;
  end;
  
  function FindControlByCaption(
    AControl: TControl; ACaption: string; ACount: integer = 1): TControl;
  begin
    Result := DoFindControlByCaption(AControl, ACaption, ACount);
  end;
  
  function FindControlByName(AControl: TControl; AName: string): TControl;
  var
    k: integer;
  begin
    Result := NIL;
    Application.ProcessMessages;
    if not AControl.Visible then
      Exit;
    if AnsiSameText(AControl.Name, AName) then
      Result := AControl
    else
    begin
      if AControl is TWinControl then
        for k := 0 to (AControl as TWinControl).ControlCount - 1 do
        begin
          Result := FindControlByName((AControl as TWinControl).Controls[k], AName);
          if Result <> NIL then
            Exit;
        end;
    end;
  end;
These functions look for a control recursively starting with the control given in AControl. So, usually, you would call this function with your form as the first parameter.

FindControlByCaption uses DoFindControlByCaption to keep track of the ACount value; FindControlByName doesn't need to do that. There's also some more code in the first function to handle accelerators in control captions.

Obviously, you may define many more procedures/functions that build on these - but that probably depends on your needs (for instance, in testing the YAC Interview Kit applications, I often check whether a control is visible - whether the correct responses are shown to the respondent).

Note also, that because it's a unit test, you can do with the GUI whatever you want before, during, and after the test. If you wish to fill the list box with some list of items, for instance, you just code it in standard Delphi fashion. With an external testing application, this could be much more difficult. ;-)

Also, the test and the GUI run on the same thread, which means that there are no problems with synchronization. If you call SimText, right there and then the text is sent to the control and processed by it. There's no need for any additional code that would wait on that control (as you would have to do in an external application).

Anyway, the approach above works for me (almost) perfectly. There are some catches - but that's for another post. :-)

Top

Comments
Alas!
No comments yet...

Top

Add a comment (fields with an asterisk are required)
Name / nick *
Mail (will remain hidden) *
Your website
Comment (no tags) *
Enter the text displayed below *
 

Top

Tags

Testing

UI Testing

Delphi


Related pages

AssertWasCalled and Methods Called Multiple Times

AssertWas[Not]Called and Object Properties

Rhino Mocks's AssertWasNotCalled

Visual Studio - moving coded UI tests to a new / different project results in null reference exceptions

PrivateObject and Out/ByRef parameters

PrivateObject, WithEvents, and generics

Delphi interfaces... again

Accessing private members of base classes

PrivateObject and WithEvents

Accessing private and protected members - PrivateObject and PrivateType

VS - Test Run Error - "COM object that has been separated from its underlying RCW cannot be used"

Get the TreeViewItem for an Item in a WPF TreeView

Output in MSTest Tests (VS 2010)

Automated WPF tests and "Invalid URI: Invalid port specified."

Checking Property Change Notifications

Checking "Dangling" Event Handlers in Delphi Forms

Rhino Mocks's AssertWasCalled in VB.NET

First steps with Rhino Mocks (in VB.NET)

VS Pending Tests

Drag-n-drop files onto the application window

Intraweb and MaxConnections

A Case for FreeAndNIL

Intraweb as an Apache DSO module

"Device not supported" in Intraweb

Rounding and precision on the 8087 FPU

SessionTimeout in Intraweb

Using TChart with Intraweb

Unknown driver: MySQL

Automated GUI Testing in VMs

TIdMessage's CharSet

Software Guarantees

Automated Testing of Window Forms

TChart - Missing Labels in Axes

Memory Leaks and Connection Explosions in DBExpress

Controlling Conditional Defines and Compilation Switches

Detecting Memory Leaks with DUnit

last_insert_id() and DBExpress

Registering Extensions

DBExpress and Thread Safety

Forms as Frames

Checking Dangling Pointers vs. the New Memory Manager

Accessing Protected Members

Objects, interfaces, and memory management in Delphi