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:
はname
がnil
の場合に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
を呼ぶ場合、self
にXCTestCase
の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];
}
すっきりしましたね。しかし、この方法にも欠点がないわけではありません。
- 複数のテストメソッドからcustom assertionを呼び出している
- 複数のテストメソッドが、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)のどちらかで行こうかなと思ってはいますが、何か他に良い方法はないものか..