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
|