© Gio Lodi 2021
G. LodiTest-Driven Development in Swifthttps://doi.org/10.1007/978-1-4842-7002-8_9

9. Testing JSON Decoding

Gio Lodi1  
(1)
Mount Martha, VIC, Australia
 

How do you use tests to drive writing logic to decode JSON data into a model object?

By feeding the tests a predefined JSON input and verify the decoding produces a value matching it.

Swift’s Decodable protocol offers a type-safe way to describe how to transform Data, such as a JSON in the body of an HTTP response, into an object or value type. With Decodable doing most of the heavy lifting, is it worth using tests to drive the implementation of JSON decoding logic?

When the JSON object has keys and values with names and types that map directly to Swift, there is no real logic you need to write to decode it. In such a case, you may feel comfortable writing the source code directly, without the help of a unit test. If the JSON object is elaborate or if you want to decode it into a Swift type with a different structure, then a test will help you along the way.

Choosing whether to deploy TDD to implement JSON decoding logic is a matter of tradeoffs. Is the extra time you’re spending writing the test worth the feedback it gives you? Once the test is in place, will it guard against regression that may otherwise go unnoticed?

To answer those questions, you first need to know how to write this kind of tests. In this chapter, we’ll explore two different techniques to write tests for JSON decoding and reflect further on the return on investment of applying TDD to this part of the app’s implementation.

We’re moving along steadily in making our app fetch the menu from a remote API. In the previous chapters, we built the view part of this new functionality. It’s now time to take one step further down the stack, coding the conversion from the raw Data a network request would return into the MenuItem domain model.

Swift provides a convenient way to decode Data into a different type via the Decodable protocol. Our job is to conform MenuItem to it and verify the decoding is successful.

As part of your application development agreement with Alberto, you’re also building his backend service. You chose to use a REST API that returns a JSON menu in the following form:1
[
    {
        "name": "spaghetti carbonara",
        "category": "pasta",
        "spicy": false
    },
    {
        "name": "penne all'arrabbiata",
        "category": "pasta",
        "spicy": true
    }
]

How do we go from that JSON to a MenuItem using TDD? As always, we start with a test. In this instance, we’ll write a test for the decoding behavior.

The question to ask when writing a test is how to verify if the desired behavior occurred. Our desired behavior is a successful JSON decoding. To verify it, we need to devise a test asserting that, given a particular JSON input, the decoded Swift object has matching properties:
// MenuItemTests.swift
@testable import Albertos
import XCTest
class MenuItemTests: XCTestCase {
    func testWhenDecodedFromJSONDataHasAllTheInputProperties() {
        // arrange the JSON Data input
        // act by decoding a MenuItem instance from the Data
        // assert the MenuItem matches the input
    }
}
If the input is:
{
    "name": "a name",
    "category": "a category",
    "spicy": true
}
then our expectations shall be:
// MenuItemTests.swift
// ...
func testWhenDecodedFromJSONDataHasAllTheInputProperties() {
    // arrange the JSON Data input
    // act by decoding a MenuItem instance from the Data
    XCTAssertEqual(item.name, "a name")
    XCTAssertEqual(item.category, "a category")
    XCTAssertEqual(item.spicy, true)
}
Swift’s JSONDecoder is the type to use to get the item instance from the input JSON Data:
// MenuItemTests.swift
// ...
func testWhenDecodedFromJSONDataHasAllTheInputProperties() throws {
    // arrange the JSON Data input
    let item = try JSONDecoder()    .decode(MenuItem.self, from: data)
    XCTAssertEqual(item.name, "a name")
    XCTAssertEqual(item.category, "a category")
    XCTAssertEqual(item.spicy, true)
}

How can we arrange the JSON input for the test? Let’s see two different approaches.

Option 1: Inline Strings

One option to generate the JSON input is to define it as a String inline in the test and then convert it into Data:
// MenuItemTests.swift
// ...
func testWhenDecodedFromJSONDataHasAllTheInputProperties() throws {
    let json = #"{ "name": "a name", "category": "a category", "spicy": true }"#
    let data = try XCTUnwrap(json.data(using: .utf8))
        // Compiler says: Instance method 'decode(_:from:)'         // requires
        // that 'MenuItem' conform to 'Decodable'
    let item = try JSONDecoder().decode(MenuItem.self, from: data)
    XCTAssertEqual(item.name, "a name")
    XCTAssertEqual(item.category, "a category")
    XCTAssertEqual(item.spicy, true)
}
The compiler points out that our MenuItem does not conform to Decodable. Let’s satisfy this condition:
// MenuItem.swift
struct MenuItem {
    // ...
}
// ...
extension MenuItem: Decodable {}

The test builds now. It also passes already because the names of the properties in MenuItem match the names of the keys in the JSON input, so the compiler takes care of all the decoding for us.

The fact that the test passed when the only change we made was to conform to a protocol without any extra code raises a question: are we testing our code’s behavior or that of Decodable?

As mentioned in this chapter’s introduction, there are instances in which using a test to drive the JSON decoding implementation may be an unnecessary overhead. We’re learning how to write this kind of tests to discern when they are useful and when they aren’t. Let’s look at another option for the Arrange phase and then discuss when using TDD for JSON decoding is appropriate.

Option 2: JSON Files

Another option to provide the input for our test is to have JSON files holding the input values.

To add a JSON file to your test target
  1. 1.

    Select the test target folder in the project navigator.

     
  2. 2.

    Right-click it and click “New File…” or press Cmd N.

     
  3. 3.

    Select the Other ➤ Empty file and then click “Next” or press Enter.

     
Figure 9-1 shows the Empty file option from the Xcode “New File…” dialog.
../images/501287_1_En_9_Chapter/501287_1_En_9_Fig1_HTML.jpg
Figure 9-1

The Xcode New File… dialog, filtered to show only empty file templates

  1. 4.

    Add the .json extension to the file name and select “Create” or press Enter.

     
Figure 9-2 shows the final dialog in the “New File…” sequence, where you can choose the name, location, and target for the file.
../images/501287_1_En_9_Chapter/501287_1_En_9_Fig2_HTML.jpg
Figure 9-2

The Xcode dialog to name and add a new file to the file system and project targets

  1. 5.

    Populate the file with the JSON for the menu item:

     
// menu_item.json
{
    "name": "a name",
    "category": "a category",
    "spicy": true
}

Alternatively, you can create the JSON file with your favorite text editor and save it into the test target folder. From Xcode, you can then right-click the test target folder and select “Add Files To ‘<test target name>’…” or press Cmd Alt A to reveal a file navigator where to select your JSON file.

Once the file is part of the project, you can load it in the tests like this:
// MenuItemTests.swift
// ...
func testDecodesFromJSONData() throws {
    let url = try XCTUnwrap(
        Bundle(for: type(of: self)).url(forResource: "menu_item", withExtension: "json")
    )
    let data = try Data(contentsOf: url)
    let item = try JSONDecoder().decode(MenuItem.self, from: data)
    XCTAssertEqual(item.name, "a name")
    XCTAssertEqual(item.category, "a category")
    XCTAssertEqual(item.spicy, true)
}

If you haven’t removed the source changes we wrote in the previous example, this test will immediately pass.

We can make the test code tidier by extracting the file-reading logic into a helper function. This is useful to do if you have more than a couple of tests for model decoding:
// XCTestCase+JSON.swift
import XCTest
extension XCTestCase {
    func dataFromJSONFileNamed(_ name: String) throws -> Data {
        let url = try XCTUnwrap(
            Bundle(for: type(of: self)).url(forResource: name, withExtension: "json")
        )
        return try Data(contentsOf: url)
    }
}
// MenuItemTests.swift
// ...
func testDecodesFromJSONData() throws {
    let data = try dataFromJSONFileNamed("menu_item")
    let item = try JSONDecoder()    .decode(MenuItem.self, from: data)
    XCTAssertEqual(item.name, "a name")
    XCTAssertEqual(item.category, "a category")
    XCTAssertEqual(item.spicy, true)
}

Which Option to Choose?

