Creating Type-safe JSON in Swift
And how it will improve your unit tests’ mock data
When you are testing your network layer, you need to mock the network requests with fake data. This data will be your unit tests input. You usually create one mock data for each test case you want to test, which means you will end up with a lot of them in your code. You also need to store all the data somewhere, and often in string format like this:
This approach is very prone to typos and syntax errors, such as escaping double quotes or forgetting to add commas/colons/quotes.
This kind of error lurks inside your code. When you least expect, your production code somehow fails even though you thought you had unit tests protecting you.
In this post, I will show you a much safer way to provide test data for your unit tests by creating type-safe JSON mock data.
Let’s start with an example. You are creating an application that allows people to ask and answer software development questions. One of the use cases is to retrieve all posts from a specific user.
You can query an endpoint to retrieve all the user posts in JSON format, but there is one catch: A post can be either a question or an answer to a question. Take a look at an example JSON from this endpoint:
Let me emphasize: Posts can be either a question or an answer. The either and or are key words. It gives you a hint that you should model your data as a Swift enum, like this:
Observe that Question
and Answer
have different fields, so you need an associated value in the Post
enum cases. This means that you need custom parsing.
In this case, it is not hard to make Post
conform to Decodable
. You only need to switch the type
field and call the Decodable
initializer from Question
or Answer
.
Now you need unit tests to validate that the custom parsing works as expected. The tests will receive some input data, will produce the output model, and will validate that the model was parsed correctly.
One approach that I used before is to have the data stored in string format inside a nested struct. Take a look at this example:
But as I told you before, using JSON strings is error prone and not safe. To improve this, you can employ the JSONValue
enum.
If you didn’t read my last post, I introduced a JSONValue
enum to model any JSON type, one case for each possible value. You can take a peek at it below:
I also made it conform to the Decodable
protocol and showed how you can use it to parse a generic JSON field. If you missed the last post and want to take a look at it, you can find a link at the bottom of this post.
JSONValue
allows you to create the same kind of input data that you had in before in string format. But this time, if you make any mistake, the compiler will catch the error and the test will not build.
You can statically build the test cases with the JSONValue
initializers:
It is a lot more readable now without all the character escaping. And it is type- and syntax-safe! You don’t need to worry if you add or omit JSON
symbols like :
and ,
because the swift compiler will take care of it for you.
If you like to have the data pretty printed, the Xcode code indentation shortcut can now give you a hand.
Since your tests receive JSON strings, and now you have JSONValue
, you need to convert it. First, make JSONValue
conform to the Encodable
protocol. To do that, switch JSONValue
, and for each case encode the wrapped value.
All wrapped values in JSONValue
are Encodable
, so you don’t have to add any code. It works out of the box.
It still does not give you a string, so you need to call JSONEncoder.encode()
. It is a throwing function, which means that you’ll need to add error handling to your tests.
Here is a small extension that you can add to your unit tests target that will make the error handling much easier. Take a look:
Not a good idea to use this in production code, since it may crash your app with a fatalError
. But it’s amazing for unit tests.
Passing the #file
and #line
to the fatalError
function allows the error to be displayed in the correct place in your test.
Since the compiler is validating most of the syntax, the only time I got a fatalError
was when I tried to encode a JSON Fragment
. A JSON fragment is a JSON that does not have an Object
or an Array
as the root.
If you do need the ability to encode JSON Fragments, you can change the jsonString
function to handle the fragment cases in a different way:
The function now encodes JSON fragments as simple strings. Objects and arrays still call JSONEncoder
.
The new test data is a big improvement over the raw JSON strings approach, but it is still not perfect. Wouldn’t it be nice if you could remove all those .string
, .object
, .array
, and other enum cases?
You can do that! Swift provides a wayusing ExpressibleByLiteral
protocols. These protocols allow you to assign a literal value to a custom type. For example, you can make JSONValue
conform to the ExpressibleByStringLiteral
protocol:
extension JSONValue: ExpressibleByStringLiteral {
public init(stringLiteral value: String) {
self = .string(value)
}
}
Then you can initialize it with a static string:
let json: JSONValue = "I am a JSONValue, not a String!"
Looks really good, right? There is one protocol for each possible JSON type, and you can make JSONValue
conform to all of them! Take a look at the snippet below:
Now you are able to create JSONValue
from literal types, just as you would create a String
, Int
, Bool
, Double
, Array
or Dictionary
. If you remove all the, now obsolete, enum initializers from the previous PostTestCases
example, you will end up with:
What a big improvement! It looks much more like a JSON
structure now.
Actually, this is the same JSON
from the first example, with the double quote escaping removed, all braces {}
replaced with brackets []
and Pretty-printing active.
You can paste any JSON
file, do a simple find/replace from braces to brackets, and end up with a type-safe data for your tests! Woohoo!
There is one more little trick in my bag that I can show you: by adding a tiny extension to JSONValue
, you can decode a JSONValue
to any Decodable
type!
Now you can create any Decodable
type in DSL form. You can try it out with the previous sample data:
Pretty cool, right? 😎
I hope this new knowledge helps you. It certainly helped me.
If you liked this post, don’t forget to give some claps 👏 and share it with your friends 😄.
You can access the previous post that introduced the JSONValue enum by clicking the link below: