Unit-Testing in Objective-C, Teil 1

Die testgetriebene Entwicklung (Test-Driven Development, abgekürzt TDD) ist eine mittlerweile weit verbreitete Praktik des Extreme Programming bzw. der agilen Entwicklungsmethodik. Mit TDD wird die eigentliche Funktionalität erst dann programmiert, wenn mindestens ein Test fehlschlägt. Für fast alle Programmiersprachen existieren entsprechende Testing-Frameworks, die die Definition, Ausführung und Verifikation der Tests maßgeblich unterstützen. Für Objetive-C wurde ein solches Framework names OCUnit ursprünglich von der schweizer Firma Sen:te entwickelt und im Jahr 2005 von Apple offiziell in Xcode 2.1 integriert. Dennoch liest man (im deutschsprachigen Raum) recht wenig über TDD in Zusammenhang mit Objective-C. Der nachfolgende Artikel soll dahingehend Abhilfe schaffen und ist der Anfang einer kleinen Serie über Unit-Testing für die Entwicklung von Programmen für OS X auf Mac und iPhone.

Ausgangsbasis

Im ersten Teil wollen wir beschreiben, wie man ein einfaches Cocoa-Projekt um die Möglichkeit des Unit-Testings erweitert und Testfälle definiert. Als Entwicklungsumgebung wird Xcode 3 benötigt, konkret haben wir Xcode 3.1.2 verwendet, welches mit iPhone SDK 2.2 ausgeliefert wurde. Ferner steht das Projekt als Git-Repository zur Verfügung. Man kann es entweder lokal klonen,

git clone git://www.komprovisation.de/objcut1.git

oder auf der Projektseite anschauen und durchstöbern. Dies hat den Vorteil, dass das Projekt nicht nur als finales Ergebnis runtergeladen werden kann, sondern auch die einzelnen Schritte nachvollziehbar sind. Entsprechende Stellen sind im Artikel durch die jeweilige Revision vermerkt.

Erste Iteration

Das Ziel der ersten Iteration ist es, ein neues Projekt in Xcode zu erstellen und dieses durch ein sogenanntes Unit Test Bundle zu erweitern. Sofern nicht schon geschehen, starten wir Xcode und wählen den Menüpunkt "File > New Project" aus. Im folgenden Dialog wählen wir in der linken Spalte unterhalb von Mac OS X den Punkt "Application" aus und dann das Template "Cocoa Application". Weiter geht's mit "Choose", wir geben dem Projekt den Namen "ObjCUT1", wählen ein Verzeichnis aus und erstellen das Projekt mit Klick auf den Button "Save". Es erscheint das nachfolgende Fenster mit dem Projekt. [Revision bcb32406]

Durch Klick auf den Button "Build & Go" oder alternativ Cmd-R kompilieren wir das Projekt und führen es anschließend aus. Es öffnet sich nach kurzer Zeit unser Programm mit einem leeren Fenster.

Zurück zum Projektfenster. Dort finden wir in der linken Spalte "Groups & Files" die Gruppe "Targets". Einziges Mitglied ist bisher das Target "ObjCUT1", welches für die gerade probierte Kompilierung unseres Programms zuständig ist. Durch einen Rechtsklick auf "Target" und die Auswahl des Menüpunkts "Add > New Target" fügen wir nun ein neues Target hinzu. Es öffnet sich ein ähnlicher Dialog wie vorher bei der Erstellung des Projektes, in der linken Spalte wählen wir dieses Mal "Cocoa" aus, dann im rechten Bereich das "Unit Test Bundle" und weiter geht's mit "Next". Im nächsten Dialog geben wir als Name des Target "UnitTests" ein und bestätigen mit "Finish". Die Wahl des Namens ist grundsätzlich egal, einzige Bedingung ist, dass nicht schon ein Target mit gleichem Namen existiert. Im Projektfenster sehen wir jetzt zwei Targets, zusätzlich öffnet sich automatisch das Info-Fenster für das neu erstellte Target (falls nicht, kann man das Fenster entweder per Rechtsklick auf das Target und den Menüpunkt "Get Info" oder durch Linksklick und Cmd-I öffnen).

Im Reiter "General" des Info-Fensters gibt es zwei Listen: "Direct Dependencies" und "Linked Libaries". Wir interessieren uns hier nur für die erste. Direct Dependencies sind solche Targets, von denen unser Unit-Testing-Target abhängig ist. Xcode stellt vor dem Kompilieren sicher, ob diese Abhängigkeiten auf dem neuesten Stand sind oder zunächst kompiliert werden müssen. Wir fügen der Liste einen neuen Eintrag hinzu, indem wir auf den Button mit dem Pluszeichen klicken, das Target "ObjCUT1" auswählen und mit "Add Target" bestätigen.

Als nächstes wechseln wir zum Reiter "Build", wo zwei Einstellungen zu setzen sind. Am einfachsten ist dies durch Eingabe des Namens der Einstellung in das Suchfeld. Die erste Einstellung heißt "Bundle Loader" und wir setzen sie auf

