39

単体テストを理解するのに本当に苦労しています。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;
}
4

1 に答える 1

95

更新: この回答を 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)));
}

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

- (NSNumber *)nextReminderId;

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

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

テストは、「予想 <0> でしたが、<-1> でした」というメッセージで失敗します。テストが失敗することは重要です。これは、テストをテストする方法であり、記述したコードがテストを失敗状態から合格状態に確実に反転させるためです。ステップ 1 は完了です。

ステップ 2: テスト test に合格させましょう。ただし、テストに合格する最も単純なコードが必要であることを忘れないでください。ものすごーくバカバカしくなります。

- (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 の言い方です。「このモック オブジェクトは、一度この方法で呼び出されるべきでした」。テストを実行すると、"Expected 1 matching invocation, but received 0" というエラーが表示されます。ステップ 1 は完了です。

ステップ 2: 合格する最も単純なコード。準備?ここに行きます:

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

「しかし、なぜ@0その値を持つ変数ではなく、ユーザーのデフォルトに保存するのですか?」あなたが尋ねる。それは私たちがテストした限りだからです。ちょっと待って、私たちはそこに着きます。

ステップ 3: リファクタリング。繰り返しますが、テストには重複したコードがあります。ivarとして抜きましょうmockUserDefaults

@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 を使用して、"should return zero" テストをコピーして変更します。

- (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: リファクタリングするものはありますか? 最初にこれを書いたとき、私は「そうではない」と言いました。しかし、 Clean Code のエピソード 3を見た後でそれを見てみると、Bob おじさんが私に言っているのが聞こえます。それは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という書籍を入手してください。これが私の本のレビューです

于 2012-12-06T08:07:28.520 に答える