mock in iOS

騎著jm的hi發表於2018-07-18

部落格連結

在物件導向程式設計中,有個非常有趣的概念叫做duck type,意思是如果有一個走路像鴨子、游泳像鴨子,叫聲像鴨子的東西,那麼它就可以被認為是鴨子。這意味著當我們需要一個鴨子物件時,可以通過instantiation或者interface兩種機制來提供鴨子物件:

@interface Duck : NSObject

@property (nonatomic, assign) CGFloat weigh;

- (void)walk;
- (void)swim;
- (void)quack;

@end

/// instantiation
id duckObj = [[Duck alloc] init];
[TestCase testWithDuck: duckObj];

/// interface
@protocol DuckType

- (void)walk;
- (void)swim;
- (void)quack;
- (CGFloat)weigh;
- (void)setWeigh: (CGFloat)weigh;

@end

@interface MockDuck : NSObject<DuckType>
@end

id duckObj = [[MockDuck alloc] init];
[TestCase testWithDuck: duckObj];
複製程式碼

後者定義了一套鴨子介面,模仿出了一個duck type物件,雖然物件是模擬的,但這並不阻礙程式的正常執行,這種設計思路,可以被稱作mock

通過製造模擬真實物件行為的假物件,來對程式功能進行測試或除錯

interface和mock

雖然上面通過interface的設計實現了mock的效果,但兩者並不能劃上等號。從設計思路上來說,interface是抽象出一套行為介面或者屬性,且並不關心實現者是否存在具體實現上的差異。而mock需要模擬物件和真實物件兩者具有相同的行為和屬性,以及一致的行為實現:

/// interface
一個測試工程師進了一間酒吧點了一杯啤酒
一個開發工程師進了一間咖啡廳點了一杯咖啡

/// mock
一個測試工程師進了一間酒吧點了一杯啤酒
一個模擬的測試工程師進了一間酒吧點了一杯啤酒
複製程式碼

從實現上來說,雖然interface可以通過抽象出真實物件所有的行為和屬性來完成對真實物件的百分百還原,但這樣就違背了interface應只提供一系列相同功能介面的原則,因此interface更適用於模組解耦、功能擴充套件相關的工作。而mock由於要求模擬物件對真實物件百分百的copy,更多的應用在除錯、測試等方面的工作

如何實現mock

個人把mock根據模擬程度分為行為模擬和完全模擬兩種情況,對於真實物件的模擬,總共包括四種方式:

  • inherit
  • interface
  • forwarding
  • isa_swizzling

行為模擬

行為模擬追求的是對真實物件的核心行為進行還原。由於OC的訊息處理機制,因此無論是interface的介面擴充套件還是forwarding的轉發處理都可以完成對真實物件的模擬:

/// interface
@interface InterfaceDuck : NSObject<DuckType>
@end

/// forwarding
@interface ForwardingDuck : NSObject

@property (nonatomic, strong) Duck *duck;

@end

@implementation MockDuck

- (id)forwardingTargetForSelector: (SEL)selector {
    return _duck;
}

@end
複製程式碼

interfaceforwarding的區別在於後者的真正處理者可以是真實物件本身,不過由於forwarding不一定非要轉發給真實物件處理,所以二者既可以是行為模擬,也可以是完全模擬。但更多時候,兩者是duck type

完全模擬

完全模擬要求以假亂真,在任何情況下模擬物件可以表現的跟真實物件無差別化:

@interface MockDuck : Duck
@end

/// inherit
MockDuck *duck = [[MockDuck alloc] init];
[TestCase testWithDuck: duck];

/// isa_swizzling
Duck *duck = [[Duck alloc] init];
object_setClass(duck, [MockDuck class]);
[TestCase testWithDuck: duck];
複製程式碼

雖然inheritisa_swizzling兩種方式的行為沒有任何差別,但是後者更像是借用了子類的所有屬性、結構,而只呈現Duck的行為。但在單元測試中的mock,由於並不存在直接進行isa_swizzling的真實物件,還需要動態的生成class來完成模擬物件的構建:

Class MockClass = objc_allocateClassPair(RealClass, RealClassName, 0);
objc_registerClassPair(MockClass);

for (Selector s in getClassSelectors(RealClass)) {
    Method m = class_getInstanceMethod(RealClass, s);
    class_addMethod(MockClass, s, method_getImplementation(m), method_getTypeEncoding(m));
}
id mockObj = [[MockClass alloc] init];
[TestCase testWithObj: mockObj];
複製程式碼

結構模擬

結構模擬是一種威力和破壞能力同樣強大的mock方式,由於資料結構最終採用二進位制儲存,結構模擬嘗試構建整個真實物件的二進位制結構佈局,然後修改結構內變數。同時,結構模擬並不要求必須掌握物件的準確佈局資訊,只要清楚我們需要修改的資料位置就行了。譬如OCblock實際上是一個可變長度的結構體,結構體的大小會隨著捕獲的變數數量增大,但是前32位的儲存資訊是固定的,其結構體如下:

struct Block {
    void *isa;
    int flags;
    int reserved;
    void (*invoke)(void *, ...);
    struct BlockDescriptor *descriptor;
    /// catched variables
};
複製程式碼

其中invoke指標指向了其imp的函式地址,只要修改這個指標值,就能改變block的行為:

struct MockBlock {
    ...
};

void printHelloWorld(void *context) {
    printf("hello world\n");
};

dispatch_block_t block = ^{
    printf("I'm block!\n");
};
struck MockBlock *mock = (__bridge struct MockBlock *)block;
mock->invoke(NULL);
mock->invoke = printHelloWorld;
block();
複製程式碼

通過mock真實物件的結構佈局來獲取真實物件的行為,甚至修改行為,雖然這種做法非常強大,但如果因為系統版本的差異導致物件的結構佈局存在差異,或者獲取的佈局資訊並不準確,就會破壞資料本身,導致意外的程式錯誤

什麼時候用mock

從個人開發經歷來看,如果有以下情況,我們可以考慮使用mock來替換真實物件:

  • 型別缺失執行環境
  • 結果依賴於非同步操作
  • 真實物件對外不可見

其中前兩者更多發生在單元測試中,而後者多與除錯工作相關

型別缺失執行環境

NSUserDefaults會將資料以key-value的對應格式儲存在沙盒目錄下,但在單元測試的環境下,程式並沒有程式設計成二進位制包,因此NSUserDefaults無法被正常使用,因此使用mock可以還原測試場景。通常會選擇OCMock來完成單元測試的mock需求:

- (void)testUserDefaultsSave: (NSUserDefaults *)userDefaults {
      [userDefaults setValue: @"value" forKey: @"key"];
      XCTAssertTrue([[userDefaults valueForKey: @"key"] isEqualToString: @"value"])
}

id userDefaultsMock = OCMClassMock([NSUserDefaults class]);
OCMStub([userDefaultsMock valueForKey: @"key"]).andReturn(@"value");
[self testUserDefaultsSave: userDefaultsMock];
複製程式碼

實際上在單元測試中,與沙盒相關的IO類都幾乎處於不可用狀態,因此mock這樣的資料結構可以很好的提供對沙盒儲存功能的支援

結果依賴於非同步操作

XCTAssert為非同步操作提供了一個延時介面,當然並沒有卵用。非同步處理往往是單元測試的殺手,OCMock同樣提供了對於非同步的介面支援:

- (void)requestWithData: (NSString *)data complete: (void(^)(NSDictionary *response))complete;

OCMStub([requestMock requestWithData: @"xxxx" complete: [OCMArg any]]).andDo(^(NSInvocation *invocation) {
    /// invocation是request方法的封裝
    void (^complete)(NSDictionary *response);
    [invocation getArgument: &complete atIndex: 3];

    NSDictionary *response = @{
                              @"success": @NO,
                              @"message": @"wrong data"
                              };
    complete(response);
});
複製程式碼

拋開已有的第三方工具,通過訊息轉發機制也可以實現一個處理非同步測試的工具:

@interface AsyncMock : NSProxy {
    id _callArgument;
}

- (instancetype)initWithAsyncCallArguments: (id)callArgument;

@end

@implementation AsyncMock

- (void)forwardInvocation: (NSInvocation *)anInvocation {
    id argument = nil;
    for (NSInteger idx = 2; idx <anInvocation.methodSignature.numberOfArguments; idx++) {
        [anInvocation getArgument: &argument atIndex: idx];
        if ([[NSString stringWithUTF8String: @encode(argument)] hasPrefix: @"@?"]) {
            break;
        }
    }
    if (argument == nil) {
        return;
    }

    void (^block)(id obj)  = argument;
    block(_callArgument;)
}

@end

NSDictionary *response = @{
                          @"success": @NO,
                          @"message": @"wrong data"
                          };
id requestMock = [[AsyncMock alloc] initWithAsyncCallArgument: response];
[requestMock requestWithData: @"xxxx" complete: ^(id obj) {
    /// do something when request complete
}];
複製程式碼

轉發的最後一個階段會將訊息包裝成NSInvocation物件,invocation提供了遍歷獲取呼叫引數的資訊,通過@encode()對引數型別進行判斷,獲取回撥block並且呼叫

真實物件對外不可見

真實物件對外不可見存在兩種情況:

  • 結構不可見
  • 結構例項均不可見

幾乎在所有情況下我們遇到的都是結構不可見,比如私有類、私有結構等,上文中提到的block結構體就是最明顯的例子,通過clang命令重寫類檔案基本可以得到這類物件的結構內部。由於上文已經展示過block的佈局模擬,這裡就不再多說

clang -rewrite-objc xxx.m
複製程式碼

而後者比較特殊,無論是結構佈局,還是例項物件,我們都無法獲取到。打個比方,我需要統計應用編譯包的二進位制段的資訊,通過使用hopper工具可以得到objc_classlist_DATA段的情況:

mock in iOS

由於此時沒有任何的真實物件和結構參考,只能知道每一個__objc_data的長度是72位元組。因此這種情況下需要先模擬出等長於二進位制資料的結構體,然後通過輸出16進位制資料來匹配資料段的佈局資訊:

struct __mock_binary {
    uint vals[18];
};

NSMutableArray *binaryStrings = @[].mutableCopy;
for (int idx = 0; idx <18; idx++) {
    [binaryStrings appendString: [NSString stringWithFormat: @"%p", (void *)binary->vals[idx]]];
}
NSLog(@"%@", [binaryStrings componentsJoinedByString: @"  "]);
複製程式碼

通過分析16進位制段資料,結合hopper得出的資料段資訊,可以繪製出真實物件的佈局資訊,然後採用結構模擬的方式構建模擬的結構體:

struct __mock_objc_data {
    uint flags;
    uint start;
    uint size;
    uint unknown;
    uint8_t *ivarlayouts;
    uint8_t *name;
    uint8_t *methods;
    uint8_t *protocols;
    uint8_t *ivars;
    uint8_t *weaklayouts;
    uint8_t *properties;
};

struct __mock_objc_class {
    uint8_t *meta;
    uint8_t *super;
    uint8_t *cache;
    uint8_t *vtable;
    struct __mock_objc_data *data;
};

struct load_command *cmds = (struct load_command *)sizeof(struct mach_header_64);
for (uint idx = 0; idx <header.ncmds; idx++, cmds = (struct load_command *)(uint8_t *)cmds + cmds->cmdsize) {
    struct segment_command_64 *segCmd = (struct segment_command_64 *)cmds;
    struct section_64 *sections = (struct section_64 *)((uint8_t *)cmds +sizeof(struct segment_command_64));

    uint8_t *secPtr = (uint8_t *)section->offset;
    struct __mock_objc_class *objc_class = (struct __mock_objc_class *)secPtr;
    struct __mock_objc_data *objc_data = objc_class->data;
    printf("%s in objc_classlist_DATA\n", objc_data->name);
    ......
}
複製程式碼

上述程式碼已做簡化展示。實際上遍歷machO需要將二進位制檔案載入記憶體,還要考慮hopper載入跟自己手動載入的地址偏移差,最終求出一個正確的地址值。在整個遍歷過程中,除了headercommand等結構是系統暴露的之後,其他儲存物件都需要去檢視hopper加上進位制數值進行推導,最終mock出結構完成工作

總結

mock並不是一種特定的操作或者程式設計手段,它更像是一種剖析工程細節來解決特殊環境下難題的解決思路。無論如何,如果我們想要繼續在開發領域上繼續深入,必然要學會從更多的角度和使用更多的工具來理解和解決開發上的難題,而mock絕對是一種值得學習的開發思想

關注我的公眾號獲取更新資訊

相關文章