Skip to content

Instantly share code, notes, and snippets.

@mgrider
Created December 4, 2023 16:59
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mgrider/796ec39e28f417fbc4784d56ab9da081 to your computer and use it in GitHub Desktop.
Save mgrider/796ec39e28f417fbc4784d56ab9da081 to your computer and use it in GitHub Desktop.
parent XCTestCase class for storing snapshot tests in a separate repository
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))
}
}
@mgrider
Copy link
Author

mgrider commented Dec 4, 2023

Feedback would be welcome on this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment