Skip to content

Instantly share code, notes, and snippets.

@pietrobasso
Created September 30, 2019 05:48
Show Gist options
  • Save pietrobasso/e9f793bd265a7bcb30e4724234a6a7b4 to your computer and use it in GitHub Desktop.
Save pietrobasso/e9f793bd265a7bcb30e4724234a6a7b4 to your computer and use it in GitHub Desktop.
SnapshotTesting DSL
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