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:
The last thing we need to do is to handle user actions during the change of node state:
The test code which I have actually used:
P.S. Thanks for translating to Roman Yankovsky.
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.
Комментариев нет:
Отправить комментарий