MindStream. As we write software under FireMonkey. Part 3. DUnit + FireMonkey

    Part 1 .
    Part 2 .

    Hello.

    In this article I want to introduce readers to the process of porting VCL code to FireMonkey. In the standard Delphi package, starting in my opinion with version 2009, the DUnit project comes out of the box.

    However, it was written in ancient times by VCL. And although it allows you to test code written for FireMonkey (thanks to console output), it doesn’t have a “nice” GUIRunner, which many of us are used to, because it’s very quick and easy to remove tests that we don’t want launch "right now."

    image




    For those who are very or little familiar with DUnit. In normal mode out of the box, the documentation suggests doing File-> New-> Other-> Unit Test-> TestProject. Next, you need to choose a GUI or console option. Thanks to these not so complicated manipulations, you have a new project that should look something like this (at least “mine” XE7, generated exactly this kind of code) for the GUI:

    program Project1Tests;
    {
      Delphi DUnit Test Project
      -------------------------
      This project contains the DUnit test framework and the GUI/Console test runners.
      Add "CONSOLE_TESTRUNNER" to the conditional defines entry in the project options
      to use the console test runner.  Otherwise the GUI test runner will be used by
      default.
    }
    {$IFDEF CONSOLE_TESTRUNNER}
    {$APPTYPE CONSOLE}
    {$ENDIF}
    uses
      DUnitTestRunner,
      TestUnit1 in 'TestUnit1.pas',
      Unit1 in '..\DUnit.VCL\Unit1.pas';
    {$R *.RES}
    begin
      DUnitTestRunner.RunRegisteredTests;
    end.
    

    Next we add TestCase, this is also done (File-> New-> Other-> Unit Test-> TestCase), the result should be something similar:

    unit TestUnit1;
    {
      Delphi DUnit Test Case
      ----------------------
      This unit contains a skeleton test case class generated by the Test Case Wizard.
      Modify the generated code to correctly setup and call the methods from the unit 
      being tested.
    }
    interface
    uses
      TestFramework, System.SysUtils, Vcl.Graphics, Winapi.Windows, System.Variants,
      System.Classes, Vcl.Dialogs, Vcl.Controls, Vcl.Forms, Winapi.Messages, Unit1;
    type
      // Test methods for class TForm1
      TestTForm1 = class(TTestCase)
      strict private
        FForm1: TForm1;
      public
        procedure SetUp; override;
        procedure TearDown; override;
      published
        procedure TestDoIt;
      end;
    implementation
    procedure TestTForm1.SetUp;
    begin
      FForm1 := TForm1.Create;
    end;
    procedure TestTForm1.TearDown;
    begin
      FForm1.Free;
      FForm1 := nil;
    end;
    procedure TestTForm1.TestDoIt;
    var
      ReturnValue: Integer;
    begin
      ReturnValue := FForm1.DoIt;
      // TODO: Validate method results
    end;
    initialization
      // Register any test cases with the test runner
      RegisterTest(TestTForm1.Suite);
    end.
    

    In general, my example shows how easy it is to add testing, even for Delphi7. All we need is to call DUnitTestRunner.RunRegisteredTests ;. And add new files with TestCase to the project. In more detail, the question of testing using DUnit is discussed here .

    For implementation, I decided that you just need to repeat the guys who did DUnit.
    The first problem (the fact that TTreeNode and TTreeViewItem “are not brothers at all” I will not even say, the documentation will save everyone) that I encountered was here:

    type
      TfmGUITestRunner = class(TForm)
      ...
      protected
        FSuite: ITest;
        procedure SetSuite(Value: ITest);  
      ...  
      public
        property Suite: ITest read FSuite write SetSuite;
      end;  
    procedure RunTestModeless(aTest: ITest);
    var
      l_GUI: TfmGUITestRunner;
    begin
      Application.CreateForm(TfmGUITestRunner, l_GUI);
      l_GUI.Suite := aTest;
      l_GUI.Show;
    end;
    procedure TfmGUITestRunner.SetSuite(Value: ITest);
    begin
      FSuite := Value; // AV здесь
      if FSuite <> nil then
        InitTree;
    end;
    

    The problem, as always, is “recognized” in the debug, well, or in the documentation :). In FireMonkey, Application.CreateForm (); does not create a form. Yes, oddly enough. TApplication.CreateForm

    My comment on the commit when I figured out :)
    FSuite, It’s not created yet, because Application.CreateForm actually, if you do not explicitly kick it, “does not create a bitch, normal forms, but only links to future classes. Which accordingly affects the members of the class who are not at all nil, as they should be. ”

    AV will crawl out into System._IntfCopy (var Dest: IInterface; const Source: IInterface);
    And it will come out because we have garbage in Dest, and not interface or nil. And this will manifest itself when we at the previous interface (if it is not // nil) will subtract 1.

    Even if we write such a line, it’s up to
    FSuite: = nil;


    Here is another link on this subject -. It doesn't do what it says it does! To be honest, I was also a little shocked by the fact that the method called Make Form does not do it.
    We solve the problem by creating forms explicitly (l_GUI: = TfmGUITestRunner.create (nil);) and move on.

    Now we need to build a test tree based on TestCase, which are added for testing. If you noticed, the process of building a form begins with the RunRegisteredTestsModeless method:

    procedure RunRegisteredTestsModeless;
    begin
      RunTestModeless(registeredTests)
    end;
    

    I decided not to put this method in a separate module, like the creators of DUnit, so to connect fmGUITestRunner, you need to specify the module in the project code, and actually call the desired method. In my case, the project code looks like this:

    program FMX.DUnit;
    uses
      FMX.Forms,
      // Форма тестирования
      u_fmGUITestRunner in 'u_fmGUITestRunner.pas' {fmGUITestRunner},
      // Тесты
      u_FirstTest in 'u_FirstTest.pas',
      u_TCounter in 'u_TCounter.pas',
      u_SecondTest in 'u_SecondTest.pas';
    {$R *.res}
    begin
     Application.Initialize;
     // Вызываем метод который я описал
     u_fmGUITestRunner.RunRegisteredTestsModeless;
     Application.Run;
    end.
    

    An attentive reader, pay attention that we did not add any registeredTests, and did not indicate at all which tests we would add. RegisteredTests is a "global" TestFrameWork method that is connected to our form; it returns a global variable - __TestRegistry: ITestSuite;

    How TestCase "fall" into this variable, I will leave out of the scope of this article, especially since the creators of DUnit did the work. However, if readers express interest in this topic, I will answer in comments. So, back to the tree. Method for initializing the tree:

    procedure TfmGUITestRunner.InitTree;
    begin
      FTests.Clear;
      FillTestTree(Suite);
      TestTree.ExpandAll;
    end;
    

    FTests, this is a list of interface objects that will store a list of our tests. In turn, the FillTestTree method is overloaded, this is done, since we do not know whether we are working with the root element of the tree, or with a normal node:

    ...
        procedure FillTestTree(aTest: ITest); overload;
        procedure FillTestTree(aRootNode: TTreeViewItem; aTest: ITest); overload;
    ...
    procedure TfmGUITestRunner.FillTestTree(aRootNode: TTreeViewItem; aTest: ITest);
    var
      l_TestTests: IInterfaceList;
      l_Index: Integer;
      l_TreeViewItem: TTreeViewItem;
    begin
      if aTest = nil then
        Exit;
      l_TreeViewItem := TTreeViewItem.Create(self);
      l_TreeViewItem.IsChecked := True;
      // Добавляем тест в список, и в свойстве Tag сохраняем его индекс. Опыт работы с БД из прошлой работы :)
      l_TreeViewItem.Tag := FTests.Add(aTest);
      l_TreeViewItem.Text := aTest.Name;
      // Тут я думаю, всё ясно
      if aRootNode = nil then
        TestTree.AddObject(l_TreeViewItem)
      else
        aRootNode.AddObject(l_TreeViewItem);
      // ITest, содержит метод Tests, который является списком(IInterfaceList) "вложенных" тестов
      // Рекурсивно проходимся по всем тестам
      l_TestTests := aTest.Tests;
      for l_Index := 0 to l_TestTests.Count - 1 do
        FillTestTree(l_TreeViewItem, l_TestTests[l_Index] as ITest);
    end;
    

    As you can see, in the method we not only filled the tree, but also gave information to each node which of the tests corresponds to it. In order to get the test by node, we write the NodeToTest method:

    function TfmGUITestRunner.NodeToTest(aNode: TTreeViewItem): ITest;
    var
      l_Index: Integer;
    begin
      assert(aNode.Tag >= 0);
      l_Index := aNode.Tag;
      Result := FTests[l_Index] as ITest;
    end;
    

    Now add the “knowledge” to the tests. Each test has a GUIObject variable, such as TObject. We will call SetupGUINodes on FormShow.

    procedure TfmGUITestRunner.SetupGUINodes(aNode: TTreeViewItem);
    var
      l_Test: ITest;
      l_Index: Integer;
    begin
      for l_Index := 0 to Pred(aNode.Count) do
      begin
    	// Получаем тест
        l_Test := NodeToTest(aNode.Items[l_Index]);
        assert(assigned(l_Test));
    	// Ассоциируем тест с необходимым узлом
        l_Test.GUIObject := aNode.Items[l_Index];
        SetupGUINodes(aNode.Items[l_Index]);
      end;
    end;
    

    In order to get a node from the test, we will write a method:

    function TfmGUITestRunner.TestToNode(test: ITest): TTreeViewItem;
    begin
      assert(assigned(test));
      Result := test.GUIObject as TTreeViewItem;
      assert(assigned(Result));
    end;
    

    The way I “connected” the tests with the tree, I, and the senior colleague, did not like it. Why did DUnit developers go this way, I guess. DUnit was written for a long time, and then there were no Generics. In the future, we will certainly redo this. At the end of the article I will write about our next improvements and Wishlist.

    So - our tree is under construction, tests are in FTests. Tests and tree are interconnected. It's time to run the tests, and process the results. In order for the form to do this, add the implementation of the ITestListener interface described in TestFrameWork:

      { ITestListeners get notified of testing events.
        See TTestResult.AddListener()
      }
      ITestListener = interface(IStatusListener)
        ['{114185BC-B36B-4C68-BDAB-273DBD450F72}']
        procedure TestingStarts;
        procedure StartTest(test: ITest);
        procedure AddSuccess(test: ITest);
        procedure AddError(error: TTestFailure);
        procedure AddFailure(Failure: TTestFailure);
        procedure EndTest(test: ITest);
        procedure TestingEnds(testResult :TTestResult);
        function  ShouldRunTest(test :ITest):Boolean;
      end;
    

    Add these methods to the class description and implement them:

    procedure TfmGUITestRunner.TestingStarts;
    begin
      FTotalTime := 0;
    end;
    procedure TfmGUITestRunner.StartTest(aTest: ITest);
    var
      l_Node: TTreeViewItem;
    begin
      assert(assigned(TestResult));
      assert(assigned(aTest));
      l_Node := TestToNode(aTest);
      assert(assigned(l_Node));
    end;
    procedure TfmGUITestRunner.AddSuccess(aTest: ITest);
    begin
      assert(assigned(aTest));
      SetTreeNodeFont(TestToNode(aTest), c_ColorOk)
    end;
    procedure TfmGUITestRunner.AddError(aFailure: TTestFailure);
    var
      l_ListViewItem: TListViewItem;
    begin
      SetTreeNodeFont(TestToNode(aFailure.failedTest), c_ColorError);
      l_ListViewItem := AddFailureNode(aFailure);
    end;
    procedure TfmGUITestRunner.AddFailure(aFailure: TTestFailure);
    var
      l_ListViewItem: TListViewItem;
    begin
      SetTreeNodeFont(TestToNode(aFailure.failedTest), c_ColorFailure);
      l_ListViewItem := AddFailureNode(aFailure);
    end;
    procedure TfmGUITestRunner.EndTest(test: ITest);
    begin
      // Закоментил, потому как тут надо обновлять общую информацию о результатах
      // тестов. А нам пока нечего показывать.
      // И если будет утверждение, то после первого захода сюда, результаты не отображаются
      // Пока так, однозначно TODO
      // assert(False);
    end;
    procedure TfmGUITestRunner.TestingEnds(aTestResult: TTestResult);
    begin
      FTotalTime := aTestResult.TotalTime;
    end;
    function TfmGUITestRunner.ShouldRunTest(aTest: ITest): Boolean;
    var
      l_Test: ITest;
    begin
      // Метод проверяет, стоит ли запускать тест. То как тесты "узнают" о "доступности" опишу ниже
      l_Test := aTest;
      Result := l_Test.Enabled
    end;
    

    There is nothing special to explain. Although if there are questions, I will answer in detail. In the original, DUnitRunner, when "receiving" the test result, changed the picture of the corresponding tree node. I decided not to fool around with the pictures, because now I don’t have them out of the box, and adding the picture to the node is somehow messed up through the styles. Therefore, I decided to limit myself to changing FontColor and FontStyle for each node.

    It seems like a business for 1 minute, but spent a couple of hours digging through all the documentation:

    procedure TfmGUITestRunner.SetTreeNodeFont(aNode: TTreeViewItem;
      aColor: TAlphaColor);
    begin
      // Пока не укажешь какие из настроек стиля разрешены к работе, они работать не будут
      aNode.StyledSettings := aNode.StyledSettings - [TStyledSetting.ssFontColor, TStyledSetting.ssStyle];
      aNode.Font.Style := [TFontStyle.fsBold];
      aNode.FontColor := aColor;
    end;
    

    We will use ListView to display the results. Features of TListView in FireMonkey are such that the list is fully tailored for mobile applications. And lost the wonderful Columns property. To add errors, add the AddFailureNode method:

    function TfmGUITestRunner.AddFailureNode(aFailure: TTestFailure): TListViewItem;
    var
      l_Item: TListViewItem;
      l_Node: TTreeViewItem;
    begin
      assert(assigned(aFailure));
      l_Item := lvFailureListView.Items.Add;
      l_Item.Text := aFailure.failedTest.Name + '; ' + 
                     aFailure.thrownExceptionName + '; ' + 
    				 aFailure.thrownExceptionMessage + '; ' + 
    				 aFailure.LocationInfo + '; ' + 
    				 aFailure.AddressInfo + '; ' + 
    				 aFailure.StackTrace;
      l_Node := TestToNode(aFailure.failedTest);
      while l_Node <> nil do
      begin
        l_Node.Expand;
        l_Node := l_Node.ParentItem;
      end;
      Result := l_Item;
    end;
    

    It's time to run our tests, for which we add a button and a launch method:

    procedure TfmGUITestRunner.btRunAllTestClick(Sender: TObject);
    begin
      if Suite = nil then
        Exit;
      ClearResult;
      RunTheTest(Suite);
    end;
    procedure TfmGUITestRunner.RunTheTest(aTest: ITest);
    begin
      TestResult := TTestResult.Create;
      try
        TestResult.addListener(self);
        aTest.run(TestResult);
      finally
        FreeAndNil(FTestResult);
      end;
    end;
    

    We start our Runner, click the test run button, as a result of which we see:

    image


    The last thing that remains for us to do is process the user’s actions when the state of the node changes:

    procedure TfmGUITestRunner.TestTreeChangeCheck(Sender: TObject);
    begin
      SetNodeEnabled(Sender as TTreeViewItem, (Sender as TTreeViewItem).IsChecked);
    end;
    procedure TfmGUITestRunner.SetNodeEnabled(aNode: TTreeViewItem;
      aValue: Boolean);
    var
      l_Test: ITest;
    begin
      l_Test := NodeToTest(aNode);
      if l_Test <> nil then
        l_Test.Enabled := aValue;
    end;
    

    Change the state of the checkboxes of some nodes:

    image


    The test code on which I actually tested:

    unit u_SecondTest;
    interface
    uses
      TestFrameWork;
    type
      TSecondTest = class(TTestCase)
      published
        procedure DoIt;
        procedure OtherDoIt;
        procedure ErrorTest;
        procedure SecondErrorTest;
      end; // TFirstTest
    implementation
    procedure TSecondTest.DoIt;
    begin
      Check(true);
    end;
    procedure TSecondTest.ErrorTest;
    begin
      raise ExceptionClass.Create('Error Message');
    end;
    procedure TSecondTest.OtherDoIt;
    begin
      Check(true);
    end;
    procedure TSecondTest.SecondErrorTest;
    begin
      Check(False);
    end;
    initialization
    TestFrameWork.RegisterTest(TSecondTest.Suite);
    end.
    

    To summarize - at this stage, we got a very working application for testing FireMonkey code using the familiar GUIRunner. The project is open, so everyone can use it.

    Future plans:
    Write a tree traversal method that will receive a lambda . The tree has to be bypassed constantly, but the actions with each branch are different, so the lambda seems appropriate to me.

    Remarks and suggestions from my senior colleague :
    Remake the Test Node link on TDictionarydocwiki.embarcadero.com/Libraries/XE7/en/System.Generics.Collections.TDictionary
    Add a graphical indicator of “passing tests”. Buttons - select all, remove all, etc. as well as the conclusion of testing results (lead time, number of successful and failed, etc.).
    Add a Decorator pattern to get rid of the “crutch” GUIObject.

    In the near future, we will begin to test our main project, MindStream, and we will bring Runner to mind a little bit. Thanks to everyone who read to the end. Comments and criticism, as always welcome in the comments.

    Link to the repository.

    ps The project is located in the MindStream \ FMX.DUnit repository The

    links that I found and which were useful to me in the process:
    sourceforge.net/p/radstudiodemos/code/HEAD/tree/branches/RadStudio_XE5_Update/FireMonkey/Delphi
    fire-monkey.ru
    18delphi.blogspot.ru
    www.gunsmoker.ru
    GUI testing "in Russian". A note on test levels
    Once again on “test levels”
    and of course
    docwiki.embarcadero.com/RADStudio/XE7/en/Main_Page

    Part 3.1

    Also popular now: