One of the key benefits of the Model-View-ViewModel pattern is improved testability. It isn't possible to write unit tests for the SwiftUI views we created, but we can unit test the view models that drive those views. The good news is that writing unit tests for a well-designed view model isn't difficult.
What About User Interface Tests
User interface tests have value, but compared to unit tests, they are expensive to create and maintain. A unit test focuses on a discrete piece of functionality, and that is what makes them limiting and powerful at the same time. It makes them powerful because it is easy to create them if the code they test is written with testability in mind. A unit test is limiting because it isn't aware of the bigger picture. For example, a unit test shouldn't be used to test the sign up flow of your application. That is what a user interface test is for.
Simple and Complex View Models
In this series, we created two types of view models, simple view models and complex view models. A simple view model provides a SwiftUI view with bits of data it displays whereas a complex view model contains the project's business logic. Both types of view models have their value and place in a project that embraces the Model-View-ViewModel pattern.
In this episode, we write unit tests for a simple view model. It is easy and an ideal starting point if you are new or unfamiliar with unit testing. In the next episode, we write unit tests for a complex view model.
An example of a simple view model is the AddLocationCellViewModel struct. It encapsulates a Location object and exposes a handful of computed properties. It ensures the view doesn't have access to the model layer, the Location object in this example.
The AddLocationViewModel class is a complex view model. It uses a geocoding service to forward geocode the address the user enters in a text field and adds the user's selection, a location, to a store. Writing unit tests for the AddLocationViewModel class is only possible if the view model is properly architected and decoupled from the dependencies it relies on. We discuss this in more detail in the next episodes.
Writing Unit Tests for a Simple View Model
Let's focus on the AddLocationCellViewModel struct. Add a Swift file to the ThunderstormTests group by choosing the Unit Test Case Class template. Name the XCTestCase subclass AddLocationCellViewModelTests. We add an import statement for the Thunderstorm target to access its internal entities. Note that we prefix the import statement with the testable attribute. Remove the contents of the XCTestCase subclass. We start with a clean slate.
import XCTest
@testable import Thunderstorm
final class AddLocationCellViewModelTests: XCTestCase {
}
The plan is to unit test two aspects of the view model, its conformance to the Identifiable protocol and the computed properties it exposes. We won't unit test its conformance to the Equatable protocol because the code that conforms the view model to the Equatable protocol is generated for us by the compiler.
Before we write the first unit test, we need mock data to work with. Add a group to the ThunderstormTests group and name it Extensions. Add a Swift file to the group and name it Location+Mocks.swift. Add an import statement for the Thunderstorm target at the top and define an extension for the Location struct.
import Foundation
@testable import Thunderstorm
extension Location {
}
Open Location.swift and copy the preview and previews properties. Change the name of the preview and previews properties to mock and mocks respectively. The mock property returns the first element of the array returned by the mocks property.
import Foundation
@testable import Thunderstorm
extension Location {
static var mock: Location {
mocks[0]
}
static var mocks: [Location] {
[
.init(id: "1", name: "Paris", country: "France", latitude: 48.857438, longitude: 2.295790),
.init(id: "2", name: "New York", country: "United States", latitude: 40.690337, longitude: -74.045473),
.init(id: "3", name: "Cape Town", country: "South Africa", latitude: -33.957369, longitude: 18.403098)
]
}
}
You may be wondering why we don't use the preview and previews properties in the test suite. The data we use to populate previews tends to change during development. If we were to use that same data in the test suite, we would constantly break the test suite, making it unreliable and a pain to maintain.
The unit tests we need to write for the AddLocationCellViewModel struct are simple. Declare a method with name testIdentifiableConformance(). It is a throwing method although that isn't strictly necessary in this example.
import XCTest
@testable import Thunderstorm
final class AddLocationCellViewModelTests: XCTestCase {
// MARK: - Tests for Identifiable Conformance
func testIdentifiableConformance() throws {
}
}
We create a Location object using the mock property we implemented a few moments ago. We use the Location object to create the view model, an AddLocationCellViewModel object. To unit test the conformance to the Identifiable protocol, we assert that the id property of the AddLocationCellViewModel struct is equal to the id property of the Location object.
import XCTest
@testable import Thunderstorm
final class AddLocationCellViewModelTests: XCTestCase {
// MARK: - Tests for Identifiable Conformance
func testIdentifiableConformance() throws {
let location: Location = .mock
let viewModel = AddLocationCellViewModel(location: location)
XCTAssertEqual(viewModel.id, location.id)
}
}
What is the value of this unit test if we simply copy the implementation of the id property of the AddLocationCellViewModel struct? You could argue that this unit test is unnecessary. I disagree, though. Let's say that someone changes the implementation of the computed id property. Instead of returning the location's identifier, it now returns the location's name. That seems harmless. Right?
The view model is used to populate a List view. We do that using a ForEach loop. If the array we pass to the ForEach loop contains objects with the same identifier, a runtime error is thrown. That is something we need to avoid.
So what is the purpose of the unit test we just wrote? It ensures the test suite breaks when someone changes the implementation of the computed id property. The person making the change won't be able to make that change without being very explicit about it because it breaks the test suite.
Run the test suite by choosing Test from Xcode's Product menu. The unit test we wrote should pass without issues.
Let's write the unit test that tests the computed properties of the AddLocationCellViewModel struct. Add a throwing method with name testComputedProperties(). We copy the top two lines of the previous unit test as a staring point.
import XCTest
@testable import Thunderstorm
final class AddLocationCellViewModelTests: XCTestCase {
// MARK: - Tests for Identifiable Conformance
func testIdentifiableConformance() throws {
let location: Location = .mock
let viewModel = AddLocationCellViewModel(location: location)
XCTAssertEqual(viewModel.id, location.id)
}
// MARK: - Tests for Computed Properties
func testComputedProperties() throws {
let location: Location = .mock
let viewModel = AddLocationCellViewModel(location: location)
}
}
In the unit test, we make three assertions, we assert that the value returned by the computed name property is equal to the location's name, we assert that the value returned by the computed country property is equal to the location's country, and we assert that the value returned by the computed coordinates property is a concatenation of the location's latitude and longitude.
import XCTest
@testable import Thunderstorm
final class AddLocationCellViewModelTests: XCTestCase {
// MARK: - Tests for Identifiable Conformance
func testIdentifiableConformance() throws {
let location: Location = .mock
let viewModel = AddLocationCellViewModel(location: location)
XCTAssertEqual(viewModel.id, location.id)
}
// MARK: - Tests for Computed Properties
func testComputedProperties() throws {
let location: Location = .mock
let viewModel = AddLocationCellViewModel(location: location)
XCTAssertEqual(viewModel.name, location.name)
XCTAssertEqual(viewModel.country, location.country)
XCTAssertEqual(
viewModel.coordinates,
"\(location.latitude), \(location.longitude)"
)
}
}
The unit tests we wrote in this episode are straightforward but that doesn't make them less valuable. Because the view model provides the data the view displays to the user, we are able to unit test this aspect of the project without relying on expensive user interface tests.
Enabling Code Coverage
Let's take a look at how well the unit tests we wrote cover the AddLocationCellViewModel struct. We first need to enable code coverage for the test plan. Open the Test Navigator on the left, click the ThunderstormTests test plan at the top, and choose Edit Test Plan from the menu. Select the Configurations tab at the top and set Code Coverage to On in the Code Coverage section. Click the checkbox Gather coverage for all targets to enable code coverage. It is important that you save the test plan to commit the changes.

Open AddLocationCellViewModel.swift, click the Editor Options button in the top right, and choose Code Coverage from the menu. Run the test suite one more time. Note that the AddLocationCellViewModel struct is fully covered by the unit tests we wrote. That's a nice start.
What's Next?
It is important to understand that the complexity of a unit test doesn't say anything about its value. In fact, you should always try to keep your unit tests as simple as possible. If a unit test becomes too complex, it is hard to maintain, prone to failure, and it is often an indication that the code it tests needs some work.