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