swift-snapshot-testing-macros

main

A Swift Macro library for generating snapshot tests from functions
adammcarter/swift-snapshot-testing-macros

Snapshot Tests

Overview

SnapshotTestingMacros is a thin layer over swift-testing and swift-snapshot-testing to allow for macro based snapshots using a syntax similar to Swift Testing.

Just as Swift Testing has @Suite and @Test, SnapshotTestingMacros uses @SnapshotSuite and @SnapshotTest to mark up code.

This allows for snapshots to quickly be created by simply marking up functions that return views.

Example code

In the simplest case this is all that's needed for a snapshot test:

// ✅ Create a simple snapshot test for some SwiftUI text.

@Suite
@SnapshotSuite
struct MySnapshots {

  @SnapshotTest
  func myView() -> some View {
    Text("Some text")
  }
}

Note that while @Suite isn't explicitly needed to run the snapshots, it's currently recommneded so Xcode can pickup the generated Suite inside the macro. Due to macro limitations it seems that Xcode cannot see Suites when they're embedded inside macro expansion code.



Customisation


Sensible defaults

Here, the macro uses sensible defaults to generate snapshot tests for the myView() function.

These sensible defaults include sizing the snapshots to its minimum size and even rendering them in both light and dark mode variants.

The above code example produces these images when run:

Theme Light mode Dark mode
Image
Filename myView_min-size_light.1.png myView_min-size_dark.1.png

These defaults can be overriden using traits, similar to Swift Testing, to change how the snapshots are rendered as well as other settings like forcing them to record.

Note the filenames for the images generated above which concisely describe the configuration of the snapshot.


Explicit names

By default, snapshots have a display name based on the name of the function that makes the view, but this can be overriden for more user friendly names.

Just like Swift Testing the @SnapshotTest macro can take a display name as its first argument:

// ✅ Use explicit names for test

@Suite
@SnapshotSuite
struct MySnapshots {

  @SnapshotTest("Sample text") // ⬅️ Added display name
  func myView() -> some View {
    Text("Some text")
  }
}

This allows tests to adopt these display names in the generated file name for the snapshot images.

Sample code renderings
Theme Light mode Dark mode
Image
Filename Sample-text_min-size_light.1.png Sample-text_min-size_dark.1.png

Note how the filenames now use the 'Sample-text' display name for their prefix.

⚠️ @SnapshotSuite can also have a display name but this is currently unused. There's future plans to use this as potentially the folder name for the snapshots and (if/when Xcode supports it) overloading the display name of the Suites so it can be seen in the Xcode GUI in the Test Navigator.


Traits

Both @SnapshotSuite and @SnapshotTest can take predefined traits to overrride and customise the snapshots as well as the way the snapshots are run.

Many of the Swift Testing traits are available here as well as some new ones bespoke to snapshots such as: custom sizing, add padding, force recording.

For more examples of using traits see the test fixtures for both SnapshotSuite and SnapshotTest.

Inheritance

Traits can be added to either the @SnapshotSuite to apply the traits to all the tests or to specific @SnapshotTests to override that one specific test.

⚠️ When applying a trait to @SnapshotTest it will override the @SnapshotSuite trait if one exists explicitly or implicitly (eg a default value).

Traits can be used with other traits in the same suite or test declaration.

⚠️ Don't use multiple traits of the same kind in the same suite or test as this will not work. You can only override traits - e.g. set a device size for all tests in a suite and overrride the size in a specific test.

Sizes

Device(s)

You can set the rendered image's size to a speficic device size by passing one or more device sizes to this trait.

Passing more than one size will generate a bespoke snapshot for each of the devices.

// 📱 Use explicit device sizes

@Suite
@SnapshotSuite(
  .sizes(devices: .iPhoneX, .iPadPro11) // ⬅️ Set the devices for all the tests in this suite
)
struct MySnapshots {

  @SnapshotTest
  func myView() -> some View {
    Text("Some text")
  }

  @SnapshotTest
  func anotherView() -> some View {
    Text("Some other text")
  }

  @SnapshotTest(
    .sizes(devices: .iPhoneX) // ⬅️ Set this test to be iPhone sized
  )
  func myPhoneView() -> some View {
    Text("I'm the size of a phone")
  }
}
Sample code renderings

Here you can see the files that have been rendered:

Screenshot of the explicit device sizes snapshots output

Note how myPhoneView() only has images for the iPhoneX size.

In this mode the device size is also included in the name of the snapshot test for ease of understanding.

fitting

