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, 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, 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( matching value: @autoclosure () throws -> Value, as snapshotting: Snapshotting, 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)) } }