Search code examples
delphitestingdelphi-xegui-testing

Delphi GUI Testing and Modal Forms


In this interesting blog post on delphiXtreme I read about DUnit's built-in GUI testing capabilities (basically an alternative test case class TGUITestCase defined in unit GUITesting that has several utility functions for invoking actions in the GUI). I was quite happy with it until I noticed that it didn't work with modal forms. For example the following sequence won't work if the first button shows a modal configuration form:

Click ('OpenConfigButton');
Click ('OkButton');

The second Click is only executed when the modal form is closed, which I have to do manually.

I don't know much about how modal forms work in the background but there must be some way to circumvent this behaviour. Naively, I want to somehow execute the ShowModal "in a thread" so that the "main thread" stay responsive. Now I know that running ShowModal in a thread will probably mess up everything. Are there any alternatives? any way to circumvent the blocking nature of a ShowModal? Has anybody some experiences with GUI testing in Delphi?

I know about external tools (from QA or others) and we use those tools, but this question is about GUI testing within the IDE.

Thanks!


Solution

  • You can't test modal forms by calling ShowModal; because as you have quite rightly discovered, that results in your test case code 'pausing' while the modal form awaits user interaction.

    The reason for this is that ShowModal switches you into a "secondary message loop" that does not exit until the form closes.

    However, modal forms can still be tested.

    1. Show the usually Modal form using the normal Show method.
    2. This allows your test case code to continue, and simulate user actions.
    3. These actions and effects can be tested as normal.
    4. You will want an additional test quite particular to Modal forms:
      1. A modal form is usually closed by setting the modal result.
      2. The fact that you used Show means the form won't be closed by setting the modal result.
      3. Which is fine, because if you now simulate clicking the "Ok" button...
      4. You can simply check that the ModalResult is correct.

    WARNING

    You can use this technique to test a specific modal form by explicitly showing it non-modally. However, any code under test that shows a modal form (e.g. Error Dialog) will pause your test case.

    Even your sample code: Click ('OpenConfigButton'); results in ShowModal being called, and cannot be tested in that manner.

    To resolve this, you need your "show commands" to be injectible into your application. If you're unfamliar with dependency injection, I recommend Misko Hevery's Clean Code Talks videos available on You Tube. Then while testing, you inject a suitable version of your "show commands" that won't show a modal form.

    For example, your modal form may show an error dialog if validation fails when the Ok button is clicked.

    So:

    1) Define an interface (or abstract base class) to display an error messages.

    IErrorMessage = interface
      procedure ShowError(AMsg: String);
    end;
    

    2) The form you're testing can hold an injected reference to the interface (FErrorMessage: IErrorMessage), and use it to show an error whenever validation fails.

    procedure TForm1.OnOkClick;
    begin
      if (Edit1.Text = '') then
        FErrorMessage.ShowError('Please fill in your name');
      else
        ModalResult := mrOk; //which would close the form if shown modally
    end;
    

    3) The default version of IErrorMessage used / injected for production code will simply display the message as usual.

    4) Test code will inject a mock version of IErrorMessage to prevent your tests from being paused.

    5) Your tests can now execute cases that would ordinarily display an error message.

    procedure TTestClass.TestValidationOfBlankEdit;
    begin
      Form1.Show; //non-modally
      //Do not set a value for Edit1.Text;
      Click('OkButton');
      CheckEquals(0, Form1.ModalResult);  //Note the form should NOT close if validation fails
    end;
    

    6) You can take the mock IErrorMessage a step further to actually verify the message text.

    TMockErrorMessage = class(TInterfaceObject, IErrorMessage)
    private
      FLastErrorMsg: String;
    protected
      procedure ShowError(AMsg: String); //Implementaion trivial
    public
      property LastErrorMsg: String read FLastErrorMsg;
    end;
    
    TTestClass = class(TGUITesting)
    private
      //NOTE!
      //On the test class you keep a reference to the object type - NOT the interface type
      //This is so you can access the LastErrorMsg property
      FMockErrorMessage: TMockErrorMessage;
      ...
    end;
    
    procedure TTestClass.SetUp;
    begin
      FMockErrorMessage := TMockErrorMessage.Create;
      //You need to ensure that reference counting doesn't result in the
      //object being destroyed before you're done using it from the 
      //object reference you're holding.
      //There are a few techniques: My preference is to explicitly _AddRef 
      //immediately after construction, and _Release when I would 
      //otherwise have destroyed the object.
    end;
    

    7) Now the earlier test becomes:

    procedure TTestClass.TestValidationOfBlankEdit;
    begin
      Form1.Show; //non-modally
      //Do not set a value for Edit1.Text;
      Click('OkButton');
      CheckEquals(0, Form1.ModalResult);  //Note the form should NOT close if validation fails
      CheckEqulsString('Please fill in your name', FMockErrorMessage.LastErrorMsg);
    end;