Unit-Testing in Objective-C, Teil 3

Im dritten Teil unserer Serie über Unit-Testing in Objective-C wollen wir das Beispielprojekt aus Teil 1 fortsetzen und dabei die Erstellung von Mock-Objekten mit OCMock kennen lernen. Der Beispielcode steht wieder als Git-Repository zur Verfügung, welches per

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

lokal geklont oder auf der Projektseite durchstöbert werden kann. Zudem ist im Artikel an dedizierten Stellen die jeweilige Revision vermerkt.

Gegeben

Aufbauend auf der letzten Revision des ersten Teils haben wir das Modell User bereits um eine einfache Datenbankanbindung erweitert. Als Datenbank wird SQLite zusammen mit der Bibliothek PLDatabase verwendet. Letztere hat den Vorteil, dass die Aufruf an SQLite gekapselt werden und damit ein Wechsel auf ein anderes Datenbanksystem leichter möglich sein sollte. Die Klasse User verwendet die Datenbank über die Hilfsklasse UserDatabase, die im Wesentlichen die folgenden zwei Methoden zur Verfügung stellt:

- (User *)findWithEmail:(NSString *)email {
    [database open];

    User *user = nil;
    NSObject<PLResultSet> *results;
    results = [database executeQuery: @"SELECT * FROM users WHERE email = ?", email];
    if ([results next]) {
        user = [[User alloc] init];
        user.email = [results stringForColumn:@"email"];
        user.hashedPassword = [results stringForColumn:@"hashed_password"];
    }
    [results close];

    [database close];

    return [user autorelease];
}

- (BOOL)saveWithEmail:(NSString *)email hashedPassword:(NSString *)hashedPassword {
    [database open];

    NSObject<PLPreparedStatement> *statement;
    statement = [database prepareStatement: @"INSERT INTO users (email, hashed_password) VALUES (?, ?)"];
    [statement bindParameters: [NSArray arrayWithObjects: email, hashedPassword, nil]];
    BOOL result = [statement executeUpdate];
    [statement close];

    [database close];

    return result;
}

Die Methode findWithEmail: sucht einen Benutzer anhand der E-Mail-Adresse und liefert eine Instanz des Objektes User zurück. Die Methode saveWithEmail:hashedPassword: speichert einen Benutzer mit seiner E-Mail-Adresse und dem verschlüsselten Password. Die Verschlüsselung des Passwortes ist unabhängig von der Datenbank und erfolgt daher in User mittels der Methode encrypt:.

+ (NSString *)encrypt:(NSString *)password {
    unsigned char hash[CC_SHA256_DIGEST_LENGTH];
    CC_SHA256([password UTF8String],
              [password lengthOfBytesUsingEncoding:NSUTF8StringEncoding], 
              hash);

    NSMutableString *hashedPassword;
    hashedPassword = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH];

    for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; ++i) {
        [hashedPassword appendString:[NSString stringWithFormat:@"%02x", hash[i]]];
    }

    return hashedPassword;
}

[Revision 7d181d7d]

Gesucht

Ziel ist es nun, die Methode loginWithEmail:password:

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

so abzuändern, dass diese eine Klassenmethode ist und eine Instanz von User zurückliefert, sofern die Authentifizierung mit E-Mail-Adresse und Passwort erfolgreich war. E-Mail und Passwort sollen dabei mit den Werten in der Datenbank verglichen werden, d.h. die Methode muss im Gegensatz zur bisherigen Fake-Version korrekt funktionieren.

Lösung

Im ersten Schritt wandeln wir die beiden Testmethoden in UserTest.m so ab, dass sie der gewünschten Signatur entsprechend. Zur Erinnerung nachfolgend zunächst die bisherige Version:

-(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");
}

Die Methode soll in eine Klassenmethode umgewandelt werden, daher müssen wir sie statt an die Instanz user an die Klasse User senden. Außerdem soll sie eine Instanz von User zurückliefern, entsprechend sind die Aufrufe von STAssertEquals durch STAssertNotNil bzw. STAssertNil zu ersetzen:

- (void)testValidLogin {
    STAssertNotNil([User loginWithEmail:@"thomas@dohmke.de" password:@"foobar"],
                   @"Should return user object.");
}

- (void)testInvalidLogin {
    STAssertNil([User loginWithEmail:@"thomas@dohmke.de" password:@"wrong"],
                @"Should return nil.");
}

Bei der Ausführung schlagen die Tests erwartungsgemäß fehl, da die erwartete Signatur der Methode nicht mit der Implementierung übereinstimmt. Entsprechend ändern wir die Deklaration in User.h

+ (User *)loginWithEmail:(NSString *)login password:(NSString *)password;

und passen die Definition in User.m entsprechend an:

+ (User *)loginWithEmail:(NSString *)login password:(NSString *)password {
    User *user = [User findWithEmail:login];
    if ([[User encrypt:password] isEqualToString:user.hashedPassword]) {
        return user;
    }
    else {
        return nil;
    }
}

Die Implementierung sucht den Benutzer mittels der Methode findWithEmail:, verschlüsselt den Parameter password und vergleicht das Ergebnis mit dem verschlüsselten Passwort in der von findWithEmail: gefundenen Instanz. [Revision 13222fee]

Die Tests werden nun bestanden, allerdings ergibt sich ein neues Problem: Damit die Methode den Benutzer überhaupt in der Datenbank finden kann, müssen wir diesen dort speichern. Dies erfolgt bereits in der Methode setUp des Testfalls:

- (void)setUp {
    user = [User userWithEmail:@"thomas@dohmke.de" password:@"foobar"];
    STAssertEquals([user save], YES, @"save failed.");
}

Da das Speichern jedoch in derselben Datenbank erfolgt, die auch vom eigentlichen Programm benutzt wird, würden wir bei einem echten System möglicherweise reale Benutzerdaten überschreiben. Das ist unschön. Zudem wird der Benutzer mit jeder Testmethode neu angelegt wird, wie wir uns leicht auf der Konsole überzeugen können:

$ sqlite3 objcut1.db 
SQLite version 3.4.0
Enter ".help" for instructions
sqlite> select * from users;
1|thomas@dohmke.de|c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2
2|thomas@dohmke.de|c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2
3|thomas@dohmke.de|c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2
4|thomas@dohmke.de|c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2

Hier kommt nun OCMock ins Spiel.

Bessere Lösung

OCMock erlaubt uns, die Klasse UserDatabase durch einen Dummy zu ersetzen und in Folge zu verhindern, dass tatsächlich in die Datenbank geschrieben wird. Entsprechend wird UserDatabase selbst nicht mehr getestet, was in Ordnung geht, da wir in UserTest.m nur die Klasse User testen wollen.

Zuerst installieren wir OCMock, in dem wir es von seiner Homepage runterladen (aktuell ist derzeit Version 1.29), das Archiv entpacken und das Verzeichnis OCMock.framework nach /Library/Frameworks kopieren. Dann klicken wir unter Groups & Files rechts auf Frameworks > Linked Frameworks, Menüpunkt Add > Existing Frameworks, suchen das gerade kopierte Verzeichnis und bestätigen mit Add.

Als nächstes fügen wir der Testklasse in UserTest.h eine Instanzvariable für das Mock-Objekt hinzu:

@interface UserTest : SenTestCase {
    // ...

    id databaseMock;
}

In UserTest.m importieren wir OCMock

#import <OCMock/OCMock.h>

und definieren eine neue Methode, die das Mock-Objekt erstellt:

- (void)setUpMockDatabase {
    BOOL yes = YES; 
    NSValue *wrappedValue = [NSValue valueWithBytes:&yes objCType:@encode(BOOL)];

    databaseMock = [OCMockObject mockForClass:[UserDatabase class]];
    [[[databaseMock stub] andReturnValue:wrappedValue] saveWithEmail:user.email hashedPassword:user.hashedPassword];
    [[[databaseMock stub] andReturn:user] findWithEmail:user.email];
    [User setDatabase:databaseMock];
}

Die ersten zwei Zeilen packen den booleschen Wert YES in eine Instanz der Klasse NSValue ein. In den folgenden drei Zeilen wird das Mock-Objekt für die Klasse UserDatabase mit zwei Stub-Methoden erstellt. Die Signaturen entsprechend dabei den Deklarationen in UserDatabase.h, wobei saveWithEmail:hashedPassword: stets YES und findWithEmail: die in setUp erstellte Instanz von User zurückgeben soll. In der letzten Zeile wird das Mock-Objekt der Klasse User übergeben, die dieses in Folge für alle Datenbankzugriffe verwendet.

Schließlich rufen wir setUpMockDatabase in der setup-Methode auf:

- (void)setUp {
    user = [User userWithEmail:@"thomas@dohmke.de" password:@"foobar"];
    [self setUpMockDatabase];
    STAssertEquals([user save], YES, @"save failed.");
}

Fertig. Die Tests werden weiterhin bestanden, greifen aber nicht mehr auf die Datenbank zu. [Revision aeb659e1]

Erster Nachgang

Neben stub können Stub-Methoden auch mit expect definiert werden. Der Unterschied besteht darin, dass eine mit expect definierte Methode genau einmal aufgerufen werden muss, während die Anzahl der Aufrufe bei "stub" gleichgültig ist, d.h. auch kein Aufruf ist erlaubt. Will man mehr als einen Aufruf kontrollieren, ist das expect-Statement entsprechend oft zu wiederholen.

Als Beispiel verändern wir setUpMockDatabase wie folgt

- (void)setUpMockDatabase {
    // ...    

    [[[databaseMock expect] andReturnValue:wrappedValue] saveWithEmail:user.email hashedPassword:user.hashedPassword];

    // ...    
}

und verifizieren die Anzahl der Aufruf in tearDown:

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

Die Methode verify überprüft nach jedem Test, ob die mit expect definierte Stub-Methoden jeweils genau einmal aufgerufen wurde (was im Beispiel der Fall ist, da vor jedem Test in setUp die Instanz user gespeichert wird). [Revision 11065e8d]

Zweiter Nachgang

Wie würde die Lösung aussehen, wenn man kein Mock-Objekt verwenden will, sondern die Anbindung der Datenbank in UserDatabase bewusst mittesten möchte? SQLite erlaubt die Erstellung einer Datenbank im Hauptspeicher, so dass wir eine neue Methode setUpTestDatabase in UserTest.m wie folgt definieren

- (void)setUpTestDatabase {
    userDatabase = [[UserDatabase alloc] initWithPath:@":memory:"];
    [userDatabase.database executeUpdate: @"DROP TABLE users;"];
    STAssertTrue([userDatabase.database executeUpdate: @"CREATE TABLE users (id INTEGER PRIMARY KEY NOT NULL, email VARCHAR(255), hashed_password VARCHAR(255));"], @"");
    [User setDatabase:userDatabase];
}

und statt setUpMockDatabase in setUp aufrufen könnten:

- (void)setUp {
    user = [User userWithEmail:@"thomas@dohmke.de" password:@"foobar"];
    [self setUpTestDatabase];
    STAssertEquals([user save], YES, @"save failed.");
}

Es ist zu beachten, dass die SQL-Befehle in setUpTestDatabase hier nur beispielhaft aufgeführt werden, im realen Leben würde man die Klasse UserDatabase um entsprechende Methoden erweitern, die diese kapseln und damit Dopplungen im Code vermeiden.

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

Von Thomas Dohmke vor 371 Tagen hinzugefügt


Kommentare

Kommentar hinzufügen

Twitter

Uns auf Twitter verfolgen: