Background
In my app, I have class called FavoritesController that manages objects that the user's marked as favorites, and this favorite status is then used throughout the app. The FavoritesController is designed as a singleton class as there are a number of UI elements throughout the app that needs to know the 'favorite status' for objects in different places, also network requests need to be able to signal that a favorite needs to be invalidated if the server says so.
This invalidation part happens when the server responds with a 404 error, indicating that the favorite object must be removed from the user's favorites. The network fetch function throws an error, which triggers the FavoritesController to remove the object and then send a notification to interested parties that they need to refresh.
The problem
When using a unit test to check the quality of the 404 implementation, all methods are triggered as intended – the error is thrown and caught, the FavoritesController deletes the object and sends the notification. In some instances though, the deleted favorite is still there – but it depends on from where the query is done!
If I query inside the singleton the deletion went OK, but if I query from a class that makes use of the singleton, the deletion didn't happen.
Design details
The FavoritesController property favorites uses an ivar with all accesses #synchronized(), and the value of the ivar is backed by a NSUserDefaults property.
A favorite object is an NSDictionary with two keys: id and name.
Other info
One weird thing which I fail to understand why it happens: in some deletion attempts, the name value for the favorite object gets set to "" but the id key retains its value.
I've written unit tests that add an invalid favorite and checks that it gets removed on first server query. This test passes when starting with empty set of favorites, but fails when there is an instance of 'semi-deleted' object as above (that retains its id value)
The unit test now consistently passes, but in live usage the failure to delete remains. I suspect that this is due to NSUserDefaults not saving to disk immediately.
Steps I've tried
Making sure that the singleton implementation is a 'true' singleton, i.e. sharedController always returns the same instance.
I thought there was some sort of 'capture' problem, where a closure would keep its own copy with outdated favorites, but I think not. When NSLogging the object ID it returns the same.
Code
FavoritesController main methods
- (void) serverCanNotFindFavorite:(NSInteger)siteID {
NSLog(#"Server can't find favorite");
NSDictionary * removedFavorite = [NSDictionary dictionaryWithDictionary:[self favoriteWithID:siteID]];
NSUInteger index = [self indexOfFavoriteWithID:siteID];
[self debugLogFavorites];
dispatch_async(dispatch_get_main_queue(), ^{
[self removeFromFavorites:siteID completion:^(BOOL success) {
if (success) {
NSNotification * note = [NSNotification notificationWithName:didRemoveFavoriteNotification object:nil userInfo:#{#"site" : removedFavorite, #"index" : [NSNumber numberWithUnsignedInteger:index]}];
NSLog(#"Will post notification");
[self debugLogFavorites];
[self debugLogUserDefaultsFavorites];
[[NSNotificationCenter defaultCenter] postNotification:note];
NSLog(#"Posted notification with name: %#", didRemoveFavoriteNotification);
}
}];
});
}
- (void) removeFromFavorites:(NSInteger)siteID completion:(completionBlock) completion {
if ([self isFavorite:siteID]) {
NSMutableArray * newFavorites = [NSMutableArray arrayWithArray:self.favorites];
NSIndexSet * indices = [newFavorites indexesOfObjectsPassingTest:^BOOL(NSDictionary * entryUnderTest, NSUInteger idx, BOOL * _Nonnull stop) {
NSNumber * value = (NSNumber *)[entryUnderTest objectForKey:#"id"];
if ([value isEqualToNumber:[NSNumber numberWithInteger:siteID]]) {
return YES;
}
return NO;
}];
__block NSDictionary* objectToRemove = [[newFavorites objectAtIndex:indices.firstIndex] copy];
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(#"Will remove %#", objectToRemove);
[newFavorites removeObject:objectToRemove];
[self setFavorites:[NSArray arrayWithArray:newFavorites]];
if ([self isFavorite:siteID]) {
NSLog(#"Failed to remove!");
if (completion) {
completion(NO);
}
} else {
NSLog(#"Removed OK");
if (completion) {
completion(YES);
}
}
});
} else {
NSLog(#"Tried removing site %li which is not a favorite", (long)siteID);
if (completion) {
completion(NO);
}
}
}
- (NSArray *) favorites
{
#synchronized(self) {
if (!internalFavorites) {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
self->internalFavorites = [self.defaults objectForKey:k_key_favorites];
});
if (!internalFavorites) {
internalFavorites = [NSArray array];
}
}
return internalFavorites;
}
}
- (void) setFavorites:(NSArray *)someFavorites {
#synchronized(self) {
internalFavorites = someFavorites;
[self.defaults setObject:internalFavorites forKey:k_key_favorites];
}
}
- (void) addToFavorites:(NSInteger)siteID withName:(NSString *)siteName {
if (![self isFavorite:siteID]) {
NSDictionary * newFavorite = #{
#"name" : siteName,
#"id" : [NSNumber numberWithInteger:siteID]
};
dispatch_async(dispatch_get_main_queue(), ^{
NSArray * newFavorites = [self.favorites arrayByAddingObject:newFavorite];
[self setFavorites:newFavorites];
});
NSLog(#"Added site %# with id %ld to favorites", siteName, (long)siteID);
} else {
NSLog(#"Tried adding site as favorite a second time");
}
}
- (BOOL) isFavorite:(NSInteger)siteID
{
#synchronized(self) {
NSNumber * siteNumber = [NSNumber numberWithInteger:siteID];
NSArray * favs = [NSArray arrayWithArray:self.favorites];
if (favs.count == 0) {
NSLog(#"No favorites");
return NO;
}
NSIndexSet * indices = [favs indexesOfObjectsPassingTest:^BOOL(NSDictionary * entryUnderTest, NSUInteger idx, BOOL * _Nonnull stop) {
if ([[entryUnderTest objectForKey:#"id"] isEqualToNumber:siteNumber]) {
return YES;
}
return NO;
}];
if (indices.count > 0) {
return YES;
}
}
return NO;
}
Singleton implementation of FavoritesController
- (instancetype) init {
static PKEFavoritesController *initedObject;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
initedObject = [super init];
self.defaults = [NSUserDefaults standardUserDefaults];
});
return initedObject;
}
+ (instancetype) sharedController
{
return [self new];
}
Unit testing code
func testObsoleteFavoriteRemoval() {
let addToFavorites = self.expectation(description: "addToFavorites")
let networkRequest = self.expectation(description: "network request")
unowned let favs = PKEFavoritesController.shared()
favs.clearFavorites()
XCTAssertFalse(favs.isFavorite(313), "Should not be favorite initially")
if !favs.isFavorite(313) {
NSLog("Adding 313 to favorites")
favs.add(toFavorites: 313, withName: "Skatås")
}
let notification = self.expectation(forNotification: NSNotification.Name("didRemoveFavoriteNotification"), object: nil) { (notification) -> Bool in
NSLog("Received notification: \(notification.name.rawValue)")
return true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
NSLog("Verifying 313 is favorite")
XCTAssertTrue(favs.isFavorite(313))
addToFavorites.fulfill()
}
self.wait(for: [addToFavorites], timeout: 5)
NSLog("Will trigger removal for 313")
let _ = SkidsparAPI.fetchRecentReports(forSite: 313, session: SkidsparAPI.session()) { (reports) in
NSLog("Network request completed")
networkRequest.fulfill()
}
self.wait(for: [networkRequest, notification], timeout: 10)
XCTAssertFalse(favs.isFavorite(313), "Favorite should be removed after a 404 error from server")
}
To give context around my answers, this is what the code in question looked like when suggesting the change:
- (NSArray *)favorites {
#synchronized(internalFavorites) {
if (!internalFavorites) {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
internalFavorites = [self.defaults objectForKey:k_key_favorites];
});
if (!internalFavorites) {
internalFavorites = [NSArray array];
}
}
}
return internalFavorites;
}
I was suspicious of the check if (!internalFavorites) { that followed #synchronized(internalFavorites) because that meant that there was an expectation of #synchronized being passed nil, which results in a noop.
This meant multiple calls to to favorites or setFavorites could happen in funny ways since they wouldn't actually be synchronized. Giving #sychronized an actual object to synchronize on was crucial for thread safety. Synchronizing on self is fine, but for a particular class, you have to be careful not to synchronize too many things on self or you'll be bound to create needless blocking. Providing simple NSObjects to #sychronized is a good way to narrow the scope of what you're protecting.
Here's how you can avoid using self as your lock.
- (instancetype)init {
static PKEFavoritesController *initedObject;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
initedObject = [super init];
self.lock = [NSObject new];
self.defaults = [NSUserDefaults standardUserDefaults];
});
return initedObject;
}
+ (instancetype)sharedController {
return [self new];
}
- (NSArray *)favorites {
#synchronized(_lock) {
if (!internalFavorites) {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
self->internalFavorites = [self.defaults objectForKey:k_key_favorites];
});
if (!internalFavorites) {
internalFavorites = [NSArray array];
}
}
}
return internalFavorites;
}
Regarding the abnormalities between test runs, definitely calling synchronize on the NSUserDefaults will help because calls to change the defaults are asynchronous, which means other threads are involved. There are like 3 layers of caching as well, and specifically for the purposes of running tests synchronize should ensure that things are completely and cleanly committed before Xcode pulls the plug on the test run. The documentation very abruptly insists that it's not a necessary call, but if it truly weren't necessary it wouldn't exist :-). On my first iOS projects, we always called synchronize after every defaults change... so, I think that documentation is more aspirational on the Apple engineers' parts. I'm glad this intuition helped you.
I have method which I want to unit test:
- (void)fetchInfo {
[AMKAccountService getInfo]
.then(^(AMKInfoResponse *response) {
if (response.accounts.count > 0) {
_viewModel = [[AMKInfoViewModel alloc] initWithInfoResponse:response];
[self.view setAsOfDate:_viewModel.asOfDate];
} else {
[self.view showError:[AMKStrings feedbackForCode:#"testError"]];
}
}).catch(^(NSError *error) {
DLog(#"Error getting info: %#", error);
[self.view showError:[AMKStrings feedbackForCode:#"testError"]];
});
}
In this method, 'getInfo' method makes a service call and returns response of type PMKPromise object.
My question is how to mock the getInfo method and make the 'then' block called for one unit test and 'catch' block called for the other unit test.
[Update]
Here's getInfo method:
+ (PMKPromise *)getInfo {
AMKServicesClient *client = [AMKServicesClient sharedInstance];
return [client GET:#"/amk-web-services/rest/info" parameters:nil].thenInBackground(^(NSDictionary *responseDictionary) {
return [[AMKInfoResponse alloc] initWithResponse:responseDictionary];
});
}
In order to test this, you will have to either break the dependency between getInfo and the shared AMKServicesClient, or set up some sort of system that allows you to load a mock services client when [AMKServicesClient sharedInstance] is called.
I am facing some hard time in mocking a block inside one of my methods which i want to test.
Below is more or less how my code looks like
- (void) startFetching:(MyParameter *) parameter
{
self.fetcher = [[MyFetcher alloc] initWithContext:xxxx andObserver:nil];
self.fetcher.parameters = #[parameter];
[self.fetcher startWithCompleteionBlock:^(id<MyOperation> _Nonnull operation) {
if(operation.errors.count > 0) {
[self.delegate failedWithError:operation.errors.firstObject];
} else{
FetcherResponse *response = [MyFetcherResponse cast:operation];
NSArray *array = response.responseArray;
if(array.count == 1) {
[self.delegate completedWithSuccess:array.firstObject];
}
}
}];
}
Now i have a test method like testStartFetching and i want to test this method. i don't understand how i can stub this part [self.fetcher startWithCompleteionBlock:^(id<MyOperation> _Nonnull operation) inside my method so that, in success case it return proper array and in failure case it return errors and if i stub it for with errors then failedWithError:operation is called and completedWithSuccess is called otherwise.
I am using OCMock framework in objective c and i am new to unit testing. Any help will be highly appreciated.
I stub method with completion block which returns operation (with errors). Then I verify that calls delegate's method - failedWithError with right parameter (error).
id<MyOperation> operation = [[MyClassOperaion alloc] init];
NSError *error = [NSError new];
operation.errors = #[error];
OCMStub([self.fetcher startWithCompleteionBlock:([OCMArg checkWithBlock:^BOOL(void(^passedBlock)(id<MyOperation> _Nonnull operation)) {
passedBlock(operation);
return YES;
}])]);
OCMVerify([self.delegate failedWithError:error]);
How to check passed callback block is called from tested method? Here's simplified method I want to test:
- (void)loadModel:(Callback)callback {
// Data loading part...
// Callbacks with data. Second param usually passes data, but now it will pas nil for demo
callback(YES, nil, nil);
}
Here's my unit test where I want to test if callback was called in method loadModel:
- (void)testLoadModelCallbackIsCalled {
HomeModel *model = [[HomeModel alloc] init];
id modelMock = OCMPartialMock(model);
Callback callback = ^(BOOL success, id result, NSError *error){
XCTAssertTrue(YES, #"Callback not called");
};
//[modelMock expect] ...how to expect?];
[model loadModel:callback];
//[modelMock verify];
}
The problem is this test will always pass regardless of callback was called on not. Thought to workaround this with proxy block, however then the whole loadModel method is overridden with the proxy block. I just need to verify loadModel actually calls what I pass.
UPDATE Meanwhile I ended in simplified workaround:
- (void)testLoadModelCallbackIsCalled {
HomeModel *model = [[HomeModel alloc] init];
// Create flag for checking
__block BOOL isCalled = NO;
Callback callback = ^(BOOL success, id result, NSError *error){
isCalled = YES;
};
XCTAssertTrue(isCalled, #"Callback not called");
}
UPDATE2 #jszumski answer was exactly what I was looking for thought the idea behind XCTestExpectation is very similar to using your own isCalled bool flag.
Use XCTestExpectation, which is part of the asynchronous testing framework:
- (void)testLoadModelCallbackIsCalled {
HomeModel *model = [[HomeModel alloc] init];
id modelMock = OCMPartialMock(model);
XCTestExpectation *expectation = [self expectationWithDescription:#"Completion called"];
Callback callback = ^(BOOL success, id result, NSError *error){
[expectation fulfill];
};
[model loadModel:callback];
[self waitForExpectationsWithTimeout:1 handler:nil];
}
I'm trying to test Diary class that has dependency to Network.
So Diary code:
- (PMKPromise *)saveAndUploadToServer:(DiaryItem *)item
{
return [self save:item].then(^{
return [self upload:item]; << See UPDATE //I put breakpoint here, it is never called
});
}
- (PMKPromise *)save:(DiaryItem *)item
{
return [PMKPromise new:^(PMKPromiseFulfiller fulfill, PMKPromiseRejecter reject) {
[self.entryCreationManagedContext performBlock:^{
BOOL success;
NSError *saveError = nil;
item.status = #(UploadingStatus);
success = [self.entryCreationManagedContext save:&saveError];
if (success) {
fulfill(item.objectID);
}
else {
reject(saveError);
}
}];
}];
}
- (PMKPromise*)upload:(DiaryItem*)item
{
return [self.network POST:self.diaryUrl parameters:[item dictionary]].then(^{
return [self reportUploadAnalytics];
});
}
And the test:
- (void)testFailedUploadingReportsAnalytics
{
XCTestExpectation *expectation = [self expectationWithDescription:#"Operations completed"];
[self uploadToServerAndReturnCallback].finally(^{
[expectation fulfill];
});
[self waitForExpectationsWithTimeout:5 handler:^(NSError *error) {
assertThat(error, is(nilValue()));
//check that mock called
}];
}
The Network is mock in this test. But what I see that chain of promises is not executed. It stuck. Maybe because then: block is called on main thread as well XCTest is pausing it. But at the same time it should probably continue after 5 sec. What can be the issue?
UPDATE
Looks like it is nothing with my original assumption. If I replace [self.entryCreationManagedContext save:&saveError] with YES then debug reaches breakpoint.
UPDATE 2
It looks like issue with this particular saving of managed context. It is triggering notification about synchronising another managed contexts. And we are discovering what else there.
It is ended up in different issue and nothing connected to PromiseKit.
We discovered growing amount of used memory. That NSManagedObjectContextDidSaveNotification was producing deadlock on different store coordinators. After fixing that tests started working as expected.