Use case: Class Helper in Unittests verwenden


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 ParseStream getestet werden. ParseStream liest eine Baumstruktur aus dem übergebenen Stream und liefert den Root-Knoten der gelesenen Struktur zurück.

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 ParseStream ü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.

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;