Unit testing the DayViewModel
struct isn't very different from unit testing the view models of the SettingsViewController
class. The only tricky aspect is creating a DayViewModel
object in a unit test.
To create a DayViewModel
object, we need a model. Should we fetch weather data from the weather API while we execute the test suite? The answer is no. To guarantee that the unit tests for the DayViewModel
struct are fast and reliable, we need to make use of stubs.
The idea is simple. We fetch a response from the weather API, add it to the unit testing bundle, and load the response when the unit tests for the view model are executed. Let me show you how this works.
Adding a Stub
I already saved a response from the weather API to my desktop. This is nothing more than a JSON file. Before we can use it in the test case, we add the JSON file to the unit testing bundle. The JSON file is included in the finished project of this episode. Drag it in the Stubs group of the CloudyTests target. Make sure that Copy items if needed is checked and that the file is only added to the CloudyTests target.
Loading a Stub
Because we plan to use the stub in multiple test cases, we start by creating a helper method to load the stub from the unit testing bundle. Add a Swift file to the Extensions group of the unit testing bundle and name it XCTestCase.swift.
Replace the import statement for Foundation with an import statement for XCTest and define an extension for the XCTestCase
class.
XCTestCase.swift
import XCTest
extension XCTestCase {
}
Define a method with name loadStub(name:extension:)
.
XCTestCase.swift
// MARK: - Helper Methods
func loadStub(name: String, extension: String) -> Data {
}
The method defines two parameters:
- the name of the file
- the extension of the file
We first obtain a reference to the unit testing bundle. We create an instance of the Bundle
class by invoking the init(for:)
initializer, passing in the type of the XCTestCase
instance. We then ask the unit testing bundle for the URL of the stub, passing in the name and extension of the file. The URL is used to create a Data
object.
XCTestCase.swift
// MARK: - Helper Methods
func loadStub(name: String, extension: String) -> Data {
// Obtain Reference to Bundle
let bundle = Bundle(for: type(of: self))
// Ask Bundle for URL of Stub
let url = bundle.url(forResource: name, withExtension: extension)
// Use URL to Create Data Object
return try! Data(contentsOf: url!)
}
Notice that we forced unwrap url
and use the try
keyword with an exclamation mark. This is something I only ever do when writing unit tests. It is important to understand that we are only interested in the results of the unit tests. If anything else goes wrong, we made a mistake we need to fix. In other words, I am not interested in error handling or safety when writing and running unit tests. If something goes wrong, the unit tests fail anyway.
Unit Testing the Day View View Model
We can now create the test case for the DayViewModel
struct. Create an XCTestCase
subclass and name the file DayViewModelTests.swift. We start by adding an import statement for the Cloudy module and prefix it with the testable
attribute.
DayViewModelTests.swift
import XCTest
@testable import Cloudy
class DayViewModelTests: XCTestCase {
// MARK: - Set Up & Tear Down
override func setUpWithError() throws {
}
override func tearDownWithError() throws {
}
}
To simplify the unit tests, we won't be creating a DayViewModel
object in each unit test. Instead, we create a DayViewModel
object in the setUpWithError()
method. Let me show you how that works and what the benefits are.
We first define a property for the DayViewModel
object. This means every unit test has access to the DayViewModel
object.
DayViewModelTests.swift
import XCTest
@testable import Cloudy
class DayViewModelTests: XCTestCase {
// MARK: - Properties
var viewModel: DayViewModel!
// MARK: - Set Up & Tear Down
override func setUpWithError() throws {
}
override func tearDownWithError() throws {
}
}
Notice that the property is an implicitly unwrapped optional. This is dangerous, but remember that we don't care if the test suite crashes and burns. If that happens, it means we made a mistake we need to fix. This is really important to understand. When we execute the test suite, we are interested in the test results. We often use shortcuts for convenience to improve the clarity and the readability of the unit tests. This becomes clear in a moment.
In the setUpWithError()
method, we invoke loadStub(name:extension:)
to load the contents of the stub we added earlier. We create a JSONDecoder
instance, set its dateDecodingStrategy
property to secondsSince1970
, and use it to create a WeatherData
object. The WeatherData
object is used to create the DayViewModel
object we use in the unit tests.
DayViewModelTests.swift
override func setUpWithError() throws {
// Load Stub
let data = loadStub(name: "weather", extension: "json")
// Create JSON Decoder
let decoder = JSONDecoder()
// Configure JSON Decoder
decoder.dateDecodingStrategy = .secondsSince1970
// Decode JSON
let weatherData = try decoder.decode(WeatherData.self, from: data)
// Initialize View Model
viewModel = DayViewModel(weatherData: weatherData)
}
With the DayViewModel
object ready to use, it is time to write some unit tests. The first unit test is as simple as unit tests get. We test the date
computed property of the DayViewModel
struct. We assert that the value of the date
computed property is equal to the value we expect.
DayViewModelTests.swift
// MARK: - Tests for Date
func testDate() {
XCTAssertEqual(viewModel.date, "Mon, June 22")
}
We can keep the unit test this simple because we control the stub. If we were to fetch a response from the weather API, the model managed by the view model would be different every time the unit test was executed. The unit test would be slow, asynchronous, and prone to all kinds of issues.
The second unit test we write is for the time
computed property of the DayViewModel
struct. Because the value of the time
computed property depends on the user's preference, stored in the user defaults database, we need to write two unit tests for complete code coverage.
DayViewModelTests.swift
// MARK: - Tests for Time
func testTime_TwelveHour() {
}
func testTime_TwentyFourHour() {
}
The body of the first unit test looks very similar to some of the unit tests we wrote in the previous episode. We set the time notation setting in the user defaults database and assert that the value of the time
computed property is equal to the value we expect. Let me repeat that we can only do this because we control the stub and, as a result, the model the view model manages.
DayViewModelTests.swift
func testTime_TwelveHour() {
let timeNotation: TimeNotation = .twelveHour
UserDefaults.standard.set(timeNotation.rawValue, forKey: "timeNotation")
XCTAssertEqual(viewModel.time, "04:53 PM")
}
The second unit test for the time
computed property is very similar. Only the assertion and the value we set in the user defaults database are different.
DayViewModelTests.swift
func testTime_TwentyFourHour() {
let timeNotation: TimeNotation = .twentyFourHour
UserDefaults.standard.set(timeNotation.rawValue, forKey: "timeNotation")
XCTAssertEqual(viewModel.time, "16:53")
}
The remaining unit tests for the DayViewModel
struct follow the same pattern. Pause the video for a moment and try to implement them. I have to warn you, though, the unit test for the image
computed property is a bit trickier. But you can do this. You can find the remaining unit tests in the finished project of this episode.
DayViewModelTests.swift
// MARK: - Tests for Summary
func testSummary() {
XCTAssertEqual(viewModel.summary, "Overcast")
}
// MARK: - Tests for Temperature
func testTemperature_Fahrenheit() {
let temperatureNotation: TemperatureNotation = .fahrenheit
UserDefaults.standard.set(temperatureNotation.rawValue, forKey: "temperatureNotation")
XCTAssertEqual(viewModel.temperature, "68.7 °F")
}
func testTemperature_Celsius() {
let temperatureNotation: TemperatureNotation = .celsius
UserDefaults.standard.set(temperatureNotation.rawValue, forKey: "temperatureNotation")
XCTAssertEqual(viewModel.temperature, "20.4 °C")
}
// MARK: - Tests for Wind Speed
func testWindSpeed_Imperial() {
let unitsNotation: UnitsNotation = .imperial
UserDefaults.standard.set(unitsNotation.rawValue, forKey: "unitsNotation")
XCTAssertEqual(viewModel.windSpeed, "6 MPH")
}
func testWindSpeed_Metric() {
let unitsNotation: UnitsNotation = .metric
UserDefaults.standard.set(unitsNotation.rawValue, forKey: "unitsNotation")
print(viewModel.windSpeed)
XCTAssertEqual(viewModel.windSpeed, "10 KPH")
}
// MARK: - Tests for Image
func testImage() {
let viewModelImage = viewModel.image
let imageDataViewModel = viewModelImage!.pngData()!
let imageDataReference = UIImage(named: "cloudy")!.pngData()!
XCTAssertNotNil(viewModelImage)
XCTAssertEqual(viewModelImage!.size.width, 236.0)
XCTAssertEqual(viewModelImage!.size.height, 172.0)
XCTAssertEqual(imageDataViewModel, imageDataReference)
}
The unit test for the image
computed property is slightly different. Comparing images isn't straightforward. Because image
is of type UIImage?
, we first assert that the value returned by image
isn't equal to nil
.
DayViewModelTests.swift
XCTAssertNotNil(viewModelImage)
We then convert the image to a Data
object and compare it to a reference image, loaded from the application bundle. You can take this as far as you like. For example, I added assertions for the dimensions of the image. This isn't critical for the application, but it shows you what is possible.
DayViewModelTests.swift
XCTAssertEqual(viewModelImage!.size.width, 236.0)
XCTAssertEqual(viewModelImage!.size.height, 172.0)
XCTAssertEqual(imageDataViewModel, imageDataReference)
Before we run the test suite, we need to tie up some loose ends. In the tearDownWithError()
method, we reset the state we set in the unit tests. We covered the importance of this step in the previous episode.
DayViewModelTests.swift
override func tearDownWithError() throws {
// Reset User Defaults
UserDefaults.standard.removeObject(forKey: "timeNotation")
UserDefaults.standard.removeObject(forKey: "unitsNotation")
UserDefaults.standard.removeObject(forKey: "temperatureNotation")
}
Press Command + U to run the test suite to make sure the unit tests for the DayViewModel
struct pass.
What's Next?
In the next episode, we unit test the view models for the WeekViewController
class.