DTO Testing with the New Swift Testing System

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 a RecipeDetailDTO 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.

This article depends on the beta of Xcode 16. It's a beta, which means it has some issues. In writing these tests, I found cases where the Testing framework wasn't being found and I would get other strange errors. This fix for this was to quit and restart Xcode.

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

Set the Test target information

Once you click Finish, you'll have a new target and directory added to the project.

New MeatDBDTOTests folder and file

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.

0:00
/0:10

Add file to test target

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.

Holding down the option key and making a selection that encompasses all the fileprivate keywords is the most efficient way of changing these.
struct RecipeDetailDTO: Decodable {
    let id: String
    let name: String
    let category: String
    let instructions: String
    let mealThumbnailURL: URL?
    let keywords: String?
    
    let ingredient1: String?
    let ingredient2: String?
    let ingredient3: String?
    let ingredient4: String?
    let ingredient5: String?
    let ingredient6: String?
    let ingredient7: String?
    let ingredient8: String?
    let ingredient9: String?
    let ingredient10: String?
    let ingredient11: String?
    let ingredient12: String?
    let ingredient13: String?
    let ingredient14: String?
    let ingredient15: String?
    let ingredient16: String?
    let ingredient17: String?
    let ingredient18: String?
    let ingredient19: String?
    let ingredient20: String?
    let measure1: String?
    let measure2: String?
    let measure3: String?
    let measure4: String?
    let measure5: String?
    let measure6: String?
    let measure7: String?
    let measure8: String?
    let measure9: String?
    let measure10: String?
    let measure11: String?
    let measure12: String?
    let measure13: String?
    let measure14: String?
    let measure15: String?
    let measure16: String?
    let measure17: String?
    let measure18: String?
    let measure19: String?
    let measure20: String?

RecipeDetailDTO.swift changes

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.

import Testing
import Foundation

func createRecipeDTO(JSON json: String) throws -> RecipeDetailDTO? {
    let data = json.data(using: .utf8)!
    return try JSONDecoder().decode(RecipeDetailDTO.self,
                                    from: data)
}

struct RecipeDetailDTOTests {

    @Test func testExample() async throws {
        // Write your test here and use APIs like `#expect(...)` to check expected conditions.
    }

}

RecipeDetailDTOTests.swift

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.

struct JSONTestCases {
   static var passingJSON: String = """
{
    "idMeal": "52958",
    "strMeal": "Peanut Butter Cookies",
    "strCategory": "Dessert",
    "strInstructions": "Preheat oven to 350 degrees.",
    "strMealThumb": "https:\\/\\/www.themealdb.com\\/images\\/media\\/meals\\/1544384070.jpg",
    "strTags": "Breakfast,UnHealthy,BBQ",
    "strIngredient1": "Peanut Butter",
    "strIngredient2": "Sugar",
    "strIngredient3": "Egg",
    "strIngredient4": "",
    "strMeasure1": "1 cup",
    "strMeasure2": "1\\/2 cup",
    "strMeasure3": "1",
    "strMeasure4": "",
}
"""
}

JSONTestCases.swift partial contents

At no point in the original article did I make note of the fact that the Peanut Butter Cookies recipe in TheMealDB data assigns them both Breakfast and BBQ tags, which any sane person would take issue with. I'd even argue the Unhealthy tag is wrong, at least in moderation. 🤷

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.

0:00
/0:05

Autocomplete of @Test macro

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.

Test succeeded

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:

    @Test("JSON Parsing - missing name")
    func jsonParsingMissingName() async throws {
        #expect(throws: Swift.DecodingError.self) {
            _ = try createRecipeDTO(JSON: JSONTestCases.missingMeal)
        }
    }

Test case 2 - JSONDecoder fails due to missing required field.

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.

import Testing

struct RecipeDetailTests {

    @Test("DTO Conversion - All Fields") 
    func testRecipeDetailFromDTOAllFields() async throws {
        let testRecipeDTO = try createRecipeDTO(JSON: JSONTestCases.passingJSON)
        let recipeDetail = RecipeDetail(fromDTO: testRecipeDTO!)

        #expect(recipeDetail.id == "52958")
        #expect(recipeDetail.name == "Peanut Butter Cookies")
        #expect(recipeDetail.category == "Dessert")
        #expect(recipeDetail.instructions == "Preheat oven to 350 degrees.")
        #expect(recipeDetail.mealThumbnailURL?.absoluteString == "https://www.themealdb.com/images/media/meals/1544384070.jpg")
        #expect(recipeDetail.keywords == ["Breakfast", "UnHealthy", "BBQ"])
        #expect(recipeDetail.ingredients.count == 3)
        #expect(recipeDetail.ingredients[0].name == "Peanut Butter")
        #expect(recipeDetail.ingredients[0].measure == "1 cup")
        #expect(recipeDetail.ingredients[1].name == "Sugar")
        #expect(recipeDetail.ingredients[1].measure == "1/2 cup")
        #expect(recipeDetail.ingredients[2].name == "Egg")
        #expect(recipeDetail.ingredients[2].measure == "1")
    }

}

RecipeDetailTests.swift file

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.

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.

sanguish (@sanguish@iosdev.space)
6.27K Posts, 159 Following, 1K Followers · Searching for a macOS or iOS engineering position Ex-Apple DevPubs (12+yrs, remote). I’d go back. macOS/iOS engineer, & author. OCD-sufferer. Like any rational adult, I dislike mimes.
I'm looking for a full-time iOS or macOS senior position. Feel free to contact me or check out my Resume.