Posted 4/24/2018.
One of the most powerful new features in Swift 4.1 is conditional conformance. With conditional conformance, a generic type can declare conformance to a protocol "only when its type arguments meet certain requirements" (see SE-0143). This feature is particularly powerful with collection types, which can now declare conformance to a protocol if all of its elements also conform to that protocol.
In this tutorial, we'll look at a case study of using conditional conformance in unit tests. The problem we'll be solving is a common one. You have a set of stubbed JSON responses and you want to test that they are serialized correctly into Swift model types. Some responses return a single model resource while others return a collection. Our task is to test serialization of these responses in a generic, protocol-oriented way.
To start, create a Swift framework (any platform will work) and make sure "Include Unit Tests" is checked. Next, create a file "Park.swift" in the framework target and add the following model, which represents a national park:
public final class Park: Codable {
public let id: String
public let name: String
public let location: String
public init(id: String, name: String, location: String) {
self.id = id
self.name = name
self.location = location
}
}
Now rename the auto-generated Swift file in the Tests target to "SerializationTests.swift" and remove all of the generated code. Then add the following two JSON files, called "Park_index.json" and "Park_show.json" respectively:
[
{
"id":"1",
"name":"Acadia",
"location":"Maine"
},
{
"id":"2",
"name":"Yosemite",
"location":"California"
},
{
"id":"3",
"name":"Zion",
"location":"Utah"
}
]
{
"id": "1",
"name": "Acadia",
"location": "Maine"
}
The first represents an "index" endpoint for the Park
resource and returns JSON that will be serialized into an array of parks. The second represents a "show" endpoint and returns JSON that will be serialized into a single park object.
Our goal here is to test JSON serialization. In order to do that thoroughly, we want to check that each property on our Park
model is serialized correctly, and we want to see exactly where any errors occur. So while you might be tempted to make Park
conform to Equatable
and assert that the serialized object is equal to an expected value, for the purposes of tests we may want to have a different definition of equality. For example, in the framework two objects may be considered equal if they have the same unique identifier. In tests, we want to check that the value of every property matches exactly. Let's declare a protocol in the test target to do that:
protocol TestEquatable: Codable {
func assertIsEqual(to other: Self)
}
The protocol's only requirement is that conforming types implement a function for comparing self
against another object of the same type. Now we can make Park
conform to the protocol:
extension Park: TestEquatable {
func assertIsEqual(to other: Park) {
XCTAssertEqual(id, other.id)
XCTAssertEqual(name, other.name)
XCTAssertEqual(location, other.location)
}
}
Here we're simply asserting that every property on self
is equal to the same property on other
. If there are any errors in serialization, we'll see on which properties they occurred.
Next let's add a struct to represent an individual serialization test:
struct SerializationTest<T: TestEquatable>: TestRepresentable {
let filename: String
let expected: T
func evaluate() {
let url = Bundle(for: SerializationTests.self).url(forResource: filename, withExtension: "json")!
let data = try! Data(contentsOf: url)
let model = try! JSONDecoder().decode(T.self, from: data)
model.assertIsEqual(to: expected)
}
}
Notice that this struct conforms to a protocol we haven't declared yet, TestRepresentable
. We'll get to that in a moment.
The important piece here is that we're pairing a filename representing a JSON stub with an expected value. The evaluate
function first loads the stub from its related file, uses a JSONDecoder to serialize it, then compares the serialized value to the expected value. That's where the TestEquatable
protocol we declared above comes in. Because the struct is generically constrained to a type conforming to TestEquatable
, and we are decoding a value of the same type, we can use the protocol to assert that the serialized and expected values match exactly.
Now what about TestRepresentable
? Since our TestEquatable
protocol has a "Self" requirement, it can only be used as a generic constraint. And since we eventually want a heterogeneous collection of test cases, where the TestEquatable
type could be Park
, [Park]
, or any other conforming type, we need to abstract away the generic constraints. We do that by delcaring a protocol TestRepresentable
that simply requires conforming types to have an evaluate
function:
protocol TestRepresentable {
func evaluate()
}
We're now ready to write our tests. Let's declare a XCTestCase
class that tests serialization of both JSON stubs.
class SerializationTests: XCTestCase {
func testSerialization() {
let acadia = Park(id: "1", name: "Acadia", location: "Maine")
let yosemite = Park(id: "2", name: "Yosemite", location: "California")
let zion = Park(id: "3", name: "Zion", location: "Utah")
let parkShow = SerializationTest(filename: "Park_show", expected: acadia)
let parkIndex = SerializationTest(filename: "Park_index", expected: [acadia, yosemite, zion])
let cases: [TestRepresentable] = [parkShow, parkIndex]
cases.forEach { $0.evaluate() }
}
}
If you run this you'll get an error. The array [acadia, yosemite, zion]
does not conform to TestEquatable
. Finally, we've reached the problem that conditional conformance solves. We can extend the generic Array
type to conform to TestEquatable
only if all elements in the array also conform to TestEquatable
.
extension Array: TestEquatable where Element: TestEquatable {
func assertIsEqual(to other: [Element]) {
guard count == other.count else { return XCTFail() }
zip(self, other).forEach { $0.0.assertIsEqual(to: $0.1) }
}
}
First we check that the counts are equal. Then we check that the pairs of values in self
and other
match exactly. Now if you run the tests they should build successfully and pass.
Conditional conformance adds significant power and flexibility to generic types. In this tutorial we've seen a real-world example of how conditional conformance can be used to streamline unit tests of JSON serialization. Whether or not you use conditional conformance in your own code, by using Swift 4.1 you're already taking advantage of the feature. Apple uses it extensively in the standard library, which you can read about here.