Optionally, you can set the fitting parameter to specify which dimensions you want to use of the device,

  • widthAndHeight - use both width and height of the device
  • widthButMinimumHeight - use the width of the device with the minimum height of the view
  • heightButMinimumWidth - use the height of the device with the minimum width of the view

These options might be useful for example if rendering a row of a list or a table view cell, where you might use widthButMinimumHeight so the view expands the width of the device while using the minimum possible height of the view.

// 📱 Set a fitting size

@Suite
@MainActor
@SnapshotSuite(
  .sizes(devices: .iPhoneX, fitting: .widthButMinimumHeight) // ⬅️ Use device width with minimum height
)
struct MySnapshots {

  @SnapshotTest
  func myListRow() -> some View {
    HStack {
      Image(systemName: "person.fill")

      Text("My account")

      Spacer()
    }
    .padding()
  }
}
Sample code renderings

The above code renders these images:

Theme Light mode Dark mode
Image
Filename myListRow_iPhoneX-min-height_light.1.png myListRow_iPhoneX-min-height_dark.1.png

Custom wdth(s) and height(s)

Another version of the sizes trait allows for explicit sizes to be set.

These sizes can be an explicit size in points or a predefined size .minimum

@Suite
@SnapshotSuite
struct MySnapshots {

  @SnapshotTest(.sizes(width: 320, height: 480))
  func size320x480() -> some View {
    Text("320x480 size")
  }

  @SnapshotTest(.sizes(width: 320))
  func width320() -> some View {
    Text("320 width")
  }

  @SnapshotTest(.sizes(.minimum))
  func minimumSize() -> some View {
    Text("Minimum size")
  }
}
Sample code renderings

The above code renders these images:

Screenshot of the custom width and height sizes output

We can also speicify multiple sizes:

@Suite
@SnapshotSuite
struct MySnapshots {

  @SnapshotTest(
    .sizes(
      SizesSnapshotTrait.Size(width: 320, height: 480),
      SizesSnapshotTrait.Size(width: 600, height: 200)
    )
  )
  func testMultipleSizes() -> some View {
    Text("Will render in different sizes")
  }
}
Sample code renderings

The above code renders these images:

Screenshot of the multiple sizes output

Padding

Sometimes when rendering snapshots we might need to add padding around the image for readability.

You can use the padding trait to add a set amount of padding around the view before its snapshot is rendered.

@Suite
@SnapshotSuite
struct MySnapshots {

  @SnapshotTest(.padding)
  func paddingDefault() -> some View {
    Text("Add system default padding to all sides")
  }

  @SnapshotTest(.padding(20))
  func padding20() -> some View {
    Text("Add 20 padding to all sides")
  }

  @SnapshotTest(.padding(.horizontal, 15))
  func paddingSpecificSides() -> some View {
    Text("Add 15 padding to horizontal sides")
  }

  @SnapshotTest(
    .padding(
      EdgeInsets(
        top: 20,
        leading: 30,
        bottom: 10,
        trailing: 40
      )
    )
  )
  func paddingEdgeInsets() -> some View {
    Text("Add specific edge inset padding")
  }
}
Sample code renderings

The above code renders these images:

Theme Light mode Dark mode
Image
Filename padding20_min-size_light.1.png padding20_min-size_dark.1.png
Theme Light mode Dark mode
Image
Filename paddingDefault_min-size_light.1.png paddingDefault_min-size_dark.1.png
Theme Light mode Dark mode
Image
Filename paddingEdgeInsets_min-size_light.1.png paddingEdgeInsets_min-size_dark.1.png
Theme Light mode Dark mode
Image
Filename paddingSpecificSides_min-size_light.1.png paddingSpecificSides_min-size_dark.1.png

Background color

Sometimes when rendering snapshots we might need to add a specific background colour.

While you can bake this in to the view that gets returned this can add some unnecessary ceremony, especially when using UIKit views where you might need to assign the value just to set the background colour.

By default, the snapshots will render using the UIColor.systemBackground color.

You can use the .backgroundColor trait to specify the background of a test or all tests inside of a suite.

@Suite
@SnapshotSuite
struct MySnapshots {

  @SnapshotTest(
    .backgroundColor(.red)
  )
  func red() -> some View {
    Text("Red")
  }

  @SnapshotTest(
    .backgroundColor(.blue)
  )
  func blue() -> some View {
    Text("Blue")
  }

  @SnapshotTest(
    .backgroundColor(.green)
  )
  func green() -> some View {
    Text("Green")
  }
}
Sample code renderings

The above code renders these images:

Theme Light mode Dark mode
Image
Filename red_min-size_light.1.png red_min-size_dark.1.png
Theme Light mode Dark mode
Image
Filename blue_min-size_light.1.png blue_min-size_dark.1.png
Theme Light mode Dark mode
Image
Filename green_min-size_light.1.png green_min-size_dark.1.png

Theme(s)

Use this trait to specify a specific theme (light or dark) or to set all themes (light and dark).

@Suite
@SnapshotSuite
struct MySnapshots {

  @SnapshotTest(.theme(.light))
  func light() -> some View {
    Text("Light theme")
  }

  @SnapshotTest(.theme(.dark))
  func dark() -> some View {
    Text("Dark theme")
  }

  @SnapshotTest(.theme(.all))
  func all() -> some View {
    Text("Both light and dark")
  }
}
Sample code renderings

The above code renders these images:

Theme Light mode Dark mode
Image n/a
Filename light_min-size_light.1.png n/a
Theme Light mode Dark mode
Image n/a
Filename n/a dark_min-size_dark.1.png
Theme Light mode Dark mode
Image
Filename all_min-size_light.1.png all_min-size_dark.1.png

Record

Use this trait to force a test or entire suite to re-render their images

@Suite
@SnapshotSuite
struct MySnapshots {

  @SnapshotTest(.record(true)) // ⬅️ Force snapshots in to record mode
  func recordTrue() -> some View {
    Text("Force record (explicit)")
  }

  @SnapshotTest(.record) // ⬅️ Shorthand version of '.record(true)'
  func record() -> some View {
    Text("Force record")
  }

  @SnapshotTest(.record(false)) // ⬅️ Default value so not needed
  func recordFalse() -> some View {
    Text("Doesn't re-record")
  }
}

Strategy

Use this trait to change the snapshot strategy and the snapshot's output.

Supported strategies:

  • image (default): A snapshot strategy for comparing views based on pixel equality.
  • recursiveDescription: A snapshot strategy for comparing views based on a recursive description of their properties and hierarchies.
@Suite
@SnapshotSuite
struct StrategySnapshots {

  @SnapshotTest(
    .strategy(.image)
  )
  func image() -> some View {
    Text("generates an image file")
  }

  @SnapshotTest(
    .strategy(.recursiveDescription)
  )
  func recursiveDescription() -> some View {
    Text("generates a recursive description text file")
  }
}

Swift Testing traits

Snapshot testing supports most of the SwiftTesting traits too so they can also be passed along:

These use the same format and callsites as the Swift Testing equivalent for ease of use - you can see the docs in Swift Testing for more info.



Parameterised tests

Just as in Swift Testing you can pass arguments, SnapshotTestingMacros uses configurations.

These configurations take a name and a value so the snapshots can be grouped on their configuration and create a cleaner, easier to navigate library of reference snapshot on disk.

Configurations

You can pass configurations, creating instances of SnapshotConfiguration to define the name and the value you want to pass.

This will run the function once for every configuration passing in the value.

For example, the below code calls myView(value:) twice; the first time with value: 1 and the second time with value: 2.

@Suite
@SnapshotSuite
struct MySnapshots {

  @SnapshotTest(
    configurations: [
      SnapshotConfiguration(name: "Name 1", value: 1),
      SnapshotConfiguration(name: "Name 2", value: 2),
    ]
  )
  func myView(value: Int) -> some View {
    Text("value: \(value)")
  }
}

On disk a folder is created for each configuration, with each folder containing the snapshots for that configuration.

💡 This is especially useful if you set traits with multiple variants, e.g. multiple sizes and themes where the number of snapshots can quickly grow.

Screenshot of folder hierarchy for the configuration snapshots
'Name 1' folder snapshots

The above code renders these images:

Configuration Light mode Dark mode
Image
Filename myView_min-size_light.1.png myView_min-size_dark.1.png
'Name 2' folder snapshots

The above code renders these images:

Configuration Light mode Dark mode
Image
Filename myView_min-size_light.1.png myView_min-size_dark.1.png

Value

value: can be anything you'd like, from primitive types to your own struct, class or tuples.

When using tuples as the value, the macro library will unpack the values and pass them along to your function for ease of use.

For example, below, the tuple value (Int, String) is unpacked and passed along to myView(int: Int, string: String)'s parameters.

@Suite
@SnapshotSuite
struct MySnapshots {

  @SnapshotTest(
    configurations: [
      SnapshotConfiguration(name: "Name 1", value: (1, "one")),
      SnapshotConfiguration(name: "Name 2", value: (2, "two")),
    ]
  )
  func myView(int: Int, string: String) -> some View { // ⬅️ Note how the tuple values from 'value:' are unpacked in this function's parameters
    Text("value: \(int) is typed as: \(string)")
  }
}
Rendered snapshots

The above code renders these images:

'Name 1' folder snapshots

Configuration Light mode Dark mode
Image
Filename myView_min-size_light.1.png myView_min-size_dark.1.png

'Name 2' folder snapshots

Configuration Light mode Dark mode
Image
Filename myView_min-size_light.1.png myView_min-size_dark.1.png

Closures and functions

configurations can also accept a function or closure.

This allows us to define complex configurations in a helper function and pass this along for a cleaner callsite or more complex setups.

@Suite
@SnapshotSuite
struct MySnapshots {

  @SnapshotTest(configurations: configurations) // ⬅️ Pass in the configurations() function to make our configurations
  func myView(int: Int, string: String) -> some View {
    Text("value: \(int) is typed as: \(string)")
  }
}

private func configurations() -> [SnapshotConfiguration<(Int, String)>] {
  [
    SnapshotConfiguration(name: "Name 1", value: (1, "one")),
    SnapshotConfiguration(name: "Name 2", value: (2, "two")),
  ]
}

Or using a more complex setup:

@Suite
@SnapshotSuite
struct MySnapshots {

  @SnapshotTest(configurations: MyConfigurationGenerator.generateConfigurations)
  func myView(int: Int) -> some View {
    Text("value: \(int)")
  }
}

private struct MyConfigurationGenerator {
  static func generateConfigurations() -> [SnapshotConfiguration<Int>] {
    // Some really complex logic ...

    return []
  }
}

Configuration Values

Sometimes the name of a configuration can be inferred from the value.

Using the configurationValues: parameter solves this problem for us by avoiding unnecessary duplication of name and value.

Examples

Int

This simple case adds unnecessary ceremony and maintenance by duplicating the name and value:

// ⚠️ This works but isn't optimal

@Suite
@SnapshotSuite
struct MySnapshots {

  @SnapshotTest(configurations: [
    SnapshotConfiguration(name: "1", value: 1),
    SnapshotConfiguration(name: "2", value: 2)
  ])
  func myView(int: Int) -> some View {
    Text("value: \(int)")
  }
}

Where it would be more convenient to have the snapshot generator infer the name from the value:

// ✅ This is preferred

@Suite
@SnapshotSuite
struct MySnapshots {

  @SnapshotTest(configurationValues: [1, 2])
  func myView(int: Int) -> some View {
    Text("value: \(int)")
  }
}

Both of these output the same configurations and snapshots, but configurationValues avoids unnecessary copy/paste.

Screenshot of folder hierarchy for configuration values

Enum cases

A more realistic example might be looping over a set of enum cases where me might be tempted to compute the name from the value like so:

// ⚠️ This works but isn't optimal

enum Compass: CaseIterable {
  case north, east, south, west
}

@Suite
@SnapshotSuite
struct MySnapshots {

  @SnapshotTest(
    configurations: Compass.allCases.map {
      SnapshotConfiguration(name: "\($0)", value: $0) // ⬅️ This might be tempting
    }
  )
  func myView(compass: Compass) -> some View {
    Text("Pointing \(compass)")
  }
}

Instead we can use configurationValues to infer the name from the enum case's values:

// ✅ This is preferred

enum Compass: CaseIterable {
  case north, east, south, west
}

@Suite
@SnapshotSuite
struct MySnapshots {

  @SnapshotTest(configurationValues: Compass.allCases) // ⬅️ Use configurationValues when the name can be computed
  func myView(compass: Compass) -> some View {
    Text("Pointing \(compass)")
  }
}

💡 Just like configurations, configurationValues can also take a function/closure to simplify the callsite or do more complext setup.

Supported views

TODO: Add more docs about supported views

  • SwiftUI views
  • UIKit:
    • UIView
    • UIViewController
  • AppKit:
    • NSView
    • NSViewController

Running tests

TODO: Add more docs about running tests

  • SnapshotsIntegrationTests runs on iPhone 16 - 18.4
  • SnapshotsUnitTests runs on macOS

Description

  • Swift Tools 6.1.0
View More Packages from this Author

Dependencies

Last updated: Thu May 15 2025 05:38:34 GMT-0900 (Hawaii-Aleutian Daylight Time)