What a Parser Is and Isn't Responsible For
Parsing JSON into a plain model object is straightforward. Parsing that same JSON when Core Data is involved is not.
Once Core Data enters the picture, the parser suddenly needs an NSManagedObjectContext - and all the baggage that comes with it: who owns that context, which thread it runs on, and how save conflicts are resolved.
None of that baggage is parsing.
A parser has one job: transform data from one representation into another. The problem is that Core Data makes it very easy for its responsibilities to bleed into whatever it touches.

In this post, we'll parse a JSON representation of a Post into its Core Data equivalent - while clearly defining what the parser is, and isn't, responsible for.
How to Keep the Focus on Parsing
Our parser will transform this JSON representation of a Post:
{
"id": "456",
"created_at": "2016-04-11T10:00:00Z",
"country": "UK",
"content": "Hello world",
"share_count": 5,
"is_shared": true,
"views_count": 100,
"author": {
"id": "789",
"name": "William",
"avatar_url": "https://example.com/avatar.png"
}
}
Into these Core Data entities:
@interface WBMPost : NSManagedObject
@property (nonatomic, strong) NSString *postID;
@property (nonatomic, strong) NSDate *createdDate;
@property (nonatomic, strong) NSString *country;
@property (nonatomic, strong) NSString *content;
@property (nonatomic, strong) NSNumber *shareCount;
@property (nonatomic, strong) NSNumber *localUserHasShared;
@property (nonatomic, strong) NSNumber *viewCount;
@property (nonatomic, strong) WBMAuthor *author;
@end
@interface WBMAuthor : NSManagedObject
@property (nonatomic, strong) NSString *authorID;
@property (nonatomic, strong) NSString *name;
@property (nonatomic, strong) NSString *avatarURL;
@property (nonatomic, strong) NSSet *posts;
@end
Our parser shouldn't care where the data it's parsing comes from, what thread it's running on, or which Core Data context it's operating within. The easiest way to enforce that boundary is to require those details to be supplied:
@interface WBMPostParser : NSObject
- (WBMPost *)parsePost:(NSDictionary *)postDictionary
inContext:(NSManagedObjectContext *)managedObjectContext;
@end
WBMPostParser declares a single method that takes exactly what it needs - a postDictionary and a managedObjectContext - and returns a WBMPost instance. By using Dependency Injection, the parser avoids making assumptions about the environment it runs in.
Where did the dictionary come from - a network request, a local cache? Which queue owns the context? Those questions are irrelevant to WBMPostParser. It simply defines its inputs and performs its transformation. The caller owns everything else.
Let's see the implementation of parsePost:inContext:
@implementation WBMPostParser
- (WBMPost *)parsePost:(NSDictionary *)postDictionary
inContext:(NSManagedObjectContext *)managedObjectContext
{
WBMPost *post = nil;
// 1
if (postDictionary[@"id"])
{
NSString *postID = [NSString stringWithFormat:@"%@", postDictionary[@"id"]];
// 2
NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"Post"];
fetchRequest.predicate = [NSPredicate predicateWithFormat:@"postID == %@", postID];
post = [[managedObjectContext executeFetchRequest:fetchRequest error:nil] firstObject];
// 3
if (!post)
{
post = [NSEntityDescription insertNewObjectForEntityForName:@"Post"
inManagedObjectContext:managedObjectContext];
post.postID = postID;
}
// 4
NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
dateFormatter.dateFormat = @"yyyy-MM-dd'T'HH:mm:ssZZZZZ";
post.createdDate = [dateFormatter dateFromString:postDictionary[@"created_at"]];
post.country = postDictionary[@"country"];
post.content = postDictionary[@"content"];
post.shareCount = postDictionary[@"share_count"];
post.localUserHasShared = postDictionary[@"is_shared"];
post.viewCount = postDictionary[@"views_count"];
// 5
NSDictionary *authorDictionary = postDictionary[@"author"];
if (authorDictionary[@"id"])
{
NSString *authorID = [NSString stringWithFormat:@"%@", authorDictionary[@"id"]];
NSFetchRequest *authorFetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"Author"];
authorFetchRequest.predicate = [NSPredicate predicateWithFormat:@"authorID == %@", authorID];
WBMAuthor *author = [[managedObjectContext executeFetchRequest:authorFetchRequest error:nil] firstObject];
if (!author)
{
author = [NSEntityDescription insertNewObjectForEntityForName:@"Author"
inManagedObjectContext:managedObjectContext];
author.authorID = authorID;
}
author.name = authorDictionary[@"name"];
author.avatarURL = authorDictionary[@"avatar_url"];
post.author = author;
}
}
return post;
}
@end
Here's what we did above:
- Guard against a missing
idfield. If there's noid, we can't meaningfully create or find a post, so we bail early and returnnil. - Search the provided context for an existing post with this
id. This is the fetch half of fetch-or-create. - If no existing post was found, create a new one in the provided context and assign its
postID. This is the create half of fetch-or-create. - Map the remaining dictionary values onto the post's properties. At this point, we don't care whether the post was fetched or created - either way, we update it with the latest data from
postDictionary. - Extract the nested author dictionary, perform a second fetch-or-create for the
WBMAuthor, map its fields, and assign it to the post.
I'm creating an instance of
NSDateFormattereach time this method is called - that isn't very performant, you would want to create it once and cache it. I won't show how to do that here, as it is outside the scope of this post, but if you are interested, check outSneaky Date Formatters Exposing More Than You Thinkon how to do this well.
This parser works. It produces the correct output. But look at the duplication: two guard clauses, two fetch requests, two insert paths, two sets of field mappings. WBMPostParser has two responsibilities: parsing the post and parsing the author. We can split them apart.
Letting the JSON Show Us the Boundary
Look at the JSON again:
{
"id": "456",
"created_at": "2016-04-11T10:00:00Z",
"country": "UK",
"content": "Hello world",
"share_count": 5,
"is_shared": true,
"views_count": 100,
"author": {
"id": "789",
"name": "William",
"avatar_url": "https://example.com/avatar.png"
}
}
The author object isn't just a handful of fields hanging off the post - it's a self-contained entity in its own right. The API is telling us there are two things here, not one. That object boundary in the JSON suggests a boundary in our code. When we collapsed that boundary into a single parser, we also collapsed responsibilities.
Let's extract WBMAuthorParser:
@interface WBMAuthorParser : NSObject
- (WBMAuthor *)parseAuthor:(NSDictionary *)authorDictionary
inContext:(NSManagedObjectContext *)managedObjectContext;
@end
@implementation WBMAuthorParser
- (WBMAuthor *)parseAuthor:(NSDictionary *)authorDictionary
inContext:(NSManagedObjectContext *)managedObjectContext
{
WBMAuthor *author = nil;
if (authorDictionary[@"id"])
{
NSString *authorID = [NSString stringWithFormat:@"%@", authorDictionary[@"id"]];
NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"Author"];
fetchRequest.predicate = [NSPredicate predicateWithFormat:@"authorID == %@", authorID];
author = [[managedObjectContext executeFetchRequest:fetchRequest error:nil] firstObject];
if (!author)
{
author = [NSEntityDescription insertNewObjectForEntityForName:@"Author"
inManagedObjectContext:managedObjectContext];
author.authorID = authorID;
}
author.name = authorDictionary[@"name"];
author.avatarURL = authorDictionary[@"avatar_url"];
}
return author;
}
@end
WBMAuthorParser follows the same pattern as WBMPostParser - inject the context, guard on id, fetch-or-create, map fields. Now we can update WBMPostParser to use it:
@interface WBMPostParser : NSObject
// 1
- (WBMPost *)parsePost:(NSDictionary *)postDictionary
inContext:(NSManagedObjectContext *)managedObjectContext
authorParser:(WBMAuthorParser *)authorParser;
@end
@implementation WBMPostParser
- (WBMPost *)parsePost:(NSDictionary *)postDictionary
inContext:(NSManagedObjectContext *)managedObjectContext
authorParser:(WBMAuthorParser *)authorParser
{
WBMPost *post = nil;
if (postDictionary[@"id"])
{
// Omitted unchanged functionality
NSDictionary *authorDictionary = postDictionary[@"author"];
// 2
post.author = [authorParser parseAuthor:authorDictionary
inContext:managedObjectContext];
}
return post;
}
@end
Here's what we changed:
- Updated the old
parsePost:inContextmethod to take aWBMAuthorParserinstance now. Injecting another parser this way allows each parser to focus on its role while allowing any number of other parsers to be shared so that we don't end up unnecessarily duplicating functionality. - Delegate author parsing to the injected
WBMAuthorParser, passing along the same context.
The author fetch-or-create logic, the author guard clause, and the author field mapping - all of those now live where they belong: WBMAuthorParser. WBMPostParser knows that an author needs to be parsed, but it's no longer responsible for knowing how.
Each parser now has one responsibility and demands its dependencies upfront.
Testing the Parsers
Before we can write unit tests for our parsers, we need a way to create a clean, isolated Core Data stack for each test. By using an in-memory store, we avoid disk I/O and ensure no state leaks between test runs. Better still, none of this requires any changes to the parsers themselves. Let's add a category on NSManagedObjectContext to create that in-memory context:
// 1
@interface NSManagedObjectContext (TestHelpers)
+ (NSManagedObjectContext *)wbm_inMemoryTestContext;
@end
@implementation NSManagedObjectContext (TestHelpers)
+ (NSManagedObjectContext *)wbm_inMemoryTestContext
{
// 2
NSURL *modelURL = [[NSBundle bundleForClass:[self class]] URLForResource:@"ParsingExample"
withExtension:@"momd"];
NSManagedObjectModel *model = [[NSManagedObjectModel alloc] initWithContentsOfURL:modelURL];
// 3
NSPersistentStoreCoordinator *coordinator = [[NSPersistentStoreCoordinator alloc] initWithManagedObjectModel:model];
[coordinator addPersistentStoreWithType:NSInMemoryStoreType
configuration:nil
URL:nil
options:nil
error:nil];
// 4
NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
context.persistentStoreCoordinator = coordinator;
return context;
}
@end
Here's what we did above:
- Created a category on
NSManagedObjectContextthat adds thewbm_inMemoryTestContextclass method. By placing this in the test target, it's available to any test file that imports the header. - Load the compiled Core Data model from the test bundle - the same
.xcdatamodeldused in production, so our test entities match exactly. - Create a persistent store coordinator backed by an in-memory store. This gives us a fully functional Core Data stack without any disk I/O.
- Create a main queue context wired to the coordinator. In tests, we don't need background threading, so a single main queue context keeps things simple.
Change
ParsingExampleto match your project.
Now that we can create a test-friendly NSManagedObjectContext instance, let's write some tests for WBMPostParser:
@interface WBMPostParserTests : XCTestCase
@property (nonatomic, strong) NSManagedObjectContext *managedObjectContext;
@property (nonatomic, strong) WBMAuthorParser *authorParser;
@property (nonatomic, strong) WBMPostParser *sut;
@end
@implementation WBMPostParserTests
// 1
- (void)setUp
{
[super setUp];
self.managedObjectContext = [NSManagedObjectContext wbm_inMemoryTestContext];
self.authorParser = [[WBMAuthorParser alloc] init];
self.sut = [[WBMPostParser alloc] init];
}
- (void)tearDown
{
self.managedObjectContext = nil;
self.authorParser = nil;
self.sut = nil;
[super tearDown];
}
#pragma mark - Tests
// 2
- (void)test_parsePost_withValidDictionary_createsPost
{
NSDictionary *postDictionary = @{
@"id": @"456",
@"created_at": @"2016-04-11T10:00:00Z",
@"country": @"UK",
@"content": @"Hello world",
@"share_count": @5,
@"is_shared": @YES,
@"views_count": @100,
@"author": @{
@"id": @"789",
@"name": @"William",
@"avatar_url": @"https://example.com/avatar.png"
}
};
WBMPost *post = [self.sut parsePost:postDictionary
inContext:self.managedObjectContext
authorParser:self.authorParser];
XCTAssertNotNil(post);
XCTAssertEqualObjects(post.postID, @"456");
XCTAssertEqualObjects(post.country, @"UK");
XCTAssertEqualObjects(post.content, @"Hello world");
XCTAssertEqualObjects(post.shareCount, @5);
XCTAssertEqualObjects(post.localUserHasShared, @YES);
XCTAssertEqualObjects(post.viewCount, @100);
XCTAssertNotNil(post.author);
XCTAssertEqualObjects(post.author.authorID, @"789");
XCTAssertEqualObjects(post.author.name, @"William");
XCTAssertEqualObjects(post.author.avatarURL, @"https://example.com/avatar.png");
}
// 3
- (void)test_parsePost_withExistingPost_updatesPost
{
NSDictionary *originalDictionary = @{
@"id": @"456",
@"created_at": @"2016-04-11T10:00:00Z",
@"country": @"UK",
@"content": @"Hello world",
@"share_count": @5,
@"is_shared": @YES,
@"views_count": @100,
@"author": @{
@"id": @"789",
@"name": @"William",
@"avatar_url": @"https://example.com/avatar.png"
}
};
[self.sut parsePost:originalDictionary
inContext:self.managedObjectContext
authorParser:self.authorParser];
NSDictionary *updatedDictionary = @{
@"id": @"456",
@"created_at": @"2016-04-11T10:00:00Z",
@"country": @"US",
@"content": @"Updated content",
@"share_count": @10,
@"is_shared": @NO,
@"views_count": @200,
@"author": @{
@"id": @"789",
@"name": @"Will",
@"avatar_url": @"https://example.com/avatar.jpg"
}
};
WBMPost *post = [self.sut parsePost:updatedDictionary
inContext:self.managedObjectContext
authorParser:self.authorParser];
XCTAssertEqualObjects(post.country, @"US");
XCTAssertEqualObjects(post.content, @"Updated content");
XCTAssertEqualObjects(post.shareCount, @10);
XCTAssertEqualObjects(post.localUserHasShared, @NO);
XCTAssertEqualObjects(post.viewCount, @200);
XCTAssertNotNil(post.author);
XCTAssertEqualObjects(post.author.authorID, @"789");
XCTAssertEqualObjects(post.author.name, @"Will");
XCTAssertEqualObjects(post.author.avatarURL, @"https://example.com/avatar.jpg");
}
// 4
- (void)test_parsePost_withExistingPost_doesNotCreateDuplicate
{
NSDictionary *postDictionary = @{
@"id": @"456",
@"created_at": @"2016-04-11T10:00:00Z",
@"country": @"UK",
@"content": @"Hello world",
@"share_count": @5,
@"is_shared": @YES,
@"views_count": @100,
@"author": @{
@"id": @"789",
@"name": @"William",
@"avatar_url": @"https://example.com/avatar.png"
}
};
[self.sut parsePost:postDictionary
inContext:self.managedObjectContext
authorParser:self.authorParser];
[self.sut parsePost:postDictionary
inContext:self.managedObjectContext
authorParser:self.authorParser];
NSFetchRequest *postsFetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"Post"];
NSArray *posts = [self.managedObjectContext executeFetchRequest:postsFetchRequest error:nil];
XCTAssertEqual(posts.count, 1);
NSFetchRequest *authorsFetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"Author"];
NSArray *authors = [self.managedObjectContext executeFetchRequest:authorsFetchRequest error:nil];
XCTAssertEqual(authors.count, 1);
}
// 5
- (void)test_parsePost_withMissingID_returnsNil
{
NSDictionary *postDictionary = @{
@"created_at": @"2016-04-11T10:00:00Z",
@"country": @"UK",
@"content": @"Hello world",
@"share_count": @5,
@"is_shared": @YES,
@"views_count": @100
};
WBMPost *post = [self.sut parsePost:postDictionary
inContext:self.managedObjectContext
authorParser:self.authorParser];
XCTAssertNil(post);
}
@end
Here's what we did above:
- Create an in-memory context using our category, an instance of
WBMAuthorParserand an instance ofWBMPostParser(which is namedsut-Subject Under Test). Because the context is in-memory, each test run starts with a clean, empty store. - Test the happy path - a valid dictionary with all fields should produce a
WBMPostwith each property correctly mapped. - Test the update path - parsing the same
idtwice with different values should update the existing entity rather than leaving stale data. - Test that the fetch-or-create logic doesn't produce duplicates - parsing the same
idtwice should still result in only oneWBMPostand oneWBMAuthorin the store. - Test the guard - a dictionary without an
idshould returnnilsince we can't meaningfully create or find a post without one.
The test suite passes, but it has the same flaw as our original WBMPostParser had — too many responsibilities. Because WBMPostParser depends directly on the concrete WBMAuthorParser class, any test we write is silently testing both parsers. If author parsing has a bug, post parsing tests fail even though WBMPostParser was working as expected. Without a way to swap in a test double, these aren't unit tests but integration tests — tests that verify two classes working together rather than one class working correctly. We're halfway there by using Dependency Injection to pass in the author parser. We just need to take it further so that WBMPostParser depends on what WBMAuthorParser does, not how it does it.
To see more about how
Dependency Injectioncan lead to a better design, check outLet Dependency Injection Lead You to a Better Design.
We express this contract as a protocol:
@protocol WBMAuthorParsing <NSObject>
- (WBMAuthor *)parseAuthor:(NSDictionary *)authorDictionary
inContext:(NSManagedObjectContext *)managedObjectContext;
@end
Now we make WBMAuthorParser conform to WBMAuthorParsing:
@interface WBMAuthorParser : NSObject <WBMAuthorParsing>
- (WBMAuthor *)parseAuthor:(NSDictionary *)authorDictionary
inContext:(NSManagedObjectContext *)managedObjectContext;
@end
And now we update WBMPostParser to accept any type that conforms to WBMAuthorParsing rather than the concrete WBMAuthorParser:
@interface WBMPostParser : NSObject
- (WBMPost *)parsePost:(NSDictionary *)postDictionary
inContext:(NSManagedObjectContext *)managedObjectContext
authorParser:(id<WBMAuthorParsing>)authorParser;
@end
This single change gives us two things. In production, we pass in the real WBMAuthorParser, and everything works exactly as before. In tests, we can pass in a stub that returns whatever we configure - meaning WBMPostParser tests verify post parsing logic and nothing else.
Now that we can inject a test double that conforms to WBMAuthorParsing, we can create that test double:
// 1
@interface WBMStubAuthorParser : NSObject
// 2
@property (nonatomic, strong) WBMAuthor *parseAuthorReturnValue;
@end
@implementation WBMStubAuthorParser
// 3
- (WBMAuthor *)parseAuthor:(NSDictionary *)authorDictionary
inContext:(NSManagedObjectContext *)managedObjectContext
{
return self.parseAuthorReturnValue;
}
@end
Here's what we did above:
- Conform
WBMStubAuthorParsertoWBMAuthorParsing. - A configurable return value that the test can set before calling the parser. This gives the test full control over what the stub returns without any real parsing taking place.
- The stub ignores both parameters and returns whatever was configured. This means when testing
WBMPostParser, we can verify it correctly assigns the author to the post without caring whether the author was parsed correctly - that'sWBMAuthorParser's responsibility and is covered by its own tests.
Now that we have WBMStubAuthorParser, let's update WBMPostParserTests to use it:
@interface WBMPostParserTests : XCTestCase
@property (nonatomic, strong) NSManagedObjectContext *managedObjectContext;
// 1
@property (nonatomic, strong) WBMStubAuthorParser *authorParser;
@property (nonatomic, strong) WBMPostParser *sut;
@end
@implementation WBMPostParserTests
// 2
- (void)setUp
{
[super setUp];
self.managedObjectContext = [NSManagedObjectContext wbm_inMemoryTestContext];
self.authorParser = [[WBMStubAuthorParser alloc] init];
self.sut = [[WBMPostParser alloc] init];
}
- (void)tearDown
{
self.managedObjectContext = nil;
self.authorParser = nil;
self.sut = nil;
[super tearDown];
}
#pragma mark - Tests
// 3
- (void)test_parsePost_withValidDictionary_createsPost
{
NSDictionary *postDictionary = @{
@"id": @"456",
@"created_at": @"2016-04-11T10:00:00Z",
@"country": @"UK",
@"content": @"Hello world",
@"share_count": @5,
@"is_shared": @YES,
@"views_count": @100,
@"author": @{
@"id": @"789",
@"name": @"William",
@"avatar_url": @"https://example.com/avatar.png"
}
};
WBMPost *post = [self.sut parsePost:postDictionary
inContext:self.managedObjectContext
authorParser:self.authorParser];
XCTAssertNotNil(post);
XCTAssertEqualObjects(post.postID, @"456");
XCTAssertEqualObjects(post.country, @"UK");
XCTAssertEqualObjects(post.content, @"Hello world");
XCTAssertEqualObjects(post.shareCount, @5);
XCTAssertEqualObjects(post.localUserHasShared, @YES);
XCTAssertEqualObjects(post.viewCount, @100);
}
// 4
- (void)test_parsePost_withExistingPost_updatesPost
{
NSDictionary *originalDictionary = @{
@"id": @"456",
@"created_at": @"2016-04-11T10:00:00Z",
@"country": @"UK",
@"content": @"Hello world",
@"share_count": @5,
@"is_shared": @YES,
@"views_count": @100,
@"author": @{
@"id": @"789",
@"name": @"William",
@"avatar_url": @"https://example.com/avatar.png"
}
};
[self.sut parsePost:originalDictionary
inContext:self.managedObjectContext
authorParser:self.authorParser];
NSDictionary *updatedDictionary = @{
@"id": @"456",
@"created_at": @"2016-04-11T10:00:00Z",
@"country": @"US",
@"content": @"Updated content",
@"share_count": @10,
@"is_shared": @NO,
@"views_count": @200,
@"author": @{
@"id": @"789",
@"name": @"Will",
@"avatar_url": @"https://example.com/avatar.jpg"
}
};
WBMPost *post = [self.sut parsePost:updatedDictionary
inContext:self.managedObjectContext
authorParser:self.authorParser];
XCTAssertEqualObjects(post.country, @"US");
XCTAssertEqualObjects(post.content, @"Updated content");
XCTAssertEqualObjects(post.shareCount, @10);
XCTAssertEqualObjects(post.localUserHasShared, @NO);
XCTAssertEqualObjects(post.viewCount, @200);
}
// 5
- (void)test_parsePost_withExistingPost_doesNotCreateDuplicate
{
NSDictionary *postDictionary = @{
@"id": @"456",
@"created_at": @"2016-04-11T10:00:00Z",
@"country": @"UK",
@"content": @"Hello world",
@"share_count": @5,
@"is_shared": @YES,
@"views_count": @100,
@"author": @{
@"id": @"789",
@"name": @"William",
@"avatar_url": @"https://example.com/avatar.png"
}
};
[self.sut parsePost:postDictionary
inContext:self.managedObjectContext
authorParser:self.authorParser];
[self.sut parsePost:postDictionary
inContext:self.managedObjectContext
authorParser:self.authorParser];
NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"Post"];
NSArray *posts = [self.managedObjectContext executeFetchRequest:fetchRequest error:nil];
XCTAssertEqual(posts.count, 1);
}
// 6
- (void)test_parsePost_withMissingID_returnsNil
{
NSDictionary *postDictionary = @{
@"created_at": @"2016-04-11T10:00:00Z",
@"country": @"UK",
@"content": @"Hello world",
@"share_count": @5,
@"is_shared": @YES,
@"views_count": @100
};
WBMPost *post = [self.sut parsePost:postDictionary
inContext:self.managedObjectContext
authorParser:self.authorParser];
XCTAssertNil(post);
}
// 7
- (void)test_parsePost_withAuthor_assignsAuthorToPost
{
WBMAuthor *stubAuthor = [NSEntityDescription insertNewObjectForEntityForName:@"Author"
inManagedObjectContext:self.managedObjectContext];
stubAuthor.authorID = @"789";
stubAuthor.name = @"William";
self.authorParser.parseAuthorReturnValue = stubAuthor;
NSDictionary *postDictionary = @{
@"id": @"456",
@"created_at": @"2016-04-11T10:00:00Z",
@"country": @"UK",
@"content": @"Hello world",
@"share_count": @5,
@"is_shared": @YES,
@"views_count": @100,
@"author": @{
@"id": @"789",
@"name": @"William",
@"avatar_url": @"https://example.com/avatar.png"
}
};
WBMPost *post = [self.sut parsePost:postDictionary
inContext:self.managedObjectContext
authorParser:self.authorParser];
XCTAssertEqualObjects(post.author, stubAuthor);
}
@end
Here's what changed:
- Replaced
WBMAuthorParserwithWBMStubAuthorParser- a test double that conforms toWBMAuthorParsingand returns whatever we configure, removing the dependency on real author parsing logic. - Instantiate
WBMStubAuthorParserinstead ofWBMAuthorParser. - The happy path test no longer asserts on author fields - those assertions were verifying the behaviour of
WBMAuthorParser, not the behaviour ofWBMPostParser. This test now focuses purely on post field mapping. - The update path test no longer asserts on updated author fields.
- The deduplication test no longer checks
Authorcount - author deduplication isWBMAuthorParser's responsibility. This test now only verifies thatWBMPostParserdoesn't create duplicate posts. - No change - this test was already focused on
WBMPostParser's guard clause. - A new test was added that asserts that the
WBMAuthorreturned fromauthorParseris being correctly assigned to theauthorproperty. We don't assert on individual author fields (authorID,name,avatarURL); this is no longer the responsibility ofWBMPostParser.
No real author parsing takes place during any of these tests.
With the WBMPostParserTests test suite now solely responsible for testing the direct functionality of WBMPostParser, we need to add tests for WBMAuthorParser:
@interface WBMAuthorParserTests : XCTestCase
@property (nonatomic, strong) NSManagedObjectContext *managedObjectContext;
@property (nonatomic, strong) WBMAuthorParser *sut;
@end
@implementation WBMAuthorParserTests
- (void)setUp
{
[super setUp];
self.managedObjectContext = [NSManagedObjectContext wbm_inMemoryTestContext];
self.sut = [[WBMAuthorParser alloc] init];
}
- (void)tearDown
{
self.managedObjectContext = nil;
self.sut = nil;
[super tearDown];
}
#pragma mark - Tests
- (void)test_parseAuthor_withValidDictionary_createsAuthor
{
NSDictionary *authorDictionary = @{
@"id": @"123",
@"name": @"William",
@"avatar_url": @"https://example.com/avatar.png"
};
WBMAuthor *author = [self.sut parseAuthor:authorDictionary
inContext:self.managedObjectContext];
XCTAssertNotNil(author);
XCTAssertEqualObjects(author.authorID, @"123");
XCTAssertEqualObjects(author.name, @"William");
XCTAssertEqualObjects(author.avatarURL, @"https://example.com/avatar.png");
}
- (void)test_parseAuthor_withExistingAuthor_updatesAuthor
{
NSDictionary *originalDictionary = @{
@"id": @"123",
@"name": @"William",
@"avatar_url": @"https://example.com/avatar.png"
};
[self.sut parseAuthor:originalDictionary
inContext:self.managedObjectContext];
NSDictionary *updatedDictionary = @{
@"id": @"123",
@"name": @"Will",
@"avatar_url": @"https://example.com/new_avatar.png"
};
WBMAuthor *author = [self.sut parseAuthor:updatedDictionary
inContext:self.managedObjectContext];
XCTAssertEqualObjects(author.name, @"Will");
XCTAssertEqualObjects(author.avatarURL, @"https://example.com/new_avatar.png");
}
- (void)test_parseAuthor_withExistingAuthor_doesNotCreateDuplicate
{
NSDictionary *authorDictionary = @{
@"id": @"123",
@"name": @"William",
@"avatar_url": @"https://example.com/avatar.png"
};
[self.sut parseAuthor:authorDictionary
inContext:self.managedObjectContext];
[self.sut parseAuthor:authorDictionary
inContext:self.managedObjectContext];
NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"Author"];
NSArray *results = [self.managedObjectContext executeFetchRequest:fetchRequest error:nil];
XCTAssertEqual(results.count, 1);
}
- (void)test_parseAuthor_withMissingID_returnsNil
{
NSDictionary *authorDictionary = @{
@"name": @"William",
@"avatar_url": @"https://example.com/avatar.png"
};
WBMAuthor *author = [self.sut parseAuthor:authorDictionary
inContext:self.managedObjectContext];
XCTAssertNil(author);
}
@end
And with those tests added, we are done! Congratulations on making it here! 🎉🎉🎉
What Did We Gain?
Every change we made served the same principle: clearly defining what each type is, and isn't, responsible for. The combined parser became two focused parsers. The tangled test suite became two focused test suites.
Core Data was involved only where it added value - a context passed in, a fetch request, an insert. No context creation, no threading decisions, no save conflict resolution. Those concerns belong elsewhere. The parsers remained responsible for one thing: transforming data from one representation into another.