How to speed up development and testing in SwiftUI using PreviewSnapshots

One of the great features of developing in SwiftUI is Xcode Previews, which provide fast UI iteration by visualizing code changes in real time along with SwiftUI code. At DoorDash, we actively use Xcode Previews along with the library Snapshot Testing from Point Freeto make sure the screens look the way we expect them to when we develop them, and to ensure they don’t change in unexpected ways over time.

SnapshotTesting can be used to capture the rendered image VIEW and creation XCTest – crash if the new image does not match the reference image on disk. Xcode Previews in combination with SnapshotTesting can be used to provide fast iterations while ensuring that views continue to look the way they are intended without fear of unexpected changes.

The difficulty with using Xcode Previews and SnapshotTesting together is that it can lead to a lot of boilerplate and code duplication between previews and tests. To solve this problem, DoorDash engineers developed preview snapshots, an open source snapshot preview tool that can be used to easily exchange configurations between Xcode previews and snapshot tests. In this article, we will explore this topic in depth, first providing some background on how Xcode preview and SnapshotTesting work, and then explaining how to use the new open source tool, with explanatory examples on how to eliminate code duplication by passing view configurations between previews and snapshots.

How Xcode Previews Work

Xcode Previews allow developers to return one or more versions view from PreviewProviderand Xcode renders the live version view along with the implementation code.

Starting with Xcode 14, multiple preview views are presented as selectable tabs at the top of the preview window, as shown in Figure 1.

Figure 1: The Xcode editor showing the SwiftUI View code for displaying a simple message, and the Xcode Preview window showing two versions of that view.  One with a short message and one with a long message.
Figure 1: The Xcode editor showing the SwiftUI View code for displaying a simple message, and the Xcode Preview window showing two versions of that view. One with a short message and one with a long message.

How Snapshot Testing Works

Library Snapshot Testing allows developers to write test statements about the appearance of their views. By asserting that the views correspond to the reference images on disk, developers can be sure that the views will not change in unexpected ways over time.

Example code in fig. 2 compares short and long versions MessageView with reference images stored on disk as testSnapshots.1 and testSnapshots.2 respectively. Snapshots were originally captured using SnapshotTesting and automatically named after the test function name and the position of the assertion within the function.

Rice.  2. An Xcode editor showing the SwiftUI View code using PreviewSnapshots to create Xcode Previews for four different input states, and an Xcode Preview window rendering (*drawing) the view using each of these states.
Rice. 2. An Xcode editor showing the SwiftUI View code using PreviewSnapshots to create Xcode Previews for four different input states, and an Xcode Preview window rendering (*drawing) the view using each of these states.

Problem of sharing Xcode Previews and SnapshotTesting

There are many similarities between the code used for Xcode Previews and the code for creating snapshot tests. This similarity can lead to code duplication and additional efforts by developers to try to cover both technologies. Ideally, developers would write code to preview views in different configurations, and then reuse that code to test view snapshots in the same configurations.

Introducing PreviewSnapshots

PreviewSnapshots can help solve this code duplication problem. PreviewSnapshots allows developers to create a single set of view states for Xcode Previews and create sample snapshot tests for each state with a single test statement. Below we will see how this works with a simple example.

Using PreviewSnapshots for a simple view

Let’s say we have a view that takes a list of names and displays them in some interesting way.

Traditionally, we would like to create a preview for several states of a given view that are of interest to us. May be: empty, one name, short list of names and long list of names.

struct NameList_Previews: PreviewProvider {
  static var previews: some View {
    NameList(names: [])
      .previewDisplayName("Empty")
      .previewLayout(.sizeThatFits)

    NameList(names: [“Alice”])
      .previewDisplayName("Single Name")
      .previewLayout(.sizeThatFits)

    NameList(names: [“Alice”, “Bob”, “Charlie”])
      .previewDisplayName("Short List")
      .previewLayout(.sizeThatFits)

    NameList(names: [
      “Alice”,
      “Bob”,
      “Charlie”,
      “David”,
      “Erin”,
      //...
    ])
    .previewDisplayName("Long List")
    .previewLayout(.sizeThatFits)
  }
}

Next, we would write very similar code for testing snapshots.

final class NameList_SnapshotTests: XCTestCase {
  func test_snapshotEmpty() {
    let view = NameList(names: [])
    assertSnapshot(matching: view, as: .image)
  }

  func test_snapshotSingleName() {
    let view = NameList(names: [“Alice”])
    assertSnapshot(matching: view, as: .image)
  }

  func test_snapshotShortList() {
    let view = NameList(names: [“Alice”, “Bob”, “Charlie”])
    assertSnapshot(matching: view, as: .image)
  }

  func test_snapshotLongList() {
    let view = NameList(names: [
      “Alice”,
      “Bob”,
      “Charlie”,
      “David”,
      “Erin”,
      //...
    ])
    assertSnapshot(matching: view, as: .image)
  }
}

Long list of names can potentially be shared between previews and snapshot testing using a static property, but there is no way to avoid manually writing a separate snapshot test for each view state.

PreviewSnapshots allows developers to define a single collection of configurations of interest and then trivially reuse them between previews and snapshot tests.

This is what an Xcode preview looks like using PreviewSnapshots:

struct NameList_Previews: PreviewProvider {
  static var previews: some View {
    snapshots.previews.previewLayout(.sizeThatFits)
  }

  static var snapshots: PreviewSnapshots<[String]> {
    PreviewSnapshots(
      configurations: [
        .init(name: "Empty", state: []),
        .init(name: "Single Name", state: [“Alice”]),
        .init(name: "Short List", state: [“Alice”, “Bob”, “Charlie”]),
        .init(name: "Long List", state: [
          “Alice”,
          “Bob”,
          “Charlie”,
          “David”,
          “Erin”,
          //...
        ]),
      ],
      configure: { names in NameList(names: names) }
    )
  }
}

To create a collection of PreviewSnapshots, we instantiate PreviewSnapshots with an array of configurations along with a function configure to configure the view for this configuration. A configuration consists of a name and an instance StateThe that will be used to customize the view. In this case, the state type for the name array would be [String].

To create a preview, we return snapshots.previews from the standard preview static property, as shown in Figure 1. 3. snapshots.previews will create a preview with the correct name for each configuration preview snapshots.

Rice.  3. An Xcode editor showing SwiftUI View code using PreviewSnapshots to generate Xcode Previews for four different input states, along with an Xcode Preview window displaying a view using each of those states.
Rice. 3. An Xcode editor showing SwiftUI View code using PreviewSnapshots to generate Xcode Previews for four different input states, along with an Xcode Preview window displaying a view using each of those states.

PreviewSnapshots provides some extra structure for a small view that is easy to build but does little to reduce the number of lines of code in the preview. The main advantage of small views comes when it comes time to write tests for preview snapshots.

final class NameList_SnapshotTests: XCTestCase {
  func test_snapshot() {
    NameList_Previews.snapshots.assertSnapshots()
  }
}

This single statement will perform a snapshot test of each configuration in PreviewSnapshots. On fig. Figure 4 shows the sample code along with reference images in Xcode. In addition, if any new configurations are added to the preview, the snapshot test will automatically be applied to them without changing the test code.

Figure 4: Xcode unit test using PreviewSnapshots to test the four different input states defined above with a single call to assertSnapshots
Figure 4: Xcode unit test using PreviewSnapshots to test the four different input states defined above with a single call to assertSnapshots

For more complex views with more arguments, there are even more advantages.

Using PreviewSnapshots for a More Complex View

In the second example, we will consider formviewwhich takes several Bindings, an optional error message, and an action closure as arguments in its initializer. This example will show the increased benefits of PreviewSnapshots in a situation where the complexity of building a view increases.

struct FormView: View {
  init(
    firstName: Binding<String>,
    lastName: Binding<String>,
    email: Binding<String>,
    errorMessage: String?,
    submitTapped: @escaping () -> Void
  ) { ... }

  // ...
}

Because the preview snapshots is a generic for the input state, we can combine the various inputs into a small helper structure to pass to the block configureand only once it will be necessary to compose formview. As an added convenience preview snapshots provides protocol NamedPreviewState to make it easier to create login configurations by grouping the preview name together(*according to) the preview state.

struct FormView_Previews: PreviewProvider {
  static var previews: some View {
    snapshots.previews
  }

  static var snapshots: PreviewSnapshots<PreviewState> {
    PreviewSnapshots(
      states: [
        .init(name: "Empty"),
        .init(
          name: "Filled",
          firstName: "John", lastName: "Doe", email: "john.doe@doordash.com"
        ),
        .init(
          name: "Error",
          firstName: "John", lastName: "Doe", errorMessage: "Email Address is required"
        ),
      ],
      configure: { state in
        NavigationView {
          FormView(
            firstName: .constant(state.firstName),
            lastName: .constant(state.lastName),
            email: .constant(state.email),
            errorMessage: state.errorMessage,
            submitTapped: {}
          )
        }
      }
    )
  }
  
  struct PreviewState: NamedPreviewState {
    let name: String
    var firstName: String = ""
    var lastName: String = ""
    var email: String = ""
    var errorMessage: String?
  }
}

In the example code, we have created a structure PreviewStatecorresponding in form NamedPreviewState and containing the preview name along with the first name, last name, email address, and an optional error message to create the preview. Then, based on the configuration state passed in, in the configure block, we create one instance of formview. Returning snapshots.preview from PreviewProvider.previews, preview snapshots will iterate over the input states and generate an Xcode preview with the proper name for each state, as shown in Figure 5.

Figure 5: Xcode editor showing SwiftUI View code using PreviewSnapshots to generate Xcode previews for three different input states, along with an Xcode Preview window rendering the view using each of these states.
Figure 5: Xcode editor showing SwiftUI View code using PreviewSnapshots to generate Xcode previews for three different input states, along with an Xcode Preview window rendering the view using each of these states.

Once we’ve defined a set of PreviewSnapshots for the preview, we can again create a set of snapshot tests with a single unit test statement.

final class FormView_SnapshotTests: XCTestCase {
  func test_snapshot() {
    FormView_Previews.snapshots.assertSnapshots()
  }
}

As in the simpler example above, this test case will compare each of the preview states defined in FormView_Previews.snapshotswith the reference image written to disk, and generate a test failure if the images don’t match expectations.

Conclusion

This article discussed certain benefits of using Xcode Previews and SnapshotTesting when developing with SwiftUI. He also demonstrated some of the pain points and code duplication that can result from using the two technologies together, and how PreviewSnapshots allows developers to save time by reusing the effort they put into writing Xcode previews to test snapshots.

Instructions for including PreviewSnapshots in your project, as well as an example application using PreviewSnapshots, are available at GitHub.

Similar Posts

Leave a Reply

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