The benefits of having an exhaustive automated tests suite are obvious to anyone who ever worked on an app for more than a few months. We’ve all seen what lack of code maintenance might do: duplication, hidden side effects, convoluted business logic. A project codebase is like a garden – to flourish it must be regularly pruned, fertilised, etc. Fortunately, we do not have to get our hands dirty in a literal sense. A good refactoring, along with implementing business features, would be just fine. And in order to do that safely, we need to have a way of verifying if we have not introduced regressions – a comprehensive Unit Tests suite.
When it comes to testing business logic, the course of action is quite clear. We try to control the environment by injecting fixture dependencies into the tested object and verify its behavior, interactions and data it produces. Picture-perfect BDD 🤣
But what should we do with the UI? How can we efficiently test views? In UIKit days, we could at least traverse through the tested view children to verify their properties. But in SwiftUI? We have no such option. After all, a SwiftUI view is merely a description. What the user would eventually see on the screen might not be an exact match of what was defined in the view.
In this post we’ll try to find a way to efficiently test the UI layer of our iOS apps. We’ll go through testing strategies suitable for UIKit and SwiftUI-powered interfaces and try to outline their strengths and limitations. Finally, we’ll discuss which edge cases we might choose not to cover (and why). Strap in and let’s dive in!
How to test SwiftUI views?
Let’s begin with a seemingly stupid question: what does a manual Quality Assurance specialist (QA) do to check if the view is implemented properly? Of course, they verify its functionality and adherence to the designs. For a trained eye, it takes literally seconds to compare a resulting view with its design in e.g. Figma. Can this process be reliably automated? How can we “retire” a QA with a test suite?
Yes and no 😉 In terms of verifying view functionality, manual testing is still king. However when it comes to its adherence to the designs, say hello to Snapshot Tests 😉
And what is a snapshot test? TLDR, it’s a form of integration test. It provides a view with a fixture configuration, renders it, makes a snapshot and uses it as a reference each time the tests are executed. The verification is fairly straightforward: the algorithm compares each pixel of a reference image ❶ with a corresponding pixel of an acquired snapshot ❷. If there’s a difference in color or opacity, this pixel is marked as invalid ❸. If the combined number of such pixels is greater than manually defined threshold (usually much less than 1%), the test is marked as a failure.
Unless you have a strong reason not to, I highly recommend trying out Swift Snapshot Testing library to implement snapshot tests in your app. As an example, we’ll use a generic ErrorView from why should you always KISS your SwiftUI views post.
In order to generate a snapshot, we have to wrap a SwiftUI view in an UIView or UIHostingViewController:
extension View {
func wrappedInHostingViewController() -> UIViewController {
UIHostingController(rootview: self)
}
}
Next, we need to have some way of “telling” the view what data to show. And it turns out, there’s a very easy way to do so – we’ll inject the view with a fake view model:
final class ErrorViewTest: XCTestCase {
var fakeViewModel: FakeErrorViewModel!
var sut: ErrorView<FakeErrorViewModel>!
override func setUp() {
let fakeViewModel = FakeErrorViewModel()
sut = ErrorView(viewModel: fakeViewModel)
self.fakeViewModel = fakeViewModel
}
}
We can do that as the ErrorView is a generic structure we can initialize with any view model, as long as it conforms to ErrorViewModel protocol:
final class FakeErrorViewModel: ErrorViewModel {
@Published var viewConfiguration: ErrorViewConfiguration = .noNetwork
var viewConfigurationPublished: Published<ErrorViewConfiguration> { _viewConfiguration }
var viewConfigurationPublisher: Published<ErrorViewConfiguration>.Publisher { $viewConfiguration }
…
}
Let’s say we want to snapshot the view displaying the no network connection error. Nothing could be simpler: just pass an appropriate configuration to our fake view model and run a test. If the snapshot of that name does not exist, it would be automatically recorded for you. In the subsequent test runs, it will be used as a reference.
func testNoNetworkErrorScreenSnapshotGeneration() {
// given:
fakeViewModel.viewConfiguration = .noNetwork
// then:
executeSnapshotTests(
forViewController: sut.wrappedInHostingViewController(), named: "ErrorView_NoNetwork")
}
Where executeSnapshotTests is a useful helper:
import SnapshotTesting
func executeSnapshotTests(
forViewController viewController: UIViewController,
named name: String,
precision: Float = 0.995,
isRecording: Bool = false,
file: StaticString = #file,
functionName: String = #function,
line: UInt = #line
) {
assertSnapshot(
matching: viewController,
as: .image(on: .iPhone12, precision: precision, perceptualPrecision: precision),
named: name,
record: isRecording,
file: file,
testName: "iPhone12",
line: line
)
}
Then we need to repeat the process for each application error we wish to cover. As a result, we have a bunch of reference snapshots – each representing a distinct state the view can be in:
Before we move on, a word of explanation. The views we tested in this example fill up the entire screen, but in general, they don’t have to! It seems obvious, but arguably the best application of Snapshot Tests is the coverage of universal, reusable views like custom text fields, buttons, etc. Just imagine having a test suite covering all states of e.g. a text input with a prompt, error indicator, etc. If you need to add new functionality to such a component, or even apply some refactoring, the tests would immediately detect any regressions you might’ve caused. And show you exactly what changed in the form of a differential image. For practical example of such tests go to ErrorInformationView tests below!
UIKit? No problem
Let’s start with an UIView. Naturally, we’ll require a way to pass the data it needs to render itself – just like its SwiftUI counterpart. Let’s aggregate that data in a form of a configuration structure:
extension ErrorInformationView {
struct Configuration {
let title: String
let description: String
let icon: UIImage
let buttonTitle: String?
}
}
Now we can pass it to the view. Arguably, the cleanest way to do so is to use the constructor:
final class ErrorInformationView: UIView {
…
init(configuration: Configuration) {
self.configuration = configuration
imageview = UIImageview.makeImageviewWithAspectFitMode(with: configuration.icon)
titleLabel = UILabel.makeSimpleLabel(
title: configuration.title,
fontStyle: .h2,
textColor: .text01
)
descriptionLabel = UILabel.makeAttributedLabel(
title: configuration.description,
fontStyle: .caption,
textColor: .text01
)
primaryButton.setTitle(configuration.buttonTitle, forType: .defaultAcceptance)
primaryButton.isHidden= configuration.buttonTitle == nil
super.init(frame: .zero)
…
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Now, let’s try to prepare snapshot tests for it:
final class ErrorInformationViewTests: XCTestCase {
var didTapOpenSettings: Bool?
var sut: ErrorInformationView!
override func setUp() {
didTapOpenSettings = nil
}
func test_whenProvidedWithJailbrokenDevicesConfiguration_should RenderProperly() {
// given:
let fixtureviewConfiguration = .devicePasscodeDisabled
let fixtureFrame = CGRect(...)
sut = ErrorInformationView(configuration: fixtureviewConfiguration)
sut.onPrimaryButtonTap = {
self.didTapOpenSettings = true
}
sut.frame = fixtureFrame
// when:
sut.primaryButton.simulateTap()
// then:
XCTAssertEqual(didTapOpenSettings, true, "Should trigger a proper callback")
executeSnapshotTests(forView: sut, named: "ErrorInformationView-passcodeDisabled", frame: fixtureFrame)
}
}
As you can see, in addition to testing how the view is rendered, we also check if tapping a CTA button resulted in executing a provided callback. Unfortunately, this is not an option for SwiftUI views. At least not directly…
Anyway, executeSnapshotTests() is another nifty helper – this time setting up and performing a snapshot tests on a provided UIView:
extension XCTestCase {
func executeSnapshotTests(
forView view: UIView,
named name: String,
isRecording: Bool,
frame: CGRect = .zero,
precision: Float = 0.995,
file: StaticString = #file,
functionName: String = #function,
line: UInt = #line
) {
assertSnapshot(
matching: view,
as: .image(precision: precision, size: frame.size),
named: name,
record: isRecording,
file: file,
testName: functionName,
line: line
)
}
}
And how does it look like for the UIViewController? It’s even simpler, as you no longer have to worry about the view frame. After all, we’ve already set up such tests for… our SwiftUI views. As you can surely recall, these views had to be wrapped in an UIHostingController (which is a UIViewController subclass). As an example, let’s take a look at a very simple UIViewController presenting a bunch of transactions:
func testSnapshots() {
// given:
let fixtureTransactions = prepareTransactions()
// when:
sut.loadViewIfNeeded()
sut.viewWillAppear(false)
sut.viewDidAppear(false)
fakeViewModel.simulatedTransactions = fixtureTransactions
fakeViewModel.simulateStateUpdate(state: .transactionsLoaded)
// then:
executeSnapshotTests(
forViewController: sut, named: "PaymentsHistoryviewController-Transactions")
// when:
fakeViewModel.simulateStateUpdate(state: .loading)
// then:
executeSnapshotTests(forViewController: sut, named: "PaymentsHistoryviewController-Loading")
...
}
And just like this, we’ve implemented a quick and reliable way to test UI components in both: UIKit and SwiftUI. Amazing!
Are there any limitations?
As they say: there is no rose without a thorn. Although an amazing tool, Snapshot Tests have certain limitations.
Let’s start with an obvious example: animated views. The whole idea of Snapshot Tests is to compare pictures of a view taken in a similar state. If an animation is playing while the snapshot is taken, we have no guarantee which frame will eventually be captured. What should we do then? Well… disable animations (if you can).
In UIKit, we can use nifty: UIView.setAnimationsEnabled(false). Be aware however, that not all animations can be disabled this way. E.g. presenting and alert or a popup animation will not be affected by this flag.
It’s even more challenging for SwiftUI views. Popups and alerts are displayed on a separate layer of an UIWindow. So, when testing a SwiftUI view in isolation (wrapped in an UIHostingViewController), no alerts or popups it triggers will show. To remedy that, we can attach our UIHostingController to the main application window, wait for the view hierarchy to redraw and then snapshot the entire window. I know, it sounds scary, but it’s actually quite simple to do. Take a look at the tests I prepared for one such view. In addition to that, every internal SwiftUI view animations can be overridden using transaction modifiers.
TLDR: Unless you have a way to ensure a consistent state of the view throughout the tests, it’s probably best not to cover it with Snapshot Tests.
Next, Snapshot Tests cases can take up to 10x more time to execute than Unit Tests cases for the same view. It’s perfectly reasonable when you think of it – you have to capture a snapshot, read the reference image and compare them. It’s not an issue if your tests are running on the CI verifying a PR. But, if you are a TDD fan executing an entire test suite each time you write a new line of code, it could be a nuisance… In such a case, you might consider extracting Snapshot Tests to a separate test target.
Moreover, Snapshot Tests require regular maintenance. Apple seems to enjoy changing fonts, shadows and colours ever so slightly each time there’s a significant iOS version bump. More often than not, these changes are too small to make a difference. Unfortunately, sometimes they blow up the entire test suite. Conveniently, Swift Snapshot Testing offers a global “switch” to turn on the recording mode for the entire suite and bulk-record reference images.
Last, but not the least, we have to talk about the “device” we perform the tests on. We need to make sure the tests are always executed on a specific simulator device (e.g. iPhone 14), running the same iOS version. The rule applies to the members of your team, as well as the CI you use. Although the SnapshotTesting library can synthesise traits of virtually every simulated device available, it’s best to keep it safe and minimise discrepancies between testing conditions. Similar problem occurred when M1 MacbBooks were introduced. It turned out that colour space on Apple Silicon and Intel-based simulators was slightly different, which caused many false positives. The issue was fixed by introducing the perceptualPrecision parameter to the assertion signature.
Summary
Overall, Snapshot Tests are a great way to test the UI, especially universal and reusable views like text fields or application error screens. In contrast to XCUITests, you are in full control over the testing environment. E.g. you can inject a fake data provider, preventing the tested view from interacting with the real backend / data.
It’s relatively easy to test both: SwiftUI and UIKit view in isolation and in the “settled” state (when no animations are running). Snapshotting a presented alert or popup in SwiftUI can be more challenging, as we have to attach the view to the main application window and wait for the view hierarchy to redraw. In UIKit we usually don’t have such a problem, as we can operate on the navigation controller directly: push or present a view without animations, etc. If the UIView is still not cooperating (e.g. not measuring correctly), adding it to the application window usually does the trick.
Naturally, Snapshot Tests won’t be much of a help for animated views. Unless there is a way to override these animations, there is a high chance the tests would become flaky (fail at random). In addition, once implemented, our Snapshot Tests suite would have to be regularly maintained and re-captured in case of major UI changes brought by iOS updates. It’s not a big deal, as these changes do not happen that often.
Finally, if you’re thinking which views you should implement the snapshot tests for, the Pareto Principle (a.k.a. 20/80 Rule) should help. It’s highly probable that only 20% of your app views handle 80% of the user activity. Which ones are those? App Analytics data can identify these views for sure!
Also, it’s often a good idea to add Snapshot Tests to newly created views. Being able to see an actual picture alongside the code of a given view when reviewing changes is a great help!
Don’t miss any new blogposts!
By subscribing, you agree with our privacy policy and our terms and conditions.
Disclaimer: All memes used in the post were made for fun and educational purposes only
They were not meant to offend anyone
All copyrights belong to their respective owners
The thumbnail was generated with text2image