web-dev-qa-db-ja.com

OCUnitを使用した単体テストの例

ユニットテストを理解するのに本当に苦労しています。 TDDの重要性は理解していますが、私が読んだ単体テストの例はすべて非常に単純で些細なもののようです。たとえば、プロパティが設定されていること、またはメモリが配列に割り当てられていることを確認するためのテスト。どうして?コード化した場合..alloc] init]、本当に動作することを確認する必要がありますか?

私は開発に不慣れなので、TDDを取り巻くすべての流行で、ここで何かを見逃していると確信しています。

私の主な問題は、実用的な例が見つからないことだと思います。テストに適した候補と思われるメソッドsetReminderIdを次に示します。これが機能していることを確認するには、便利な単体テストはどのように見えますか? (OCUnitを使用)

- (NSNumber *)setReminderId: (NSDictionary *)reminderData
{
    NSNumber *currentReminderId = [[NSUserDefaults standardUserDefaults] objectForKey:@"currentReminderId"];
    if (currentReminderId) {
        // Increment the last reminderId
        currentReminderId = @(currentReminderId.intValue + 1);
    }
    else {
        // Set to 0 if it doesn't already exist
        currentReminderId = @0;
    }
    // Update currentReminderId to model
    [[NSUserDefaults standardUserDefaults] setObject:currentReminderId forKey:@"currentReminderId"];

    return currentReminderId;
}
39
mnort9

更新:この答えを2つの点で改善しました。これはスクリーンキャストになり、プロパティインジェクションからコンストラクタインジェクションに切り替えました。 Objective-C TDDの使用を開始する方法を参照してください

トリッキーな部分は、メソッドが外部オブジェクトNSUserDefaultsに依存していることです。 NSUserDefaultsを直接使用する必要はありません。代わりに、この依存関係を何らかの形で注入する必要があります。これにより、偽のユーザーのデフォルトをテスト用に置き換えることができます。

これにはいくつかの方法があります。 1つは、メソッドに追加の引数として渡すことです。もう1つは、それをクラスのインスタンス変数にすることです。そして、このivarを設定するにはさまざまな方法があります。イニシャライザ引数で指定される「コンストラクタインジェクション」があります。または、「プロパティインジェクション」があります。 iOS SDKの標準オブジェクトの場合、私の好みは、デフォルト値を持つプロパティにすることです。

それでは、プロパティがデフォルトでNSUserDefaultsであることのテストから始めましょう。ちなみに、私のツールセットはXcodeの組み込みOCUnitです。アサーションの場合は OCHamcrest 、モックオブジェクトの場合は OCMockito です。他にも選択肢はありますが、それを使用しています。

最初のテスト:ユーザーデフォルト

より適切な名前がない場合、クラスにはExampleという名前が付けられます。インスタンスは、「テスト中のシステム」のsutという名前になります。プロパティの名前はuserDefaultsになります。 ExampleTests.mで、デフォルト値を確認する最初のテストを次に示します。

#import <SenTestingKit/SenTestingKit.h>

#define HC_SHORTHAND
#import <OCHamcrestIOS/OCHamcrestIOS.h>

@interface ExampleTests : SenTestCase
@end

@implementation ExampleTests

- (void)testDefaultUserDefaultsShouldBeSet
{
    Example *sut = [[Example alloc] init];
    assertThat([sut userDefaults], is(instanceOf([NSUserDefaults class])));
}

@end

この段階では、これはコンパイルされません—テストの失敗としてカウントされます。見てください。角かっことかっこをスキップして目を離せれば、テストはかなり明確になるはずです。

そのテストをコンパイルして実行し、失敗するようにできる最も簡単なコードを書いてみましょう。 Example.hは次のとおりです。

#import <Foundation/Foundation.h>

@interface Example : NSObject
@property (strong, nonatomic) NSUserDefaults *userDefaults;
@end

そして畏敬の念を起こさせるExample.m:

#import "Example.h"

@implementation Example
@end

ExampleTests.mの最初に行を追加する必要があります。

#import "Example.h"

テストが実行され、「NSUserDefaultsのインスタンスが必要ですが、nilでした」というメッセージで失敗します。まさに私たちが欲しかったもの。最初のテストのステップ1に達しました。

ステップ2は、そのテストに合格するための最も単純なコードを記述することです。これはどう:

- (id)init
{
    self = [super init];
    if (self)
        _userDefaults = [NSUserDefaults standardUserDefaults];
    return self;
}

合格!ステップ2が完了しました。

ステップ3は、すべての変更を本番コードとテストコードの両方に組み込むためにコードをリファクタリングすることです。しかし、まだクリーンアップするものはまだ何もありません。最初のテストが完了しました。これまでに何がありますか? NSUserDefaultsにアクセスできるが、テスト用にオーバーライドされるクラスの始まり。

2番目のテスト:一致するキーがない場合、0を返します

次に、メソッドのテストを記述しましょう。何をしたいですか?ユーザーのデフォルトに一致するキーがない場合は、0を返します。

モックオブジェクトを最初に使用するときは、最初に手作業で作成することをお勧めします。次に、モックオブジェクトフレームワークの使用を開始します。しかし、先にジャンプしてOCMockitoを使用して物事を高速化します。次の行をExampleTest.mに追加します。

#define MOCKITO_SHORTHAND
#import <OCMockitoIOS/OCMockitoIOS.h>

デフォルトでは、OCMockitoベースのモックオブジェクトは、すべてのメソッドに対してnilを返します。しかし、「objectForKey:@"currentReminderId"を要求すると、nilが返されます」と言って、期待を明確にするための追加のコードを記述します。そして、これらすべてを考慮して、メソッドがNSNumber 0を返すようにしたいと思います(引数の意味がわからないので、引数を渡しません。また、メソッドにnextReminderIdという名前を付けます)。

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldReturnZero
{
    Example *sut = [[Example alloc] init];
    NSUserDefaults *mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:nil];

    assertThat([sut nextReminderId], is(equalTo(@0)));
}

これはまだコンパイルされていません。 Example.hでnextReminderIdメソッドを定義してみましょう。

- (NSNumber *)nextReminderId;

そして、これがExample.mの最初の実装です。テストを失敗させたいので、偽の数を返します。

- (NSNumber *)nextReminderId
{
    return @-1;
}

テストは失敗し、「予期された<0>でしたが<-1>でした」というメッセージが表示されます。これはテストをテストする私たちの方法であるため、テストが失敗することは重要です。また、コードを記述して、テストを失敗状態から合格状態に切り替えます。ステップ1が完了しました。

ステップ2:テストテストに合格します。ただし、テストに合格する最も単純なコードが必要であることを忘れないでください。それはひどくばかげて見えるでしょう。

- (NSNumber *)nextReminderId
{
    return @0;
}

すごい、合格!ただし、このテストはまだ完了していません。次に、ステップ3:リファクタリングに進みます。テストに重複したコードがあります。テスト対象のシステムであるsutをivarに取り込みましょう。 -setUpメソッドを使用して設定し、-tearDownを使用してクリーンアップ(破棄)します。

@interface ExampleTests : SenTestCase
{
    Example *sut;
}
@end

@implementation ExampleTests

- (void)setUp
{
    [super setUp];
    sut = [[Example alloc] init];
}

- (void)tearDown
{
    sut = nil;
    [super tearDown];
}

- (void)testDefaultUserDefaultsShouldBeSet
{
    assertThat([sut userDefaults], is(instanceOf([NSUserDefaults class])));
}

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldReturnZero
{
    NSUserDefaults *mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:nil];

    assertThat([sut nextReminderId], is(equalTo(@0)));
}

@end

テストが再度実行され、テストが成功することを確認します。リファクタリングは、「緑」または合格の状態でのみ行う必要があります。リファクタリングがテストコードで行われたか、本番コードで行われたかに関係なく、すべてのテストは引き続き成功するはずです。

3番目のテスト:一致するキーがない場合、ユーザーのデフォルトに0を格納します

次に、別の要件をテストします。ユーザーのデフォルトを保存する必要があります。前のテストと同じ条件を使用します。ただし、既存のテストにアサーションを追加するのではなく、新しいテストを作成します。理想的には、各テストで1つのことを検証し、一致する適切な名前を付ける必要があります。

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldSaveZeroInUserDefaults
{
    // given
    NSUserDefaults *mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:nil];

    // when
    [sut nextReminderId];

    // then
    [verify(mockUserDefaults) setObject:@0 forKey:@"currentReminderId"];
}

verifyステートメントは、OCMockitoで「このモックオブジェクトはこの方法で一度呼び出されるべきだった」という言い方です。テストを実行すると、「1回の一致呼び出しが予期されていますが、0回のエラーが発生しました」というエラーが発生します。ステップ1が完了しました。

ステップ2:通過する最も単純なコード。準備はいい?ここに行く:

- (NSNumber *)nextReminderId
{
    [_userDefaults setObject:@0 forKey:@"currentReminderId"];
    return @0;
}

「しかし、なぜその値を持つ変数ではなく、ユーザーのデフォルトで@0を保存するのですか?」あなたが尋ねる。それは、私たちがテストした限りです。少々お待ちください。

ステップ3:リファクタリング。ここでも、テストに重複したコードがあります。 mockUserDefaultsをivarとして引き出しましょう。

@interface ExampleTests : SenTestCase
{
    Example *sut;
    NSUserDefaults *mockUserDefaults;
}
@end

テストコードは、「 'mockUserDefaults'のローカル宣言によりインスタンス変数を非表示にします」という警告を表示します。 ivarを使用するように修正します。次に、ヘルパーメソッドを抽出して、各テストの開始時にユーザーデフォルトの条件を確立します。 nilを別の変数に取り出して、リファクタリングに役立てましょう。

    NSNumber *current = nil;
    mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:current];

最後の3行を選択し、コンテキストクリックして、[リファクタリング]→[抽出]を選択します。 setUpUserDefaultsWithCurrentReminderId:という新しいメソッドを作成します

- (void)setUpUserDefaultsWithCurrentReminderId:(NSNumber *)current
{
    mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:current];
}

これを呼び出すテストコードは次のようになります。

    NSNumber *current = nil;
    [self setUpUserDefaultsWithCurrentReminderId:current];

この変数の唯一の理由は、自動リファクタリングを支援することでした。それをインライン化しましょう:

    [self setUpUserDefaultsWithCurrentReminderId:nil];

テストはまだ成功しています。 Xcodeの自動リファクタリングでは、そのコードのすべてのインスタンスが新しいヘルパーメソッドの呼び出しに置き換えられなかったため、自分で行う必要があります。したがって、テストは次のようになります。

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldReturnZero
{
    [self setUpUserDefaultsWithCurrentReminderId:nil];

    assertThat([sut nextReminderId], is(equalTo(@0)));
}

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldSaveZeroInUserDefaults
{
    // given
    [self setUpUserDefaultsWithCurrentReminderId:nil];

    // when
    [sut nextReminderId];

    // then
    [verify(mockUserDefaults) setObject:@0 forKey:@"currentReminderId"];
}

移動しながら継続的に掃除する方法をご覧ください。テストが実際に読みやすくなりました!

4番目のテスト:一致するキーで、インクリメントされた値を返します

ここで、ユーザーのデフォルトに何らかの値がある場合、1つ大きい値を返すことをテストします。任意の値3を使用して、「ゼロを返す必要がある」テストをコピーして変更します。

- (void)testNextReminderIdWithCurrentReminderIdInUserDefaultsShouldReturnOneGreater
{
    [self setUpUserDefaultsWithCurrentReminderId:@3];

    assertThat([sut nextReminderId], is(equalTo(@4)));
}

これは、必要に応じて失敗します。「<4>が必要ですが、<0>でした」。

テストに合格するための簡単なコードは次のとおりです。

- (NSNumber *)nextReminderId
{
    NSNumber *reminderId = [_userDefaults objectForKey:@"currentReminderId"];
    if (reminderId)
        reminderId = @([reminderId integerValue] + 1);
    else
        reminderId = @0;
    [_userDefaults setObject:@0 forKey:@"currentReminderId"];
    return reminderId;
}

そのsetObject:@0を除いて、これはあなたの例のように見え始めています。リファクタリングするものはまだありません。 (実際はありますが、後で気がついたので、続けましょう。)

5番目のテスト:一致するキーを使用して、増分された値を格納します

これで、もう1つのテストを確立できます。同じ条件が与えられた場合、新しいリマインダーIDがユーザーのデフォルトに保存されます。これは、以前のテストをコピーして変更し、適切な名前を付けることですばやく実行できます。

- (void)testNextReminderIdWithCurrentReminderIdInUserDefaultsShouldSaveOneGreaterInUserDefaults
{
    // given
    [self setUpUserDefaultsWithCurrentReminderId:@3];

    // when
    [sut nextReminderId];

    // then
    [verify(mockUserDefaults) setObject:@4 forKey:@"currentReminderId"];
}

そのテストは失敗し、「1回の一致呼び出しが期待されますが、0を受け取りました。もちろん、パスを取得するには、setObject:@0setObject:reminderIdに変更するだけです。すべてが通過します。終わったね!

待って、私たちは終わっていません。ステップ3:リファクタリングするものはありますか?私がこれを最初に書いたとき、私は「本当ではない」と言った。しかし、見た後にそれを見直す クリーンコードエピソード 、ボブおじさんが「関数はどれくらい大きくすべきか?4行でよい、おそらく5行でよい。6行は大丈夫です。10行はそうです。大きすぎる。"それは7行です。私は何を取りこぼしたか?それは、複数のことを行うことによって、関数の規則に違反しているに違いありません。

ボブおじさん:「関数が1つのことを確実に行う唯一の方法は、ドロップするまで抽出することです。」これらの最初の4行は一緒に機能します。実際の値を計算します。それらを選択して、リファクタリング▶抽出します。エピソード2からのボブおじさんのスコープルールに従って、使用範囲が非常に限られているため、ニースの長くわかりやすい名前を付けます。自動リファクタリングによって得られるものは次のとおりです。

- (NSNumber *)determineNextReminderIdFromUserDefaults
{
    NSNumber *reminderId = [_userDefaults objectForKey:@"currentReminderId"];
    if (reminderId)
        reminderId = @([reminderId integerValue] + 1);
    else
        reminderId = @0;
    return reminderId;
}

- (NSNumber *)nextReminderId
{
    NSNumber *reminderId;
    reminderId = [self determineNextReminderIdFromUserDefaults];
    [_userDefaults setObject:reminderId forKey:@"currentReminderId"];
    return reminderId;
}

よりきれいにするためにそれをきれいにしましょう:

- (NSNumber *)determineNextReminderIdFromUserDefaults
{
    NSNumber *reminderId = [_userDefaults objectForKey:@"currentReminderId"];
    if (reminderId)
        return @([reminderId integerValue] + 1);
    else
        return @0;
}

- (NSNumber *)nextReminderId
{
    NSNumber *reminderId = [self determineNextReminderIdFromUserDefaults];
    [_userDefaults setObject:reminderId forKey:@"currentReminderId"];
    return reminderId;
}

これで、各メソッドは非常にタイトになり、メインメソッドの3行を読んで、それが何をするかを簡単に確認できます。しかし、私はそのユーザーのデフォルトのキーが2つの方法に分散していることに不快です。それをExample.mの先頭にある定数に抽出してみましょう。

static NSString *const currentReminderIdKey = @"currentReminderId";

その定数は、そのキーが製品コードに現れるところならどこでも使用します。ただし、テストコードは引き続きリテラルを使用します。これにより、誰かがその定数キーを誤って変更してしまうのを防ぎます。

結論

だからあなたはそれを持っています。 5つのテストで、私はあなたが要求したコードにTDDしました。うまくいけば、TDDの方法と、それが価値がある理由が明確にわかります。 3ステップワルツに従うことによって

  1. 失敗したテストを1つ追加する
  2. ばかげているように見えても、パスする最も単純なコードを記述します
  3. リファクタリング(製品コードとテストコードの両方)

同じ場所にいるだけではありません。あなたは次のようになります:

  • 依存性注入をサポートする分離されたコード
  • テストされたものだけを実装する最小限のコード、
  • 各ケースのテスト(テスト自体が検証されています)、
  • 小さくて読みやすいメソッドを備えたきしむクリーンコード。

これらすべてのメリットにより、TDDに費やす時間よりも多くの時間を節約できます。長期的にだけでなく、すぐに節約できます。

完全なアプリを含む例については、本Test-Driven iOS Developmentを入手してください。これが 私の本のレビュー です。

95
Jon Reid