Created
September 30, 2019 05:48
-
-
Save pietrobasso/e9f793bd265a7bcb30e4724234a6a7b4 to your computer and use it in GitHub Desktop.
SnapshotTesting DSL
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
import XCTest | |
import SnapshotTesting | |
public protocol SnapshottableView { | |
var snapshotView: UIView { get } | |
} | |
extension UIView: SnapshottableView { | |
public var snapshotView: UIView { | |
return self | |
} | |
} | |
extension UIViewController: SnapshottableView { | |
public var snapshotView: UIView { | |
return view | |
} | |
} | |
public enum SnapshotSize { | |
case intrinsicContentSize | |
case constrainedWidth | |
case screen | |
} | |
struct Snapshot { | |
let name: String | |
let view: UIView | |
let size: CGSize | |
} | |
private let operatingSystemVersion = ProcessInfo().operatingSystemVersion | |
extension XCTestCase { | |
static func enforceSnapshotDevice() { | |
let is2XDevice = UIScreen.main.scale == 2 | |
let expectedVersion = OperatingSystemVersion(majorVersion: 12, minorVersion: 4, patchVersion: 0) | |
let isCorrectVersion = expectedVersion.majorVersion == operatingSystemVersion.majorVersion && expectedVersion.minorVersion == operatingSystemVersion.minorVersion | |
guard is2XDevice && isCorrectVersion else { | |
fatalError("Running device should have @2x screen scale and iOS version \(expectedVersion)") | |
} | |
} | |
} | |
internal enum SnapshotFactory { | |
static func intrinsicContentSizeSnapshot(for view: UIView) -> Snapshot { | |
view.translatesAutoresizingMaskIntoConstraints = false | |
view.setNeedsLayout() | |
view.layoutIfNeeded() | |
return Snapshot(name: String(describing: type(of: view).self), | |
view: view, | |
size: view.bounds.size) | |
} | |
static func constrainedWidthSnapshot(for view: UIView) -> Snapshot { | |
view.addConstraint(view.widthAnchor.constraint(equalToConstant: CGSize.iPhoneSe.width)) | |
return intrinsicContentSizeSnapshot(for: view) | |
} | |
static func screenSnapshots(for view: UIView, on devices: [Device]) -> [Snapshot] { | |
return devices.map { | |
return Snapshot(name: $0.name, | |
view: view, | |
size: $0.config.size!) | |
} | |
} | |
} | |
public extension CGSize { | |
static var iPhoneSe: CGSize { | |
return ViewImageConfig.iPhoneSe.size! | |
} | |
} | |
struct Device { | |
let name: String | |
let config: ViewImageConfig | |
private static var iPhoneX: Device { | |
return Device(name: "iPhoneX", config: .iPhoneX) | |
} | |
private static var iPhone8: Device { | |
return Device(name: "iPhone8", config: .iPhone8) | |
} | |
private static var iPhoneSe: Device { | |
return Device(name: "iPhoneSe", config: .iPhoneSe) | |
} | |
static let defaultDevice = Device.iPhoneX | |
static let broadSetOfDevices: [Device] = [.iPhoneSe, .iPhone8, .iPhoneX] | |
} | |
extension XCTestCase { | |
/// Asserts that a given value matches a reference on disk. It will generate a new reference in case it does not exist. | |
/// - Parameters: | |
/// - value: Either a `UIView` or a `UIViewController`. | |
/// - size: Size that `value` should have in order to be snapshotted. | |
/// * `.instrinsicContentSize`: Uses the natural size of `value`, considering only properties of the view itself. | |
/// * `.constrainedWidth`: When `value`'s height is intrinsic given a specific width, this case will layout the width based on iPhoneSE. | |
/// * `.screen`: Uses the size of iPhoneSE, iPhone8 and iPhoneX for brightMode and only iPhoneX for darkMode. | |
/// - record: Wheter should record or not a new snapshot reference. | |
/// - testName: Name of the test in which the function was called. | |
/// - line: Line number in which the snapshot was called. | |
public func snapshot(matching value: SnapshottableView, | |
size: SnapshotSize, | |
record recording: Bool = false, | |
file: StaticString = #file, | |
testName: String = #function, | |
line: UInt = #line) { | |
let snapshots: [Snapshot] | |
let viewToSnapshot = value.snapshotView | |
switch size { | |
case .intrinsicContentSize: | |
snapshots = [SnapshotFactory.intrinsicContentSizeSnapshot(for: viewToSnapshot)] | |
case .constrainedWidth: | |
snapshots = [SnapshotFactory.constrainedWidthSnapshot(for: viewToSnapshot)] | |
case .screen: | |
snapshots = SnapshotFactory.screenSnapshots(for: viewToSnapshot, on: Device.broadSetOfDevices) | |
} | |
assertSnapshots(snapshots, record: recording, file: file, testName: testName, line: line) | |
} | |
public func snapshotLayout(matching value: SnapshottableView, | |
size: SnapshotSize, | |
record recording: Bool = false, | |
file: StaticString = #file, | |
testName: String = #function, | |
line: UInt = #line) { | |
let snapshots: [Snapshot] | |
let viewToSnapshot = value.snapshotView | |
switch size { | |
case .intrinsicContentSize: | |
snapshots = [SnapshotFactory.intrinsicContentSizeSnapshot(for: viewToSnapshot)] | |
case .constrainedWidth: | |
snapshots = [SnapshotFactory.constrainedWidthSnapshot(for: viewToSnapshot)] | |
case .screen: | |
snapshots = SnapshotFactory.screenSnapshots(for: viewToSnapshot, on: Device.broadSetOfDevices) | |
} | |
assertSnapshots(snapshots, record: recording, file: file, testName: testName, line: line) | |
} | |
private func assertSnapshots(_ snapshots: [Snapshot], | |
record recording: Bool = false, | |
file: StaticString = #file, | |
testName: String = #function, | |
line: UInt = #line) { | |
XCTestCase.enforceSnapshotDevice() | |
let record = XCTestCase.recordAll ? true : recording | |
snapshots.forEach { | |
assertSnapshot(matching: $0.view, | |
as: .image(drawHierarchyInKeyWindow: true, size: $0.size), | |
named: $0.name, | |
record: record, | |
file: file, | |
testName: testName, | |
line: line) | |
} | |
} | |
} | |
extension XCTestCase { | |
static let recordAll = false | |
} | |
public struct ViewImageConfig { | |
public enum Orientation { | |
case landscape | |
case portrait | |
} | |
public var safeArea: UIEdgeInsets | |
public var size: CGSize? | |
public var traits: UITraitCollection | |
public init( | |
safeArea: UIEdgeInsets = .zero, | |
size: CGSize? = nil, | |
traits: UITraitCollection = .init() | |
) { | |
self.safeArea = safeArea | |
self.size = size | |
self.traits = traits | |
} | |
public static let iPhoneSe = ViewImageConfig.iPhoneSe(.portrait) | |
public static func iPhoneSe(_ orientation: Orientation) -> ViewImageConfig { | |
let safeArea: UIEdgeInsets | |
let size: CGSize | |
switch orientation { | |
case .landscape: | |
safeArea = .zero | |
size = .init(width: 568, height: 320) | |
case .portrait: | |
safeArea = .init(top: 20, left: 0, bottom: 0, right: 0) | |
size = .init(width: 320, height: 568) | |
} | |
return .init(safeArea: safeArea, size: size, traits: .iPhoneSe(orientation)) | |
} | |
public static let iPhone8 = ViewImageConfig.iPhone8(.portrait) | |
public static func iPhone8(_ orientation: Orientation) -> 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: .iPhone8(orientation)) | |
} | |
public static let iPhone8Plus = ViewImageConfig.iPhone8Plus(.portrait) | |
public static func iPhone8Plus(_ orientation: Orientation) -> ViewImageConfig { | |
let safeArea: UIEdgeInsets | |
let size: CGSize | |
switch orientation { | |
case .landscape: | |
safeArea = .zero | |
size = .init(width: 736, height: 414) | |
case .portrait: | |
safeArea = .init(top: 20, left: 0, bottom: 0, right: 0) | |
size = .init(width: 414, height: 736) | |
} | |
return .init(safeArea: safeArea, size: size, traits: .iPhone8Plus(orientation)) | |
} | |
public static let iPhoneX = ViewImageConfig.iPhoneX(.portrait) | |
public static func iPhoneX(_ orientation: Orientation) -> ViewImageConfig { | |
let safeArea: UIEdgeInsets | |
let size: CGSize | |
switch orientation { | |
case .landscape: | |
safeArea = .init(top: 0, left: 44, bottom: 24, right: 44) | |
size = .init(width: 812, height: 375) | |
case .portrait: | |
safeArea = .init(top: 44, left: 0, bottom: 34, right: 0) | |
size = .init(width: 375, height: 812) | |
} | |
return .init(safeArea: safeArea, size: size, traits: .iPhoneX(orientation)) | |
} | |
public static let iPhoneXsMax = ViewImageConfig.iPhoneXsMax(.portrait) | |
public static func iPhoneXsMax(_ orientation: Orientation) -> ViewImageConfig { | |
let safeArea: UIEdgeInsets | |
let size: CGSize | |
switch orientation { | |
case .landscape: | |
safeArea = .init(top: 0, left: 44, bottom: 24, right: 44) | |
size = .init(width: 896, height: 414) | |
case .portrait: | |
safeArea = .init(top: 44, left: 0, bottom: 34, right: 0) | |
size = .init(width: 414, height: 896) | |
} | |
return .init(safeArea: safeArea, size: size, traits: .iPhoneXsMax(orientation)) | |
} | |
@available(iOS 11.0, *) | |
public static let iPhoneXr = ViewImageConfig.iPhoneXr(.portrait) | |
@available(iOS 11.0, *) | |
public static func iPhoneXr(_ orientation: Orientation) -> ViewImageConfig { | |
let safeArea: UIEdgeInsets | |
let size: CGSize | |
switch orientation { | |
case .landscape: | |
safeArea = .init(top: 0, left: 44, bottom: 24, right: 44) | |
size = .init(width: 896, height: 414) | |
case .portrait: | |
safeArea = .init(top: 44, left: 0, bottom: 34, right: 0) | |
size = .init(width: 414, height: 896) | |
} | |
return .init(safeArea: safeArea, size: size, traits: .iPhoneXr(orientation)) | |
} | |
public static let iPadMini = ViewImageConfig.iPadMini(.landscape) | |
public static func iPadMini(_ orientation: Orientation) -> ViewImageConfig { | |
let size: CGSize | |
switch orientation { | |
case .landscape: | |
size = .init(width: 1024, height: 768) | |
case .portrait: | |
size = .init(width: 768, height: 1024) | |
} | |
return .init(safeArea: .init(top: 20, left: 0, bottom: 0, right: 0), size: size, traits: .iPadMini) | |
} | |
public static let iPadPro10_5 = ViewImageConfig.iPadPro10_5(.landscape) | |
public static func iPadPro10_5(_ orientation: Orientation) -> ViewImageConfig { | |
let size: CGSize | |
switch orientation { | |
case .landscape: | |
size = .init(width: 1112, height: 834) | |
case .portrait: | |
size = .init(width: 834, height: 1112) | |
} | |
return .init(safeArea: .init(top: 20, left: 0, bottom: 0, right: 0), size: size, traits: .iPadPro10_5) | |
} | |
public static let iPadPro12_9 = ViewImageConfig.iPadPro12_9(.landscape) | |
public static func iPadPro12_9(_ orientation: Orientation) -> ViewImageConfig { | |
let size: CGSize | |
switch orientation { | |
case .landscape: | |
size = .init(width: 1366, height: 1024) | |
case .portrait: | |
size = .init(width: 1024, height: 1366) | |
} | |
return .init(safeArea: .init(top: 20, left: 0, bottom: 0, right: 0), size: size, traits: .iPadPro12_9) | |
} | |
public static let tv = ViewImageConfig( | |
safeArea: .init(top: 60, left: 90, bottom: 60, right: 90), | |
size: .init(width: 1920, height: 1080), | |
traits: .init() | |
) | |
} | |
extension UITraitCollection { | |
public static func iPhoneSe(_ orientation: ViewImageConfig.Orientation) | |
-> UITraitCollection { | |
let base: [UITraitCollection] = [ | |
// .init(displayGamut: .SRGB), | |
// .init(displayScale: 2), | |
.init(forceTouchCapability: .available), | |
.init(layoutDirection: .leftToRight), | |
.init(preferredContentSizeCategory: .medium), | |
.init(userInterfaceIdiom: .phone) | |
] | |
switch orientation { | |
case .landscape: | |
return .init( | |
traitsFrom: base + [ | |
.init(horizontalSizeClass: .compact), | |
.init(verticalSizeClass: .compact) | |
] | |
) | |
case .portrait: | |
return .init( | |
traitsFrom: base + [ | |
.init(horizontalSizeClass: .compact), | |
.init(verticalSizeClass: .regular), | |
] | |
) | |
} | |
} | |
public static func iPhone8(_ orientation: ViewImageConfig.Orientation) | |
-> UITraitCollection { | |
let base: [UITraitCollection] = [ | |
// .init(displayGamut: .P3), | |
// .init(displayScale: 2), | |
.init(forceTouchCapability: .available), | |
.init(layoutDirection: .leftToRight), | |
.init(preferredContentSizeCategory: .medium), | |
.init(userInterfaceIdiom: .phone) | |
] | |
switch orientation { | |
case .landscape: | |
return .init( | |
traitsFrom: base + [ | |
.init(horizontalSizeClass: .compact), | |
.init(verticalSizeClass: .compact) | |
] | |
) | |
case .portrait: | |
return .init( | |
traitsFrom: base + [ | |
.init(horizontalSizeClass: .compact), | |
.init(verticalSizeClass: .regular) | |
] | |
) | |
} | |
} | |
public static func iPhone8Plus(_ orientation: ViewImageConfig.Orientation) | |
-> UITraitCollection { | |
let base: [UITraitCollection] = [ | |
// .init(displayGamut: .P3), | |
// .init(displayScale: 3), | |
.init(forceTouchCapability: .available), | |
.init(layoutDirection: .leftToRight), | |
.init(preferredContentSizeCategory: .medium), | |
.init(userInterfaceIdiom: .phone) | |
] | |
switch orientation { | |
case .landscape: | |
return .init( | |
traitsFrom: base + [ | |
.init(horizontalSizeClass: .regular), | |
.init(verticalSizeClass: .compact) | |
] | |
) | |
case .portrait: | |
return .init( | |
traitsFrom: base + [ | |
.init(horizontalSizeClass: .compact), | |
.init(verticalSizeClass: .regular) | |
] | |
) | |
} | |
} | |
public static func iPhoneX(_ orientation: ViewImageConfig.Orientation) | |
-> UITraitCollection { | |
let base: [UITraitCollection] = [ | |
// .init(displayGamut: .P3), | |
// .init(displayScale: 3), | |
.init(forceTouchCapability: .available), | |
.init(layoutDirection: .leftToRight), | |
.init(preferredContentSizeCategory: .medium), | |
.init(userInterfaceIdiom: .phone) | |
] | |
switch orientation { | |
case .landscape: | |
return .init( | |
traitsFrom: base + [ | |
.init(horizontalSizeClass: .compact), | |
.init(verticalSizeClass: .compact) | |
] | |
) | |
case .portrait: | |
return .init( | |
traitsFrom: base + [ | |
.init(horizontalSizeClass: .compact), | |
.init(verticalSizeClass: .regular) | |
] | |
) | |
} | |
} | |
public static func iPhoneXr(_ orientation: ViewImageConfig.Orientation) | |
-> UITraitCollection { | |
let base: [UITraitCollection] = [ | |
// .init(displayGamut: .P3), | |
// .init(displayScale: 2), | |
.init(forceTouchCapability: .unavailable), | |
.init(layoutDirection: .leftToRight), | |
.init(preferredContentSizeCategory: .medium), | |
.init(userInterfaceIdiom: .phone) | |
] | |
switch orientation { | |
case .landscape: | |
return .init( | |
traitsFrom: base + [ | |
.init(horizontalSizeClass: .regular), | |
.init(verticalSizeClass: .compact) | |
] | |
) | |
case .portrait: | |
return .init( | |
traitsFrom: base + [ | |
.init(horizontalSizeClass: .compact), | |
.init(verticalSizeClass: .regular) | |
] | |
) | |
} | |
} | |
public static func iPhoneXsMax(_ orientation: ViewImageConfig.Orientation) | |
-> UITraitCollection { | |
let base: [UITraitCollection] = [ | |
// .init(displayGamut: .P3), | |
// .init(displayScale: 3), | |
.init(forceTouchCapability: .available), | |
.init(layoutDirection: .leftToRight), | |
.init(preferredContentSizeCategory: .medium), | |
.init(userInterfaceIdiom: .phone) | |
] | |
switch orientation { | |
case .landscape: | |
return .init( | |
traitsFrom: base + [ | |
.init(horizontalSizeClass: .regular), | |
.init(verticalSizeClass: .compact) | |
] | |
) | |
case .portrait: | |
return .init( | |
traitsFrom: [ | |
.init(horizontalSizeClass: .compact), | |
.init(verticalSizeClass: .regular) | |
] | |
) | |
} | |
} | |
public static let iPadMini = iPad | |
public static let iPadPro10_5 = iPad | |
public static let iPadPro12_9 = iPad | |
private static let iPad = UITraitCollection( | |
traitsFrom: [ | |
// .init(displayScale: 2), | |
.init(horizontalSizeClass: .regular), | |
.init(verticalSizeClass: .regular), | |
.init(userInterfaceIdiom: .pad) | |
] | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment