Unit Testing View Controllers and Views in Swift
– Do you unit test your view controllers and views?
I’d venture to guess that the common answer is “no”.
The typical objection is that unit tests are not suitable for the UI: they are cumbersome to write and are fragile. In this article let’s show the opposite. We’ll cover:
- Why to test view controllers and views.
- What to test and what not to test.
- How to design
UIViewController
s for testability.
And sharpen our knowledge by implementing a view controller with 100% test coverage.
This is not an introduction to the subject. You can get started with unit testing by reading Real-World Unit Testing in Swift and then get back to this article.
Why to Unit Test View Controllers and Views
View controllers and views make up a large part of our codebase. Therefore, testing them is important and contributes to the quality of our iOS and macOS apps a lot.
The common strategies of validating user interface are end-to-end tests and snapshot tests.
Snapshots are great for validating visual layout, however, they are not suited for verifying the content. End-to-end tests (think XCUI
-driven-tests) are too far from the code that they verify, making it hard to track down the issue if they fail. Both kinds of tests are expensive to maintain since the user interface changes quite often.
The UI doesn’t have to be tested in an end-to-end fashion. Testing the content of our user interface is straightforward and can be done on low-level with unit tests. Such an approach offers a number of benefits. Unit tests are fast to write and are easy to maintain. It’s trivial to track down the issue if they fail since they are close to the code that they verify.
However, unit tests can be worse than useless in case we test the wrong thing. What we should be testing then?
What to Unit Test
It’s hard to validate with a unit test that something “looks good”. Therefore, we shouldn’t validate UIViewController
’s and UIView
’s layout and styling. Here are some examples of the wrong things to test:
UIView
background color.UILabel
text color, font, and size.- Autolayout constraints.
Instead, we must focus on the content. These are the right questions to ask when you are writing a unit test:
- Is the number of table view rows correct?
- Is
UILabel
text correct? - Is the button enabled?
- Is
UIView
frame correct?
Now that we know what to test, let’s learn how to test.
How to Make UIViewController Testable
View controllers are testable if they are passive and can be isolated from their dependencies.
Dependency injection is the technique that allows isolating a view controller from its dependencies. During testing, we replace dependencies with mocks, which mimic real objects behavior during testing.
💡 Learn Swift dependency injection techniques and patterns here.
View controllers are passive if they lend themselves to two tasks: rendering the UI and propagating user interactions. They do not pull data from the model and are not responsible for updating themselves from the model. MVVM and MVP are among the patterns, which allow us to achieve this.
💡 Martin Fowler, the originator of both MVP and MVVM patterns, describes them in the passive view and the presentation model articles respectively.
Designing UIViewController for Testability
In this example, we’ll be using the MVP pattern. It’s easy to understand and scales well. The techniques that we discuss here are not limited to MVP. They are universal and transcend a specific pattern.
The view controller must remain passive, therefore we delegate the UI-related logic to the presenter:
protocol ArtistDetailPresenter {
func onViewLoaded()
func onEdit()
}
class ArtistDetailViewController: UIViewController {
// IBOutlets
var presenter: ArtistDetailPresenter!
override func viewDidLoad() {
super.viewDidLoad()
presenter.onViewLoaded()
}
@IBAction func onEdit(_ sender: UIBarButtonItem) {
presenter.onEdit()
}
}
We trade encapsulation for testability by making
IBOutlet
s andIBAction
s public.
The view controller accepts a plain Swift object, which describes its state at a moment in time. The state is created for the view controller’s needs and must be convenient to consume.
We call the state props and create a protocol of a component that can render an instance of props (hello react):
struct ArtistDetailProps {
let title: String
let fullName: String
let numberOfAlbums: String
let numberOfFollowers: String
}
protocol ArtistDetailComponent: AnyObject {
func render(_ props: ArtistDetailProps)
}
The view controller implement this protocol and performs rendering:
extension ArtistDetailViewController: ArtistDetailComponent {
func render(_ props: ArtistDetailProps) {
navigationItem.title = props.title
fullNameLabel.text = props.fullName
numberOfAlbumsLabel.text = props.numberOfAlbums
numberOfFollowersLabel.text = props.numberOfFollowers
}
}
It’s important to emphasize that the view controller does not render itself. In our example it’s the responsibility of the presenter to push data into the view controller for rendering.
It’s common for props and model objects to look similar. We should resist the temptation to pass model objects to
render()
since the user interface and the business rules change for different reasons and with different speeds. I am touching on the subject in Layered Architecture to Design iOS Apps.
Unit Testing View Controller
The only thing that is left is to isolate the view controller from its dependency, i.e. the presenter. Let’s implement a mock presenter:
class ArtistDetailPresenterMock: ArtistDetailPresenter {
private(set) var onViewLoadedCalled = false
func onViewLoaded() {
onViewLoadedCalled = true
}
private(set) var onEditCalled = false
func onEdit() {
onEditCalled = true
}
}
After making all the preparations, writing tests is a piece of cake. First, let’s initialize the system under test and connect the dependencies:
When you test something, you refer to it as the system under test (SUT).
class ArtistDetailViewControllerTests: XCTestCase {
let presenter = ArtistDetailPresenterMock()
func makeSUT() -> ArtistDetailViewController {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let sut = storyboard.instantiateViewController(identifier: "ArtistDetailViewController") as! ArtistDetailViewController
sut.presenter = presenter
sut.loadViewIfNeeded()
return sut
}
}
Secondly, verify interaction with the presenter:
func testViewDidLoadCallsPresenter() {
let sut = makeSUT()
sut.viewDidLoad()
XCTAssertTrue(presenter.onViewLoadedCalled)
}
func testOnEditCallsPresenter() {
let sut = makeSUT()
sut.onEdit(.init())
XCTAssertTrue(presenter.onEditCalled)
}
Lastly, verify that the props are rendered correctly:
func testRender() {
let props = ArtistDetailProps(title: "TITLE", fullName: "NAME", numberOfAlbums: "1", numberOfFollowers: "2")
let sut = makeSUT()
sut.render(props)
XCTAssertEqual(sut.navigationItem.title, "TITLE")
XCTAssertEqual(sut.fullNameLabel.text, "NAME")
XCTAssertEqual(sut.numberOfAlbumsLabel.text, "1")
XCTAssertEqual(sut.numberOfFollowersLabel.text, "2")
}
That’s it, our view controller has now 100% test coverage.
In this section we’ve been following unit testing best practices, outlined here
Source code
Here is the source code for the final project.
Summary
View controllers and views can and should be unit tested. This contributes to the app quality a lot since view controllers make up a large part of the codebase.
As long as we design view controllers with testability in mind, testing them is no different from the model objects. In order to do this, we must make UIViewController
passive and practice dependency injection. Moreover, unit tests must verify the content and not the layout.
I’ve been writing about unit testing a lot. Here are some articles to suggest:
Thanks for reading!
If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content. There I write daily on iOS development, programming, and Swift.