$(BUILT_PRODUCTS_DIR)/ObjCUT1.app/Contents/MacOS/ObjCUT1

"BUILT_PRODUCTS_DIR" ist dabei eine definierte Konstante innerhalb von Xcode, welche auf das build-Verzeichnis in unserem Projekt zeigt, z.B. ~/Projects/ObjCUT1/build/Debug. Der restliche Pfad referenziert die ausführbare Datei innerhalb des Application-Bundles.

Die zweite Einstellung heißt "Test Host" und wir setzen sie auf

$(BUNDLE_LOADER)

"BUNDLE_LOADER" ist wiederum eine definierte Konstante, die der obigen Einstellung entspricht. Mit anderen Worten haben beide Einstellungen damit denselben Wert, nur haben wir uns bei der zweiten Einstellung ein wenig Tippaufwand gespart. Abschließend können wir das Info-Fenster schließen.

Im Projektfenster wählen wir nun ganz links in der Symbolleiste unterhalb des Punktes "Active Target" das neue Target "UnitTests" aus und kompilieren mit "Build & Go". Wenn alles geklappt hat, kompiliert das Target ohne Fehlermeldung und es öffnet sich das Programm mit seinem leerem Fenster. [Revision 88f16a29]

Zweite Iteration

In der zweiten Iteration wollen wir nun eine einfache Klasse mit Namen "User" testgetrieben entwickeln. Die Klasse soll innerhalb einer Model-View-Controller Architektur ein Model für die Benutzerverwaltung darstellen. Wir werden nicht die vollständige Klasse entwickeln, sondern uns auf eine einzige Beispielmethode beschränken.

Im Sinne von TDD starten wir mit einem Testfall. Im Projektfenster klicken wir mit der rechten Maustaste auf die Gruppe "Classes" und wählen "Add > New File" aus. In der linken Spalte selektieren wir "Cocoa", im rechten Feld die Vorlage "Objective-C test case class". Weiter mit "Next". Der Name der Testfallklasse soll sich aus dem Namen der zu entwickelnden Klasse und dem Suffix "Test" zusammensetzen, d.h. wir geben "UserTest.m" ein. Außerdem deselektieren wir in der Liste "Targets" den Eintrag "ObjCUT1" und selektieren stattdessen das Target "UnitTests". Nach Klick auf "Finish" werden die Dateien erstellt. Mit "Build > Build" oder Cmd-B versichern wir uns, dass keine Fehler auftreten.

Um im TDD-Zyklus voran zu kommen, ist der nächste Schritt, einen Test fehlschlagen zu lassen. Dazu öffnen wir UserTest.m und erstellen eine Testmethode:

#import "UserTest.h" 

@implementation UserTest

-(void)testValidLogin {
    STAssertEquals([user loginWithEmail:@"thomas@dohmke.de" password:@"foobar"],
                   YES, @"should return YES");
}

@end

OCUnit identifiziert Testmethoden wie auch viele andere Testing Frameworks anhand der Präfixes "test", unsere Methode heißt endsprechend "testValidLogin". Innerhalb der Methode wird das Makro "STAssertEquals" aufgerufen, welches drei Parameter akzeptiert: Der dritte Parameter wird als Fehlermeldung ausgegeben, wenn die ersten zwei Parameter nicht übereinstimmen. In diesem Fall wird außerdem die Testmethode abgebrochen, d.h. weitere Anweisungen innerhalb derselben Methode werden nicht ausgeführt. Im Beispiel wird die Nachricht "loginWithEmail:password:" an ein Objekt "user" gesendet und der Rückgabewert soll dem Wert "YES" entsprechen.

Beim Kompilieren schlägt diese Testmethode offensichtlich fehl. Xcode zeigt uns dies direkt unter der betroffenen Zeile an.

Der zweite Schritt des TDD-Zyklusses ist es, diesen Test erfolgreich zu bestehen. Wir arbeiten uns dazu Schritt für Schritt durch die Fehlermeldungen. Der Compiler kennt das Objekt "user" nicht, Abhilfe schafft die entsprechende Deklaration und anschließende Initialisierung:

#import "UserTest.h" 
#import "User.h" 

@implementation UserTest

-(void)testValidLogin {
    User *user;
    user = [[User alloc] init];
    STAssertEquals([user loginWithEmail:@"thomas@dohmke.de" password:@"foobar"],
                   YES, @"should return YES");
    [user release]
}

@end

Nun fügen wir die Klasse "User" zu unserem Projekt hinzu. Rechtsklick auf "Classes", "Add > New File", links sollte noch "Cocoa" ausgewählt sein, rechts wählen wir dieses Mal "Objective-C class", gehen weiter mit "Next", geben als Name "User.m" ein und selektieren beide Targets. Bestätigen mit "Finish" und kompilieren mit Cmd-B. [Revision cf86f69d]

Die nächste Fehlermeldung bemängelt, dass die Klasse "User" die Nachricht "loginWithEmail:password:" nicht kennt. Wir deklarieren diese in der Datei User.h

