> ## Content Index
> Fetch the complete content index at: https://williamboles.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# What a Parser Is and Isn't Responsible For
- URL: https://williamboles.com/what-a-parser-is-and-isnt-responsible-for/
- Published: 2016-04-11T14:09:58.000Z
- Updated: 2026-09-26T16:34:32.000Z
- Description: Parsing an API response into an app type is fundamental to any networked app. Here, we look at how to build a consistent response parsing approach to make that task easier than ever before.
- Author: William Boles
- Tags: Separation of Concerns, Parsing, Core Data, Testing

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](https://storage.ghost.io/c/b9/b4/b9b42214-5933-4584-8e56-9ee8ca21d23b/content/images/size/w1000/2026/02/photo-of-a-river-boundary-min.jpg)

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](https://williamboles.com/let-dependency-injection-lead-you-to-a-better-design/), 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](https://williamboles.com/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](https://developer.apple.com/documentation/coredata/nsinmemorystoretype?ref=williamboles.com), 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](https://developer.apple.com/documentation/coredata/nsmanagedobjectcontextconcurrencytype/mainqueueconcurrencytype?ref=williamboles.com) 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](https://en.wikipedia.org/wiki/Test%5Fdouble?ref=williamboles.com), 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](https://williamboles.com/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.