Swift Codable Testing

What do you test when it comes to Swift's Codable? I start with some thoughts on stuff that might be useful to test and then dive into TDD'ing an example of a custom Decodable implementation.

Test Candidates

Deciding what we could/should test is worth spending some good time thinking about. To start I'd consider the following:

1) How are required properties handled?
I expect that my type will decode successfully if all the required properties are present and any missing optional properties should have no effect.

2) Are source values being used?
I expect that if my source had the value Paul for the key name then I would end up with a parsed Swift type that reflects these values.

3) Can the data round trip through Codable?
If I am conforming to Codable and not just Decodable or Encodable individually then I expect my type to be able to go through both decoding/encoding without losing any data.


Off the shelf Codable

If you are just conforming to Codable without providing a custom init(from:) or encode(to:) then you probably don't need to add any of your own unit tests to validate the types decoding/encoding directly. Looking at the list above we can feel pretty confident that the compiler is generating code that handles all of these cases. This is a great place to be - if I have the following type:

struct Square: Codable {
    let height: Double  
    let width: Double
}

Then I have great documentation that states that both height and width are required and attempting to decode from a source that is missing either will behave in a well defined way. Due to the fact that the implementations of both encoding/decoding are being generated by the compiler I can also be confident that it will generate the correct code to create instances that contain the values from the source.


Custom Codable

For cases where I am defining my own custom implementation of Codable I need to test all the scenarios above. Let's take the example of wanting to be able to decode the following types:

struct Rectangle: Decodable, Equatable {
    let length: Double  
}

struct Square: Decodable, Equatable {
    let height: Double  
    let width: Double  
}

enum Shape: Decodable, Equatable {
    case rectangle(Rectangle)  
    case square(Square)  

    init(from decoder: Decoder) throws {
      fatalError("Not implemented")
    }
}

from JSON that looks like this:

[
  {
    "type" : "square",
    "attributes" : {
      "length" : 200
    }
  },
  {
    "type" : "rectangle",
    "attributes" : {
      "height" : 200,
      "width" : 100
    }
  }
]

First up notice that Rectangle and Square are both using the off the shelf Codable implementations provided by the compiler so it's only Shape that we need to write tests for.


Let's test drive this

Looking at the JSON we can see that there are keys for type and attributes that are not in our native type. Both of these keys are required in order to parse a Shape so we need to write tests to cover this (don't worry I simplify the following monstrosity a bit further on):

final class ShapesTests: XCTestCase {
    func testDecoding_whenMissingType_itThrows() {
        XCTAssertThrowsError(try JSONDecoder().decode(Shape.self, from: fixtureMissingType)) { error in
            if case .keyNotFound(let key, _)? = error as? DecodingError {
                XCTAssertEqual("type", key.stringValue)
            } else {
                XCTFail("Expected '.keyNotFound' but got \(error)")
            }
        }
    }

    func testDecoding_whenMissingAttributes_itThrows() {
        XCTAssertThrowsError(try JSONDecoder().decode(Shape.self, from: fixtureMissingAttributes)) { error in
            if case .keyNotFound(let key, _)? = error as? DecodingError {
                XCTAssertEqual("attributes", key.stringValue)
            } else {
                XCTFail("Expected '.keyNotFound' but got \(error)")
            }
        }
    }
}

private let fixtureMissingType = Data("""
{
  "attributes" : {
    "height" : 200,
    "width" : 400
  }
}
""".utf8)

private let fixtureMissingAttributes = Data("""
{
  "type" : "square"
}
""".utf8)

These two tests are verifying that both those properties are required.

NB: I've not placed the fixtures within the main body of ShapesTests on purpose to make it so the main body is all about the tests. I've used private let outside the scope of the unit test class instead of in an extension ShapesTests because I find it tends to be easier to maintain. If I nest inside an extension I end up more often than not breaking tests if I decide to rename the test class name and don't remember to update all the extensions.

To get these tests to pass I need to write a little bit of implementation. The simplest thing I can do is ensure that both of the keys exist without using their values and then create a default instance of Shape.rectangle (because I need to set self to something). I can do this by decode'ing from the keyed container and assigning to _:

enum Shape: Decodable, Equatable {
    case rectangle(Rectangle)
    case square(Square)

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        _ = try container.decode(String.self, forKey: .type)
        _ = try container.decode(Square.self, forKey: .attributes)

        self = .rectangle(.init(length: 200))
    }

    private enum CodingKeys: String, CodingKey {
        case attributes
        case type
    }
}

This implementation is pure nonsense but it makes the tests pass.

I'm not happy with the current tests as they are pretty hard to read and are not doing a great job of describing my intent.


Refactoring tests

I want to refactor these tests to make it really clear what their intent is. Tests that are difficult to read/understand are often just thrown away at a later date, in an effort to avoid this I'd rather spend a little bit of time making things clean.

1) Extract a helper for the assertion
That assertion is pretty difficult to read - at its core it is just checking that decoding will throw a key missing error. I create a helper by copying the original implementation into a new function and making the external interface simpler, which as you'll see below tidies up the final callsite.

func AssertThrowsKeyNotFound<T: Decodable>(_ expectedKey: String, decoding: T.Type, from data: Data, file: StaticString = #file, line: UInt = #line) {
    XCTAssertThrowsError(try JSONDecoder().decode(decoding, from: data), file: file, line: line) { error in
        if case .keyNotFound(let key, _)? = error as? DecodingError {
            XCTAssertEqual(expectedKey, key.stringValue, "Expected missing key '\(key.stringValue)' to equal '\(expectedKey)'.", file: file, line: line)
        } else {
            XCTFail("Expected '.keyNotFound(\(expectedKey))' but got \(error)", file: file, line: line)
        }
    }
}

The tests now become:

final class ShapesTests: XCTestCase {
    func testDecoding_whenMissingType_itThrows() {
        AssertThrowsKeyNotFound("type", decoding: Shape.self, from: fixtureMissingType)
    }

    func testDecoding_whenMissingAttributes_itThrows() {
        AssertThrowsKeyNotFound("attributes", decoding: Shape.self, from: fixtureMissingAttributes)
    }
}

2) Extract helper for munging JSON

The next thing I would consider refactoring is the fixture duplication. For both of the tests above I am essentially taking a working JSON object and stripping away keys one at a time and verifying that the correct error is thrown. I can leverage some good old fashion Key Value Coding to make this simple helper:

extension Data {
    func json(deletingKeyPaths keyPaths: String...) throws -> Data {
        let decoded = try JSONSerialization.jsonObject(with: self, options: .mutableContainers) as AnyObject

        for keyPath in keyPaths {
            decoded.setValue(nil, forKeyPath: keyPath)
        }

        return try JSONSerialization.data(withJSONObject: decoded)
    }
}

With the above refactorings the entire test file now looks like this (assuming the helpers were placed in new files):

final class ShapesTests: XCTestCase {
    func testDecoding_whenMissingType_itThrows() throws {
        AssertThrowsKeyNotFound("type", decoding: Shape.self, from: try fixture.json(deletingKeyPaths: "type"))
    }

    func testDecoding_whenMissingAttributes_itThrows() throws {
        AssertThrowsKeyNotFound("attributes", decoding: Shape.self, from: try fixture.json(deletingKeyPaths: "attributes"))
    }
}

private let fixture = Data("""
{
  "type" : "square",
  "attributes" : {
    "height" : 200,
    "width" : 400
  }
}
""".utf8)

Now that the tests are looking cleaner lets add some more to force the next bit of production code to be written. I'm going to chose to just verify that type is utilised correctly. I have a feeling this test will be temporary as a later test will make it redundant but let's write it to keep things moving:

func testDecoding_whenSquare_returnsASquare() throws {
    let result = try JSONDecoder().decode(Shape.self, from: fixture)

    if case .square = result {
        XCTAssertTrue(true)
    } else {
        XCTFail("Expected to parse a `square` but got \(result)")
    }
}

func testDecoding_whenRectangle_returnsARectangle() throws {
    let result = try JSONDecoder().decode(Shape.self, from: fixtureRectangle)

    if case .rectangle = result {
        XCTAssertTrue(true)
    } else {
        XCTFail("Expected to parse a `rectangle` but got \(result)")
    }
}

...

private let fixtureRectangle = Data("""
{
  "type" : "rectangle",
  "attributes" : {
    "height" : 200,
    "width" : 400
  }
}
""".utf8)

These tests are pretty permissive and will allow anything as long as it's the correct enum case. The simplest thing I can write to make this pass is to hardcode some random shapes:

init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)

    _ = try container.decode(Square.self, forKey: .attributes)

    switch try container.decode(String.self, forKey: .type) {
    case "rectangle": self = .rectangle(.init(length: 200))
    case "square":    self = .square(.init(height: 200, width: 400))
    default: fatalError("Unhandled type")
    }
}

At this point my tests are now a bit rubbish. I've had to provide a new fixtureRectangle which isn't actually representing a valid source anymore. It makes sense to remove these tests and write some more reasonable assertions. I'll start by addressing squares first:

func testDecoding_whenSquare_returnsASquare() throws {
    XCTAssertEqual(.square(square), try JSONDecoder().decode(Shape.self, from: fixture))
}

...

private let square = Square(height: 200, width: 400)

The tests pass and I didn't change the current implementation of init(from:), which is slightly worrying as I know I hardcoded values. The best thing to do is to write another assertion with different data:

func testDecoding_whenSquare_returnsASquare() throws {
    XCTAssertEqual(.square(square), try JSONDecoder().decode(Shape.self, from: fixture))
    XCTAssertEqual(.square(square2), try JSONDecoder().decode(Shape.self, from: fixture2))
}

...

private let square2 = Square(height: 100, width: 200)

private let fixture2 = Data("""
{
  "type" : "square",
  "attributes" : {
    "height" : 100,
    "width" : 200
  }
}
""".utf8)

This gets us back to a broken test as the hardcoded values I return no longer match those I expect with different fixture data.

Making the parsing code work is just a case of updating the case "square" logic in the init(from:) func:

init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)

    _ = try container.decode(Square.self, forKey: .attributes)

    switch try container.decode(String.self, forKey: .type) {
    case "rectangle": self = .rectangle(.init(length: 200))
    case "square":    self = .square(try container.decode(Square.self, forKey: .attributes))
    default: fatalError("Unhandled type")
    }
}

Let's repeat the above to get Rectangles working as well.

Add some tests:

func testDecoding_whenRectangle_returnsARectangle() throws {
    XCTAssertEqual(.rectangle(rectangle), try JSONDecoder().decode(Shape.self, from: fixture3))
    XCTAssertEqual(.rectangle(rectangle2), try JSONDecoder().decode(Shape.self, from: fixture4))
}

...

private let rectangle = Rectangle(length: 100)
private let rectangle2 = Rectangle(length: 200)

private let fixture3 = Data("""
{
  "type" : "rectangle",
  "attributes" : {
    "length" : 100
  }
}
""".utf8)

private let fixture4 = Data("""
{
  "type" : "rectangle",
  "attributes" : {
    "length" : 200
  }
}
""".utf8)

Then updating the parsing code becomes:

init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)

    switch try container.decode(String.self, forKey: .type) {
    case "rectangle": self = .rectangle(try container.decode(Rectangle.self, forKey: .attributes))
    case "square":    self = .square(try container.decode(Square.self, forKey: .attributes))
    default: fatalError("Unhandled type")
    }
}

Recap

We've now written tests that cover the requirements at the top (for the Decodable half of the Codable story). We've verified that required keys are in fact required and checked that when you do use a decoder your newly created types take the values from the source.

Looking at this new code (listed below) I can see that adding all these new tests has gotten really ugly:

final class ShapesTests: XCTestCase {
    func testDecoding_whenMissingType_itThrows() throws {
        AssertThrowsKeyNotFound("type", decoding: Shape.self, from: try fixture.json(deletingKeyPaths: "type"))
    }

    func testDecoding_whenMissingAttributes_itThrows() throws {
        AssertThrowsKeyNotFound("attributes", decoding: Shape.self, from: try fixture.json(deletingKeyPaths: "attributes"))
    }

    func testDecoding_whenSquare_returnsASquare() throws {
        XCTAssertEqual(.square(square), try JSONDecoder().decode(Shape.self, from: fixture))
        XCTAssertEqual(.square(square2), try JSONDecoder().decode(Shape.self, from: fixture2))
    }

    func testDecoding_whenRectangle_returnsARectangle() throws {
        XCTAssertEqual(.rectangle(rectangle), try JSONDecoder().decode(Shape.self, from: fixture3))
        XCTAssertEqual(.rectangle(rectangle2), try JSONDecoder().decode(Shape.self, from: fixture4))
    }
}

private let square = Square(height: 200, width: 400)
private let square2 = Square(height: 100, width: 200)

private let rectangle = Rectangle(length: 100)
private let rectangle2 = Rectangle(length: 200)

private let fixture = Data("""
{
  "type" : "square",
  "attributes" : {
    "height" : 200,
    "width" : 400
  }
}
""".utf8)

private let fixture2 = Data("""
{
  "type" : "square",
  "attributes" : {
    "height" : 100,
    "width" : 200
  }
}
""".utf8)

private let fixture3 = Data("""
{
  "type" : "rectangle",
  "attributes" : {
    "length" : 100
  }
}
""".utf8)

private let fixture4 = Data("""
{
  "type" : "rectangle",
  "attributes" : {
    "length" : 200
  }
}
""".utf8)

My concerns here are primarily based around adding lots of new fixtures that are poorly named. I could improve the naming but then I feel like the data definition and usage is really far apart. I'd most likely still have to go hunting to look at the fixtures regardless of the name.

I can start to tidy this up by inlining square, square2, rectangle and rectangle2 and deleting the constants:

func testDecoding_whenSquare_returnsASquare() throws {
    XCTAssertEqual(.square(.init(height: 200, width: 400)), try JSONDecoder().decode(Shape.self, from: fixture))
    XCTAssertEqual(.square(.init(height: 100, width: 200)), try JSONDecoder().decode(Shape.self, from: fixture2))
}

func testDecoding_whenRectangle_returnsARectangle() throws {
    XCTAssertEqual(.rectangle(.init(length: 100)), try JSONDecoder().decode(Shape.self, from: fixture3))
    XCTAssertEqual(.rectangle(.init(length: 200)), try JSONDecoder().decode(Shape.self, from: fixture4))
}

A further enhancement would be to bring the fixture data into the body of the test as well. We could use a similar idea from earlier where we just munge some existing data inside the test body where it is used so everything is kept local to the test. Here's the helper function, which again leverages the power of Key Value Coding:

extension Data {
    func json(updatingKeyPaths keyPaths: (String, Any)...) throws -> Data {
        let decoded = try JSONSerialization.jsonObject(with: self, options: .mutableContainers) as AnyObject

        for (keyPath, value) in keyPaths {
            decoded.setValue(value, forKeyPath: keyPath)
        }

        return try JSONSerialization.data(withJSONObject: decoded)
    }
}

Using the new helper gives the following tests. The code isn't as short anymore but all the data is local to the tests making it easier for future readers to figure out what data is changing to drive the various conditions:

func testDecoding_whenSquare_returnsASquare() throws {
    XCTAssertEqual(.square(.init(height: 200, width: 400)), try JSONDecoder().decode(Shape.self, from: fixture))
    XCTAssertEqual(.square(.init(height: 100, width: 200)), try JSONDecoder().decode(Shape.self, from: fixture.json(updatingKeyPaths: ("attributes", [ "height" : 100, "width" : 200 ]))))
}

func testDecoding_whenRectangle_returnsARectangle() throws {
    let rectangle = { try fixture.json(updatingKeyPaths: ("type", "rectangle"), ("attributes", [ "length" : $0 ])) }

    XCTAssertEqual(.rectangle(.init(length: 100)), try JSONDecoder().decode(Shape.self, from: rectangle(100)))
    XCTAssertEqual(.rectangle(.init(length: 200)), try JSONDecoder().decode(Shape.self, from: rectangle(200)))
}

For reference the full code listing after all of the above is:

Shapes.swift

struct Rectangle: Decodable, Equatable {
    let length: Double
}

struct Square: Decodable, Equatable {
    let height: Double
    let width: Double
}

enum Shape: Decodable, Equatable {
    case rectangle(Rectangle)
    case square(Square)

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        switch try container.decode(String.self, forKey: .type) {
        case "rectangle": self = .rectangle(try container.decode(Rectangle.self, forKey: .attributes))
        case "square":    self = .square(try container.decode(Square.self, forKey: .attributes))
        default: fatalError("Unhandled type")
        }
    }

    private enum CodingKeys: String, CodingKey {
        case attributes
        case type
    }
}

ShapesTests.swift

final class ShapesTests: XCTestCase {
    func testDecoding_whenMissingType_itThrows() throws {
        AssertThrowsKeyNotFound("type", decoding: Shape.self, from: try fixture.json(deletingKeyPaths: "type"))
    }

    func testDecoding_whenMissingAttributes_itThrows() throws {
        AssertThrowsKeyNotFound("attributes", decoding: Shape.self, from: try fixture.json(deletingKeyPaths: "attributes"))
    }

    func testDecoding_whenSquare_returnsASquare() throws {
        XCTAssertEqual(.square(.init(height: 200, width: 400)), try JSONDecoder().decode(Shape.self, from: fixture))
        XCTAssertEqual(.square(.init(height: 100, width: 200)), try JSONDecoder().decode(Shape.self, from: fixture.json(updatingKeyPaths: ("attributes", [ "height" : 100, "width" : 200 ]))))
    }

    func testDecoding_whenRectangle_returnsARectangle() throws {
        let rectangle = { try fixture.json(updatingKeyPaths: ("type", "rectangle"), ("attributes", [ "length" : $0 ])) }

        XCTAssertEqual(.rectangle(.init(length: 100)), try JSONDecoder().decode(Shape.self, from: rectangle(100)))
        XCTAssertEqual(.rectangle(.init(length: 200)), try JSONDecoder().decode(Shape.self, from: rectangle(200)))
    }
}

private let fixture = Data("""
{
  "type" : "square",
  "attributes" : {
    "height" : 200,
    "width" : 400
  }
}
""".utf8)

Conclusion

Testing Codable implementations isn't particularly hard but the boilerplate code required can get out of hand pretty quickly. I thought I'd run through a TDD process to get to the final solution as I find this stuff personally interesting and hopefully someone else might to. Hopefully I've highlighted some basic stuff to test when looking at custom Decodable implementations and shown that it's useful to refactor not only the production code but the test code as well.