Skip to content

Snapshots always failing on Xcode Cloud #981

@crleonard

Description

@crleonard

Describe the bug
We observed two related issues with filename generation when using verifySnapshot (via a helper function) within @mainactor async test functions:

Unexpected .1 Suffix: When not providing an explicit named parameter to verifySnapshot, snapshot filenames were sometimes generated with a .1 suffix (e.g., testFunctionName.1.png) even though only a single snapshot assertion was made within the test function. This occurred in @mainactor async tests.

Duplicated Filename with named Parameter: After adding an explicit named parameter to work around the first issue (e.g., named: "testFunctionName" where the name matched the function name), Xcode Cloud logs reported failures looking for a snapshot file where the name appeared duplicated (e.g., testFunctionName.testFunctionName.png). Locally, this duplication was not observed, but the initial .1 issue was resolved by adding named.

To Reproduce
Setup:

Create an @mainactor async test function.
Use a helper function like assertSnapshotUIView below, which calculates the snapshot directory based on the test class and calls verifySnapshot.
Code Structure:

Swift


import Foundation
import SnapshotTesting
import UIKit
import XCTest

// MARK: - Test Class Structure

class MySnapshotTests: XCTestCase {

    @MainActor
    func testMyFeatureSnapshot() async {
        let myView = UIView() // Replace with actual view creation
        // ... setup view ...
r
        assertSnapshotUIView(
            matching: myView,
            named: "testMyFeatureSnapshot", // Explicit name matching function
            record: false
        )
    }
}

// MARK: - Helper Functions

public func assertSnapshotUIView(
    matching view: UIView,
    named name: String? = nil, 
    width: CGFloat = 375,
    height: CGFloat? = nil,
    timeout: TimeInterval = 0.5,
    file: StaticString = #file,
    testName: String = #function, // #function passed here
    line: UInt = #line,
    record recording: Bool = false
) {
    precondition(UIScreen.main.scale == 3, "Snapshot tests require 3x screen scale")

    let snapshotDirectory = snapshotDirectory(for: file)

    let viewHeight = view
        .systemLayoutSizeFitting(
            CGSize(width: width, height: CGFloat.greatestFiniteMagnitude),
            withHorizontalFittingPriority: .required,
            verticalFittingPriority: .fittingSizeLevel
        ).height

    // Call the library's core function
    if let error = verifySnapshot(
        of: { view }(),
        as: .image(
            precision: 0.98,
            perceptualPrecision: 0.98,
            size: .init(width: width, height: height ?? viewHeight)
        ),
        named: name, 
        record: recording,
        snapshotDirectory: snapshotDirectory,
        timeout: timeout,
        file: file,
        testName: testName,
        line: line
    ) {
        XCTFail(error, file: file, line: line)
    }
}

// Custom function to determine snapshot directory based on test class file
func snapshotDirectory(
    for file: StaticString,
    relativePathComponent: String = "TESTTARGET" 
) -> String {
    var sourcePathComponents = URL(fileURLWithPath: "\(file)").pathComponents

    if let indexFolder = sourcePathComponents.firstIndex(of: relativePathComponent) {
        sourcePathComponents = Array(sourcePathComponents.prefix(upTo: indexFolder.advanced(by: 1)))
    } else {
         print("Warning: Could not find relative path component '\(relativePathComponent)' in \(file)")
         sourcePathComponents = URL(fileURLWithPath: "\(file)").deletingLastPathComponent().pathComponents
    }


    let fileUrl = URL(fileURLWithPath: "\(file)", isDirectory: false)
    let folderName = fileUrl.deletingPathExtension().lastPathComponent // e.g., MySnapshotTests

    sourcePathComponents.append("__Snapshots__")
    sourcePathComponents.append(folderName) // Subdirectory per test class

    return sourcePathComponents.joined(separator: "/")
}

Steps:

Run the test without the named parameter in assertSnapshotUIView. Observe if a .1 suffix appears on the generated filename, especially if the test function is async.

Add the named parameter with a value matching the test function name (e.g., named: "testMyFeatureSnapshot" for func testMyFeatureSnapshot()).

Record the snapshot locally (record: true). Commit the snapshot (e.g., testMyFeatureSnapshot.png).

Run the test on a CI environment like Xcode Cloud (record: false). Check the failure logs. Observe if the error message indicates it was looking for a duplicated filename like testMyFeatureSnapshot.testMyFeatureSnapshot.png.

Expected behavior
When only one verifySnapshot call occurs within a test function (even async), no .1 suffix should be appended to the filename when named is nil. The name should be derived cleanly from #function.

When named: "some_name" is provided, the resulting snapshot filename should be some_name.png (plus any expected, non-duplicated environment identifiers), located within the specified snapshotDirectory. It should not be some_name.some_name.png.

Screenshots
Example Log Message Snippet from Xcode Cloud:
failed - No reference was found on disk. Automatically recorded snapshot: … open "file:///.../Snapshots/MySnapshotTests/testMyFeatureSnapshot.testMyFeatureSnapshot.png"

Environment
swift-snapshot-testing version: [1.18.3]
Xcode version: [16.3]
Swift version: [5.0]
OS: [Xcode Cloud Environment (CI)]
Test Execution Context: Tests are @mainactor async func

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions