XCTest exception when using keyValueObservingExpectationForObject: keyPath: handler:

In my unit tests, I use the method -[XCTestCase keyValueObservingExpectationForObject:keyPath:handler:] to ensure that my NSOperation is complete, here is the code from my XCDYouTubeKit project :

 - (void) testStartingOnBackgroundThread { XCDYouTubeVideoOperation *operation = [[XCDYouTubeVideoOperation alloc] initWithVideoIdentifier:nil languageIdentifier:nil]; [self keyValueObservingExpectationForObject:operation keyPath:@"isFinished" handler:^BOOL(id observedObject, NSDictionary *change) { XCTAssertNil([observedObject video]); XCTAssertNotNil([observedObject error]); return YES; }]; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ XCTAssertFalse([NSThread isMainThread]); [operation start]; }); [self waitForExpectationsWithTimeout:5 handler:nil]; } 

This test always passes when I run it locally on my Mac, but sometimes it fails on Travis with this error:

failed: catch "NSRangeException", "Cannot delete the observer <_XCKVOExpectation 0x1001846c0> for the key path" isFinished "from <XCDYouTubeVideoOperation 0x1001b9510> because it is not registered as an observer."

Am I doing something wrong?

+8
ios key-value-observing macos xctest
source share
1 answer

Your code is correct; you have encountered a bug in the XCTest framework. Here is a detailed explanation, you can skip to the end of this answer if you are just looking for a workaround.

When you call keyValueObservingExpectationForObject:keyPath:handler: _XCKVOExpectation object is created under the hood. It is responsible for monitoring the / keyPath object that you passed. Once the KVO notification is triggered, the _safelyUnregister method is _safelyUnregister , in which the observer is deleted. Here is a (reverse engineering) implementation of the _safelyUnregister method.

 @implementation _XCKVOExpectation - (void) _safelyUnregister { if (!self.hasUnregistered) { [self.observedObject removeObserver:self forKeyPath:self.keyPath]; self.hasUnregistered = YES; } } @end 

This method is called again at the end of waitForExpectationsWithTimeout:handler: and when the _XCKVOExpectation object _XCKVOExpectation freed. Note that the operation ends with a background thread, but the test runs in the main thread. So you have a race condition: if _safelyUnregister is called in the main thread before the hasUnregistered property hasUnregistered set to YES in the background thread, the observer is deleted twice, as a result of which Can cannot remove the observer exception.

So, to get around this problem, you must protect the _safelyUnregister method with a lock. Here is a snippet of code to compile for a test purpose that will take care of fixing this error.

 #import <objc/runtime.h> __attribute__((constructor)) void WorkaroundXCKVOExpectationUnregistrationRaceCondition(void); __attribute__((constructor)) void WorkaroundXCKVOExpectationUnregistrationRaceCondition(void) { SEL _safelyUnregisterSEL = sel_getUid("_safelyUnregister"); Method safelyUnregister = class_getInstanceMethod(objc_lookUpClass("_XCKVOExpectation"), _safelyUnregisterSEL); void (*_safelyUnregisterIMP)(id, SEL) = (__typeof__(_safelyUnregisterIMP))method_getImplementation(safelyUnregister); method_setImplementation(safelyUnregister, imp_implementationWithBlock(^(id self) { @synchronized(self) { _safelyUnregisterIMP(self, _safelyUnregisterSEL); } })); } 

EDIT

This bug has been fixed in Xcode 7 beta 4 .

+10
source share

All Articles