XCTestで書いたUnit Testのリファクタリングを試みた

Posted on
Objective-c XCTest Unit Test

XCTestで書いたUnit testのrefactoringを試みたのですが、個人的にすっきりする方法が見つかりませんでした。

例として、nameというpropertyと、validateName:error:というmethodを持ったPersonというClassがあったとします。

@interface Person : NSObject
@property(nonatomic, copy) NSString *name;
- (BOOL)validateName:(id *)ioValue error:(NSError * __autoreleasing *)outError;
#import "Person.h"

@implementation Person
- (BOOL)validateName:(__autoreleasing id *)ioValue error:(NSError *__autoreleasing *)outError
{
    if (*ioValue == nil)
    {
        if (error)
        {
            NSDictionary *userInfo =
            @{
              NSLocalizedDescriptionKey: @"Invalid name.",
              NSLocalizedFailureReasonErrorKey: @"Name should not be nil."
            };
            *error = [NSError errorWithDomain:PKTErrorDomain code:PKTInvalidName userInfo:userInfo];
        }

        return NO;
    }

    return YES;
}
@end

validateName:error:namenilの場合にNSErrorを作成して返します。

validateName:error:が求めているNSErrorを返すかどうかテストしましょう。

(1) 愚直に各attributeを比較する

- (void)testNilNameReturnsExpectedError
{
    NSError *error = nil;
    NSString *name = nil;
    [_person validateName:&name error:&error];

    XCTAssertNotNil(error, @"Error object should not be nil.");
    XCTAssertEqualObjects([error domain], PKTErrorDomain, @"Error domain should be PKTErrorDomain.");
    XCTAssertEqual([error code], PKTInvalidName, @"Error code should be PKTInvalidName.");
    XCTAssertEqualObjects([error localizedDescription], @"Invalid name.", @"It should return expected localized description.");
    NSDictionary *userInfo = [error userInfo];
    NSString *failureReason = [userInfo objectForKey:NSLocalizedFailureReasonErrorKey];
    XCTAssertEqualObjects(failureReason, @"Name should not be nil.", @"It should return expected failure reason.");
}

NSErrorのattributesをひたすら期待通りかassertionで確認していますね。 Unit testの原則として、一つのテストにつき、assertionが一つなのが望ましいのですが、ここではAssertionが複数埋め込まれています。Assertion Rouletteと呼ばれ、あまり推奨されない書き方です。

test codeのrefactoringをしてみましょう。

(2) custom assertionを関数で実装

Assertion Rouletteの解決方法の一つとして、custom assertionというのがあります。 そこでcustom assertionを作ってみましょう。

static void PKYAssertNSErrorObjects(NSError *error, NSError *expected)
{
    XCTAssertEqualObjects([error domain], [expected domain],@"Domains should be the same");
    XCTAssertEqual([error code], [expected code], @"Codes should be the same");
    XCTAssertEqualObjects([error localizedDescription], [expected localizedDescription], @"localized descriptions should be the same");
    NSString *failureReason = [[error userInfo] objectForKey:NSLocalizedFailureReasonErrorKey];
    NSString *expectedFailureReason = [[expected userInfo] objectForKey:NSLocalizedFailureReasonErrorKey];
    XCTAssertEqualObjects(failureReason, expectedFailureReason, @"Failure reasons should be the same");
}

NSErrorを比較していた部分を一つの関数に移しました。

これを使って先ほどのコードを書き直すと

- (void)testNilNameReturnsExpectedError
{
    NSError *error = nil;
    NSString *name = nil;
    [_person validateName:&name error:&error];

    NSDictionary *expectedUserInfo = @
    {
        NSLocalizedDescriptionKey: @"Invalid name.",
        NSLocalizedFailureReasonErrorKey: @"Name should not be nil."
    };
    NSError *expected = [NSError errorWithDomain:PKTErrorDomain code:PKYInvalidName userInfo:expectedUserInfo];
    PKYAssertNSErrorObjects(error, expected);
}

とすっきり書ける事になります。 ところがcustom assertionがsyntaxエラーを吐いております。

{% img /images/xctest1.png %}

