What a Parser Is and Isn't Responsible For

11 Apr 2016 12 min read

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.

Photo of a river boundary showing the type of strong boundaries we are going create when writing our parsers

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:

  1. Guard against a missing id field. If there's no id, we can't meaningfully create or find a post, so we bail early and return nil.
  2. Search the provided context for an existing post with this id. This is the fetch half of fetch-or-create.
  3. 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.
  4. 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.
  5. 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 NSDateFormatter each 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 out Sneaky Date Formatters Exposing More Than You Think on 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:

  1. Updated the old parsePost:inContext method to take a WBMAuthorParser instance 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.
  2. 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:

  1. Created a category on NSManagedObjectContext that adds the wbm_inMemoryTestContext class method. By placing this in the test target, it's available to any test file that imports the header.
  2. Load the compiled Core Data model from the test bundle - the same .xcdatamodeld used in production, so our test entities match exactly.
  3. 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.
  4. 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 ParsingExample to 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:

  1. Create an in-memory context using our category, an instance of WBMAuthorParser and an instance of WBMPostParser (which is named sut - Subject Under Test). Because the context is in-memory, each test run starts with a clean, empty store.
  2. Test the happy path - a valid dictionary with all fields should produce a WBMPost with each property correctly mapped.
  3. Test the update path - parsing the same id twice with different values should update the existing entity rather than leaving stale data.
  4. Test that the fetch-or-create logic doesn't produce duplicates - parsing the same id twice should still result in only one WBMPost and one WBMAuthor in the store.
  5. Test the guard - a dictionary without an id should return nil since 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 Injection can lead to a better design, check out Let 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:

  1. Conform WBMStubAuthorParser to WBMAuthorParsing.
  2. 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.
  3. 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's WBMAuthorParser'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:

  1. Replaced WBMAuthorParser with WBMStubAuthorParser - a test double that conforms to WBMAuthorParsing and returns whatever we configure, removing the dependency on real author parsing logic.
  2. Instantiate WBMStubAuthorParser instead of WBMAuthorParser.
  3. The happy path test no longer asserts on author fields - those assertions were verifying the behaviour of WBMAuthorParser, not the behaviour of WBMPostParser. This test now focuses purely on post field mapping.
  4. The update path test no longer asserts on updated author fields.
  5. The deduplication test no longer checks Author count - author deduplication is WBMAuthorParser's responsibility. This test now only verifies that WBMPostParser doesn't create duplicate posts.
  6. No change - this test was already focused on WBMPostParser's guard clause.
  7. A new test was added that asserts that the WBMAuthor returned from authorParser is being correctly assigned to the author property. We don't assert on individual author fields (authorID, name, avatarURL); this is no longer the responsibility of WBMPostParser.

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.

What do you think? Let me know by getting in touch on Mastodon or Bluesky.