序文
ユニットテストは、日常業務の不可欠な一部として、要求開発の作業負荷を増加させますが、コードの安定性をある程度向上させることができます。特に、反復は通常より速く、より正確で、以前のアルゴリズムや境界が異常であるかどうかを検証し、手動テスト中に歴史的なロジックの詳細を見落とさないようにします。
新しいコードの場合、テスト容易性を確保することで、単一のテストをスムーズに書くことができますが、過去のコードに単一のテストを追加する必要がある場合、物事は複雑になります。Objective-Cの場合、OCMockのようなコンポーネントを使用して、コードの細部に隠れているテスト条件を偽装する必要があるかもしれません。
しかし、OCMockの多数は、単一のテストの安定性に課題をもたらすために、その理由は、OCMock自体の実装を理解するのに十分ではないということです、それは別のバッドケースの数の実装の詳細の一つかもしれません。
コア処理
Mock Class
OCMockは多くの機能を持っていますが、基本的にはメソッドをインターセプトすることに依存しています:
OCMClassMock(TestObj.self);
実際にはPreprocessの後に呼び出されます:
[OCClassMockObject alloc] initWithClass:TestObj.self];
OCClassMockObjectクラスは最初に、次の図に示すように、入力されるクラスのisaを新しいメタに向けるコア・オペレーションを行い、新しいメタのメソッド・リストに対していくつかの置換オペレーションを実行します:
この実装は KVO の実装に似ていますが、新しいメタの各 SEL に対して ocmock_replaced_ 接頭辞を持つ SEL を作成し、接頭辞を持つ SEL が元の実装 (NULL を指す) を指すというロジックが追加されています。 これは、元のメタの継承チェーン全体を繰り返し、新しいメタで同じ SEL の作成と置換を行うことに注目すべきです。これは、元のメタの継承チェーンのすべてのメソッドを繰り返し実行し、新しいメタで同じSELの作成と置換を行うことは注目に値します。
メッセージ転送
前の処理の後、TestObjのクラス・メソッドを呼び出すと、objc_msgSendは_objc_msgForwardを指すIMPを見つけることができず、メッセージのリダイレクトと転送がトリガーされます。しかし、注意深く見ていると、メッセージ転送のタイミングとコンテキストがOCMockコンポーネントによってどのように認識されるのか疑問に思うかもしれません。実は、このソース・コードではもう1つやっていることがあります:
Method myForwardMethod = class_getInstanceMethod([self mockObjectClass], @selector(forwardInvocationForClassObject:));
IMP myForwardIMP = method_getImplementation(myForwardMethod);
class_addMethod(newMetaClass, @selector(forwardInvocation:), myForwardIMP, method_getTypeEncoding(myForwardMethod));
forwardInvocationForClassObject:
これは、TestObj.selfの新しいメタのforwardInvocation:メソッドをOCClassMockObjectのメソッド実装に向けること、つまり、TestObjがメッセージ転送をトリガーするためにクラス・メソッドを呼び出すと、この新しいメソッドに行き、コンポーネントがファセットを正常に取得できるようにすることです。
+initialize
ソースコードには、TestObj.selfの新しいメタのinitializeメソッドの空の実装を追加する詳細があります。これは、新しいメタのinitializeの呼び出しがスーパーメタのinitializeに移動し、スーパーメタのこのメソッドが複数回呼び出されることを避けるためです:
((void(*)(Class, SEL))objc_msgSend)(cls, SEL_initialize);
if (self == AnyClass.self)
実際、コーディングでは、すべて最初にイニシャライズの内部で判定が行われます。
サポートされていないクラスとクラスメソッド
OCMock自体がシステム関連のクラスやメッセージのリダイレクト、フォワーディング関連のメソッドをベースにしているため、これらのクラスやメソッドをユーザがモックすることは避けなければなりませんので、1つのテストを書く際に不測の事態を避けるために制限を明確にしておく必要があります。サポートされないケースは以下の通りです:
NSString/NSArray/NSManagedObject
クラスまたはそのサブクラスです。@[@"class", @"forwardingTargetForSelector:", @"methodSignatureForSelector:", @"forwardInvocation:", @"isBlock", @"instanceMethodForwarderForSelector:", @"instanceMethodSignatureForSelector:"]
この配列に含まれるクラスメソッド。- meta クラス名には NS/UI 接頭辞が含まれ、クラス・メソッドの接頭辞または接尾辞には _ が含まれます。
Mock Instance
例えば、モックはこのマクロを使っています:
OCMPartialMock(obj);
実際にはPreprocessの後に呼び出されます:
// OCPartialMockObject OCClassMockObject
[[OCPartialMockObject alloc] initWithObject:anObject]
OCPartialMockObjectは同じことを行い、新しいクラスを指すobjオブジェクトのisaで初期化し、次のようにクラスのメソッド・リストで置換を行います:
新しいクラスでは、各 SEL の接頭辞 ocmock_replaced_ を持つ SEL を作成し、接頭辞付きの SEL が NULL を指す元の実装を指すようにします。 これは、クラスの継承チェーンのすべてのメソッドを繰り返し実行し、新しいクラスで同じ SEL の作成と置換を行います。
メッセージ転送
forwardingTargetForSelector:
これは、forwardInvocation: と obj が属する新しい Class のメソッドを追加する点を除けば、前のものと似ています。また、obj が元々属していた Class を返す -class メソッドも実装しており、クラスを世間から隠すという目的を達成するために KVO と同じになっています。
+initialize
また、インスタンス変数のモッキングには無効なメソッドもあります:
@[@"class", @"forwardingTargetForSelector:", @"methodSignatureForSelector:", @"forwardInvocation:", @"allowsWeakReference", @"retainWeakReference", @"isBlock", @"retainCount", @"retain", @"release", @"autorelease"]
配列のすべてのインスタンスメソッド。- クラス名には NS/UI 接頭辞が含まれ、インスタンスメソッドの接頭辞または接尾辞には _ が含まれます。
親クラス OCClassMockObject の初期化メソッドを呼び出します。
OCPartialMockObjectの初期化にはこのロジックがあります:
- (id)initWithObject:(NSObject *)anObject {
...
Class const class = [self classToSubclassForObject:anObject];
[super initWithClass:class];
...
}
- (Class)classToSubclassForObject:(id)object {
if([object observationInfo] != NULL)
return [object class];
return object_getClass(object);
}
KVO の実装では、オブジェクトの isa を元のクラスを継承するサブクラスに指定し、対応するセッターをオーバーライドした後、-class をオーバーライドして元のクラスを返します。classToSubclassForObject:
ここでは、オブジェクトが KVO によってリッスンされているかどうかを判断し、元のクラスを返します。
この判定を取り除こうとした後、KVOで処理されているオブジェクトをモックし、モックの停止でKVOをトリガーすると、必然的にクラッシュし、IMPアドレスが不正であることがわかります。ocmock_replaced_anySetter
考えてみてください、この時、Mockは再びKVOクラスをサブクラス化し、そしてこれはKVOクラスによってオーバーライドされたSetterではない実装を指すのでしょうか?
OCMockのGitHubのissueとPRの記録を調べたところ、確かにこのissueがあることがgithub.com/ocm:
It's been a while, but I recall not doing this was messing with KVO notifications + the associated tests. KVO dynamically subclasses your class to provide automatic notifications, and something about then re-subclassing that class wasn't working.
KVO処理されたisaが指し示すダイナミック・サブクラスは、メソッドの入れ替えを行うために再度サブクラス化する際に奇妙な問題が発生するため、ここではモックKVOダイナミック・サブクラスの親クラスを選択することになります。
[super initWithClass:class]
initWithObject:メソッドに戻りますが、ここでの初期化は、直前のモック・クラスの初期化メソッドである , を呼び出しますので、モック・インスタンスを使う場合は、クラスのisaが変更され、このクラスを使う場合は、このクラスに注意を払う必要があり、マルチスレッドによる不可解な問題につながる可能性があります。
上部関数
クラスとインスタンスに対するモック操作はOCMockのコア・ロジックであり、他の多くの関数コードはこの基本に依存しています。クラス/インスタンス・メソッド呼び出しの統一されたビューを手に入れたので、シンプルなコードでどんな関数でも実行できるようになりました。
id mockObj = OCMClassMock(TestObj.self);
OCMStub([mockObj logString]);
[TestObj logString];
前処理後のコード:
id mockObj = [OCClassMockObject alloc] initWithClass:TestObj.self];
({ ({
[OCMMacroState beginStubMacro];
OCMStubRecorder *recorder = ((void *)0);
@try{
[mockObj logString];
} @finally {
recorder = [OCMMacroState endStubMacro];
}
recorder;
}); });
[TestObj logString];
[OCMMacroState beginStubMacro]
はまず、TLSに入れるコンテキストOCMMacroStateオブジェクトを作成します:
+ (void)beginStubMacro {
OCMStubRecorder *recorder = [[[OCMStubRecorder alloc] init] autorelease];
OCMMacroState *macroState = [[OCMMacroState alloc] initWithRecorder:recorder];
[NSThread currentThread].threadDictionary[OCMGlobalStateKey] = macroState;
[macroState release];
}
- (id)forwardingTargetForSelector:(SEL)aSelector {
if([OCMMacroState globalState] != nil) {
OCMRecorder *recorder = [[NSThread currentThread].threadDictionary[OCMGlobalStateKey] recorder];
[recorder setMockObject:self];
return recorder;
}
return nil;
}
ここでは、レコーダーにリダイレクトされ、レコーダーのメッセージ転送に進み、最後にOCClassMockObjectオブジェクト内に格納されたNSInvocationオブジェクトを生成することがわかります。
-forwardInvocationForClassObject:
呼び出し時に[TestObj logString]は、メソッドが見つかりませんし、それが一致する場合、この呼び出しは、メソッドをインターセプトする目的を達成するために、つまり、NSInvocationオブジェクトと比較される前述のメソッドに転送され、それが一致しない場合、呼び出しocmock_replaced_接頭辞SELを呼び出すと、元のメソッドの実装がトリガーされます。
ロジックのこの部分は、実際には非常に丸い感じ、TLSの設計に基づいて、また、スレッドセーフではないことを運命づけられています。OCMStubの検証パラメータ、またはOCMExpect/OCCMVerifyについては、同様の処理で、基本的に2つの操作です:記録、検証。
安全でない Stop Mocking
OCMock自体がマルチスレッドをサポートしておらず、-stopMockingが安全でないため、このようなクラッシュ・シナリオがあります:モック・オブジェクトの-fooメソッドを呼び出した後、一連のメッセージ転送処理の後、最終的に-fooが呼び出されますが、オブジェクトはモックされているため、元のメソッドの実装はプレフィックス・メソッドとしてocmock_replaced_を呼び出すことになり、この期間-stopMocking操作を行っている限り、isaポインティングが回復してプレフィックス・メソッドとしてocmock_replaced_を見つけられない可能性があります。オブジェクトはモックされているので、元のメソッドは ocmock_replaced_ をプレフィックスとして呼び出されます。 この間に -stopMocking 操作が行われる限り、isa ポインティングは回復し、 ocmock_replaced_ をプレフィックスとするメソッドは見つかりません。
単発のテスト練習で同様の問題が発生した場合、エラーを回避するためのポイントを以下にまとめました:
- 明示的に -stopMocking を使わないようにしてください。
- テスト対象のビジネス・コードが非同期スレッドでモック・オブジェクト/クラスを使用する場合、XCTestExpectation を使用して、非同期ロジックが処理されるまで単一のテスト・スレッドをハングさせるか、OCMStub(...) を使用して、非同期スレッドによって呼び出されるメソッドをインターセプトします。を使用して、非同期スレッドによって呼び出されるメソッドをインターセプトします。
クラス・モック・オブジェクトとインスタンス・モック・オブジェクトを混在させることはできません。
id mock = OCMPartialMock(obj);
// fooMethode それはクラスのメソッドだ。
OCMStub(mock fooMethode]);
ここで、MockはInstanceなので、Stubクラスのメソッドに行くべきではありません。OCMStubはTargetがClassかInstanceかを区別しない設計になっているため、同じメソッド名の-fooMethodeインスタンスメソッドがあると、開発者はMockがClassメソッドだと勘違いしてしまいますが、コンポーネントはMockがInstanceメソッドだと勘違いしています。