“Use of undeclared identifier ‘self’“と表示されていますね。 self??と疑問に感じ、XCTAssertを探っていくと全てのXCTAssertion_XCTRegisterFailureを呼び出している事がわかります。

_XCTRegisterFailureを見てみましょう。

XCT_EXPORT void _XCTFailureHandler (XCTestCase *test, BOOL expected, const char *filePath, NSUInteger lineNumber, NSString * condition, NSString * format, ...) NS_FORMAT_FUNCTION(6,7);

#define _XCTRegisterFailure(condition, format...) \
({ \
    _XCTFailureHandler(self, YES, __FILE__, __LINE__, condition, @"" format); \
})

_XCTRegisterFailure_XCTFailureHandlerを呼び出していますね。 _XCTFailureHandlerの一つ目の引数のtypeはXCTestCaseなのですが、_XCTRegisterFailureは、そこにselfを渡しています。

つまり、XCTAssertionを呼ぶ場合、selfXCTestCaseのinstanceが入っていないといけない様です。

(3) Custom assertionをinstance method

では、custom assertionをinstace methodとして実装してみればどうでしょうか。

- (void)customAssertError:(NSError *)error expected:(NSError *)expected
{
    XCTAssertEqualObjects([error domain], [expected domain], @"Error domain should be the same.");
    XCTAssertEqual([error code], [expected code], @"Error code should be the same.");
    XCTAssertEqualObjects([error localizedDescription], [expected localizedDescription], @"It should return expected localized description.");
    NSDictionary *userInfo = [error userInfo];
    NSString *failureReason = [userInfo objectForKey:NSLocalizedFailureReasonErrorKey];

    NSDictionary *expectedUserInfo = [expected userInfo];
    NSString *expectedFailureReason = [expectedUserInfo objectForKey:NSLocalizedFailureReasonErrorKey];
    XCTAssertEqualObjects(failureReason, expectedFailureReason, @"It should return expected failure reason.");
}

method名がtestで始まると、テストメソッドとして認識されてしまうため、testで始まらない名前をつけます。 今回は構文エラーが表示されませんね。このcustom assertionを使って、テストコードを書きなおしてみましょう。

- (void)testNilNameReturnsExpectedError
{
    NSError *error = nil;
    NSString *name = nil;
    [_person validateName:&name error:&error];

    NSDictionary *expectedUserInfo = @
    {
        NSLocalizedDescriptionKey: @"Invalid name.",
        NSLocalizedFailureReasonErrorKey: @"Name should not be nil."
    };
    NSError *expected = [NSError errorWithDomain:PKTErrorDomain code:PKYInvalidName userInfo:expectedUserInfo];
    [self customAssertError:error expected:expected];
}

すっきりしましたね。しかし、この方法にも欠点がないわけではありません。

  1. 複数のテストメソッドからcustom assertionを呼び出している
  2. 複数のテストメソッドが、custom assertion内のassertionでこける

この2点を満たす場合、各テストがなぜ通らなかったのか非常にわかりづらく、デバッグしづらいです。

(4) NSErrorを比較

「各attributeを比較しているが、NSError同士を比較たらどうか?」と疑問に思われるかもしれません。 NSErrorではisEqual:がoverrideされているかどうかはドキュメントに書かれておらず、どう比較しているのかはわかりませんが、attributesが等しいかどうかはisEqual:でも判別できます。

しかし、テストが落ちた際、(3)よりも、なぜテストが落ちたがわかりづらいという欠点があります。

{% img /images/xctest2.png %}

マウスカーソルを失敗したassertionの上に動かさなければならず、一定時間が経つと、hoverで表示されている情報がフェードアウトします。表示されている情報も見やすいとは言えません。

まとめ

(1)重複も多いが、テストが失敗した時にどこが失敗したかすぐわかり、デバッグがしやすい
(2)selfにxctestcaseのinstanceを代入する必要がある
(3)複数のテストがcustom assertion内のassertionで失敗した時に、失敗した原因が一目瞭然ではない
(4) (3)よりも失敗原因がわかりづらい上に見づらい

(1)か(3)のどちらかで行こうかなと思ってはいますが、何か他に良い方法はないものか..