#import <Cocoa/Cocoa.h>

@interface User : NSObject {
}

-(BOOL)loginWithEmail:(NSString *)email password:(NSString *)password;

@end

und definieren sie anschließend in User.m:

#import "User.h" 

@implementation User

-(BOOL)loginWithEmail:(NSString *)email password:(NSString *)password {
    return YES;
}

@end

Die Implementierung besteht getreu dem Motto "Fake it till you make it" lediglich aus dem Zurückgeben von "YES". Der Build-Prozess läuft nun ohne Fehler durch und wir können mit Cmd-R die Anwendung starten. [Revision 0afffba8]

Dritte Iteration

Die dritte Iteration startet mit einer weiteren Testmethode, die im Gegensatz zu oben den Negativfall, d.h. den Login mit falschen Benutzerdaten, überprüft:

-(void)testInvalidLogin {
    User *user;
    user = [[User alloc] init];
    STAssertEquals([user loginWithEmail:@"thomas@dohmke.de" password:@"wrong"],
                   NO, @"should return NO");
    [user release];
}
Zwei Punkte gefallen uns auf:
  1. Ein Teil der Methode entspricht einer exakten Kopie der ersten Methode.
  2. Die "release"-Methode für "user" wird nie aufgerufen, da der Aufruf von "STAssertEquals" fehlschlägt.

Eine Lösung bietet die so genannte Test Fixture, welche den Kontext beschreibt, den die Testmethoden für ihre Ausführung voraussetzen. Sie wird in OCUnit wie in vielen anderen Testing Frameworks über die Methoden "setUp" und "tearDown" gebildet. setUp wird vor jeder Testmethode aufgerufen, tearDown garantiert danach und zwar unabhängig davon, ob der Test fehlgeschlagen ist oder nicht. Unser Beispiel sieht damit wie folgt aus: [Revision 5434f7d4]

#import "UserTest.h" 

@implementation UserTest

-(void)setUp {
    user = [[User alloc] init];
}

-(void)tearDown {
    [user release];
}

-(void)testValidLogin {
    STAssertEquals([user loginWithEmail:@"thomas@dohmke.de" password:@"foobar"],
                   YES, @"should return YES");
}

-(void)testInvalidLogin {
    STAssertEquals([user loginWithEmail:@"thomas@dohmke.de" password:@"wrong"],
                   NO, @"should return NO");
}

@end

Die Variable "user" wird entsprechend in der Header-Datei definiert:

#import <SenTestingKit/SenTestingKit.h>
#import "User.h" 

@interface UserTest : SenTestCase {
    User *user;
}

@end

Zum Schluss wollen wir noch die Klasse "User" so verändern, dass beide Tests bestanden werden. Eine reale Lösung würde dazu auf eine Datenbank oder Konfigurationsdatei zurückgreifen, wir bleiben hier bei der einfachstmöglichen Lösung und vertagen den Rest auf später: [Revision a2e14c79]

-(BOOL)loginWithEmail:(NSString *)email password:(NSString *)password {
    return [email isEqualToString:@"thomas@dohmke.de"] &&
           [password isEqualToString:@"foobar"];
}

Alle Tests werden bestanden und so endet dieser Artikel. Kommentare, Fragen und Kritik sind wie immer herzlich willkommen. Fortsetzung folgt... :)

Schlüsselwörter: objective-c, programmierung, testen, xcode

Von Thomas Dohmke vor 587 Tagen hinzugefügt


Kommentare

Von jryan vor 340 Tagen hinzugefügt

Hallo,

ich habe versucht via "git clone git://www.komprovisation.de/objcut1.git" des Quellcode runterzuladen. DIes führt allerdings zur Fehlermeldung

Initialized empty Git repository in /Users/waeschkt/help2/objcut1/.git/
www.komprovisation.de[0: 78.47.28.52]: errno=Connection refused
fatal: unable to connect a socket (Connection refused)

Gibt es eine andere Möglichkeit an das Repository zu gelangen?

Danke,
jryan

Von Thomas Dohmke vor 340 Tagen hinzugefügt

Danke für den Hinweis, das Repository war nicht für den git-daemon freigegeben. Mit

git clone git://www.komprovisation.de/objcut1.git
sollte es jetzt klappen.

Von Daniel vor 47 Tagen hinzugefügt

Hallo

Leider kann ich den Code auch nicht runterladen. Folgender Fehlermeldung tritt auf:

Initialized empty Git repository in /Users/daniel/Documents/Dev/Tests/OCTesting/objcut1/.git/
fatal: The remote end hung up unexpectedly

Gruss
Daniel

Von Thomas Dohmke vor 37 Tagen hinzugefügt

Sorry für die späte Antwort. Die Konfiguration des gitdaemon war leider etwas durcheinander gekommen und ich hatte erst heute Zeit, das zu beheben. Der Clone sollte jetzt wieder klappen.

Kommentar hinzufügen

Twitter

Uns auf Twitter verfolgen: