Dependency Injection Framework for Swift - Simple Weather App Example with Swinject Part 1

Aug 13, 2015   #swinject  #dependency-injection  #swift  #alamofire 
  • Updated on Nov 20, 2015 to migrate to Alamofire v3.x and Swinject v0.5.
  • Updated on Oct 1, 2015 for the release versions of Swift 2 and Xcode 7.

In the last blog post, we walked through the concept of dependency injection and basic usage of Swinject, the dependency injection framework for Swift. In this blog post, we are going to develop the simple weather app that you saw its screenshot in the last blog post. During the simple but essential steps of the development, you will see how to get rid of tightly coupled dependencies by using the dependency injection pattern and Swinject.

The source code used in this blog post is available at a repository on GitHub.

SwinjectSimpleExample Screenshot

Requirements

  • Xcode 7
  • OpenWeatherMap API key
  • CocoaPods 0.38 or later

We will use OpenWeatherMap for a free API to get weather information. Sign up and get a free API key.

To install Swinject and some frameworks, we will use CocoaPods.

Preparation of the Project

Let’s start with a new Xcode project. Select File > New > Project... menu and iOS > Application > Single View Application item. Set its product name to SwinjectSimpleExample, language to Swift and devices to iPhone. Check Include Unit Tests only1, then save it anywhere in your local storage.

Then, we are going to install Alamofire, SwiftyJSON, Swinject, Quick and Nimble with CocoaPods. Create Podfile with the following text content in the project root directory. Then run pod install command to install them.

source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '8.0'
use_frameworks!

pod 'Alamofire', '~> 3.1.0'
pod 'SwiftyJSON', '~> 2.3.0'
pod 'Swinject', '0.5'

target 'SwinjectSimpleExampleTests' do
    pod 'Quick', '0.8.0'
    pod 'Nimble', '3.0.0'
end

Alamofire is a networking library to write request and asynchronous response simply. SwiftyJSON is a library to access JSON elements simply. Quick is a behavior-driven development framework to write tests as specs in simple structures. Nimble is a matcher framework that is expressive and supports asynchronous tests. For details, please visit their project pages.

To use the free weather API on iOS 9, we have to allow HTTP connections. Open Info.plist and add NSAppTransportSecurity dictionary with NSAllowsArbitraryLoads element set to true2. Here is more information about the setting and its background.

Without Dependency Injection

First, without dependency injection, we are going to implement a model to handle weather information retrieved through the network service. We will see what can be a problem if we do not care about coupled dependencies.

Add City.swift to SwinjectSimpleExample group in the project. We define City to be an entity representing a city with weather information.

City.swift

struct City {
    let id: Int
    let name: String
    let weather: String
}

Add OpenWeatherMap.swift to store configurations of OpenWeatherMap API. Here please fill apiKey with your own API key.

OpenWeatherMap.swift

struct OpenWeatherMap {
    private static let apiKey = "YOUR API KEY HERE"

    private static let cityIds = [
        6077243, 524901, 5368361, 1835848, 3128760, 4180439,
        2147714, 264371, 1816670, 2643743, 3451190, 1850147
    ]

    static let url = "http://api.openweathermap.org/data/2.5/group"

    static var parameters: [String: String] {
        return [
            "APPID": apiKey,
            "id": cityIds.map { String($0) }.joinWithSeparator(",")
        ]
    }
}

Add WeatherFetcher.swift to implement WeatherFetcher, which has fetch function taking a callback to handle an optional array of Cities returned from OpenWeatherMap.

WeatherFetcher.swift

import Foundation
import Alamofire
import SwiftyJSON

struct WeatherFetcher {
    static func fetch(response: [City]? -> ()) {
        Alamofire.request(.GET, OpenWeatherMap.url, parameters: OpenWeatherMap.parameters)
            .response { _, _, data, _ in
                let cities = data.map { decode($0) }
                response(cities)
            }
    }

    private static func decode(data: NSData) -> [City] {
        let json = JSON(data: data)
        var cities = [City]()
        for (_, j) in json["list"] {
            if let id = j["id"].int {
                let city = City(
                    id: id,
                    name: j["name"].string ?? "",
                    weather: j["weather"][0]["main"].string ?? "")
                cities.append(city)
            }
        }
        return cities
    }
}

The fetch function uses Alamofire to send a request to the server and to get a response as JSON data asynchronously. The specifications of API call and response JSON format are described in “Call for several city IDs” section of OpenWeatherMap site. The data parameter in the closure passed to response from Alamofire is nil if the response has an error. We do not care about details of the error and just pass nil to the callback to fetch in this example although the error should be handled in a product app.

The decode function parses the JSON data returned from the server. It is called as data.map { decode($0) } in fetch where map executes the trailing closure if data is not nil, otherwise returns nil. The decode function uses SwiftyJSON to map the JSON data to an array of our City entities.

Let’s add a unit test to SwinjectSimpleExampleTests group in our project. The filename is WeatherFetcherSpec.swift and its target is set to SwinjectSimpleExampleTests when we create the file. The test is going to check whether the weather data can be retrieved and parsed correctly.

WeatherFetcherSpec.swift

import Quick
import Nimble
@testable import SwinjectSimpleExample

class WeatherFetcherSpec: QuickSpec {
    override func spec() {
        it("returns cities.") {
            var cities: [City]?
            WeatherFetcher.fetch { cities = $0 }

            expect(cities).toEventuallyNot(beNil())
            expect(cities?.count).toEventually(equal(12))
            expect(cities?[0].id).toEventually(equal(6077243))
            expect(cities?[0].name).toEventually(equal("Montreal"))
            expect(cities?[0].weather).toEventually(equal("Clouds"))
        }
    }
}

With Quick and Nimble, each test is written in an it closure, and each expectation is expressed as expect(something).to(condition) or expect(something).toNot(condition) synchronously, or expect(something).toEventually(condition) or expect(something).toEventuallyNot(condition) asynchronously. WeatherFetcher.fetch sets cities asynchronously when weather data is retrieved, so we use the latter ones here.

First, we check cities, which is initialized with nil, should be set to an array after fetch invokes the callback asynchronously. Second, the number of cities should be 12 because our request to the API has 12 city IDs. From the third to fifth, we check only the first city for simplicity. The id, name and weather should be 6077243, "Montreal" and "Clouds" respectively.

Okay. We are ready to run the unit test. Type Command-U to run. Did you see the test passed? I think some people saw it passed, but the others not. Why? Because the weather in “Montreal” in the real world right now must be “Clouds” to pass the test. How can we write a test passing regardless of the current weather? It is actually difficult to write if the part parsing JSON data depends on the part retrieving the data from the server.

With Dependency Injection

In the last section, we found the tightly coupled dependency of the parser on the network, namely Alamofire, made the test difficult. In this section, we are going to decouple them, inject the dependency and write a better test.

First, add Networking.swift with the following protocol definition. It has request method taking a callback to pass response data from the network.

Networking.swift

import Foundation

protocol Networking {
    func request(response: NSData? -> ())
}

Add Network.swift to implement Network that conforms Networking protocol. It encapsulates Alamofire.

Network.swift

import Foundation
import Alamofire

struct Network : Networking {
    func request(response: NSData? -> ()) {
        Alamofire.request(.GET, OpenWeatherMap.url, parameters: OpenWeatherMap.parameters)
            .response { _, _, data, _ in
                response(data)
            }
    }
}

Modify WeatherFetcher to get Networking injected when it is instantiated and to use it to request weather data to the server. Note that fetch and decode functions were static in the last section, but here they are instance methods to use the networking property. A default initializer taking networking is implicitly created by Swift. Now WeatherFetcher has no dependency on Alamofire.

WeatherFetcher.swift

struct WeatherFetcher {
    let networking: Networking

    func fetch(response: [City]? -> ()) {
        networking.request { data in
            let cities = data.map { self.decode($0) }
            response(cities)
        }
    }

    private func decode(data: NSData) -> [City] {
        let json = JSON(data: data)
        var cities = [City]()
        for (_, j) in json["list"] {
            if let id = j["id"].int {
                let city = City(
                    id: id,
                    name: j["name"].string ?? "",
                    weather: j["weather"][0]["main"].string ?? "")
                cities.append(city)
            }
        }
        return cities
    }
}

Then modify WeatherFetcherSpec to test the decoupled network and JSON parser.

WeatherFetcherSpec.swift

import Quick
import Nimble
import Swinject
@testable import SwinjectSimpleExample

class WeatherFetcherSpec: QuickSpec {
    struct StubNetwork: Networking {
        private static let json =
        "{" +
            "\"list\": [" +
                "{" +
                    "\"id\": 2643743," +
                    "\"name\": \"London\"," +
                    "\"weather\": [" +
                        "{" +
                            "\"main\": \"Rain\"" +
                        "}" +
                    "]" +
                "}," +
                "{" +
                    "\"id\": 3451190," +
                    "\"name\": \"Rio de Janeiro\"," +
                    "\"weather\": [" +
                        "{" +
                            "\"main\": \"Clear\"" +
                        "}" +
                    "]" +
                "}" +
            "]" +
        "}"

        func request(response: NSData? -> ()) {
            let data = StubNetwork.json.dataUsingEncoding(
                NSUTF8StringEncoding, allowLossyConversion: false)
            response(data)
        }
    }

    override func spec() {
        var container: Container!
        beforeEach {
            container = Container()

            // Registrations for the network using Alamofire.
            container.register(Networking.self) { _ in Network() }
            container.register(WeatherFetcher.self) { r in
                WeatherFetcher(networking: r.resolve(Networking.self)!)
            }

            // Registration for the stub network.
            container.register(Networking.self, name: "stub") { _ in
                StubNetwork()
            }
            container.register(WeatherFetcher.self, name: "stub") { r in
                WeatherFetcher(
                    networking: r.resolve(Networking.self, name: "stub")!)
            }
        }

        it("returns cities.") {
            var cities: [City]?
            let fetcher = container.resolve(WeatherFetcher.self)!
            fetcher.fetch { cities = $0 }

            expect(cities).toEventuallyNot(beNil())
            expect(cities?.count).toEventually(beGreaterThan(0))
        }
        it("fills weather data.") {
            var cities: [City]?
            let fetcher = container.resolve(WeatherFetcher.self, name: "stub")!
            fetcher.fetch { cities = $0 }

            expect(cities?[0].id).toEventually(equal(2643743))
            expect(cities?[0].name).toEventually(equal("London"))
            expect(cities?[0].weather).toEventually(equal("Rain"))
            expect(cities?[1].id).toEventually(equal(3451190))
            expect(cities?[1].name).toEventually(equal("Rio de Janeiro"))
            expect(cities?[1].weather).toEventually(equal("Clear"))
        }
    }
}

StubNetwork is a stub that conforms Networking. It has a definition of JSON data that has the same structure as the data returned from the server. Its request method returns the identical data any time regardless of the current weather in the real world. In spec, container is configured at the beginning, and it is used later in the two it specifications. Without a registration name, container is configured to use Network. With the registration name “stub”, it is configured to use StubNetwork.

The first it tests that the real network through Alamofire returns some JSON data3 by getting an instance of WeatherFetcher from container without a registration name. We do not test detail of cities. We just confirm that fetch can get some data from the server.

The second it tests that the JSON data are parsed correctly by getting an instance of WeatherFetcher with the registration name “stub”. Because the stub returns two cities as defined in StubNetwork, we write expectations for the two cities and check whether each expectation asynchronously gets the value specified in the stub definition.

Okay. We are ready to run the tests. Type Command-U to run. This time you got the tests passed regardless of the current weather, didn’t you? This is the advantage of the dependency injection pattern to decouple a component from another, in this example decoupling of the parser component from network component.

Conclusion

The problem of dependencies to write unit tests has been explained and fixed with dependency injection in the scenario to develop the app using the network service and JSON parser. By decoupling these two parts, the unit tests have become reproducible under any circumstances. In the next blog post, we will develop the UI part of the example app to learn how to use Swinject in a product app.


  1. UI tests are excluded because they are out of scope of this blog post. [return]
  2. Actually this setting is not preferable if you develop an app to release. In this blog post, I used the setting just because the free API only supports HTTP. [return]
  3. This test may fail if the network is disconnected or has a problem, but these cases can be practically ignored in our unit tests. [return]