snapshot tests based on SwiftUI Preview

Doubletappand last year I, together with my colleagues and the Yandex team, participated in the development of the application Yandex Travels. In this project, we chose SwiftUI as the user interface framework (our iOS-Head Polina Skalkina told us more about how we chose it and what came out of it here).

At the initial stage of implementing the application, we constantly made changes to many views. These included updates due to design rework, bug fixes, and optimizations that we made as our knowledge of SwiftUI grew. We wanted to control all these changes in order to catch the layout errors caused by them even at the development stage. Therefore, our team decided to use snapshot tests.

What are snapshot tests?

This is a type of test that compares some representation of an object with a reference representation. In our case, the testing object is a view, and the view is a screenshot of the view. The algorithm is simple: first, a reference screenshot is created and written to disk, after which, the next time the tests are run, a new screenshot is generated and compared with the saved one. If the screenshots are different or the standard is not found, the test will fail.

Tests of this type give us the opportunity to record what the view looks like under different model states (loading, data, error, authorized/not authorized, etc.), color schemes, screen sizes. Many variations of snapshots allow us to achieve the control we need over view changes.

Screenshots can be stored in the repository along with the application code. Then the pull request will include not only the view code, but also its snapshots. Thanks to this, the reviewer can see what the view looks like in different configurations. In addition, GitHub, GitLab and other services have built-in image comparison tools that will help you check changes in screenshots.

Image comparison tools in GitLab

Image comparison tools in GitLab

For snapshot testing we chose the library SnapshotTesting. The tests in it are similar to the usual unit tests:

import SnapshotTesting
import XCTest

final class ExampleViewTests: XCTestCase {

   func testExampleView() {
       assertSnapshot(matching: ExampleView(), as: .image)
   }
}

Function assertSnapshot works according to the algorithm described above: it takes a screenshot of the transferred view and compares it with the screenshot on the disk. If the standard is not found on the disk, it saves the newly created screenshot and the test fails.

One thing to keep in mind is that the library uses a simulator to take screenshots. On different simulators and with different simulator locale settings, SnapshotTesting will generate different screenshots. Therefore, the team will need to agree on which simulator to use.

There may also be differences in screenshots generated by computers running Intel and Apple Silicon processors. This is a known issue and has not yet been completely fixed. We got around this by reducing the parameters precision And perceptualPrecision, responsible for the verified degree of similarity of images. If you know of other methods, I will be glad to learn about them from the comments.

Combining snapshot tests and previews

After we started implementing snapshot tests, we noticed that they were very similar to SwiftUI Previews. That is, it is enough to write the necessary infrastructure code, and when adding new previews, you can get snapshot tests for view almost free of charge.

Our implementation of this infrastructure can be divided into two parts: previews and snapshot tests. A protocol connects these parts Testable. Its role is to provide a set of view variations for different model states (an array samples). For this purpose it is defined associatedtype Sampleallowing to minimize the use AnyView. In addition, in the protocol definition you can notice associatedtype Modifierbut I’ll tell you about it a little later.

import SwiftUI

public protocol Testable {
   associatedtype Sample: View
   associatedtype Modifier: PreviewModifier = IdentityPreviewModifier
  
   static var samples: [Sample] { get }
}

If we talk about the preview part, then the protocol Testable implemented together with PreviewProvider and to some extent replaces it, so the following extension is specified for their combination:

extension PreviewProvider where Self: Testable {
   static var previews: some View {
       ForEach(samples, id: \.uuid) { $0 }
           .modifier(Modifier())
   }
}

private extension View {
   var uuid: UUID { UUID() }
}

Here’s what the preview definition looks like in the simplest case, using the hotel list screen as an example:

struct HotelListView_Previews: PreviewProvider, Testable {
   static let samples = [
       HotelListView()
   ]
}

Let’s go back to associatedtype Modifier in the protocol Testable. This is a modifier that applies to all views from the samples array. PreviewModifieris an extension of the SwiftUI protocol ViewModifier and determines the possibility of instantiating its implementation without knowing its type.

import SwiftUI

public protocol PreviewModifier: ViewModifier {
   init()
}

Modifier are used in cases where you need to further customize the view before displaying it in previews and snapshot tests. For example, the background color of the screen by default matches the background of the hotel cell, and we need to change it in order to see the borders of the view. By using Modifier change the background color of the screen:

struct HotelView_Previews: PreviewProvider, Testable {
   static let samples = [
       HotelView(state: .moscowHotel)
   ]
  
   struct Modifier: PreviewModifier {
       func body(content: Content) -> some View {
           content
               .frame(maxHeight: .infinity)
               .background(Color.major)
       }
   }
}

If nothing needs to be modified, then use IdentityPreviewModifierwhich leaves the view unchanged (used by default):

public struct IdentityPreviewModifier: PreviewModifier {
   public init() {}
  
   public func body(content: Content) -> some View { content }
}

That’s all for the preview part, let’s move on to the snapshot tests part. Its two main goals are integration with the SnapshotTesting library and reducing code duplication when writing tests.

As mentioned earlier, each screenshot is a view for a given model state, size and color scheme. The various states of the model are already stored in an array samples (protocol Testable), so we defined a separate structure SnapshotEnvironment to store size (layout) and color scheme (traits):

import SnapshotTesting
import UIKit

struct SnapshotEnvironment {
   let layout: SwiftUISnapshotLayout
   let traits: UITraitCollection
   let descriptionComponents: [String]
}

Field descriptionComponents needed when creating a screenshot name. The following will show how it is created and used.

To record the screen sizes of devices that we are interested in testing and the supported color schemes, enumerations are defined Device And Theme:

extension SnapshotEnvironment {
   enum Device: String {
       case iPhone13, iPhone8, iPhoneSe
      
       fileprivate var viewImageConfig: ViewImageConfig {
           switch self {
           case .iPhone13: return .iPhone13
           case .iPhone8: return .iPhone8
           case .iPhoneSe: return .iPhoneSe
           }
       }
   }
  
   enum Theme: String, CaseIterable {
       case light, dark
      
       fileprivate var traitCollection: UITraitCollection {
           UITraitCollection(userInterfaceStyle: interfaceStyle)
       }
      
       private var interfaceStyle: UIUserInterfaceStyle {
           switch self {
           case .light: return .light
           case .dark: return .dark
           }
       }
   }
}

These enums, in turn, are used in the factory methods of test configurations:

extension SnapshotEnvironment {
   static func device(_ device: Device, theme: Theme) -> SnapshotEnvironment {
       SnapshotEnvironment(
           layout: .device(config: device.viewImageConfig),
           traits: theme.traitCollection,
           descriptionComponents: [device.rawValue, theme.rawValue]
       )
   }
  
   static func sizeThatFits(theme: Theme) -> SnapshotEnvironment {
       SnapshotEnvironment(
           layout: .sizeThatFits,
           traits: theme.traitCollection,
           descriptionComponents: ["sizeThatFits", theme.rawValue]
       )
   }
}

Depending on the view, a certain set of test configurations (size + color scheme) may be required. The sets we use are specified by enumeration cases SnapshotBatch:

enum SnapshotBatch {
   case regular, extended, component
  
   var snapshotEnvironments: [SnapshotEnvironment] {
       switch self {
       case .regular:
           return SnapshotEnvironment.Theme.allCases.map { .device(.iPhone13, theme: $0) }
       case .extended:
           return SnapshotEnvironment.Theme.allCases.map { .device(.iPhone13, theme: $0) } + [
               .device(.iPhone8, theme: .light),
               .device(.iPhoneSe, theme: .light)
           ]
       case .component:
           return SnapshotEnvironment.Theme.allCases.map(SnapshotEnvironment.sizeThatFits)
       }
   }
}

And finally, all the previously described code is used in the method testperforming snapshot testing of array elements samples protocol Testable. The screenshot file name is automatically generated from the name of the test function, which is substituted into the parameter testNameand fields descriptionComponents structures SnapshotEnvironmentwhich I showed earlier.

import SnapshotTesting

extension Testable {
   static func test(
       batch: SnapshotBatch,
       file: StaticString = #file,
       testName: StaticString = #function,
       line: UInt = #line
   ) {
       let name = testName.description.removingPrefix("test").removingSuffix("()")
       for sample in samples.map({ $0.modifier(Modifier()) }) {
           for environment in batch.snapshotEnvironments {
               assertSnapshot(
                   matching: sample,
                   as: .image(layout: environment.layout, traits: environment.traits),
                   file: file,
                   testName: assembleTestName(name, for: environment),
                   line: line
               )
           }
       }
   }
  
   private static func assembleTestName(_ testName: String, for environment: SnapshotEnvironment) -> String {
       ([testName] + environment.descriptionComponents).joined(separator: "-")
   }
}

The infrastructure is ready! Now to fully test the view, just call the method test and pass the required one into it SnapshotBatch. For example, in case HotelListView snapshot testing will look like this:

func testHotelListView() {
   HotelListView_Previews.test(batch: .extended)
}

As a result of the test, screenshots will be generated

The number after the dot in the file name is added automatically by the library and in our case indicates the number of the element in the array samples.

Bottom line

The use of snapshot tests satisfied our need to control the changes that were constantly being made to the view. In addition, screenshots of screens stored in the code repository made the process of reviewing UI components much more convenient. Thanks to this, we caught layout errors in a timely manner even before the changes were merged into the main branch of the project.

The infrastructure created around the SnapshotTesting library has made writing snapshot tests easy and fast. The solution turned out to be quite flexible, so that with minor modifications it can be used on other projects.

You can learn more about the long-term experience of using snapshot tests and the results of using the described tool in Yandex Travels in the report from the author of the idea, Nikolay Puchko, at Vertis Mobile Meetup October 13.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *