La fabrique mobile Viseo Toulouse

Playground Driven Development (Part 3)

This post will show how to share code between your Playgrounds and your project.

This article is the third part of our "Playground Driven Development" serie. The code for the following article can be found in this repository (continuing from second part):

https://github.com/giln/Playgrounds-Driven-Development-Part3.git

We will start by transfering the test code from the playground AppStoreRessourceTest.playground to our project unit tests.

We add a New File in AppStoreViewerTests folder:

NewApp

Choose "Unit Test Case Class", name it AppTest and click on create.

The cut/paste the following code from the playground to the App:

// Tests for App class
class AppTest: XCTestCase {
    let testJson = """
    {
                    "im:name": {
                        "label": "Toca Hair Salon 3"
                    },
                    "im:image": [{
                            "label": "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/13/13/aa/1313aa3d-a782-5951-b902-735048917624/AppIcon-1x_U007emarketing-0-85-220-0-8.png/53x53bb-85.png",
                            "attributes": {
                                "height": "53"
                            }
                        },
                        {
                            "label": "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/13/13/aa/1313aa3d-a782-5951-b902-735048917624/AppIcon-1x_U007emarketing-0-85-220-0-8.png/75x75bb-85.png",
                            "attributes": {
                                "height": "75"
                            }
                        },
                        {
                            "label": "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/13/13/aa/1313aa3d-a782-5951-b902-735048917624/AppIcon-1x_U007emarketing-0-85-220-0-8.png/100x100bb-85.png",
                            "attributes": {
                                "height": "100"
                            }
                        }
                    ],
                    "summary": {
                        "label": "Welcome to Toca Hair Salon 3! Our most popular app"
    }
    }
    """

    func testApp() {
        XCTAssertNotNil(App(name: "name", summary: "summary", thumbImageUrl: "url"))
    }

    func testDecodableName() {
        let data = testJson.data(using: .utf8)
        let app = try? JSONDecoder().decode(App.self, from: data!)
        XCTAssertEqual(app?.name, "Toca Hair Salon 3")
    }

    func testDecodableSummary() {
        let data = testJson.data(using: .utf8)
        let app = try? JSONDecoder().decode(App.self, from: data!)
        XCTAssertEqual(app?.summary, "Welcome to Toca Hair Salon 3! Our most popular app")
    }

    func testDecodableThumbImageURL() {
        let data = testJson.data(using: .utf8)
        let app = try? JSONDecoder().decode(App.self, from: data!)
        XCTAssertEqual(app?.thumbImageUrl, "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/13/13/aa/1313aa3d-a782-5951-b902-735048917624/AppIcon-1x_U007emarketing-0-85-220-0-8.png/53x53bb-85.png")
    }
}

We have compilation errors in our test file:

AppErrors

To fix them we need to copy App struct from our playground to the project.

We create a new "Model" group in our AppStoreViewer/Classes folder and add a New Swift file that we call App.swift:

App

In this file we cut/paste the following code from our playground:

public struct App {
    let name: String
    let summary: String
    let thumbImageUrl: String
}

Don't forget to check App and Tests in target Membership to ensure the class is visible from your tests:

AppMembership.jpg

We still get compilation errors in our AppTest class because our App struct does not conform to Decodable yet.

To fix this we add another file to our "Model" group, called App+Decodable in which we cut/paste the following code from our playground:



extension App: Decodable {
    private enum CodingKeys: String, CodingKey {
        case name = "im:name"
        case summary
        case image = "im:image"
    }

    private enum LabelKeys: String, CodingKey {
        case label
    }

    // Custom decoding
    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        // Read name
        let nameContainer = try container.nestedContainer(keyedBy: LabelKeys.self, forKey: .name)
        name = try nameContainer.decode(String.self, forKey: .label)

        // Read summary
        let summaryContainer = try container.nestedContainer(keyedBy: LabelKeys.self, forKey: .summary)
        summary = try summaryContainer.decode(String.self, forKey: .label)

        var imagesContainer = try container.nestedUnkeyedContainer(forKey: .image)

        var tempImageThumb = ""

        // We take the first image url
        while !imagesContainer.isAtEnd {
            let imageContainer = try imagesContainer.nestedContainer(keyedBy: LabelKeys.self)

            tempImageThumb = try imageContainer.decode(String.self, forKey: LabelKeys.label)
            break
        }

        thumbImageUrl = tempImageThumb
    }
}

Now run your Tests (Product > Test), and you all your tests should pass.

We then cut/paste the following code in a new file called AppStoreRessourceTest.swift in our AppStoreViewerTests target:

// Tests for AppStoreRessource class
class AppStoreRessourceTest: XCTestCase {
    private var appStoreRessource: AppStoreRessource!
    private var mockFetcher: MockFetcher!

    override func setUp() {
        super.setUp()

        mockFetcher = MockFetcher()
        appStoreRessource = AppStoreRessource(datafetcher: mockFetcher)
    }

    override func tearDown() {
        super.tearDown()
    }

    func testAppStoreRessource() {
        XCTAssertNotNil(appStoreRessource)
    }

    func getTopAppsHelper(expectationDescription: String, jsonString: String? = nil, completion: @escaping (XCTestExpectation, [App], RessourceError?) -> Void) {
        let getExpectation = self.expectation(description: expectationDescription)

        mockFetcher.mockData = jsonString?.data(using: .utf8)
        appStoreRessource.getTopApps { apps, error in
            completion(getExpectation, apps, error)
        }

        self.wait(for: [getExpectation], timeout: 1.0)
    }

    func testGetTopAppsCompletes() {
        getTopAppsHelper(expectationDescription: "getTopApps did not complete") { expectation, _, _ in
            expectation.fulfill()
        }
    }

    func testGetTopAppsCompletesWithNetworkError() {
        // We don't pass any data to the mock on purpose
        getTopAppsHelper(expectationDescription: "getTopApps network error") { expectation, _, error in

            if case .some(.networkError) = error {
                expectation.fulfill()
            }
        }
    }

    func testGetTopAppsCompletesWithNoFeedError() {
        // our json string misses the "feed" on purpose
        getTopAppsHelper(expectationDescription: "getTopApps noFeedError",
                         jsonString: "{}") { getExpectation, _, error in
                            if case let .some(.decodingKeyNotFound(key)) = error, key.stringValue == "feed" {
                                getExpectation.fulfill()
                            }
        }
    }

    func testGetTopAppsCompletesWithNoEntryError() {
        // our json string misses the "entry" on purpose
        getTopAppsHelper(expectationDescription: "getTopApps noEntryError",
                         jsonString: "{ \"feed\" : {} }") { getExpectation, _, error in
                            if case let .some(.decodingKeyNotFound(key)) = error, key.stringValue == "entry" {
                                getExpectation.fulfill()
                            }
        }
    }

    func testGetTopAppsCompletesWithNoAppsError() {
        // our json string misses the apps array
        getTopAppsHelper(expectationDescription: "getTopApps noAppsError",
                         jsonString: "{ \"feed\" : { \"entry\" : {} } }") { getExpectation, _, error in
                            if case .some(.decodingTypeMismatch) = error {
                                getExpectation.fulfill()
                            }
        }
    }
}

We get compilation errors:

MockError.jpg

To fix them we create a new AppStoreRessource.swift file in our "Model" group and cut/paste the following code from the playgrounds:

public enum RessourceError: Error {
    case networkError
    case decodingKeyNotFound(key: CodingKey)
    case decodingTypeMismatch
    case otherDecodingError
}

public class AppStoreRessource {
    // MARK: - Init

    init(datafetcher: DataFetching) {
        self.datafetcher = datafetcher
    }

    private let datafetcher: DataFetching

    // Internal struct used because of the way the json is wrapped
    private struct ServerResponse: Decodable {
        let feed: Feed
    }

    // Internal struct used because of the way the json is wrapped
    private struct Feed: Decodable {
        let entry: [App]
    }

    public func getTopApps(completion: @escaping ([App], RessourceError?) -> Void) {
        let urlString = "https://itunes.apple.com/fr/rss/toppaidapplications/limit=10/json"

        let url = URL(string: urlString)!

        datafetcher.fetchData(url: url) { data, _ in

            guard let data = data else {
                completion([], .networkError)
                return
            }

            do {
               let serverResponse = try JSONDecoder().decode(ServerResponse.self, from: data)
                completion(serverResponse.feed.entry, nil)
            } catch DecodingError.keyNotFound(let key, _) {
                completion([], .decodingKeyNotFound(key: key))
            } catch DecodingError.typeMismatch(_, _) {
                completion([], .decodingTypeMismatch)
            } catch {
                print(error)
                completion([], .otherDecodingError)
            }
        }
    }
}

We then create a new DataFetching.swift file in our "Model" group and cut/paste the following code from the playgrounds:

// Protocol used to mock network calls for testing
public protocol DataFetching {
    func fetchData(url: URL, completion: @escaping (Data?, Error?) -> Void)
}

Then create a new NetworkFetcher.swift file in our "Model" group and cut/paste the following code from the playgrounds:

// Network fetching
class NetworkFetcher: DataFetching {
    func fetchData(url: URL, completion: @escaping (Data?, Error?) -> Void) {
        let session = URLSession.shared

        session.dataTask(with: url) { data, _, error in
            DispatchQueue.main.async {
                completion(data, error)
            }
            }.resume()
    }
}

Finally we create a MockFetcher.swift file but this time in our AppStoreViewerTests group:

// Mock fetching
class MockFetcher: DataFetching {
    public var mockData: Data?
    public var mockError: Error?

    func fetchData(url _: URL, completion: @escaping (Data?, Error?) -> Void) {
        completion(mockData, mockError)
    }
}

At this point our playground should be pretty much empty (except for the 2 defaultTestsuite.run lines that we can delete):

EmptyPlayground.jpg

If we run our tests (Product -> Test), everything should be green. We now have our model classes and the corresponding unit tests in our project.

What we want to achieve now is to be able to keep using the classes and structs from our project in our playgrounds. To do this we need to do a little setup.

First we need to create a new Framework target in our project. Go to your project targets:

Targets.jpg

Click on the + button to add a new target:

Framework.jpg

Select "Cocoa Touch Framework" then click next:

Next.jpg

Do Not embed in Application (Embed in Application -> None) and click "Finish":

Finish.jpg

Now that we have create our Framework, we need to add the source code files to it. To do this we select all classes from our Model group and check "AppStoreViewerFrameWork" in the Target Membership window:

TargetMembership.jpg

We need to compile our Framework. Select AppStoreViewerFramework target and build it:

Compile.jpg

Now we can go back to our Playground to see if we can use our classes and make a network call. To use our Framework we need to import it:

Import.jpg

The @testable keyword is used to import everyclass as if it was in the same target. This allows us to use even internal classes from our playgrounds.

Let's write the following code:

import UIKit

@testable import AppStoreViewerFramework


let appStoreRessource = AppStoreRessource(datafetcher: NetworkFetcher())


appStoreRessource.getTopApps { (apps, error) in
    _ = apps.map { print($0.name) }
}

Run It (Cmd-Shift-Enter) and watch result in result window:

Result.jpg

You should see the name of the top 10 apps appear in your result Window.

As you can see we are successfully using classes from our Project in our playground. By injecting the NetworkFetcher to our AppStoreRessource class, we are really making network calls and seing the results from the Json downloaded from Apple Feed.

In next post we will continue investigating how we can build our Application with Xcode Playgrounds.

Tagged with: