background

Unit tests have been written using XCTest + OCMock for some time now. I never really understood how OCMock was implemented, so I wanted to read the source code sometime and demythologizesocMock. When reading the source code, I found that the core mechanism is NSProxy + message forwarding, so before looking at the source code, I will briefly review the relevant knowledge.

forward

Objc_msgSend is an object whose parent class and root class do not find a method in the method cache or method list: Unrecognized selector sent to instance, but before a crash, there is a message forwarding mechanism to try to save.

The first step, first will call forwardingTargetForSelector: method to get a can handle the object Selector. Resend the message to the object, and go to the second step if it returns nil.

The second step, call methodSignatureForSelector: Method to get the method signature NSMethodSignature, which contains Selector and parameter information, used to generate the NSInvocation and throw a doesNotRecognizeSelector exception if returned to nil.

Step 3: call forwardInvocation: NSInvocation is handled, if the parent class is not handled all the way to the root class, doesnot return cognizeselector.

This is the case with simply collating messages and forwarding mechanisms. For more on this principle, I recommend reading the objective-C Message sending and forwarding mechanism by Xiaoyu Yang.

NSProxy

An abstract superclass defining An API for objects that act as standins for other objects or for objects that don’t exist yet.

As explained in the documentation, NSProxy is an abstract parent class (root class is more appropriate) that defines an API for objects and acts as a stand-in for other objects or objects that no longer exist.

The root classes in iOS are NSObject and NSProxy. NSObject is both the root class and the protocol, and NSProxy implements the protocol, and as an abstract class, it doesn’t provide an initialization method, and it throws an exception if it receives a message that it doesn’t respond to, so, Need to use a subclass inherits implement initialization method, and then by rewriting forwardInvocation: and methodSignatureForSelector: method to handle its own unrealized message processing.

Here are two Tips that are often used.

YYWeakProxy

YYWeakProxy is a tool provided in YYKit for holding a weak object and is usually used to solve the problem of NSTimer and CADisplayLink cyclic references. For example, we often use NSTimer in an object, the object strongly references NSTimer, and when the object is used as a target, it will be strongly referenced by NSTimer, forming a circular reference, so that it cannot be freed.

See the full source code here

A brief introduction to how YYWeakProxy is implemented, first use the initialization method, weak reference target object.

- (instancetype)initWithTarget:(id)target {
    _target = target;
    return self;
}
Copy the code

By implementing forwardingTargetForSelector: ways to forward the message to _taget, ACTS as the bridge, breaking the strong reference to target such as NSTimer.

- (id)forwardingTargetForSelector:(SEL)selector {
    return _target;
}
Copy the code

And then there’s another implementation of these two methods, and why is that?

- (void)forwardInvocation:(NSInvocation *)invocation {
    void *null = NULL;
    [invocation setReturnValue:&null];
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector {
    return [NSObject instanceMethodSignatureForSelector:@selector(init)];
}
Copy the code

Because the target is a weak reference, if released, will be set to nil, forwarding method forwardingTargetForSelector: is equivalent to the returns nil, so there is no way to deal with the news, will lead to collapse.

The invocation invocation invocation invocation invocation is not invoked but returns nil. The invocation will return nil, no crash.

Implementing multiple inheritance

Multiple inheritance is not possible in objC, but we can use NSProxy to simulate the effect of multiple inheritance by actually turning the target in the above example into an array to hold multiple targets.

Then we can implement multiple inheritance by forwarding the method to each target according to respondto Selector: who can handle it.

- (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;
}
Copy the code

Let’s get down to business and start looking at the core source implementation of OCMock.

example

I’m going to read the source code implementation of OCMock bit by bit from a frequently used example.

In unit testing, it is often necessary to screen out the interference of external factors, such as the results of external methods that the method relies on. In our project, a large number of switch configurations are used, such as the following line of code to determine whether to turn on a certain function.

BOOL enableXX = [[RemoteConfig sharedRemoteConfig] enableXXFeature];
Copy the code

Use OCMock to Mock the result as follows:

// Setup
id configMock = OCMClassMock([RemoteConfig class]);
OCMStub([configMock sharedRemoteConfig]).andReturn(configMock);
OCMStub([configMock enableXXFeature]).andReturn(YES);
// Assert.// Teardown
[configMock stopMocking];
Copy the code

The first line creates a mock object of the RemoteConfig class called configMock;

The second line mock out the class method of [configMock sharedRemoteConfig] and andReturn adds the return value to the mock object. RemoteConfig sharedRemoteConfig always returns a mock object. Then add a return value to the enableXXFeature method of the mock object to implement the mock switch.

In line 3, mock out the instance method of [configMock enableXXFeature] and andReturn adds a constant return value of YES.

OCMock uses a lot of macro definitions, so take a step-by-step look at the Preprocess functionality provided by Xcode to see what’s going on.

OCMClassMock

In the first line, the OCMClassMock macro expands as follows:

id configMock = [OCMockObject niceMockForClass:[RemoteConfig class]].Copy the code

This OCMockObject is a subclass of NSProxy that implements message forwarding, and niceMockForClass is essentially a call

+ (id)mockForClass:(Class)aClass {
    return [[[OCClassMockObject alloc] initWithClass:aClass] autorelease];
}
Copy the code

We set an isNice instance variable and mark it with YES. This does not change the core principle. OCMock uses OCMStrictClassMock to make a strict mock. The OCMClassMock will crash, and this OCMClassMock is nice, and there’s no Stub method to protect it from crashing, and the OCMClassMock that we use is nice.

OCMStub

The whole OCMStub is the core point, and the rest Expect and Reject are mostly the same, bit by bit.

enableXXFeature

an

OCMStub([configMock enableXXFeature]).andReturn(YES);
Copy the code

To start with, let’s look at the expansion of the OCMStub. I’ve cleaned it up a bit and it looks like this:

({
    [OCMMacroState beginStubMacro];
    OCMStubRecorder *recorder = ((void *)0);
    @try{
        [configMock enableXXFeature];
    } @finally {
        recorder = [OCMMacroState endStubMacro];
    }
    recorder;
});
Copy the code

The two begin and end methods add an OCMStubRecorder tag to the dictionary of the current thread. The code is as follows:

+ (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];
}
Copy the code

Stub

Key in the middle row [configMock enableXXFeature] call, there is this OCMStubRecorder mark, will be in the message forwarding forwardingTargetForSelector: This method records the configMock object while returning the Recorder object for processing.

- (id)forwardingTargetForSelector:(SEL)aSelector {
    if([OCMMacroState globalState] ! =nil) {
        OCMRecorder *recorder = [[OCMMacroState globalState] recorder];
        [recorder setMockObject:self];
        return recorder;
    }
    return nil;
}
Copy the code

So in order to understand the above why to put the recorder object into the dictionary of the current thread, is to the same line of code [configMock enableXXFeature], in whether there is a Recorder, there can be two very different processing routes, is very clever. That is, when the Stub is defined, it can be handed over to the Recorder to handle, and when the method is actually called, the mock object can handle the following flow of message forwarding.

The Recorder object is of type OCMStubRecorder, which inherits from OCMRecorder, which in turn inherits from NSProxy. So the Recorder also needs to handle the message forwarding mechanism.

The recorder methodSignatureForSelector: If it doesn’t, then it gets the method signature from the class method. If it does, mark it in the invocationMatcher record to see if it is a class method or not, and return nil. According to message forwarding mechanism, DoesNotRecognizeSelector exception is thrown.

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    if([invocationMatcher recordedAsClassMethod])
        return [mockedClass methodSignatureForSelector:aSelector];

    NSMethodSignature *signature = [mockObject methodSignatureForSelector:aSelector];
    if(signature == nil) {
       if([mockedClass respondsToSelector:aSelector]) {
            // mark the Selector as a class method, and mark it on the invocationMatcher
            [self classMethod];
            // Re-call this method before the method, so that it is returned by the first two lines
            signature = [selfmethodSignatureForSelector:aSelector]; }}return signature;
}
Copy the code

The first two lines mean that the method signature of the class method is returned directly if it is already marked as a class method.

The forwardInvocation (vice versa) will not be used without the invocation.

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    [anInvocation setTarget:nil];
    [invocationMatcher setInvocation:anInvocation];
    [mockObject addStub:invocationMatcher];
}
Copy the code

The purpose is to disable the Invocation with setTarget:nil, record and manage the Invocation with invocationMatcher, We then pass this invocationMatcher to the mockObject, the configMock object we documented above.

In the addStub: method, if it’s an instance method it just saves the invocationMatcher in an array, and if it’s a class method we’ll look at Stub sharedRemoteConfig class method later.

So the whole OCMStub process is understood. In a simple arrangement of the relationship between objects, easy to understand.

Mock objects hold an array of invocationMatcher objects, each representing a Stub(or Expect, etc.) once, and whether the method is a class method or an instance method.

Each invocationMatcher holds the Invocation object, which is used for invocation invocation, matching with the invocation invocation, and checking the parameters.

In a Stub process, the Recorder object acts as a process manager and records information about the process. After the Stub statement is complete, it is actually released.

andReturn

The OCMStub actually returns the OCMStubRecorder object. Record the required method return value in this object. Expand as follows:

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;
}));
Copy the code

In addition, @encode is a compiler instruction that returns a string internally represented by a type. For example, the YES used here is a BOOL and the internal string representation is “B”. It is more in-depth and easier to judge and process types. About @encode recommended reading this article

Type Encodings

So, the whole logic is basically to wrap this return value in an NSValue, which you can read as

recorder._addReturn(_nsval);
Copy the code

So this _addReturn() is a block that passes in an NSValue and returns itself for chaining purposes. Essentially, valueProviders are wrapped differently depending on the type of the returned value, whether it’s a primitive type or an object. Use OCMBoxedReturnValueProvider basic types, object using OCMReturnValueProvider.

Let’s look at the relationship between objects:

ValueProviders logic is added, with each invocationMatcher holding multiple providers. Because not only can andReturn specify a return value, for example, you can also andDo specify a block to execute after a method is called, and so on. ValueProviders follow a ValueProviders protocol, which requires the HandleProviders invocation:. It doesn’t matter.

Call the process

Call process is actually no recorder, in the OCMStub line after the end of the code will be released. The object relationship looks like this:

Really call is to call such as enableXXFeature method configMock namely OCClassMockObject as said earlier, the process of not having a recorder, forwardingTargetForSelector: The subsequent message forwarding process goes back to get the method signature, which is then processed in forwardInvocation:.

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    @try
    {
        if([self handleInvocation:anInvocation] == NO)
            [self handleUnRecordedInvocation:anInvocation];
    } @catch(NSException*e) { ... }}Copy the code

The core steps are in the handleInvocation:, sorted as follows

- (BOOL)handleInvocation:(NSInvocation *)anInvocation {

// 1. Record the 'Invocation' for 'Expect' verification logic
    @synchronized(invocations) {
        [anInvocation retainObjectArgumentsExcludingObject:self];
        [invocations addObject:anInvocation];
    }

// 2. Match 'invocationMatcher' recorded in 'addStub:'
    OCMInvocationStub *stub = nil;
    @synchronized(stubs) {
        for(stub in stubs) {
            if([stub matchesInvocation:anInvocation])
                break;
        }
        [stub retain];
    }
    if(stub == nil)
        return NO;

/ /... The logic of the Expectaion is omitted

// 3. This stub is called 'invocationMatcher' and is handled by it.
    @try {
        [stub handleInvocation:anInvocation];
    } @finally {
        [stub release];
    }

    return YES;
}
Copy the code

The processing logic of invocationMatcher is as follows:

- (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:[OCMArgActionclass]])
            continue;

        [recordedArg handleArgument:passedArg];
    }

// 4. Pass the record 'ValueProvider' to it for processing
    [invocationActions makeObjectsPerformSelector:@selector(handleInvocation:) withObject:anInvocation];
}
Copy the code

In OCMBoxedReturnValueProvider for example, processing logic is as follows

