内容简介:使用 XCTest + OCMock 写单元测试也有一段时间了. 一直没了解 OCMock 到底是怎么实现的, 所以就想找个时间读读源码, 揭开 OCMock 的神秘面纱. 在阅读源码时发现比较核心的机制就是 NSProxy + 消息转发, 所以在看源码之前, 先简单复习一下相关知识.先来看看消息转发, Objective-C 的消息机制就不赘述了, 在第一步, 首先会调用
使用 XCTest + OCMock 写单元测试也有一段时间了. 一直没了解 OCMock 到底是怎么实现的, 所以就想找个时间读读源码, 揭开 OCMock 的神秘面纱. 在阅读源码时发现比较核心的机制就是 NSProxy + 消息转发, 所以在看源码之前, 先简单复习一下相关知识.
消息转发
先来看看消息转发, Objective-C 的消息机制就不赘述了, 在 objc_msgSend
时, 如果对象的和其父类一直到根类都没有在方法缓存和方法列表中找到对应的方法就会发生这样的错误: unrecognized selector sent to instance
, 但是在崩溃前, 会有消息转发的机制来尝试挽救.
第一步, 首先会调用 forwardingTargetForSelector:
方法获取一个可以处理该 Selector
的对象. 对该对象重新进行发送消息, 如果返回为 nil
, 则走第二步.
第二步, 调用 methodSignatureForSelector:
方法来获得方法签名 NSMethodSignature
, 包含 Selector
和 参数的信息, 用于生成 NSInvocation
, 如果返回为 nil
, 则抛出 doesNotRecognizeSelector
异常.
第三步, 调用 forwardInvocation:
对 NSInvocation
进行处理, 如果本身, 父类一直到根类都没有处理, 则还是会抛出 doesNotRecognizeSelector
异常.
简单整理消息转发到机制就是这样, 更深的原理推荐阅读杨萧玉大神的这篇文章: Objective-C 消息发送与转发机制原理 .
NSProxy
An abstract superclass defining an API for objects that act as stand-ins for other objects or for objects that don’t exist yet.
在文档中的解释是这样的, NSProxy 是一个抽象的父类(说根类更为合适), 用于定义对象的 API, 可以充当其他对象或者已经不存在的对象的替身.
在 iOS 中的根类是 NSObject 和 NSProxy, NSObject 即是根类也是协议, NSProxy 也实现了该协议, 并且作为一个抽象类, 它并不提供初始化方法, 如果接收到它没有响应的消息时会抛出异常, 所以, 需要使用子类继承实现初始化方法, 然后通过重写 forwardInvocation:
和 methodSignatureForSelector:
方法来处理它本身未实现的消息处理.
这里列出两个经常会使用到的小 Tips.
YYWeakProxy
YYWeakProxy
是 YYKit
中提供的工具, 用于持有一个 weak
对象, 通常用来解决 NSTimer
和 CADisplayLink
循环引用的问题. 比如我们经常会在对象内使用 NSTimer
, 该对象强引用着 NSTimer
, 而该对象在作为 target
时就又会被 NSTimer
强引用着, 就构成了循环引用, 导致都无法释放.
简单介绍一下 YYWeakProxy
是如何实现的, 首先使用初始化方法, 弱引用着 target
对象.
- (instancetype)initWithTarget:(id)target { _target = target; return self; } 复制代码
通过实现 forwardingTargetForSelector:
方法来将消息转发给 _taget
, 充当了桥梁, 破除了如 NSTimer
对 target
的强引用.
- (id)forwardingTargetForSelector:(SEL)selector { return _target; } 复制代码
然后这里又另外实现了这两个方法, 这是为了什么呢?
- (void)forwardInvocation:(NSInvocation *)invocation { void *null = NULL; [invocation setReturnValue:&null]; } - (NSMethodSignature *)methodSignatureForSelector:(SEL)selector { return [NSObject instanceMethodSignatureForSelector:@selector(init)]; } 复制代码
因为 target
是弱引用的, 如果释放了, 就会被置为 nil
, 转发方法 forwardingTargetForSelector:
就相当于返回了 nil
, 那么没有办法处理消息, 则会导致发生崩溃.
所以这里就是随便返回了一个方法签名, 直接返回 NSObject
的 init
的方法签名, invocation
并未调用 invoke
只是返回 nil
, 相当于此时发送什么消息都会返回 nil
, 不会崩溃.
实现多继承
在 objc 中是不能多继承的, 但是我们可以使用 NSProxy 来模拟多继承的效果, 其实将上面的例子的 target
变成一个数组来持有多个 target
.
然后将方法按照 respondsToSelector:
谁能处理, 来转发给各个 target
就可以实现多继承了, 比较简单, 这里用最简单的方法实现如下:
- (id)forwardingTargetForSelector:(SEL)selector { __block id target = nil; [self.tagets enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { if ([obj respondsToSelector:selector]) { target = obj; *stop = YES; } }]; return target; } 复制代码
接下来进入正题, 来开始看一下 OCMock
的核心源码实现.
例子
我准备从一个经常会用到的例子来一点一点阅读 OCMock 的源码实现.
在单元测试中, 经常需要屏蔽掉外界因素的干扰, 比如方法中依赖的外部方法的结果, 在我们的项目中, 大量的使用了下发的开关配置, 比如下面这行代码来判断是否开启某个功能.
BOOL enableXX = [[RemoteConfig sharedRemoteConfig] enableXXFeature]; 复制代码
使用 OCMock 来 Mock 该结果的方式如下:
// Setup id configMock = OCMClassMock([RemoteConfig class]); OCMStub([configMock sharedRemoteConfig]).andReturn(configMock); OCMStub([configMock enableXXFeature]).andReturn(YES); // Assert ... // Teardown [configMock stopMocking]; 复制代码
第一行创建一个 RemoteConfig
类的 mock 对象, 命名为 configMock
;
第二行 mock 掉 [configMock sharedRemoteConfig]
的 类方法 , 并且 andReturn
添加返回值为该 mock 对象. 这样通过 [RemoteConfig sharedRemoteConfig]
就可以永远返回一个 mock 的对象, 接下来只要在对这个 mock 的对象的 enableXXFeature
方法添加一个返回值就可以实现 mock 开关了;
第三行, mock 掉 [configMock enableXXFeature]
的 实例方法 并且 andReturn
添加返回值恒定为 YES
.
OCMock
使用了大量的宏定义, 那么就通过 Xcode
提供的 Preprocess
的功能来一步一步看看到底是怎么回事吧.
OCMClassMock
第一行, OCMClassMock
宏展开后如下:
id configMock = [OCMockObject niceMockForClass:[RemoteConfig class]]; 复制代码
这个 OCMockObject
就是我们刚刚说到的 NSProxy
的一个子类, 来实现消息转发, niceMockForClass
其实就是调用了
+ (id)mockForClass:(Class)aClass { return [[[OCClassMockObject alloc] initWithClass:aClass] autorelease]; } 复制代码
只不过设置了一个 isNice
的实例变量, 并且标记为 YES
, 这个不影响核心原理的理解, 简单说一下, OCMock 中使用 OCMStrictClassMock
可以进行一个严格的 mock, 如果调用没有 Stub
住的方法时, 就会崩溃, 而这个 OCMClassMock
就是 nice
的, 没有 Stub
的方法会进行一下保护, 不会产生崩溃, 比较 nice
, 我们比较常用到的就是比较 nice
的 OCMClassMock
.
OCMStub
整个 OCMStub
是最核心的点, 其他的 Expect
和 Reject
原理大都一致, 一点一点看.
enableXXFeature
展开
OCMStub([configMock enableXXFeature]).andReturn(YES); 复制代码
先从这行代码来看起, 先看 OCMStub 的展开, 我稍微整理了一下, 代码如下:
({ [OCMMacroState beginStubMacro]; OCMStubRecorder *recorder = ((void *)0); @try{ [configMock enableXXFeature]; } @finally { recorder = [OCMMacroState endStubMacro]; } recorder; }); 复制代码
其中上下两个 begin
和 end
的方法就是为了增加一个 OCMStubRecorder
标记, 并且存放在当前线程的字典中. 代码如下:
+ (void)beginStubMacro { OCMStubRecorder *recorder = [[[OCMStubRecorder alloc] init] autorelease]; OCMMacroState *macroState = [[OCMMacroState alloc] initWithRecorder:recorder]; [NSThread currentThread].threadDictionary[OCMGlobalStateKey] = macroState; [macroState release]; } + (OCMStubRecorder *)endStubMacro { NSMutableDictionary *threadDictionary = [NSThread currentThread].threadDictionary; OCMMacroState *globalState = threadDictionary[OCMGlobalStateKey]; OCMStubRecorder *recorder = [(OCMStubRecorder *)[globalState recorder] retain]; [threadDictionary removeObjectForKey:OCMGlobalStateKey]; return [recorder autorelease]; } 复制代码
Stub
关键在中间一行 [configMock enableXXFeature]
的调用, 存在这个 OCMStubRecorder
标记时 , 会在消息转发的 forwardingTargetForSelector:
这个方法中进行处理, 记录 configMock
对象的同时, 返回这个 recorder
对象进行处理.
- (id)forwardingTargetForSelector:(SEL)aSelector { if([OCMMacroState globalState] != nil) { OCMRecorder *recorder = [[OCMMacroState globalState] recorder]; [recorder setMockObject:self]; return recorder; } return nil; } 复制代码
所以便理解了上面为什么要将 recorder
对象放入当前线程的字典中, 是为了同样是这样一行代码 [configMock enableXXFeature]
, 在是否有 recorder
时, 可以有两种截然不同的处理路线, 很是巧妙. 即在定义 Stub
时, 可以交给 recorder
去处理, 而在真正调用该方法时, 可以由这个 mock 的对象按照消息转发接下来的流程处理.
这个 recorder
对象是 OCMStubRecorder
类型, 继承自 OCMRecorder
, 而 OCMRecorder
又继承自 NSProxy
. 所以这个 recorder
也需要处理消息转发机制.
recorder
在 methodSignatureForSelector:
中, 先按照实例方法去获取 mock 对象的方法签名, 如果没有的话再按照类方法去获取方法签名, 如果获取到则在 invocationMatcher
记录标记一下, 是 类方法
, 还是没获取到就会返回 nil
了, 按照消息转发机制, 则会抛出 doesNotRecognizeSelector
异常.
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector { if([invocationMatcher recordedAsClassMethod]) return [mockedClass methodSignatureForSelector:aSelector]; NSMethodSignature *signature = [mockObject methodSignatureForSelector:aSelector]; if(signature == nil) { if([mockedClass respondsToSelector:aSelector]) { // 标记一下证明该 Selector 是类方法, 标记到 invocationMatcher 上 [self classMethod]; // 重新调用这个方法取方法前面, 这样就会被前两行返回 signature = [self methodSignatureForSelector:aSelector]; } } return signature; } 复制代码
前面两行的意思是如果已经被标记为类方法了, 则直接返回类方法的方法签名.
再来看 forwardInvocation:
处理的方法, 我按照继承关系整理了一下方便阅读:
- (void)forwardInvocation:(NSInvocation *)anInvocation { [anInvocation setTarget:nil]; [invocationMatcher setInvocation:anInvocation]; [mockObject addStub:invocationMatcher]; } 复制代码
其目的就是通过 setTarget:nil
来禁止这个 invocation
调用, 用 invocationMatcher
来记录并且管理一下这个 invocation
, 然后把这个 invocationMatcher
传递给 mockObject
就是我们上面记录过的 configMock
对象.
在 addStub:
方法中, 如果是实例方法只是将这个 invocationMatcher
保存到了一个数组中, 如果是类方法等下再看 Stub sharedRemoteConfig
这个类方法时再看.
这样整个 OCMStub
的过程就理解了. 在简单整理一下对象间的关系, 方便理解.
mock 对象持有一个 invocationMatcher
对象的数组, 每一个 invocationMatcher
对象表示一次的 Stub(或者是 Expect 等), 还记录着该方法是个类方法还是实例方法.
每一个 invocationMatcher
持有 invocation
对象, 用于进行在调用的时候, 和调用的 invocation
进行匹配, 以及参数校验等逻辑.
在 Stub 流程中, 这个 recorder
对象相当于一个流程管理者, 记录了该流程的信息, 再 Stub 语句完整结束后, 其实就被释放了, 后面在看.
andReturn
OCMStub
实际上是返回了 OCMStubRecorder
这个对象. 在这个对象中记录需要的方法返回值. 展开后如下:
recorder._andReturn(({ __typeof__((YES)) _val = ((YES)); NSValue *_nsval = [NSValue value:&_val withObjCType:@encode(__typeof__(_val))]; if (OCMIsObjectType(@encode(__typeof(_val)))) { objc_setAssociatedObject(_nsval, "OCMAssociatedBoxedValue", *(__unsafe_unretained id *) (void *) &_val, OBJC_ASSOCIATION_RETAIN); } _nsval; })); 复制代码
补充说明一下, @encode
是一个编译器指令, 返回一个类型内部进行表示的字符串, 比如这里使用的 YES
是 BOOL
类型, 内部字符串表示就是 "B"
, 更深入的, 更方便对类型进行判断和处理, 关于 @encode
推荐阅读这篇文章
所以, 整体逻辑简单来说实际上就是将这个返回值通过 NSValue
进行包装, 可以理解为
recorder._addReturn(_nsval); 复制代码
这个 _addReturn()
是一个 block
, 传入一个 NSValue
, 返回自身方便链式编写. 本质上就是根据返回值的类型, 是基本类型还是对象使用不同的 ValueProvider
进行包装. 基本类型使用 OCMBoxedReturnValueProvider
, 对象则使用 OCMReturnValueProvider
.
在来看刚刚的对象间关系:
这时就增加了 ValueProviders
的逻辑, 每一个 invocationMatcher
持有多个. 因为不仅仅可以 andReturn
指定返回值, 例如还可以 andDo
指定一个 block
, 在方法被调用后执行等等. 不过感觉 OCMock
此处的处理还可以再完善一下, 这些类似于的 ValueProviders
都遵从一个 ValueProviders
的协议, 然后协议要求实现 handleInvocation:
, 不过既然是人家内部的逻辑, 也无所谓啦.
调用过程
调用过程中实际上是没有 recorder
的, 在 OCMStub
整行代码结束后就被释放啦. 对象关系就变成这样了:
真正调用的是对 configMock
即 OCClassMockObject
进行调用如 enableXXFeature
方法的过程就像前面说过的, 由于没有了 recorder
, forwardingTargetForSelector:
会返回 nil
接下来的消息转发流程回去获取方法签名, 然后在 forwardInvocation:
中处理.
- (void)forwardInvocation:(NSInvocation *)anInvocation { @try { if([self handleInvocation:anInvocation] == NO) [self handleUnRecordedInvocation:anInvocation]; } @catch(NSException *e) { ... } } 复制代码
核心步骤在 handleInvocation:
中, 整理如下
- (BOOL)handleInvocation:(NSInvocation *)anInvocation { // 1. 记录 `invocation` 用于实现 `Expect` 的校验逻辑 @synchronized(invocations) { [anInvocation retainObjectArgumentsExcludingObject:self]; [invocations addObject:anInvocation]; } // 2. 取刚刚 `addStub:` 中记录的 `invocationMatcher` 进行匹配 OCMInvocationStub *stub = nil; @synchronized(stubs) { for(stub in stubs) { if([stub matchesInvocation:anInvocation]) break; } [stub retain]; } if(stub == nil) return NO; // ...expectaion 相关逻辑省略 // 3. 这个 stub 就是 `invocationMatcher`, 交由它处理. @try { [stub handleInvocation:anInvocation]; } @finally { [stub release]; } return YES; } 复制代码
invocationMatcher
的处理逻辑如下:
- (void)handleInvocation:(NSInvocation *)anInvocation { NSMethodSignature *signature = [recordedInvocation methodSignature]; NSUInteger n = [signature numberOfArguments]; for(NSUInteger i = 2; i < n; i++) { id recordedArg = [recordedInvocation getArgumentAtIndexAsObject:i]; id passedArg = [anInvocation getArgumentAtIndexAsObject:i]; if([recordedArg isProxy]) continue; if([recordedArg isKindOfClass:[NSValue class]]) recordedArg = [OCMArg resolveSpecialValues:recordedArg]; if(![recordedArg isKindOfClass:[OCMArgAction class]]) continue; [recordedArg handleArgument:passedArg]; } // 4. 通过记录的 `ValueProvider` 交给它去处理 [invocationActions makeObjectsPerformSelector:@selector(handleInvocation:) withObject:anInvocation]; } 复制代码
以 OCMBoxedReturnValueProvider
为例子, 处理逻辑如下
- (void)handleInvocation:(NSInvocation *)anInvocation { const char *returnType = [[anInvocation methodSignature] methodReturnType]; NSUInteger returnTypeSize = [[anInvocation methodSignature] methodReturnLength]; char valueBuffer[returnTypeSize]; NSValue *returnValueAsNSValue = (NSValue *)returnValue; // 5. 将返回值设置到 `invocation` 中 `[anInvocation setReturnValue:valueBuffer]` if([self isMethodReturnType:returnType compatibleWithValueType:[returnValueAsNSValue objCType]]) { [returnValueAsNSValue getValue:valueBuffer]; [anInvocation setReturnValue:valueBuffer]; } else if([returnValueAsNSValue getBytes:valueBuffer objCType:returnType]) { [anInvocation setReturnValue:valueBuffer]; } else { [NSException raise:NSInvalidArgumentException format:@"Return value cannot be used for method; method signature declares '%s' but value is '%s'.", returnType, [returnValueAsNSValue objCType]]; } } 复制代码
这样就完成了整个调用过程, 其中如果没有找到匹配的方法等等原因则会判断如果不是 isNice
则会抛出异常.
- (void)handleUnRecordedInvocation:(NSInvocation *)anInvocation { if(isNice == NO) { [NSException raise:NSInternalInconsistencyException format:@"%@: unexpected method invoked: %@ %@", [self description], [anInvocation invocationDescription], [self _stubDescriptions:NO]]; } } 复制代码
sharedRemoteConfig
看明白了实例方法实际上是通过 mock 对象进行消息转发进行处理, 然后获取期望的结果并返回的, 那类方法又是如何实现 mock 的呢?
关键就在初始化时做了一个准备工作 prepareClassForClassMethodMocking
和刚刚 addStub:
的处理上, 一个一个看.
prepareClassForClassMethodMocking
用注释总结整理如下:
- (void)prepareClassForClassMethodMocking { // 1. 排除一些会引起错误的类 `NSString` / `NSArray` / `NSManagedObject` if([[mockedClass class] isSubclassOfClass:[NSString class]] || [[mockedClass class] isSubclassOfClass:[NSArray class]]) return; if([mockedClass isSubclassOfClass:objc_getClass("NSManagedObject")]) return; // 2. 如果之前有对该类进行的 mock 未停止则停止 id otherMock = OCMGetAssociatedMockForClass(mockedClass, NO); if(otherMock != nil) [otherMock stopMockingClassMethods]; OCMSetAssociatedMockForClass(self, mockedClass); // 3. 动态创建一个 mock 的类(例子里是 `RemoteConfig` )的子类. classCreatedForNewMetaClass = OCMCreateSubclass(mockedClass, mockedClass); originalMetaClass = object_getClass(mockedClass); id newMetaClass = object_getClass(classCreatedForNewMetaClass); // 4. 创建一个空方法 `initializeForClassObject`, 作为子类的 `initialize` 方法, 以便排除 mock 类 `initialize` 中特殊逻辑的影响. Method myDummyInitializeMethod = class_getInstanceMethod([self mockObjectClass], @selector(initializeForClassObject)); const char *initializeTypes = method_getTypeEncoding(myDummyInitializeMethod); IMP myDummyInitializeIMP = method_getImplementation(myDummyInitializeMethod); class_addMethod(newMetaClass, @selector(initialize), myDummyInitializeIMP, initializeTypes); // 5. `object_setClass(mockedClass, newMetaClass)` 设置 mock 的类的 Class 为新创建的子类的元类. object_setClass(mockedClass, newMetaClass); // 6. 为其元类添加一个 `+ (void)forwardInvocation:` 的实现 `forwardInvocationForClassObject:` 以便可以对类方法进行消息转发. Method myForwardMethod = class_getInstanceMethod([self mockObjectClass], @selector(forwardInvocationForClassObject:)); IMP myForwardIMP = method_getImplementation(myForwardMethod); class_addMethod(newMetaClass, @selector(forwardInvocation:), myForwardIMP, method_getTypeEncoding(myForwardMethod)); // 7. 遍历该元类的方法列表, 对其自身的方法(非 `NSObject` 继承来的) 方法执行 `setupForwarderForClassMethodSelector:` NSArray *methodBlackList = @[@"class", @"forwardingTargetForSelector:", @"methodSignatureForSelector:", @"forwardInvocation:", @"isBlock", @"instanceMethodForwarderForSelector:", @"instanceMethodSignatureForSelector:"]; [NSObject enumerateMethodsInClass:originalMetaClass usingBlock:^(Class cls, SEL sel) { if((cls == object_getClass([NSObject class])) || (cls == [NSObject class]) || (cls == object_getClass(cls))) return; NSString *className = NSStringFromClass(cls); NSString *selName = NSStringFromSelector(sel); if(([className hasPrefix:@"NS"] || [className hasPrefix:@"UI"]) && ([selName hasPrefix:@"_"] || [selName hasSuffix:@"_"])) return; if([methodBlackList containsObject:selName]) return; @try { [self setupForwarderForClassMethodSelector:sel]; } @catch(NSException *e) { // ignore for now } }]; } 复制代码
addStub:
的特殊逻辑实际上也是执行了 setupForwarderForClassMethodSelector:
, 该方法进行了排重. 实现如下:
- (void)setupForwarderForClassMethodSelector:(SEL)selector { SEL aliasSelector = OCMAliasForOriginalSelector(selector); if(class_getClassMethod(mockedClass, aliasSelector) != NULL) return; Method originalMethod = class_getClassMethod(mockedClass, selector); IMP originalIMP = method_getImplementation(originalMethod); const char *types = method_getTypeEncoding(originalMethod); Class metaClass = object_getClass(mockedClass); IMP forwarderIMP = [originalMetaClass instanceMethodForwarderForSelector:selector]; class_addMethod(metaClass, aliasSelector, originalIMP, types); class_replaceMethod(metaClass, selector, forwarderIMP, types); } 复制代码
添加一个 ocmock_replaced_原方法名
, 将该方法指向原来方法的方法指针, 并且将原来方法指向到一个不存在的方法上, 以便可以走消息转发, 也就是刚刚添加的 forwardInvocationForClassObject:
在 forwardInvocationForClassObject:
方法真正调用时, 也是调用了 handleInvocation:
, 便统一了消息转发的流程, 实现了对类方法的 mock, 不同的是对于没有匹配到的方法直接执行了 invocation
,
StopMocking
对于 stopMocking
方法的调用不是必须的, 在 mock 对象释放掉的时候 dealloc
中会先调用 stopMocking
, 其中干的事就是打扫战场, 由于设置了 mock 对象的元类为动态创建的子类的元类, 所以需要还原
object_setClass(mockedClass, originalMetaClass); 复制代码
然后删除掉动态创建的子类, 选择使用动态创建的子类作为元类并且添加方法, 而不是直接修改元类中的方法, 也是为了最后还原比较容易, 直接释放掉即可.
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Node.js开发实战
[美] Jim R. Wilson / 梅晴光、杜万智、陈琳、纪清华、段鹏飞 / 华中科技大学出版社 / 2018-11-10 / 99.90元
2018年美国亚马逊书店排名第一的Node.js开发教程。 . Node.js是基于Chrome V8引擎的JavaScript运行环境,它采用事件驱动、非阻塞式I/O模型,具有轻量、高效的特点。Node.j s 工作在前端代码与 数据存储层之间,能够提高web应用的工作效率和 响应速度。本书以最新版Node.js 8为基础,从实际案例出发 讲解Node.js的核心工作原理和实用开发技......一起来看看 《Node.js开发实战》 这本书的介绍吧!