Snapshot Testing is a really easy way to test your layout when working with SwiftUI and it is gaining more popularity as the framework evolves ever-nearer to its more mature UIKit sibling. All this method of testing does is take an image (snapshot) of a particular view or hierarchy and store it locally. You can then compare that later on for any changes and make sure it looks as expected, catching layout issues before anything gets merged.

What’s awesome is that if there is a change, the png is saved to the repo and will automatically show in the pull request. This gives your team visual feedback with before and after states of the View in question: no more reading view hierarchies and clicking ‘Approve’ without seeing anything.

Recently I wanted to write a simple way to test any SwiftUI View in a project and did so with a bit of help from my good friend Tim Mikelj.

Let’s take a look at how this test will read in the end:

class LoginViewTests: XCTestCase, SnapShotTesting {

   func test_lightAppearance_Snapshot() {
      testSnapshot(for: .display(mode: .light))
   }
  
   func test_darkAppearance_Snapshot() {
     testSnapshot(for: .display(mode: .dark)) 
   }

   func test_deviceSizeClassCompact_Snapshot() {
     testSnapshot(for: .sizeClass(class: .compact))
   }

   func test_defaultAppearance_Snapshot() {
     testSnapshot(for: .frameSize)
   }

  func test_allFontSizeCategories_Snapshot() {
    ContentSizeCategory.allCases.forEach { sizeCategory in
      testSnapshot(for: .size(category: sizeCategory))
    }
  }
}

So what we have is a readable way to test for things like dark mode and different size classes and it is built up using enum cases. All you have to do is conform to the SnapShotTesting protocol which we’ll implement shortly and you have access to everything!

Let’s Begin

Start by adding the snapshot testing library to your Xcode project using Swift Package Manager. Do this by going to File > Swift Packages > Add Package Dependency and enter https://github.com/pointfreeco/swift-snapshot-testing.git. Make sure you select your test target here and not your main project.

Next create a new Swift file called SnapShotTesting.swift and add the protocol that will outline the behaviour we want:

protocol SnapShotTesting {
   associatedtype ViewType: View
   var referenceSize: CGSize { get }
   var previewDevice: PreviewDevice { get }
   func makeTestView() -> ViewType
   func testSnapshot(for type: TestType)
}

Here we don’t know which kind of View we’ll be dealing with so we use an associated type to represent it. Next we specify a size we will use for testing within a frame if we wish whilst also requiring a preview device to render on. This is important as the views will look different from device-to-device so specifying this will stop tests failing on another machine down the line. Finally we give the developer a simple method to supply the particular view to test and access to the test snapshot function where the magic will happen.

enum TestType {
  case size(category: ContentSizeCategory)
  case display(mode: ColorScheme)
  case sizeClass(class: UserInterfaceSizeClass)
  case frameSize
}

Here we supply a declarative enum which will show our support for 4 test cases for our views – later on a developer can add other tests here and open up the functionality to all Views.

Next let’s create a default implementation by extending SnapShotTesting:

extension SnapShotTesting {
  // Create default size and preview device implementation
  var referenceSize: CGSize { CGSize(width: 300, height: 200) }
  
  var previewDevice: PreviewDevice { PreviewDevice(rawValue: "iPhone 12 Pro Max") }
  
  // Create default test snapshot implementation
  func testSnapshot(for type: TestType) {
    // call the method to supply the view
    let view = makeTestView()
    
    switch type {
    /// Test for different `Color Schemes` such as `Light` and `Dark Mode`
    case .display(mode: let mode):
      assertSnapshot(
        matching: view.colorScheme(mode)
          .framed(with: referenceSize, previewDevice: previewDevice),
        as: Snapshotting.image(size: referenceSize),
        named: "\(ViewType.self)-\(type)"
      )
      
    case .frameSize:
      /// Test how the `View` looks with a given `frame`
      assertSnapshot(
        matching: view
          .framed(with: referenceSize, previewDevice: previewDevice),
        as: .image(size: referenceSize),
        named: "\(ViewType.self)-\(type)"
      )
      
    case .size(let cagetory):
      /// Test how the `View` looks with a given a different `Size Category` - important for users who utilise `Accessibility`
      assertSnapshot(
        matching: view
          .framed(with: referenceSize, previewDevice: previewDevice)
          .environment(\.sizeCategory, cagetory),
        as: .image(size: referenceSize),
        named: "\(ViewType.self)-\(type)"
      )
      
    case .sizeClass(let sizeClass):
      assertSnapshot(
        matching: view
          .deviceLocked(with: previewDevice)
          .environment(\.horizontalSizeClass, sizeClass),
        as: .image(size: referenceSize),
        named: "\(ViewType.self)-\(type)"
      )
    }
  }
}

In each of these we create a snapshot, checking for different properties on each View using custom modifiers which we’ll add next. Notice we also use a named property to make sure our image names are unique for each case otherwise they will get overwritten and fail.

Let’s write that image extension next:

extension Snapshotting where Value: SwiftUI.View, Format == UIImage {
  static func image(
    drawHierarchyInKeyWindow: Bool = false,
    precision: Float = 1,
    size: CGSize? = nil,
    traits: UITraitCollection = .init()
  ) -> Snapshotting {
    Snapshotting<UIViewController, UIImage>.image(
      drawHierarchyInKeyWindow: drawHierarchyInKeyWindow,
      precision: precision,
      size: size,
      traits: traits
    )
    .pullback(UIHostingController.init(rootView:))
  }
}

Here we’ve made a convenient way of grabbing an image representation of our Views using the framework imported earlier and it allows us to be really precise in that we can pass in a particular size or traits to render. Thanks again Tim for this!

Finally lets add some custom modifiers to make sure we can manipulate the views nicely as in the previous code example:

// MARK: - View Modifiers

// Frame
fileprivate struct FrameForTesting: ViewModifier {
  var frame: CGSize
  var previewDevice: PreviewDevice
  
  func body(content: Content) -> some View {
    content
      .frame(width: frame.width, height: frame.height)
      .deviceLocked(with: previewDevice)
  }
}

fileprivate extension View {
  func framed(with frame: CGSize, previewDevice: PreviewDevice) -> some View {
    modifier(FrameForTesting(frame: frame, previewDevice: previewDevice))
  }
}

// Preview Device
fileprivate struct ConsistentDevice: ViewModifier {
  var device: PreviewDevice
  
  func body(content: Content) -> some View {
    content
      .previewDevice(device)
  }
}

fileprivate extension View {
  func deviceLocked(with device: PreviewDevice) -> some View {
    modifier(ConsistentDevice(device: device))
  }
}

These modifiers make it super-easy to make sure we lock to a single preview device as well as making sure the frame is identical each time: 2 important components to Snapshot Testing.

All we need to do is head over to our test cases for our view and conform to the protocol and add the only required method:

  func makeTestView() -> some View {
    LoginView()
      .environmentObject(someObject) // Pass stuff in here if required
  }

If you run the test suite for the first time you will generate the images and tests will fail. After you run it a 2nd time the images will be stored in your local directly and be committed to source control with passing tests.

What is really cool is many of the properties we’d want to change come from CaseIterable enums so we could do things like :

  func test_allFontSizeCategories_Snapshot() {
    ContentSizeCategory.allCases.forEach { sizeCategory in
      testSnapshot(for: .size(category: sizeCategory))
    }
  }

This will iterate over every size category for us and generate snapshots for us.

I hope you found this useful and enjoy snapshot testing!

code-disciple

Author code-disciple

More posts by code-disciple

Leave a Reply