- (void)handleInvocation:(NSInvocation *)anInvocation {
    const char *returnType = [[anInvocation methodSignature] methodReturnType];
    NSUInteger returnTypeSize = [[anInvocation methodSignature] methodReturnLength];
    char valueBuffer[returnTypeSize];
    NSValue *returnValueAsNSValue = (NSValue *)returnValue;
    
/ / 5. Set the return value to ` invocation of ` ` [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]]; }}Copy the code

This completes the call, where if no matching method is found and so on it decides if it’s not isNice it throws an exception.

- (void)handleUnRecordedInvocation:(NSInvocation *)anInvocation {
    if(isNice == NO) {[NSException raise:NSInternalInconsistencyException format:@"%@: unexpected method invoked: %@ %@"[self description], [anInvocation invocationDescription], [self _stubDescriptions:NO]]. }}Copy the code

sharedRemoteConfig

Now that you see that instance methods are actually processed by a mock object through message forwarding, and then get the expected result and return it, how does that class implement mock?

Key in the initialization time made a preparation prepareClassForClassMethodMocking and just addStub: on the processing of one by one.

PrepareClassForClassMethodMocking with annotation summary arrangement is as follows:

- (void)prepareClassForClassMethodMocking
{
// 1. Exclude some classes' NSString '/' NSArray '/' ns-Managedobject 'that cause errors
    if([[mockedClass class] isSubclassOfClass:[NSString class]] || [[mockedClass class] isSubclassOfClass:[NSArray class]])
        return;
    
    if([mockedClass isSubclassOfClass:objc_getClass("NSManagedObject")])
        return;

// 2. Stop if any previous mock on the class has not been stopped
    id otherMock = OCMGetAssociatedMockForClass(mockedClass, NO);
    if(otherMock ! =nil)
        [otherMock stopMockingClassMethods];

    OCMSetAssociatedMockForClass(self, mockedClass);

// 3. Dynamically create a mock subclass (in this case, 'RemoteConfig').
    classCreatedForNewMetaClass = OCMCreateSubclass(mockedClass, mockedClass);
    originalMetaClass = object_getClass(mockedClass);
    id newMetaClass = object_getClass(classCreatedForNewMetaClass);

// 4. Create an empty method 'initializeForClassObject' as a subclass of 'initialize' to exclude special logic from the mock class '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)' sets the mock Class Class to the metaclass of the newly created subclass.
    object_setClass(mockedClass, newMetaClass);

/ / 6. Add a ` + for its metaclass (void) forwardInvocation: the realization of the ` ` forwardInvocationForClassObject: ` so that the class methods can be forward.
    Method myForwardMethod = class_getInstanceMethod([self mockObjectClass], @selector(forwardInvocationForClassObject:));
    IMP myForwardIMP = method_getImplementation(myForwardMethod);
    class_addMethod(newMetaClass, @selector(forwardInvocation:), myForwardIMP, method_getTypeEncoding(myForwardMethod));

/ / 7. Method of traverse the metaclass list, in its own way (not ` NSObject ` inherited) method performs ` 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}}]; }Copy the code

AddStub: special logic is also performed setupForwarderForClassMethodSelector: actually, this method has carried on the line. The implementation is as follows:

- (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);
}
Copy the code

Add a ocmock_replaced_ original method name, the pointer to the original method method, the method and the original method to a nonexistent method, so that you can go forward, that is just add forwardInvocationForClassObject:

In forwardInvocationForClassObject: The handleInvocation method is also used when the Invocation is made, which makes the message transfer process uniform and mock the class method. The difference is that the Invocation is directly executed when the method is not matched.

StopMocking

StopMocking methods are not mandatory. When a mock object is released, stopMocking will be invoked first in Dealloc to clean up the field. As the metaclass of the mock object is set to the metaclass of a dynamically created subclass, you will need to restore the mock object

object_setClass(mockedClass, originalMetaClass);
Copy the code

Then delete the dynamically created subclass, choose to use the dynamically created subclass as the metaclass and add methods, rather than directly modify the methods in the metaclass, also in order to make the final restore easier, directly release.

The last

For other OCMPartialMock and OCMProtocolMock, the basic principle is similar, so I will not record it. The general principle of OCMock is basically clear. In fact, there are many details that need to be further understood with further learning, welcome to exchange 👏.

References

  • OCMock
  • Principle of Objective-C message sending and forwarding mechanism
  • YYKit
  • Type Encodings