Use Dependency Injection to Unit Test a ViewModel in Swift

Dependency Injection is a technique to pass in one or more dependent object to another object. This article will build on the Weather App to pass in the weather service to the weather ViewModel at initialisation. This will allow the use of a mock weather service to test the ViewModel in unit tests without requiring any access to OpenWeather or any network calls.

Dependency Injection is used to make a class independent from the creation of objects that it depends on and helps create loosely coupled applications.



Starting Point

The starting point is the Weather App built in Read JSON with codeable in Swift that retrieves the current weather data for a specified location. The app uses Open Weather API to retrieve the current weather and requires an API key obtained by creating a free account.


WeatherService

 1class WeatherService {
 2    private let apiKey = "OPEN WEATHER API KEY"
 3    private let baseUrl = "https://api.openweathermap.org/data/2.5/weather"
 4
 5    func getCurrentWeather(latitude: CLLocationDegrees,
 6                           longitude: CLLocationDegrees) async throws -> WeatherRawData {
 7
 8        // Set the API with the appId for OpenWeather
 9        guard let url = URL(string: "\(baseUrl)?lat=\(latitude)&lon=\(longitude)&units=metric&appid=\(apiKey)") else {
10            fatalError("Missing url")
11        }
12
13        // Call the API asynchronously and wait for the response
14        let urlRequest = URLRequest(url: url)
15        let (data, response) = try await URLSession.shared.data(for: urlRequest)
16
17        // TODO: Remove artificial slow down
18        sleep(2)
19
20        guard (response as? HTTPURLResponse)?.statusCode == 200 else {
21            fatalError("Error retrieving weather data")
22        }
23
24        return parseWeatherJson(data)
25    }
26
27    func parseWeatherJson(_ data: Data) -> WeatherRawData {
28        do {
29            return try JSONDecoder().decode(WeatherRawData.self, from: data)
30        } catch {
31            fatalError("Unable to decode  \"\(data)\" as \(WeatherRawData.self):\n\(error)")
32        }
33    }
34}

Weather ViewModel

The WeatherViewModel is dependent on the WeatherService, it contains a member variable for its own instance of WeatherService and this is created during initialization. If the app switched to a new weather service, then the ViewModel would need to be updated to use the new service.

 1class WeatherViewModel: ObservableObject {
 2    private var weatherService: WeatherService
 3    private(set) var cityName: String
 4
 5    @Published private var weatherModel: WeatherModel
 6    @Published private(set) var isLoading: Bool = false
 7
 8    init() {
 9        weatherService = WeatherService()
10        weatherModel = WeatherModel()
11        cityName = "not set"
12    }
13
14    var location: String {
15        return weatherModel.locationName
16    }
17
18    var weatherMain: String {
19        return weatherModel.weatherName
20    }
21
22    var description: String {
23        return weatherModel.description
24    }
25
26    var temperature: Double {
27        return weatherModel.temperature
28    }
29
30    var locationTime: String {
31        let utcDateFormatter = DateFormatter()
32        utcDateFormatter.timeZone = TimeZone(abbreviation: "UTC")
33        utcDateFormatter.timeStyle = .medium
34        let now = Date().addingTimeInterval(weatherModel.timeOffUtc)
35        let dateString = utcDateFormatter.string(from: now)
36        return dateString
37    }
38
39    @MainActor
40    func weatherForCity(_ city: City) async {
41        isLoading = true
42        print("One = \(isLoading)")
43        cityName = city.rawValue
44        let (lat, lon) = coordinates(for: city)
45
46        do {
47            let rawWeather = try await weatherService.getCurrentWeather(latitude: lat, longitude: lon)
48            weatherModel = WeatherModel(data: rawWeather)
49            isLoading = false
50            print("Two = \(isLoading)")
51        } catch {
52            print("Error fetching weather with '\(city.rawValue)' City:\n \(error)")
53        }
54        print("Three = \(isLoading)")
55    }
56}
57
58extension WeatherViewModel {
59    private func coordinates(for city: City) -> (Double, Double) {
60        print("in coordinate - city: \(city.rawValue)")
61        switch city {
62        case .newyork:
63            return (40.749939623101724, -73.98584035140507)
64        case .london:
65            return  (51.48403374752388, -0.0059268752163408114)
66        case .paris:
67            return  (48.8619958275662, 2.294848578874564)
68        case .vancouver:
69            return  (49.2791749376975, -123.10359944424778)
70        case .capetown:
71            return  (-33.96475307519853, 18.417554193804826)
72        case .sydney:
73            return  (-33.85657055055687, 151.21537180010895)
74        }
75    }
76}
77
78enum City: String, CaseIterable, Identifiable {
79    var id: Self { self }
80    case newyork = "New York"
81    case london = "London"
82    case paris = "Paris"
83    case vancouver = "Vancouver"
84    case capetown = "Cape Town"
85    case sydney = "Sydney"
86}

Weather Model

 1struct WeatherModel {
 2    var locationName: String
 3    var weatherName: String
 4    var description: String
 5    var temperature: Double
 6    var timeOffUtc: Double // timezone Shift in seconds from UTC
 7
 8    init() {
 9        locationName = ""
10        weatherName = ""
11        description = ""
12        temperature = 0.0
13        timeOffUtc = 0.0
14    }
15}
16
17extension WeatherModel {
18    init (data: WeatherRawData) {
19        locationName = data.name
20        weatherName = data.weather.first!.main
21        description = data.weather.first!.description
22        temperature = data.main.temp
23        timeOffUtc = data.timezone
24    }
25}

Weather View

 1struct CurrentWeatherView: View {
 2    @ObservedObject var weatherVm: WeatherViewModel
 3
 4    var body: some View {
 5        VStack {
 6            VStack {
 7                Text("Current weather in Cities")
 8                Button("New York") {
 9                    Task {
10                        await weatherVm.weatherForCity(_: .newyork)
11                    }
12                }
13                .buttonStyle(BlueButtonStyle())
14                .disabled(weatherVm.isLoading)
15
16                Button("Cape Town") {
17                    Task {
18                        await weatherVm.weatherForCity(_: .capetown)
19                    }
20                }
21                .buttonStyle(BlueButtonStyle())
22                .disabled(weatherVm.isLoading)
23            }
24            .frame(width: 300)
25
26            if weatherVm.isLoading {
27                WaitingView()
28                    .frame(height: 400)
29                    .frame(height: 400)
30            } else {
31                WeatherView(weatherVm: weatherVm)
32                    .frame(height: 400)
33            }
34
35            Spacer()
36        }
37    }
38}

Weather ViewModel with member variable for Weather Service
Weather ViewModel with member variable for Weather Service



Define a Protocol

The solution is to define a Protocol that specifies the functionality needed by the WeatherViewModel. In this case the only function required by the ViewModel is getCurrentWeather that takes the latitude and longitude and returns data object containing the current weather for the location. Define a protocol WeatherFetching that specifies one function fetchCurrentWeather. Fetch seems better than get, so the original getCurrentWeather is also renamed to fetchCurrentWeather.


Protocol

1protocol WeatherFetching {
2    func fetchCurrentWeather(latitude: CLLocationDegrees,
3                             longitude: CLLocationDegrees) async throws -> WeatherRawData
4}

Weather ViewModel

The WeatherViewModel is updated to change the type of weatherService member variable from a class to the protocol WeatherFetching. The initializer is also changed to require an instance of an object that conforms to the WeatherFetching protocol. This allows the object creating the ViewModel to pass in the concrete object that does the Weather Fetching functionality - Injecting in the dependent object, hence - Dependency Injection.

 1class WeatherViewModel: ObservableObject {
 2    private var weatherService: WeatherFetching
 3    private(set) var cityName: String
 4
 5    @Published private var weatherModel: WeatherModel
 6    @Published private(set) var isLoading: Bool = false
 7
 8    init(weatherFetching: WeatherFetching) {
 9        weatherService = weatherFetching
10        weatherModel = WeatherModel()
11        cityName = "not set"
12    }
13    
14    . . .
15
16}

Main App

The GetWeatherApp needs to be modified to create the WeatherService, that conforms to the WeatherFetching protocol, and pass this in to the Weather ViewModel when the application launches.

 1struct GetWeatherApp: App {
 2
 3    var weatherVm = WeatherViewModel(weatherFetching: WeatherService())
 4
 5    var body: some Scene {
 6        WindowGroup {
 7            ContentView(weatherVm: weatherVm)
 8        }
 9    }
10}

No changes are required in the Model or View and the app functions as before.

Weather ViewModel with member variable for Weather Fetching Protocol
Weather ViewModel with member variable for Weather Fetching Protocol



Implement a mock Weather service

Why bother with dependency injection? There is now a protocol for WeatherFetching and the WeatherService conforms to this protocol. But the app is still more or less the same. The difference is that it is now possible to implement another class that conforms to the WeatherFetching Protocol and to pass this object to the ViewModel. In this way the ViewModel can be tested without having to call the real OpenWeather API or requiring network access.

A mock WeatherService is added to the Unit Test target that conforms to the WeatherFetching protocol. It has to implement the fetchCurrentWeather function, which can be done by using a hard-coded string for the expected JSON data for the current weather.

 1class MockWeatherSerice: WeatherFetching {
 2    private let jsonString = """
 3{
 4    "weather": [
 5        {
 6            "id": 800,
 7            "main": "Clear",
 8            "description": "clear sky",
 9            "icon": "01d"
10        }
11    ],
12    "main": {
13        "temp": 9.4,
14        "feels_like": 8.71,
15        "temp_min": 7.22,
16        "temp_max": 11.11,
17        "pressure": 1023,
18        "humidity": 100,
19        "sea_level": 100
20    },
21    "wind": {
22        "speed": 1.5,
23        "deg": 350
24    },
25    "clouds": {
26        "all": 1
27    },
28    "timezone": -25200,
29    "name": "Mountain View"
30}
31"""
32
33    func fetchCurrentWeather(latitude: CLLocationDegrees,
34                             longitude: CLLocationDegrees) async throws -> WeatherRawData {
35
36        let jsonData = jsonString.data(using: .utf8)!
37
38        return try JSONDecoder().decode(WeatherRawData.self, from: jsonData)
39    }
40}


Add a Unit Test for ViewModel

Add a unit test on the Weather ViewModel that uses the Mock weather service. An instance of the MockWeatherSerice is created and passed to the initialiser for the WeatherViewModel. The weatherForCity function is called to use the mock service to load the JSON data into the model and one of the properties, temperature is validated to match the expected value.

 1class WeatherViewModelTests: XCTestCase {
 2
 3    func test_weatherLoaded_temperature() async throws {
 4        let mock = MockWeatherSerice()
 5        let weatherVm = WeatherViewModel(weatherFetching: mock)
 6
 7        await weatherVm.weatherForCity(.london)
 8
 9        XCTAssertEqual(weatherVm.temperature, 9.4)
10    }
11
12}

Unit test on Weather ViewModel using mock data
Unit test on Weather ViewModel using mock data




Conclusion

Dependency Injection makes a class or struct independent from the creation of objects that it depends on, helping to create loosely coupled Apps. The Weather App was updated to separate the ViewModel from the Weather Service so that the ViewModel could be tested without requiring data from Open Weather API. This was achieved with the use of a Protocol for WeatherFetching, where the implementer of this protocol is passed into the ViewModel. A mock service was created and used in a unit test to show how DI facilitates testing of the ViewModel. The mock service could be built on to test different error scenarios and simulate different data in the JSON text.