Feb 15, 2024

Generating SwiftUI snapshot tests with Swift macros

This article describes how our Kiwi.com iOS project uses swift macros to drive automatic snapshot tests generation in order to keep tests synced with SwiftUI previews and to get rid of a lot of maintenance-requiring boilerplate.

Note: Xcode currently does not offer native support for snapshot testing. This can be remedied by using third party library, such as swift-snapshot-testing. This library is being used in following code examples.

Problem

SwiftUI preview and snapshot test code live in different targets and often in different packages, especially when dealing with modular architecture.

We would like to reuse our existing (mocked and manually crafted) SwiftUI previews directly as-is for snapshot testing purposes to avoid code duplication and have the snapshot test code automatically generated from SwiftUI preview definitions, possibly with added autogenerated dark mode, accessibility size or locale variants.

Our Solution

In our Kiwi.com project, we are using the power of swift macros in order to expose our existing SwiftUI preview properties and use them to generate matching test code in the test target:

Macros on SwiftUI previews side

In order to specify what previews we want to include in snapshot testing, we will be decorating the SwiftUI previews code with macros.

Note: It seems the new #Preview macro cannot be used for these purposes, as it generates randomly named previews. We need to reach for a specific type from our test code, so for this solution, we will need to stick to using PreviewProvider previews.

On the SwiftUI previews side, we will be using two sets of macros:

  1. A single SnapshotPreviews attached member macro to mark the whole PreviewProvider struct to opt-in for snapshot testing.
  2. SnapshotTest attached peer macro to mark preview properties to opt-in for snapshot testing. Alternatively, if your project should include all properties in snapshot tests, a counterpart macro SnapshotIgnored can be used to opt-out the property from snapshot testing instead and include all others automatically.

The role of above macros is to generate and insert the list(s) of view properties that we want to snapshot test into the PreviewProvider type. Those properties can be represented by a custom SnapshotPreview type that contains the view itself, plus any metadata we will need to access in tests. This will be later consumed by the test macro that will depend on the existence of this list.

The implementation of these macros will be based on project needs. For example, in our current project, we implemented the macro to generate two separate sets of properties:

  • snapshots (for automated testing purposes, only for screens, opt-in based)
  • screenshots (for documentation-only purposes of both screens and components, opt-out based)

By switching between relevant snapshot and screenshot test plans, only relevant preview list properties are used (both locally and on the CI). This makes it possible to regenerate screenshots for the whole app after a release for example, while only keeping a limited set of snapshot tests for testing purposes, while using the same set of macros and test code.

The result of expanded SnapshotPreviews macro with two distinct property lists

Macros on the test target side

At the test side, we will use SnapshotTests attached member macro to generate test methods for provided list of PreviewProvider types, assuming they contain the generated lists of preview properties by preview macros described above. Because of current limitations of freestanding macros to generate global scope variables, we currently still need to manually specify these two parts:

  1. (Optional for modular project) Which feature package to import and test (represented by a test class)
  2. All PreviewProvider types that we want to include in the test case

Test code that we need to manually create and maintain in the repository would look like this:

@testable import FooFeatureViews

@SnapshotTests(
    FooScreenPreviews.self,
    BarComponentPreviews.self
)
class FooFeatureTests: SnapshotTestCase {}

The SnapshotTests macro on test side expands the above code into generated snapshot test code for the FooFeature package and all its previews (with a test method generated for each preview), along with required snapshot variants (device sizing, size category, locale etc.) or potentially different sets of previews (snapshots / screenshots):

@testable import FooFeatureViews

@SnapshotTests(
    FooScreenPreviews.self,
    BarComponentPreviews.self
)
class FooFeatureTests: SnapshotTestCase {}

  // Following code is generated by @SnapshotTests macro:

func testFooScreen() {
    if isScreenshotsRecording {
        for snapshot in FooScreenPreviews.screenshots {
            assert(snapshot.view, name: "\(snapshot.name)-scheme-light")
            assert(snapshot.view.environment(\.colorScheme, .dark), name: "\(snapshot.name)-scheme-dark")
            assert(snapshot.view.environment(\.sizeCategory, .accessibilityExtraLarge), name: "\(snapshot.name)-size-xl")
        }
    } else {
        for snapshot in FooScreenPreviews.snapshots {
            assert(snapshot.view, name: "\(snapshot.name)")
        }
    }
}

func testBarComponent() {
    if isScreenshotsRecording {
        for snapshot in BarComponentPreviews.screenshots {
            assert(snapshot.view, name: "\(snapshot.name)-scheme-light")
            assert(snapshot.view.environment(\.colorScheme, .dark), name: "\(snapshot.name)-scheme-dark")
            assert(snapshot.view.environment(\.sizeCategory, .accessibilityExtraLarge), name: "\(snapshot.name)-size-xl")
        }
    } else {
        for snapshot in BarComponentPreviews.snapshots {
            assert(snapshot.view, name: "\(snapshot.name)")
        }
    }
}

These generated tests correctly show up in the test plan and are automatically regenerated whenever a developer adds or removes the SnapshotTest flag from a SwiftUI property. The only thing we need to keep maintaining manually is the list of all PreviewProvider preview types we want to snapshot test.

Implementation example

The implementation of above macros might vary a lot based on project needs, but for the inspiration and to have some starting point, below are our macro implementations.

SwiftUI preview macros

// DECLARATION

/// Generates screenshot and snapshot lists inside this `PreviewProvider`.
///
/// All `View` properties are automatically added to the list of screenshots, unless opted out using
/// ``ScreenshotIgnored()``.
///
/// Use ``SnapshotTest()`` to additionally mark properties to be used as a snapshot test.
/// Both screenshot and snapshot lists are expected to be used by ``SnapshotTests(_:)`` macro.
@attached(member, names: named(screenshots), named(snapshots))
public macro SnapshotPreviews() = #externalMacro(module: "MacrosImplementation", type: "SnapshotPreviewsMacro")

/// Marks this preview property to be included in the list of snapshot tests for the
/// current `PreviewProvider` with attached ``SnapshotPreviews()`` attribute.
@attached(peer, names: overloaded)
public macro SnapshotTest() = #externalMacro(module: "MacrosImplementation", type: "SnapshotTestMacro")
// IMPLEMENTATION

import SwiftSyntax
import SwiftSyntaxMacros
import SwiftSyntaxMacroExpansion
import SwiftUI

/// Extracted properties of a specific preview used for snapshots.
public struct SnapshotPreview {
    public let view: AnyView
    /// Whether the preview is a device (fullscreen) size 
    /// (suitable for screen previews) or only a device-width size (suitable for screen content or components)
    public let isDeviceSize: Bool
    public let name: String

    public init(_ view: AnyView, isDeviceSize: Bool, name: String) {
        self.view = view
        self.isDeviceSize = isDeviceSize
        self.name = name
    }
}

public struct SnapshotTestMacro: PeerMacro {

    public static func expansion(
        of _: SwiftSyntax.AttributeSyntax,
        providingPeersOf declaration: some DeclSyntaxProtocol,
        in _: some SwiftSyntaxMacros.MacroExpansionContext
    ) throws -> [SwiftSyntax.DeclSyntax] {
        guard let variableDeclaration = declaration.as(VariableDeclSyntax.self),
              variableDeclaration.bindingSpecifier.tokenKind == .keyword(.var),
              variableDeclaration.modifiers.map(\.name.tokenKind).contains(.keyword(.static))
        else {
            throw MacroExpansionErrorMessage("`SnapshotTest` can be only applied to static `View` property")
        }

        return []
    }
}

public struct ScreenshotIgnoredMacro: PeerMacro {

    public static func expansion(
        of _: SwiftSyntax.AttributeSyntax,
        providingPeersOf declaration: some DeclSyntaxProtocol,
        in _: some SwiftSyntaxMacros.MacroExpansionContext
    ) throws -> [SwiftSyntax.DeclSyntax] {
        guard let variableDeclaration = declaration.as(VariableDeclSyntax.self),
              variableDeclaration.bindingSpecifier.tokenKind == .keyword(.var),
              variableDeclaration.modifiers.map(\.name.tokenKind).contains(.keyword(.static))
        else {
            throw MacroExpansionErrorMessage("`ScreenshotIgnored` can be only applied to static `View` property")
        }

        return []
    }
}

public struct SnapshotPreviewsMacro: MemberMacro {

    private typealias SnapshotDeclarations = (snapshots: [String], screenshots: [String])

    // Prevents misuse of number of snapshot tests per screen
    private static let snapshotTestLimit = 8

    public static func expansion(
        of _: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in _: some MacroExpansionContext
    ) throws -> [SwiftSyntax.DeclSyntax] {
        guard let structDeclaration = declaration.as(StructDeclSyntax.self),
              let inheritedTypes = structDeclaration.inheritanceClause?.inheritedTypes,
              inheritedTypes.map(\.type.trimmed.description).contains(where: { $0 == "PreviewProvider" })
        else {
            throw MacroExpansionErrorMessage("`SnapshotPreviews` can be only applied to a `PreviewProvider` struct")
        }

        let members = structDeclaration.memberBlock.members.compactMap { member in
            member.as(MemberBlockItemSyntax.self)?.decl.as(VariableDeclSyntax.self)
        }

        let previewName = structDeclaration.name.text
        let isScreenPreview = previewName.hasSuffix("ScreenPreviews")
        let (snapshots, screenshots) = try snapshotDeclarations(members: members, isScreenPreview: isScreenPreview)

        if isScreenPreview {
            switch snapshots.count {
                case 0:
                    throw MacroExpansionErrorMessage("Screen preview must specify a `@SnapshotTest` property")
                case snapshotTestLimit...:
                    throw MacroExpansionErrorMessage(
                        "Preview can specify at most \(snapshotTestLimit) `@SnapshotTest` properties"
                    )
                default:
                    break
            }
        }

        return [
            """
            static var snapshots: [SnapshotPreview] {
                [
                    \(raw: snapshots.joined(separator: ",\n"))
                ]
            }
            """,
            """
            static var screenshots: [SnapshotPreview] {
                [
                    \(raw: screenshots.joined(separator: ",\n"))
                ]
            }
            """,
        ]
    }

    private static func snapshotDeclarations(
        members: [VariableDeclSyntax],
        isScreenPreview: Bool
    ) throws -> SnapshotDeclarations {

        var snapshots = [String]()
        var screenshots = [String]()
        var isolatedScreenshot = ""
        var isGlobalIntrinsicSize = false

        for member in members {

            guard let binding = member.bindings.first,
                  let memberName = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text,
                  binding.typeAnnotation?.type.as(SomeOrAnyTypeSyntax.self)?.trimmedDescription == "some View"
            else {
                continue
            }

            guard let accessorBlock = member.bindings.first?.accessorBlock,
                  let codeBlocks = accessorBlock.accessors.as(CodeBlockItemListSyntax.self)
            else {
                throw MacroExpansionErrorMessage("\(member) is empty")
            }

            if memberName == "previews" {
                isGlobalIntrinsicSize = containsIntrinsicSizePreviewLayout(codeBlocks)
            }

            let isDeviceSize = (isGlobalIntrinsicSize || containsIntrinsicSizePreviewLayout(codeBlocks)) == false
            let declaration = """
            SnapshotPreview(AnyView(\(memberName)), isDeviceSize: \(isDeviceSize), name: "\(memberName)")
            """

            let attributeNames = member.attributes.as(AttributeListSyntax.self)?.compactMap { attribute in
                attribute.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text
            } ?? []

            if attributeNames.contains("SnapshotTest") {
                if isScreenPreview == false {
                    throw MacroExpansionErrorMessage("Only screen previews can contain `@SnapshotTest` properties")
                }

                snapshots.append(declaration)
            }

            if attributeNames.contains("ScreenshotIgnored") == false {
                if memberName == "previews" {
                    isolatedScreenshot = declaration
                } else {
                    screenshots.append(declaration)
                }
            }
        }

        if screenshots.isEmpty, isolatedScreenshot.isEmpty == false {
            screenshots.append(isolatedScreenshot)
        }

        return SnapshotDeclarations(snapshots: snapshots, screenshots: screenshots)
    }

    private static func containsIntrinsicSizePreviewLayout(_ codeBlocks: CodeBlockItemListSyntax) -> Bool {
        codeBlocks
            .compactMap { $0.item.as(FunctionCallExprSyntax.self) }
            .contains(where: previewLayoutIsIntrinsicSize)
    }

    // Recursively detect whether the view property uses `previewLayout` with intrinsic layout.
    private static func previewLayoutIsIntrinsicSize(syntax: FunctionCallExprSyntax) -> Bool {
        guard let calledExpression = syntax.calledExpression.as(MemberAccessExprSyntax.self) else {
            return false
        }

        if calledExpression.declName.trimmedDescription == "previewLayout" {
            let value = syntax.arguments.first?.expression.description ?? ""
            // The `screenContent` is our custom preview layout that bypasses the ScrollView scrollability
            return [".screenContent", ".sizeThatFits"].contains(value)
        } else {
            if let base = calledExpression.base?.as(FunctionCallExprSyntax.self) {
                return previewLayoutIsIntrinsicSize(syntax: base)
            }
        }

        return false
    }
}

Test target SnapshotTests macro

// DECLARATION

/// Adds tests for provided `PreviewProvider` types based on their snapshot and screenshot properties
/// that are generated by ``SnapshotPreviews()`` macro.
///
/// Typically, all Previews from a feature package should be listed
/// as parameters in a single test class that uses this attribute.
@attached(member, names: arbitrary)
public macro SnapshotTests(_ previews: any PreviewProvider.Type ...) = #externalMacro(module: "MacrosImplementation", type: "SnapshotTestsMacro")
// IMPLEMENTATION

import SwiftSyntaxMacros
import SwiftSyntax
import SwiftSyntaxMacroExpansion

public struct SnapshotTestsMacro: MemberMacro {

    public static func expansion(
        of node: SwiftSyntax.AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in _: some SwiftSyntaxMacros.MacroExpansionContext
    ) throws -> [SwiftSyntax.DeclSyntax] {
        guard let classDecl = declaration.as(ClassDeclSyntax.self),
              classDecl.inheritanceClause?.inheritedTypes.trimmedDescription == "SnapshotTestCase"
        else {
            throw MacroExpansionErrorMessage("`SnapshotTests` can be only applied to subclass of `SnapshotTestCase`")
        }

        guard let arguments = node.arguments?.as(LabeledExprListSyntax.self) else {
            throw MacroExpansionErrorMessage("`SnapshotTests` has no preview arguments")
        }

        let previewExressions = arguments.map(\.expression).compactMap { expression in
            expression.as(MemberAccessExprSyntax.self)
        }

        let previews = previewExressions.compactMap(\.base?.trimmedDescription)

        return previews.map { preview in
            let previewName = preview.replacingOccurrences(of: "Previews", with: "")
            let assertPrefix = "assert(snapshot.view"
            let assertBase =
                ", size: snapshot.isDeviceSize ? .device : .deviceWidth, name: \"\\(snapshot.name)"
            let assertSuffix = "\")"

            return
                """
                func test\(raw: previewName)() {
                    if isScreenshotsRecording {
                        for snapshot in \(raw: preview).screenshots {
                            \(raw: assertPrefix)\(raw: assertBase)-scheme-light\(raw: assertSuffix)
                            \(raw: assertPrefix).environment(\\.colorScheme, .dark)\(raw: assertBase)-scheme-dark\(
                                raw: assertSuffix
                            )
                            \(raw: assertPrefix).environment(\\.sizeCategory, .accessibilityExtraLarge)\(
                                raw: assertBase
                            )-size-xl\(raw: assertSuffix)
                        }
                    } else {
                        for snapshot in \(raw: preview).snapshots {
                            \(raw: assertPrefix)\(raw: assertBase)\(raw: assertSuffix)
                        }
                    }
                }
                """
        }
    }
}

Search
Share
Featured articles
Generating SwiftUI snapshot tests with Swift macros
Don’t Fix Bad Data, Do This Instead