Unittests, zum Beispiel mit DUnit, sind ein gutes Beispiel dafür, wie Delphis Class-Helper sinnvoll eingesetzt werden können. Nicht immer, aber in bestimmten Fällen kann ein Class-Helper eingesetzt werden, um Test-Cases übersichtlicher zu strukturieren und damit klarer lesbar zu machen, ohne die zu testende Klasse zu verändern.
Das Ziel von Unittests ist es zu prüfen, dass Code auch wirklich das macht, was er soll; beziehungsweise – und das ist eigentlich fast noch wichtiger – um bei späteren Codeänderungen zu prüfen, dass der Code noch immer das macht, was er soll und die Änderungen keine Fehler verursacht haben. Damit das klappt, darf der Test selbst keine Fehler enthalten. Um die Wahrscheinlichkeit für Fehler im Test-Code zu minimieren, sollten die Tests möglichst einfach gehalten werden und übersichtlich und gut lesbar sein. Sonst bräuchte man auch noch Tests für die Tests. Und dann vielleicht auch noch Tests für die Tests der Tests.
Gerade bei komplexeren Tests ist das nicht immer so einfach. Aber auch bei vermeintlich einfachen Tests kann der Test-Code schnell unübersichtlich werden: Im folgenden Beispiel soll die Funktion
getestet werden. ParseStream
liest eine Baumstruktur aus dem übergebenen Stream und liefert den Root-Knoten der gelesenen Struktur zurück.ParseStream
TTreeNode = class [...] public property ChildCount : Integer [...] property Children[ Index : Integer ] : TTreeNode [...] property Name : String [...] property Parent : TTreeNode [...] end; function ParseStream( AStream : TStream ) : TTreeNode;
Ein entsprechender Test könnte wie folgt aussehen: Ein vordefinierter Stream wird an
übergeben und dann wird für jeden Knoten abgefragt, ob die entsprechenden Eigenschaften den erwarteten Werten entsprechen. Für dieses Beispiel steht im Stream pro Zeile ein Knoten und die Einrückungen geben die Verschachtelung an. Wie genau der Stream aufgebaut ist und wie genau ParseStream
arbeitet ist für das Beispiel eigentlich unerheblich.ParseStream
TTestParseFile = class( TTestCase ) published procedure TestParseFile(); end; procedure TTestParseFile.TestParseFile(); var StringStream : TStringStream; Root : TTreeNode; begin StringStream := TTStringStream.Create( 'Root' + #13#10 + ' NodeA' + #13#10 + ' NodeB' + #13#10 + ' NodeC' + #13#10 + ' NodeD' + #13#10 + ' NodeE' ); Root := ParseStream( StringStream ); StringStream.Free(); CheckNotNull( Root ); CheckEqualsString( 'Root', Root.Name ); CheckEquals( 3, Root.ChildCount ); CheckEqualsString( 'NodeA', Root.Children[ 0 ].Name ); CheckEquals( 0, Root.Children[ 0 ].ChildCount ); CheckEqualsString( 'NodeB', Root.Children[ 1 ].Name ); CheckEquals( 1, Root.Children[ 1 ].ChildCount ); CheckEqualsString( 'NodeC', Root.Children[ 1 ].Children[ 0 ].Name ); CheckEquals( 1, Root.Children[ 1 ].Children[ 0 ].ChildCount ); CheckEqualsString( 'NodeD', Root.Children[ 1 ].Children[ 0 ].Children[ 0 ].Name ); CheckEquals( 0, Root.Children[ 1 ].Children[ 0 ].Children[ 0 ].ChildCount ); CheckEqualsString( 'NodeE', Root.Children[ 2 ].Name ); CheckEquals( 0, Root.Children[ 2 ].ChildCount ); end;
Das „Ergebnis“ sieht nicht gerade schön aus und ist recht unübersichtlich. Das, was jeweils getestet wird, ist nicht sofort erkennbar, da es sich „hinten“ im zweiten Parameter der Check-Aufrufe versteckt. Und in diesem Beispiel fehlen noch ergänzende Texte in den Check-Aufrufen, die aber notwendig wären, um einfacher zu erkennen, an welcher Stelle im Baum ein Check fehlgeschlagen ist.
Es wäre schöner, wenn sich die erwartete Struktur direkt im Code widerspiegeln würde und auf einen Blick ersichtlich ist, welcher Knoten gerade getestet wird. Die naheliegende Lösung wäre, die Klasse TTestParseFile
um entsprechende Methoden für spezielle Checks zu erweitern, beispielsweise CheckName( ANode : TTreeNode; ExpectedName : String )
zum Prüfen des Kontennamens. Das würde den Test-Code schon besser lesbar machen:
CheckNotNull( Root ); CheckName( Root, 'Root' ); CheckChildCount( Root, 3 ); CheckName( Root.Children[ 0 ], 'NodeA' ); CheckChildCount( Root.Children[ 0 ], 0 ); CheckName( Root.Children[ 1 ], 'NodeB' ); CheckChildCount( Root.Children[ 1 ], 1 ); CheckName( Root.Children[ 1 ].Children[ 0 ], 'NodeC' ); CheckChildCount( Root.Children[ 1 ].Children[ 0 ], 1 ); CheckName( Root.Children[ 1 ].Children[ 0 ].Children[ 0 ], 'NodeD' ); CheckChildCount( Root.Children[ 1 ].Children[ 0 ].Children[ 0 ], 0 ); CheckName( Root.Children[ 2 ], 'NodeE' ); CheckChildCount( Root.Children[ 2 ], 0 );
Der Code wirkt allerdings noch immer recht unruhig und man muss weiterhin genau hinschauen, ob wirklich immer der richtige Knoten getestet wird. Und gibt es weitere Test-Klassen, in denen TTreeNode
-Objekt geprüft werden sollen, dann müssen diese ebenfalls um die entsprechenden Methoden erweitert werden oder es muss eine gemeinsame Basisklasse für solche Test-Klassen erstellt werden. Letzteres wird zumindest dann kompliziert, wenn eine der Test-Klassen weitere, von einer anderen Basisklasse abhängigen Methoden benötigt. Die Variante, die Prüfmethoden direkt in die Klasse TTreeNode
zu integrieren ist auch keine Lösung, denn da gehören sie einfach nicht hin.
Die beste Lösung für das „Problem“ ist ein Class Helper für TTreeNode
, der (im Kontext der Tests) die Klasse TTreeNode
um die entsprechenden Prüfmethoden erweitert:
TTreeNodeTestHelper = class helper for TTreeNode class var FTestCase : TTestCase; class procedure TestSetup( ATestCase : TTestCase ); procedure CheckName( const ExpectedName : String ); procedure CheckChildCount( ExpectedChildCount : Integer ); function NodePath() : String; end; implementation class procedure TTreeNodeTestHelper.TestSetup( ATestCase : TTestCase ); begin FTestCase := ATestCase; end; procedure TTreeNodeTestHelper.CheckName( const ExpectedName : String ); begin FTestCase.CheckEqualsString( ExpectedName, Name, 'Node: ' + NodePath ); end; procedure TTreeNodeTestHelper.CheckChildCount( ExpectedChildCount : Integer ); begin FTestCase.CheckEquals( ExpectedChildCount, ChildCount, 'Node: ' + NodePath ); end; function TTreeNodeTestHelper.NodePath() : String; begin if ( Parent = nil ) then Result := Name else Result := Parent.NodePath() + '.' + Name; end;
Zusätzlich zu den zwei Prüfmethoden CheckName
und CheckChildCount
sind noch zwei zusätzliche Methoden enthalten: Die Methode NodePath
ist dazu da, bei einem Fehler den Knoten der den Fehler ausgelöst hat auszugeben.
Wichtiger ist aber die Klassenmethode TestSetup
, beziehungsweise die Klassenvariable FTestCase
: In den Methoden des Class-Helpers sollen die Check-Methoden der Klasse TTestCase
verwendet werden. Dabei handelt es sich aber nicht um Klassenmethoden. Also muss beim Aufruf der Helper-Methoden Zugriff auf ein gültiges TTestCase
-Objekt bestehen.
Ein Class-Helper kann zwar keine zusätzlichen Variablen definieren, aber Klassenvariablen (class var
) können definiert werden. Zusammen mit dem Wissen, dass vor dem Aufruf jedes Testfalls die virtuelle Methode TTestCase.Setup
aufgerufen wird, wird über die Class-Helper-Methode TestSetup
eine Referenz auf das aktuelle TTestCase
-Objekt in der Klassenvariablen FTestCase
gesetzt. Wer möchte könnte auch noch die Methode TTestCase.TearDown
überschreiben und dort FTestCase
wieder auf nil
setzen. Dass zum Setzen von FTestCase
die Klassenmethode TestSetup
verwendet wird ist eigentlich unnötig, sieht aber irgendwie netter aus.
Das Ergebnis der ganzen „Arbeit“ sieht dann wie folgt aus:
TTestParseFile = class( TTestCase ) [...] protected procedure SetUp(); override; [...] end; implementation procedure TTestParseFile.SetUp(); begin TTreeNode.TestSetup( self ); end; procedure TTestParseFile.TestParseFile(); var StringStream : TStringStream; Root : TTreeNode; begin StringStream := TTStringStream.Create( 'Root' + #13#10 + ' NodeA' + #13#10 + ' NodeB' + #13#10 + ' NodeC' + #13#10 + ' NodeD' + #13#10 + ' NodeE' ); Root := ParseStream( StringStream ); StringStream.Free(); CheckNotNull( Root ); Root.CheckName( 'Root' ); Root.CheckChildCount( 3 ); Root.Children[ 0 ].CheckName( 'NodeA '); Root.Children[ 0 ].CheckChildCount( 0 ); Root.Children[ 1 ].CheckName( 'NodeB '); Root.Children[ 1 ].CheckChildCount( 1 ); Root.Children[ 1 ].Children[ 0 ].CheckName( 'NodeC '); Root.Children[ 1 ].Children[ 0 ].CheckChildCount( 1 ); Root.Children[ 1 ].Children[ 0 ].Children[ 0 ].CheckName( 'NodeD '); Root.Children[ 1 ].Children[ 0 ].Children[ 0 ].CheckChildCount( 0 ); Root.Children[ 2 ].CheckName( 'NodeE '); Root.Children[ 2 ].CheckChildCount( 0 ); end;
In dieser Form ist direkt zu erkennen, auf welchem Knoten der gelesenen Struktur was überprüft wird. Und gleichzeitig ist die erwartete Struktur direkt am Code des Testfalls zu erkennen. Eine deutliche Verbesserung gegenüber des Anfangscodes.
Werden mehr als nur zwei Eigenschaften der eingelesenen Knoten geprüft, dann kann man hier guten Gewissens mit with
Anweisungen arbeiten, um das Ganze etwas einfacher und übersichtlicher zu machen, statt immer längere Root.Children[ x ].Children[ y ].Children[ z ]...
Konstrukte zu verwenden. Vernünftig eingerückt bleibt der Code trotzdem klar lesbar und übersichtlich:
CheckNotNull( Root ); with ( Root ) do begin CheckName( 'Root' ); CheckCildCount( 3 ); CheckPropertyX( 'X' ); CheckPropertyY( 'Y' ); CheckPropertyZ( 'Z' ); with ( Children[ 0 ] ) do begin CheckName( 'NodeA' ); CheckCildCount( 0 ); [ ... ] end; with ( Children[ 1 ] ) do begin CheckName( 'NodeB' ); CheckChildCount( 1 ); with ( Children[ 0 ] ) do begin CheckName( 'NodeC' ); CheckChildCount( 1 ); CheckPropertyX( 'X' ); CheckPropertyY( 'Y' ); CheckPropertyZ( 'Z' ); with ( Children[ 0 ] ) do begin Children[ 0 ].CheckName( 'NodeD' ); Children[ 0 ].CheckChildCount( 0 ); [ ... ] end; end; end; with ( Children[ 2 ] ) do begin CheckName( 'NodeE' ); CheckChildCount( 0 ); [ ... ] end; end;