记XCTest测试框架使用中碰到的“白马非马”问题探究

一、前言

“白马非马,可乎?”曰:“可。”

曰:“何哉?”曰:“马者,所以命形也。白者,所以命色也。命色者,非命形也,故曰白马非马。”

曰:“有白马,不可谓无马也。不可谓无马者,非马也?有白马为有马,白之非马,何也?”

曰:“求马,黄、黑马皆可致。求白马,黄、黑马不可致。使白马乃马也,是所求一也,所求一者,白者不异马也。所求不异,如黄、黑马有可有不可,何也?可与不可其相非明。故黄、黑马一也,而可以应有马,而不可以应有白马,是白马之非马审矣。” ……

上面这段是战国时期平原君门客公孙龙的名言,也就是让诸多先哲吐血的“白马非马”辩题。据说孔子的六世孙孔穿(这名字我也是服了……)当时年轻气盛,上门挑战公孙龙,结果被他以自己老祖宗孔丘的一段话作为论据针锋相对驳得体无完肤,恐怕孔老夫子也没想到自己这不成器的孙子居然是因此而青史留名……

其实说到底,“白马非马”其实是逻辑辩证中很普通的论断,也就是集合“马”的真子集“白马”,肯定不等于“马”这个集合本身罢了,如果“是”代表完全等同,那么自然就得出了结论——白马非马;但如果我们定义“是”表示从属归属这意思,那么白马又是马了。那么多大牛被公孙龙的诡辩给忽悠了,无非是没有意识到给绕进去罢了。

当然,计算机语言世界里的OO思想其实也有相似的论断:类A是父类,类B是继承于A的子类,那么A、B并不等同,但如果存在一个可以判定继承关系的方法 isSubclassOf,那么必然有如下(伪代码)表达式

B:isSubclassOf(A) == true 

的条件判断成立,相信大家对此都无异议。

但不幸的是,在使用XCTest构造某个项目的单元测试case时,我却发现了匪夷所思与上述论断相悖的情况……

二、追根溯源


曲径通幽

其实那部分单元测试代码逻辑很简单,无非是利用TBJsonModel(厂内JsonModel实现)将反序列化后的NSArray/NSDictionary容器对象通过ORM转换成自定义的Model对象(定义如下)。

@protocol CpmDataModel <NSObject>
@end

@interface CpmDataModel : TBJSONModel
....
@property (nonatomic, strong) NSString *synth;
....
@end

@interface CpmDataListModel : TBJSONModel
@property (nonatomic, strong) NSArray<CpmDataModel> *model;
@end

然后继续处理 CpmDataListModel对象中model属性容纳的CpmDataModel实例,但在如下语句执行时报错并crash,LLDB提示的原因iOS同学再熟悉不过了:“-[__NSDictionary0 synth]: unrecognized selector sent to instance 0x61000001e600”……

if (listModel.model.firstObject.synth.integerValue == 1) {
    ......
}

很明显,反序列化后的NSDictionary对象并没有如愿转换成 CpmDataModel类实例,可这是为什么呢?


穷追不舍

既然看起来是TBJsonModel的锅,那么思路也自然而然转到研判其内部实现上来 —— 下载好源码后,在podfile指定TBJsonModel使用本地源码而非远程仓库地址作为依赖然后一起编译,然后跑起来即可。

结果就碰到了让我头大的“白马非马”问题,下面的这个条件判断表达式,在modelContainerElementClass为CpmDataModel.class时(参照定义代码,CpmDataModel继承于TBJSONModel),居然结果是 NO……

if (modelContainerElementClass && [modelContainerElementClass isSubclassOfClass:[TBJSONModel class]]) {
    ......
}

调试到这的时候,我的心情是崩溃的,这……难道千百年来公孙龙的冤魂仍然在作祟?
作为不信邪的社会主义新青年,我毅然决然做了新的尝试:

NSString *className1 = NSStringFromClass(modelContainerElementClass.superclass);
NSString *className2 = NSStringFromClass([TBJSONModel class]);
BOOL flag = [className1 isEqualToString:className2];

结果居然是 flag 为YES!!😱😱😱

再试:

(lldb) po [modelContainerElementClass isSubclassOfClass:[TBJSONModel class]]  
YES

(lldb) po ((objc_class *)modelContainerElementClass.superclass)->isa
TBJSONModel

哎??在console中执行判断指令,居然结果反而是YES??难道是LLDB 运行时库精神分裂了吗??🙄🙄🙄


破釜沉舟

整整一天都在纠结这个诡异的问题,但却百思不得其解,甚至把 TBJsonModel的作者卢克都给打扰了一通,也是毫无收获。

正在走投无路,忽然间console里的一条日志引起了我的注意

objc[97073]: Class XXXX is implemented in both /Users/..../munionDemo.app/munionDemo (0x1027316f8) and /Users/..../munionDemo.app/PlugIns/munionDemoTests.xctest/munionDemoTests (0x117fb5618). One of the two will be used. Which one is undefined.

