In a previous article I discussed the use of a Data Transfer Object (DTO) when dealing with JSON that doesn't fit well with your object model. Let's discuss what's involved in testing that DTO.
What Needs Testing?
There are two classes that are involved in the conversion of the JSON to the model object for theMealDB example: RecipeDetailDTO
and RecipeDetail
. And we need to test two aspects of these classes:
- Does the
RecipeDetailDTO
class decode the JSON as expected. - Does initializing a
RecipeDetail
instance from aRecipeDetailDTO
object result in the expected result.
Choosing a Testing System
For the unit tests for the DTO we'll choose to use the new Swift Testing system that was added in Xcode 16 beta. I made this decision based on the fact that moving forward this will become more common. I also find it to be more understandable with a single test macro as opposed to all the various XCAssert functions, which I always had difficulty remembering. And when a test fails there are facilities within Xcode to examine the failure in new ways.
Getting Started
Assuming we're starting from the existing MealDB-DTO repo first we need to add a unit test target to the project. Select File -> New -> Target, and then filter to show and select the Unit Testing Bundle. Click Next and then provide the information about your target.
The Project and Target to be Tested will be set automatically. You may need to adjust the organization identifier, and of course your Team will be different.
This is where you'll choose between the legacy XCTest system and the new Swift Testing system. Select Swift Testing system
Once you click Finish, you'll have a new target and directory added to the project.
The final step in preparing for testing is to add the existing model files to the MealDBDTOTests target. Select the files in the Project Navigator and show the File Inspector. Click the + under Target Membership to add the files to another target. You'll need to type something in the filter (I'm assuming that's a beta issue). Select the MealDBDTOTests target and click Save.
I'm going to rename MealDBDTOTests.swift
to RecipeDetailDTOTests.swift
and will be adding another file RecipeDetailTests.swift
to help separate out my test code into relevant files.
Required Changes to RecipeDetailDTO
The RecipeDetailDTO
class will need some changes in order to be testable. We'll need to change the visibility of the properties of the class. They're currently fileprivate
and we'll need to change their access to make them available to the test code. This is an unfortunate side effect of testing. However, in this case, we'll be leaving the properties as read only (let
), so the risk impact is minimal.
fileprivate
keywords is the most efficient way of changing these.Test Setup
In the case of the RecipeDetailDTO
, we want to ensure that it parses the JSON data correctly. For clarity sake, we'll add a global function to the RecipeDetailDTOTests.swift
file that creates a RecipeDetailDTO
object from a file in the main bundle.
Now we need our JSON to test against. To provide that we'll create a struct called JSONTestCases
with static vars that provide JSON for our tests. That file, in its entirety, is in the GitHub repo, so I won't include the full contents here. But this is what the successfully parsing JSON looks like.
RecipeDetailDTO
Tests
For the RecipeDetailDTO
class there are two test cases of interest:
- Test case 1 - successful parse with all the required fields plus the thumbnail URL , 3 ingredients/measurements with values, and 1 ingredient/measurement containing empty strings. I'll be omitting the rest of the ingredient/measurement pairs, which will cause them to be considered nil, and we'll test that case.
- Test case 2 - unsuccessful parsing due to the meal name field missing from the JSON.
Writing tests using the new Swift Testing system is done using the @Test
and #expect
macros. There are variations of both with different parameters, but we'll start by using an @Test
that provides a descriptive name for the test, and #expect
macros that do basic comparisons.
We start creating a new test function by type @Test
and Xcode will give us the option of autocompleting the basic test function.
We'll give the function a descriptive name in the @Test
and a less descriptive name for the function. The descriptive name is what shows up in the Test Navigator.
The test code starts by loading the passing JSON using the createRecipeDTO(JSON:)
function and using the#expect
macro to test that the decoded id
property is the value of the JSON idMeal
field.
@Test("Test JSON Parsing")
func jsonParsing() async throws {
let testRecipeDTO = try createRecipeDTO(JSON: JSONTestCases.passingJSON)
#expect(testRecipeDTO?.id == "52958")
}
This is much more straightforward to me than using the old-style XCAssertEqual
function. That it asserts when it isn't equal, where the name implies that it will assert if it is equal was constantly messing me up.
But the #expect
macro is easy. Does the comparison succeed? If it does, then the test passes, otherwise it fails.
We can continue adding comparisons for all the fields accept the ingredient/measurement pairs.
@Test("Test JSON Parsing")
func jsonParsing() async throws {
let testRecipeDTO = try createRecipeDTO(JSON: JSONTestCases.passingJSON)
#expect(testRecipeDTO?.id == "52958")
#expect(testRecipeDTO?.name == "Peanut Butter Cookies")
#expect(testRecipeDTO?.category == "Dessert")
#expect(testRecipeDTO?.instructions == "Preheat oven to 350 degrees.")
#expect(testRecipeDTO?.mealThumbnailURL?.absoluteString == "https://www.themealdb.com/images/media/meals/1544384070.jpg")
#expect(testRecipeDTO?.keywords == "Breakfast,UnHealthy,BBQ")
#expect(testRecipeDTO?.ingredient1 == "Peanut Butter")
#expect(testRecipeDTO?.ingredient2 == "Sugar")
#expect(testRecipeDTO?.ingredient3 == "Egg")
#expect(testRecipeDTO?.ingredient4 == "")
#expect(testRecipeDTO?.ingredient5 == nil)
#expect(testRecipeDTO?.measure1 == "1 cup")
#expect(testRecipeDTO?.measure2 == "1/2 cup")
#expect(testRecipeDTO?.measure3 == "1")
#expect(testRecipeDTO?.measure4 == "")
#expect(testRecipeDTO?.measure5 == nil)
}
These comparisons are straightforward with the possible exception of ingredient5 and measure5. They were purposefully left out of the JSON data so they would be nil, as that sometimes occurs when an ingredient is not specified, and it isn't an empty string (which it is in most cases). For ingredient5 and measure5 we use == comparison again rather than having to use XCAssertNil (which is again, for me, confusing).
With the full function for test case 1 now written, we can try and run it by clicking on the diamond beside the function, or in the Test Navigator.
Green diamonds mean the test succeeded, so our first test case is a success.
We can now add our second test case, parsing the JSON failing because a required field is missing. This uses a slightly different #expect
macro parameter signature #expect(throws:, performing:)
and you provide the error you expect to receive as the first parameter and then the code to execute in a closure to the second. Because the code to execute is a trailing closure, we can omit the parameter name. Our test case 2 becomes:
Because the required strMeal
field is missing in the JSON for this test, attempting to decode the JSON within the createRecipeDTO(JSON:)
function throws a DecodingError and our test succeeds because the error matches our expected error.
Running all the tests for the project causes all of them to pass.
RecipeDetail
Tests
There is only really one test case for the RecipeDetail class, and that's to check if the init(fromDTO:) works as expected.
To do that testing we need to set properties in the RecipeDetailDTO
to specific values and test the RecipeDetail
init(fromDTO:)
. That requires a member-wise initializer for RecipeDetailDTO
, which has almost 60 parameters. The majority of the ingredient/measurement parameters could be left out of the init signature and be set to nil
. We could avoid exposing that member-wise init to the general application by adding it to an extension
of RecipeDetailDTO
that is only in the test target, but it's still a lot of parameters to explicitly set in the test.
But we can cheat, instead of adding that initializer, we'll just use the JSON parsing and provide JSON with the values to test against. If the JSON parsing is failing the RecipeDetailDTO
tests will expose that issue and can be debugged from there. (In fact, using some of the new Swift Testing functionality, we could put the JSON testing into a separate Suite that we could run separate from the DTO tests.)
The function that performs the DTO test resides in the RecipeDetailTests.swift
file and looks like this.
The JSON is used to create a RecipeDetailDTO
instance, and then a RecipeDetail
object is initialized from that instance. From there the comparisons are quite similar to the RecipeDetailDTO
tests until we reach the .keywords
property comparison.
Because the DTO conversion breaks the comma-separated string up into an array the comparison is to an array with the three expected values. As long as they tall exist, that #expect
is true.
When the DTO conversion handles the ingredient/measure keys it creates an array of Ingredient
objects, which have a name
and measure
field. The JSON provides valid values for the first three ingredient/measurements, and then an empty string for the fourth set. That empty string value should be ignored.
So first we test to ensure that the ingredients array has only three items, and then we compare each of those items to their expected values. And Xcode 16 provided autocomplete suggestions for all of those fields, including the array values. Which was quite helpful.
If we return to the Test Navigator and run the tests for the project, we'll see that all of them succeed, as expected.
And while we write tests with the hope of them succeeding, we want to catch the cases where they don't. Prior to the new Swift Testing in Xcode 16 you'd get a failure and you'd need to go look at the code, or have descriptions attached to your XCAssert that gave you more information. But testing in Xcode 16 gives you a lot more assistance when looking at why a test fails.
Making the DTO Conversion Fail
To explore this, let's make the DTO conversion fail to match the expectations. To do that we'll make a small edit to the JSON so that the strTags
string field that is mapped to the keywords Strings array has only two items. So we change the JSON for that field to this:
"strTags": ",UnHealthy,BBQ",
This when this is parsed by the init(fromDTO:) function it'll create an array of only two items, "Unhealthy", and "BBQ".
Now when we run our tests we see a failure, as you'd expect. We actually see two failures. One for the Test JSON Parsing case (because the strings no longer match) and the second failure of the DTO Conversion.
If we click on the error highlighted on line 22, we see some details about what we expected and what was actually found.
The keywords
array only contained two of the three expected values. Xcode was helpful enough to show the value that we got as well as what we expected. Which is a big improvement.
You can also click on the Show button to see more detail including the types of the objects. This particular failure is simple and contrived, but this will be much more helpful for complex failures.
More Swift Testing Features
This is just a quick look at the basics of the new Swift Testing system. You can create tags for individual tests and create test Suites allowing your tests to be grouped in a logical form code wise (as I did here with RecipeDetail
and RecipeDetailDTO
files) but also group them into a more logical form for your application as a whole. There are traits that help you write less boilerplate code and #require
macros.
There are three excellent WWDC 2024 sessions that cover Swift Testing in more detail that I'd highly recommend.
- What's new in Xcode 16 gives you a brief overview.
- Swift Testing gives you more detail specific to testing.
- Go Further with Swift Testing is a deep dive.
Go forth and write tests.
The source code for the MealDB-DTO project is available on Github. The changes made for this article from the original article "Working with the JSON You Get" are in a branch called "Swift-tests".
I'd love to hear your comments. Feel free to reach out to me at @sanguish on Mastodon.
I'm looking for a full-time iOS or macOS senior position. Feel free to contact me or check out my Resume.