четверг, 13 ноября 2014 г.

MindStream. How we develop software for FireMonkey. Part 3. Firemonkey + DUnit

Original post in Russian http://habrahabr.ru/post/241301/

Table of contents

In this post, I want to describe the process of transferring VCL code to FireMonkey. As far as I remember DUnit was a part of Delphi since Delphi 2009. It has been written in the early days of VCL and although it allows you to test code written for FireMonkey (thanks to the console output), it does not have GUIRunner, to which many of us are accustomed to, because it's very fast and easy, for instance, to disable those tests that we do not want to run.  

For those who are not familiar with DUnit, to create a DUnit project you should use File-> New-> Other-> Unit Test-> TestProject. Next, you should choose GUI or console version. After these simple manipulations, you will have a new project which will look something like (GUI, at least for my XE7) the following:
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.
Then you have to add a TestCase (File-> New-> Other-> Unit Test-> TestCase). And the result will look like:
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 you can easily add unit-testing even to Delphi 7. All we need is to call DUnitTestRunner.RunRegisteredTests and add new files with TestCases to the project. In more detail unit-testing, using DUnit, is considered here. Implementing GUIRunner for FireMonkey, I decided to repeat the guys who did the original DUnit. The first problem (I will not even tell that TTreeNode and TTreeViewItem classes are not compatible) 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 here
 
  if FSuite <> nil then
    InitTree;
end;
In FireMonkey Application.CreateForm method does not create a form. Yes, it is oddly enough. Here is another link about that. It does not do what it says it does! I was, to be honest, a bit shocked by the fact that the method called "CreateForm" does not create it. To solve this issue I create forms explicitly (l_GUI := TfmGUITestRunner.Create (nil);) and go further. Now we need to build a tests tree based on TestCases we added for testing. If you notice, the construction of the form starts in RunRegisteredTestsModeless method.
procedure RunRegisteredTestsModeless;
begin
  RunTestModeless(registeredTests)
end;
I decided not to put this method into a separate module, as DUnit developers did, thus to use fmGUITestRunner, you must specify its module in the project code and actually call the appropriate method. In my case, the code is as follows:
program FMX.DUnit;
uses
  FMX.Forms,
  // GUI Runner
  u_fmGUITestRunner in 'u_fmGUITestRunner.pas' {fmGUITestRunner},
  // Tests
  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.
The attentive reader will notice that we have not added any registeredTests, and it is never specified what kind of tests will be added. RegisteredTests is a global method of TestFrameWork, which is connected to our form; it returns __TestRegistry: ITestSuite (global variable); The way how TestRegistry gets TestCases is out of the scope of this article. Moreover, that work has been done by DUnit developer. However, if readers have an interest in this topic, I'll reply to the comments. So, back to the tree. A method to initialize the tree:
procedure TfmGUITestRunner.InitTree;
begin
  FTests.Clear;
  FillTestTree(Suite);
  TestTree.ExpandAll;
end;
FTests is a list of interface objects, which will store a list of our tests. FillTestTree is overloaded because we do not know if we are working with a root of the tree or with an ordinary 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;

   // Add Index to tag property
  l_TreeViewItem.Tag := FTests.Add(aTest);
  l_TreeViewItem.Text := aTest.Name;

  if aRootNode = nil then
    TestTree.AddObject(l_TreeViewItem)
  else
    aRootNode.AddObject(l_TreeViewItem);

  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 this method, we not only fill the tree, but also add information to each node, which of the tests corresponds to it. In order to get a test by a given node, let’s implement a method named NodeToTest:
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 let's add some “knowledge” to a test. There is a variable GUIObject (TObject) in each test. And we will call SetupGUINodes method in FormShow.
procedure TfmGUITestRunner.SetupGUINodes(aNode: TTreeViewItem);
var
  l_Test: ITest;
  l_Index: Integer;
begin
  for l_Index := 0 to Pred(aNode.Count) do
  begin
    // Give test
    l_Test := NodeToTest(aNode.Items[l_Index]);
    assert(assigned(l_Test));
    // associate node to test
    l_Test.GUIObject := aNode.Items[l_Index];
    SetupGUINodes(aNode.Items[l_Index]);
  end;
end;
In order to get a node corresponding to a test let’s write a method
function TfmGUITestRunner.TestToNode(test: ITest): TTreeViewItem;
begin
  assert(assigned(test));

   Result := test.GUIObject as TTreeViewItem;

   assert(assigned(Result));
end;
My senior colleague and I don’t like the way I connect the tests and the tree. I understand why DUnit developer done this that way. DUnit has been developed long ago; Generics were not available. We will change it in the future. At the end of this post, I will write about our upcoming improvements and wishes. So our tree is constructed, all the tests are inside FTests. Tests and the tree are connected. It's time to run the tests and interpret the results. To ensure that the form can do it, let's add to the form an 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;
Let’s add these methods to the class interface 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
  // comment this assert because if not comment, we don't have any result 
  // 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’s nothing special here to explain. Although if you have some questions, I will give a detailed answer. In the original DUnitRunner after receiving a test result, it changes a picture of the corresponding tree node. I decided not to deal with pictures but change the FontColor and FontStyle for each node. It looks like it takes a minute, but I spent a couple of hours, having dug through all the documentation
procedure TfmGUITestRunner.SetTreeNodeFont(aNode: TTreeViewItem;
  aColor: TAlphaColor);
begin
  // Set style settings
  aNode.StyledSettings := aNode.StyledSettings - [TStyledSetting.ssFontColor, TStyledSetting.ssStyle];
  aNode.Font.Style := [TFontStyle.fsBold];
  aNode.FontColor := aColor;
end;
To output results, we will use ListView. TListView in FireMonkey is fully optimized for mobile applications but lost his wonderful Columns property. AddFailureNode is a method to add failures:
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; we will 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;
After running our Runner and clicking the button, we will see the following: image

The last thing we need to do is to handle user actions during the change of node state:
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;
Changing checkboxes state for some nodes: image
The test code which I have actually used:
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 moment, we have a fully working application for testing FireMonkey code using the usual GUIRunner. The project is open, so everyone can use. Plans for the future: Write a method to traverse the tree which will get a lambda function. The tree has to be around permanently, but the steps are different for each branch, so I think lambda function seems to be appropriate. Comments and suggestions from my senior colleague: To rebuild Tests and Nodes connection with TDictionary<TTreeViewItem, ITest> docwiki.embarcadero.com/Libraries/XE7/en/System.Generics.Collections.TDictionary To add graphic indication of tests execution process. Add buttons: select all, unselect all, and so on, and the output of test results (execution time, the number of successful and failed tests, and so on). To add the Decorator pattern to get rid of the GUIObject. In the near future, we will start to cover our MindStream project with unit-tests, and also step by little step bring improvements to the Runner. Thanks to all who have read to the end. Comments and criticism are as always welcome in the comments. Link to the repository. p.s. The project is located at MindStream\FMX.DUnit path. Links that were useful: http://sourceforge.net/p/radstudiodemos/code/HEAD/tree/branches/RadStudio_XE5_Update/FireMonkey/Delphi/ http://fire-monkey.ru/ http://18delphi.blogspot.ru/ http://www.gunsmoker.ru/ And of course http://docwiki.embarcadero.com/RADStudio/XE7/en/Main_Page

P.S. Thanks for translating to Roman Yankovsky.

Комментариев нет:

Отправить комментарий