难道是因为类实现的重复引入导致的该问题?循着这个思路继续查下去,发现objective-c引以为傲的runtime库特殊的设定:

Because the Objective-C runtime uses a flat name space, you must be careful when developing a plug-in not to choose a name that conflicts with the application code or another plug-in loaded by the application. This section describes how to name your classes and other symbols to avoid this kind of conflict.

The Objective-C runtime provides only a single flat, global name space per process for all exported symbols. This includes all global variables, nonstatic functions, class names, and categories declared for individual classes; protocols have a separate global name space of their own.

也就是说,对每一个iOS沙盒app来说,Objective-C runtime分别维护了一个公用的命名空间(flat namespace),在其中存在着所有导入的符号、非静态方法、类名以及类的category。
大概的运行机制如下:

1.加载App/Bundle的二进制映像,并检查其各种依赖关系
2.每一个二进制映像被加载的同时,其中包含的各个objc类被注册到runtime命名空间中
3.如果具有相同名称的类被再次加载,objc runtime的行为则不可预知。

也就是说,如果相同名称的类被加载多次,可能的情况可能是其中某一份实现会被真正加载(应该是runtime库的默认动作);如果类名相同但其真实类型发生冲突(比如一个继承于UIViewController,另一个则是继承于UIView),那么很有可能由于实现不一致导致App crash(unrecognized selector...)

也就是说,如果上述场景中,TBJsonModel类被runtime加载了两次,其内存地址分别是0x00001111 和 0x00002222,假设那么可能的结果就是测试用例跑起来的时候CpmDataModel类随便选择了一个类继承(假设为0x00001111),那么在执行到如下逻辑时

if (modelContainerElementClass && [modelContainerElementClass isSubclassOfClass:[TBJSONModel class]]) {
    ......
}

实际执行的比较很可能为

if (modelContainerElementClass && [继承TBJsonModel(0x00001111)类的CpmDataModel类  isSubclassOfClass:[TBJSONModel(0x00002222)class]]) {
    ......
}

自然是不会通过的。通过NSLog打印 CpmDataModel.superclass 地址以及 TBJsonModel地址,也确实是不一致的

至于在console中执行的指令又满足,则可能是LLDB的特殊处理,内存中不存在重复导入类实现导致的冲突。


疾在腠理

咳咳,上面症状说了一大堆,还是得瞧瞧病根出在哪里。

由于使用了XCTest框架,实际上App可执行文件 和Unit Test Bundle是分两个二进制文件存在的,在执行Test的时候才会动态加载(因而不会在链接时触发duplicate symbols导致编译失败),进而产生了类冲突的问题。

TBJsonModel是二方组件形式引入的依赖,所以还要去描述依赖关系的podfile里探究一番:

source "git@gitlab.alibaba-inc.com:alipods/specs.git"
source "git@gitlab.alibaba-inc.com:alipods/specs-mirror.git"

platform :ios, '8.0'

def all_pods
    platform:ios, '8.0'

    ....
    pod 'TBJSONModel',      :path=>'../TBJSONModel'
    ....
end

target 'munionDemo' do
    all_pods
end

target 'munionDemoTests' do
    all_pods
end 

唉,当初为了解决单元测试Target munionDemoTests编译不过的问题,分别为App的正常target munionDemo 和 munionDemoTests 各指定了一遍组件依赖,结果编译倒是没问题了,反而导致为这两个target在编译时分别都link上了 all_pods指定的所有组件——也就是说,在单元测试case执行时,这些组件中的类都将被重复导入,真的是一失足即成千古恨……

三、闲话再叙

既然是同名类冲突,其实还有别的可能:

  • 一是业务代码被加载了多次,此情况往往是在不同target配置项 Build Phases中 Complie Sources中指定了相同的源文件导致;解决办法也简单,如果target有依赖关系,那么确保只在某一个target中编译该文件即可

  • 二是纯粹的命名冲突,自己的业务逻辑中的类定义,和二方/三方组件库中的类定义发生重复导致;这也是为什么苹果建议文件/类定义一定要带上自定义的前缀的缘故咯

言归正传~ 利用XCTest框架编写的单元测试Case独立为target,在执行 Product->Test时,会先把App可执行文件加载并run起来之后,才会动态加载执行起来的,故重复引入类的问题被延后到运行时,且加载的类其定义实现完全一致,故若非特殊场景下的检测,其实很难暴露出来。碰巧是在TBJsonModel的实现中存在继承关系的检查,才让整个冰山漏出了险峻的一角;看起来,“锅”不仅不能让 TBJsonModel 来背,原来还有功劳啊哈哈……

参考

https://stackoverflow.com/questions/6149673/class-foo-is-implemented-in-both-myapp-and-myapptestcase-one-of-the-two-will-be/6153555#6153555

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容