The advantage of using inline strings is that you can keep the input values close to the expectation they support. With the JSON file option, it’s unclear where “a name,” “a category,” and true come from, the only way to be sure is to check the JSON file as well.

On the other hand, using JSON files makes our tests tidier. As your input JSON grows in size, the inline string approach will result in longer and longer test methods, whereas tests loading the data from a file won’t need more input setup code.

Using JSON files is also useful if you can automate generating them from your API, making sure the decoding is up to date without putting in place a full end-to-end integration test suite.

If you have input data that varies a lot, you’ll end up with many JSON files, one for each variation. In this case, generating the input via string interpolation might be easier to maintain:
// MenuItem+JSONFixture.swift
@testable import Albertos
extension MenuItem {
    static func jsonFixture(
        name: String = "a name",
        category: String = "a category",
        spicy: Bool = false
    ) -> String {
        return """
{
    "name": "(name)",
    "category": "(category)",
    "spicy": (spicy)
}"""
    }
}
// MenuItemTests.swift
// ...
func testWhenDecodedFromJSONDataHasAllTheInputProperties() throws {
    let json = MenuItem.jsonFixture(    name: "a name",     category: "a category",     spicy: false)
    let data = try XCTUnwrap(json.data(using: .utf8))
    let item = try JSONDecoder().decode(MenuItem.self, from: data)
    XCTAssertEqual(item.name, "a name")
    XCTAssertEqual(item.category, "a category")
    XCTAssertEqual(item.spicy, false)
}

Which option to choose depends on your domain model. If your API has many entities, each rich with properties, then using files might make it easier to navigate your test suite.

Unless you are building an app against an existing API and already know what you’re dealing with, I recommend starting with inline strings and optimizing your tests’ understandability.

Now that we’ve explored the how of JSON decoding testing, let’s take a step back and look at the when. When is TDD useful to implement JSON decoding?

Is Testing JSON Decoding Worth It?

It depends. Like everything else in software development, it’s a matter of tradeoffs.

In the tests we wrote earlier, most of what we’re testing is the behavior of Swift’s JSONDecoder. That’s code that we can’t control, and, since it comes from the Swift standard library, it’s safe to assume it works as advertised.

If all you need to decode JSON data coming from an API is adding Decodable to the list of protocols a type conforms to, then a test may not be worth it. If you still want to have a safeguard against the code breaking, you can write a simpler test, one that merely ensures the decoding doesn’t throw an error:
func testWhenDecodingFromJSONDataDoesNotThrow() throws {
    let json = #"{ "name": "a name", "category": "a category", "spicy": true }"#
    let data = try XCTUnwrap(json.data(using: .utf8))
    XCTAssertNoThrow(try JSONDecoder().decode(MenuItem.self, from: data))
}

If you’re doing something nontrivial in the decoding, then a test to make sure everything is working correctly is valuable.

For example, let’s say the JSON comes with an object for the category instead of a plain string:
{
    "name": "tortellini alla panna",
    "category": {
        "name": "pasta",
        "id": 123
    },
    "spicy": false
}

To decode this JSON into our MenuItem object, we need some extra code on top of what the compiler synthesizes for us via Decodable:

Let’s update our initial test with this input and see what happens:
// MenuItemTests.swift
// ...
func testWhenDecodedFromJSONDataHasAllTheInputProperties() throws {
    let json = """
{
    "name": "a name",
    "category": {
        "name": "a category",
        "id": 123
    },
    "spicy": false
}
"""
    let data = try XCTUnwrap(json.data(using: .utf8))
    let item = try JSONDecoder()    .decode(MenuItem.self, from: data)
    XCTAssertEqual(item.name, "a name")
    XCTAssertEqual(item.category, "a category")
    XCTAssertEqual(item.spicy, false)
}
The test fails with
typeMismatch(
    Swift.String,
    Swift.DecodingError.Context(
        codingPath: [
            CodingKeys(stringValue: "category", intValue: nil)
        ],
        debugDescription: "Expected to decode String but found a dictionary instead.", underlyingError: nil
    )
)

The error is dense, but by reading through it, you can understand that the decoding was looking for a String for the “category” key but got a dictionary instead. That’s exactly the error we’d expect from the change we made in the JSON input.

One possible implementation that makes this test pass is introducing a nested private object to decode the category and forward its name to the category property:
// MenuItem.swift
struct MenuItem {
    let name: String
    let spicy: Bool
    private let categoryObject: Category
    var category: String { categoryObject.name }
    enum CodingKeys: String, CodingKey {
        case name, spicy
        case categoryObject = "category"
    }
    struct Category: Equatable, Decodable {
        let name: String
    }
}
// ...
extension MenuItem: Decodable {}
// This is required to make the existing code with the // hardcoded menu compile.
extension MenuItem {
    init(category: String, name: String, spicy: Bool) {
        self.categoryObject = Category(name: category)
        self.name = name
        self.spicy = spicy
    }
}

In the case of a nontrivial JSON structure like the one in this example, I feel a test is a useful feedback mechanism to guide the decoding implementation.

Something else to keep in mind is that we always call JSON decoding as part of a network request or some other kind of data transferring; it’s not something we might call directly in a ViewModel. Because the return type of the fetchMenu in MenuFetching is AnyPublisher<[MenuItem], Error>, a component fetching the menu from the remote API will also have to decode the received data into a [MenuItem]. As long as the decoding has no custom logic, the tests for the networking component will fail if something goes wrong. That is, testing the networking code implicitly exercises the JSON decoding logic.

If your API is such that you can decode it with little to no custom code, you’re better off jumping straight to implementing the networking code and saving yourself the time to write tests for the decoding logic. That’s because the Swift standard library takes care of the decoding; it’s not your responsibility to test that.

If there is a certain amount of ad hoc decoding logic, though, like in the previous example, then a set of focused tests will help you implement it and make sure it doesn’t break.

A TDD purist would be pulling their hair out and scream at me in frustration right now. How dare I suggest that it’s okay not to write tests in a book about Test-Driven Development?!

Test-Driven Development is a practice, not a way of life. If the return on investment of writing some code test-first is not worth it, both in the short and long term, then its okay not to write a test for that code.

We’ve wrestled with when deploying TDD is appropriate for writing JSON decoding logic and seen a couple of options on how to do so. It’s now time to put this logic in action by building the final component in our feature: the one responsible for fetching the menu data from the API.

Practice Time

The API responses we’ve seen in the chapter have all been flat JSON arrays, meaning we can decode an array of MenuItems as long as MenuItem itself is Decodable. How would you decode MenuItem if the response was an object? For example:
{
    "items": [
        {
            "name": "spaghetti carbonara",
            "category": "pasta",
            "spicy": false
        },
        {
            "name": "penne all'arrabbiata",
            "category": "pasta",
            "spicy": true
        }
    ]
}

Key Takeaways

  • One approach to test JSON decoding logic is to define an inline String, convert it to Data, and use it as the input for JSONDecoder. This approach is useful for input JSONs that have few keys and to make the input value to model property relationship clear.

  • Another option is to define the input in a dedicated JSON file and convert that to Data in the test. This approach is useful for input objects with many keys but adds a layer of indirection between the input and the resulting SUT properties.

  • Keep your tests tidy by extracting the logic to convert from JSON to Data into helper functions.

  • Testing JSON decoding logic is valuable if there is custom logic required to conform to Decodable. If all the model properties match the input JSON’s keys, Swift takes care of everything for us, and a test for it is redundant.

Endnote

  1. 1.

    There are options other than REST for an API backend. Two that are gaining more and more popularity are GraphQL and ProtoBufs. Both of them add a type-safety layer to the API, which you can – and should – leverage to code-generate all the conversion logic between API responses and application domain objects.

    ProtoBufs serializes data in a custom format that is more efficient than JSON. GraphQL responds with JSON objects, but stands out for its query language. Rather than having different endpoints for different resources, you ask a GraphQL backend for data by sending it a query that describes the information you need.

    In a small application like ours, the overhead of adopting either of those solutions dwarfs the benefits we would get in terms of maintainability and efficiency.

     
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.222.107.64