Skip to content

Instantly share code, notes, and snippets.

@mgrider
Created December 4, 2023 16:59
Show Gist options
  • Save mgrider/796ec39e28f417fbc4784d56ab9da081 to your computer and use it in GitHub Desktop.
Save mgrider/796ec39e28f417fbc4784d56ab9da081 to your computer and use it in GitHub Desktop.

Revisions

  1. mgrider created this gist Dec 4, 2023.
    277 changes: 277 additions & 0 deletions CustomTests.swift
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,277 @@
    import XCTest
    import SnapshotTesting

    /// A parent class for test cases in this project. This doesn't contain any actual tests itself, but rather
    /// helper functions you can use to ease testing of common cases.
    ///
    /// See `assertCustomSnapshots` for a usage example, and the default sizes that will be testsed.
    class CustomTests: XCTestCase {

    /// Whether or not to test snapshots. Change this to run unit tests without performing snapshot testing.
    ///
    /// Note that this will be toggled automatically to false if the `custom-ios-snapshots` repository
    /// isn't found (in the same directory as this project) when running the first snapshot test.
    static var testingSnapshotsEnabled: Bool = true

    /// Precision for snapshot tests
    static let snapshotPrecision: Float = 1 // 0.97

    /// Filesystem URL for saved snapshot test results
    static var snapshotURL: URL?

    /// Runs before EVERY test function. `super.setUp()` is called. See that doc for further details.
    ///
    override func setUp() {
    super.setUp()
    // change this to your preferred diff tool
    SnapshotTesting.diffTool = "ksdiff"
    }

    /// Assert multiple snapshots with this function. This tests all the device types we care about.
    ///
    /// Call this as simply as: `assertCustomSnapshots(matching: UIViewController())`
    ///
    /// - Parameters:
    /// - value: The value (view) to assert.
    /// - file: The file in which failure occurred. Defaults to the file name of the test case in which this function was called.
    /// - testName: The name of the test in which failure occurred. Defaults to the function name of the test case in which this function was called.
    /// - line: The line number on which failure occurred. Defaults to the line number on which this function was called.
    /// - snapshotTypes: A list of device size/configurations to use to generate the snapshots.
    /// - firstDo: A callback/function/lambda that'll get called just before each snapshot. Takes the view controller as an argument.
    public func assertCustomSnapshots(
    matching value: UIViewController,
    file: StaticString = #file,
    testName: String = #function,
    line: UInt = #line,
    snapshotTypes: [(Snapshotting<UIViewController, UIImage>, String)] = [
    (.image(on: .iPhone13, perceptualPrecision: CustomTests.snapshotPrecision), "iPhone13"),
    (.image(on: .iPhone13Mini, perceptualPrecision: CustomTests.snapshotPrecision), "iPhone13Mini"),
    (.image(on: .iPhone13ProMax, perceptualPrecision: CustomTests.snapshotPrecision), "iPhone13ProMax"),
    (.image(on: .iPhoneSe3rdGen, perceptualPrecision: CustomTests.snapshotPrecision), "iPhoneSE3rdGen"),
    (.image(on: .iPadPro11(.portrait), perceptualPrecision: CustomTests.snapshotPrecision), "iPadPro11Portrait"),
    ],
    firstDo: ((UIViewController) -> Void)? = nil
    ) {
    guard CustomTests.testingSnapshotsEnabled else { return }

    setKeyWindowRoot(viewController: value)

    for t in snapshotTypes {
    if let thingToDo = firstDo {
    thingToDo(value)
    }
    assertCustomSnapshot(
    matching: value,
    as: t.0,
    named: t.1,
    file: file,
    testName: testName,
    line: line
    )
    }
    }

    /// Assert a snapshots of a single view with this function.
    ///
    /// - Parameters:
    /// - value: The value (`UIView`) to assert.
    /// - file: The file in which failure occurred. Defaults to the file name of the test case in which this function was called.
    /// - testName: The name of the test in which failure occurred. Defaults to the function name of the test case in which this function was called.
    /// - line: The line number on which failure occurred. Defaults to the line number on which this function was called.
    /// - firstDo: A callback/function/lambda that'll get called just before each snapshot. Takes the view controller as an argument.
    public func assertCustomSnapshot(
    matching value: UIView,
    size: CGSize,
    file: StaticString = #file,
    testName: String = #function,
    line: UInt = #line
    ) {
    guard CustomTests.testingSnapshotsEnabled else { return }

    let snapshotTypes: [(Snapshotting<UIView, UIImage>, String)] = [
    (.image(size: size), "iPhoneView"),
    ]
    for t in snapshotTypes {
    assertCustomSnapshot(
    matching: value,
    as: t.0,
    named: t.1,
    file: file,
    testName: testName,
    line: line
    )
    }
    }

    /// Custom snapshot test function.
    ///
    /// This lets us do the following:
    /// 1. Change the path for saved snapshots.
    /// 2. Turn snspshot testing "off" on a per-instance way. (See `testSnapshotEnabled`.)
    /// 3. Not import the `SnapshotTesting` module in all of our subclasses.
    ///
    /// Note: many parameter details are swiped wholesale from the SnapshotTesting's `verifySnapshot` function comments.
    /// - Parameters:
    /// - value: A value to compare against a reference.
    /// - snapshotting: A strategy for serializing, deserializing, and comparing values.
    /// - name: An description of the snapshot that will be included in the filename.
    /// - recording: Whether or not to record a new reference.
    /// - timeout: The amount of time a snapshot must be generated in.
    /// - file: The file in which failure occurred. Defaults to the file name of the test case in which this function was called.
    /// - testName: The name of the test in which failure occurred. Defaults to the function name of the test case in which this function was called.
    /// - line: The line number on which failure occurred. Defaults to the line number on which this function was called.
    public func assertCustomSnapshot<Value, Format>(
    matching value: @autoclosure () throws -> Value,
    as snapshotting: Snapshotting<Value, Format>,
    named name: String,
    record recording: Bool = false,
    timeout: TimeInterval = 5,
    file: StaticString = #file,
    testName: String = #function,
    line: UInt = #line
    ) {
    guard CustomTests.testingSnapshotsEnabled else { return }
    if CustomTests.snapshotURL == nil {
    setupSnapshotURL()
    }
    guard let snapshotPathUrl = CustomTests.snapshotURL else { return }

    let fileName = URL(fileURLWithPath: "\(file)", isDirectory: false).deletingPathExtension().lastPathComponent
    let snapshotDirectory = snapshotPathUrl.appendingPathComponent(fileName)
    let snapshotDirectoryPath = snapshotDirectory.path
    let failure = verifySnapshot(
    matching: try value(),
    as: snapshotting,
    named: name,
    record: recording,
    snapshotDirectory: snapshotDirectoryPath,
    timeout: timeout,
    file: file,
    testName: testName
    )
    guard let message = failure else { return }
    XCTFail(message, file: file, line: line)
    }

    /// Make a `UIViewController` the root view controller in the test window. Allows testing
    /// changes to the navigation stack when they would ordinarily be invisible to the testing
    /// environment.
    ///
    /// - Parameters:
    /// - viewController: The `UIViewController` to make root view controller.
    /// - window: An optional parameter for setting the window of the view controller. This is most commonly
    /// used to override the window frame.
    ///
    func setKeyWindowRoot(viewController: UIViewController) {
    guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
    guard let window = windowScene.windows.first(where: { $0.isKeyWindow }) else { return }
    window.rootViewController = viewController
    window.makeKeyAndVisible()
    }

    /// This should only happen once per test run.
    ///
    /// Note that you may need to customize this function if this class is stored
    /// a different number of directories above your project directory.
    func setupSnapshotURL() {
    let testPathUrl = URL(fileURLWithPath: "\(#file)", isDirectory: false)
    let snapshotRepoDirectory = testPathUrl
    .deletingLastPathComponent()
    .deletingLastPathComponent()
    .deletingLastPathComponent()
    .appendingPathComponent("custom-ios-snapshots")

    var isDir = ObjCBool(true)
    let exists = FileManager.default.fileExists(atPath: snapshotRepoDirectory.path, isDirectory: &isDir)
    guard exists == true else {
    print("\n\n***\n\nWARNING: custom-ios-snapshots/ directory not found. \nSnapshots will not be tested.\n\n***\n\n")
    CustomTests.testingSnapshotsEnabled = false
    return
    }

    let snapshotDirectory = snapshotRepoDirectory
    .appendingPathComponent("Snapshots")
    CustomTests.snapshotURL = snapshotDirectory
    }
    }

    extension ViewImageConfig {

    public static let iPhone13 = ViewImageConfig.iPhone13(.portrait)

    public static func iPhone13(_ orientation: Orientation) -> ViewImageConfig {
    let safeArea: UIEdgeInsets
    let size: CGSize
    switch orientation {
    case .landscape:
    // warning: unverified (unused) values
    safeArea = .init(top: 0, left: 44, bottom: 24, right: 44)
    size = .init(width: 844, height: 390)
    case .portrait:
    safeArea = .init(top: 47, left: 0, bottom: 34, right: 0)
    size = .init(width: 390, height: 844)
    }
    return .init(safeArea: safeArea, size: size, traits: .iPhoneX(orientation))
    }

    public static let iPhone13Mini = ViewImageConfig.iPhone13Mini(.portrait)
    public static let iPhone13MiniSmall = ViewImageConfig.iPhone13Mini(.portrait, sizeCategory: .extraSmall)
    public static let iPhone13MiniLarge = ViewImageConfig.iPhone13Mini(.portrait, sizeCategory: .extraLarge)

    public static func iPhone13Mini(
    _ orientation: Orientation,
    sizeCategory: UIContentSizeCategory = .unspecified
    ) -> ViewImageConfig {
    let safeArea: UIEdgeInsets
    let size: CGSize
    switch orientation {
    case .landscape:
    // warning: unverified (unused) values
    safeArea = .init(top: 0, left: 44, bottom: 24, right: 44)
    size = .init(width: 812, height: 375)
    case .portrait:
    safeArea = .init(top: 50, left: 0, bottom: 34, right: 0)
    size = .init(width: 375, height: 812)
    }
    let baseTraits: UITraitCollection = .iPhoneX(orientation)
    let traits: UITraitCollection = .init(traitsFrom: [baseTraits, .init(preferredContentSizeCategory: sizeCategory)])
    return .init(safeArea: safeArea, size: size, traits: traits)
    }

    /// This is the same as the `iPhone13`
    public static let iPhone13Pro = ViewImageConfig.iPhone13(.portrait)

    public static let iPhone13ProMax = ViewImageConfig.iPhone13ProMax(.portrait)

    public static func iPhone13ProMax(_ orientation: Orientation) -> ViewImageConfig {
    let safeArea: UIEdgeInsets
    let size: CGSize
    switch orientation {
    case .landscape:
    // warning: unverified (unused) values
    safeArea = .init(top: 0, left: 44, bottom: 24, right: 44)
    size = .init(width: 926, height: 428)
    case .portrait:
    safeArea = .init(top: 47, left: 0, bottom: 34, right: 0)
    size = .init(width: 428, height: 926)
    }
    return .init(safeArea: safeArea, size: size, traits: .iPhoneX(orientation))
    }

    public static let iPhoneSe3rdGen = ViewImageConfig.iPhoneSe3rdGen(.portrait)

    public static func iPhoneSe3rdGen(_ orientation: Orientation = .portrait) -> ViewImageConfig {
    let safeArea: UIEdgeInsets
    let size: CGSize
    switch orientation {
    case .landscape:
    safeArea = .zero
    size = .init(width: 667, height: 375)
    case .portrait:
    safeArea = .init(top: 20, left: 0, bottom: 0, right: 0)
    size = .init(width: 375, height: 667)
    }
    return .init(safeArea: safeArea, size: size, traits: .iPhoneSe(orientation))
    }

    }