diff --git a/README.md b/README.md index a4fcae17..5782f945 100644 --- a/README.md +++ b/README.md @@ -38,16 +38,28 @@ Repeat test runs will load this reference and compare it with the runtime value. match, the test will fail and describe the difference. Failures can be inspected from Xcode's Report Navigator or by inspecting the file URLs of the failure. -You can record a new reference by setting the `record` parameter to `true` on the assertion or -setting `isRecording` globally. +You can record a new reference by customizing snapshots inline with the assertion, or using the +`withSnapshotTesting` tool: -``` swift -assertSnapshot(of: vc, as: .image, record: true) - -// or globally +```swift +// Record just this one snapshot +assertSnapshot(of: vc, as: .image, record: .all) + +// Record all snapshots in a scope: +withSnapshotTesting(record: .all) { + assertSnapshot(of: vc1, as: .image) + assertSnapshot(of: vc2, as: .image) + assertSnapshot(of: vc3, as: .image) +} -isRecording = true -assertSnapshot(of: vc, as: .image) +// Record all snapshots in an XCTestCase subclass: +class FeatureTests: XCTestCase { + override func invokeTest() { + withSnapshotTesting(record: .all) { + super.invokeTest() + } + } +} ``` ## Snapshot Anything diff --git a/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift b/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift index 4d8bfaba..bc78be83 100644 --- a/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift +++ b/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift @@ -1,7 +1,7 @@ import Foundation #if canImport(SwiftSyntax509) - import SnapshotTesting + @_spi(Internals) import SnapshotTesting import SwiftParser import SwiftSyntax import SwiftSyntaxBuilder @@ -35,7 +35,7 @@ import Foundation of value: @autoclosure () throws -> Value?, as snapshotting: Snapshotting, message: @autoclosure () -> String = "", - record isRecording: Bool = isRecording, + record isRecording: Bool? = nil, timeout: TimeInterval = 5, syntaxDescriptor: InlineSnapshotSyntaxDescriptor = InlineSnapshotSyntaxDescriptor(), matches expected: (() -> String)? = nil, @@ -44,95 +44,116 @@ import Foundation line: UInt = #line, column: UInt = #column ) { - let _: Void = installTestObserver - do { - var actual: String? - let expectation = XCTestExpectation() - if let value = try value() { - snapshotting.snapshot(value).run { - actual = $0 - expectation.fulfill() + let record = + (isRecording == true ? .all : isRecording == false ? .missing : nil) + ?? SnapshotTestingConfiguration.current?.record + ?? _record + withSnapshotTesting(record: record) { + let _: Void = installTestObserver + do { + var actual: String? + let expectation = XCTestExpectation() + if let value = try value() { + snapshotting.snapshot(value).run { + actual = $0 + expectation.fulfill() + } + switch XCTWaiter.wait(for: [expectation], timeout: timeout) { + case .completed: + break + case .timedOut: + recordIssue( + """ + Exceeded timeout of \(timeout) seconds waiting for snapshot. + + This can happen when an asynchronously loaded value (like a network response) has not \ + loaded. If a timeout is unavoidable, consider setting the "timeout" parameter of + "assertInlineSnapshot" to a higher value. + """, + file: file, + line: line + ) + return + case .incorrectOrder, .interrupted, .invertedFulfillment: + recordIssue("Couldn't snapshot value", file: file, line: line) + return + @unknown default: + recordIssue("Couldn't snapshot value", file: file, line: line) + return + } } - switch XCTWaiter.wait(for: [expectation], timeout: timeout) { - case .completed: - break - case .timedOut: - XCTFail( + let expected = expected?() + guard + record != .all, + record != .missing || expected != nil + else { + // NB: Write snapshot state before calling `XCTFail` in case `continueAfterFailure = false` + inlineSnapshotState[File(path: file), default: []].append( + InlineSnapshot( + expected: expected, + actual: actual, + wasRecording: record == .all, + syntaxDescriptor: syntaxDescriptor, + function: "\(function)", + line: line, + column: column + ) + ) + + var failure: String + if syntaxDescriptor.trailingClosureLabel + == InlineSnapshotSyntaxDescriptor.defaultTrailingClosureLabel + { + failure = "Automatically recorded a new snapshot." + } else { + failure = """ + Automatically recorded a new snapshot for "\(syntaxDescriptor.trailingClosureLabel)". + """ + } + if let difference = snapshotting.diffing.diff(expected ?? "", actual ?? "")?.0 { + failure += " Difference: …\n\n\(difference.indenting(by: 2))" + } + recordIssue( """ - Exceeded timeout of \(timeout) seconds waiting for snapshot. + \(failure) - This can happen when an asynchronously loaded value (like a network response) has not \ - loaded. If a timeout is unavoidable, consider setting the "timeout" parameter of - "assertInlineSnapshot" to a higher value. + Re-run "\(function)" to assert against the newly-recorded snapshot. """, file: file, line: line ) return - case .incorrectOrder, .interrupted, .invertedFulfillment: - XCTFail("Couldn't snapshot value", file: file, line: line) - return - @unknown default: - XCTFail("Couldn't snapshot value", file: file, line: line) - return } - } - let expected = expected?() - guard !isRecording, let expected - else { - // NB: Write snapshot state before calling `XCTFail` in case `continueAfterFailure = false` - inlineSnapshotState[File(path: file), default: []].append( - InlineSnapshot( - expected: expected, - actual: actual, - wasRecording: isRecording, - syntaxDescriptor: syntaxDescriptor, - function: "\(function)", - line: line, - column: column - ) - ) - var failure: String - if syntaxDescriptor.trailingClosureLabel - == InlineSnapshotSyntaxDescriptor.defaultTrailingClosureLabel - { - failure = "Automatically recorded a new snapshot." - } else { - failure = """ - Automatically recorded a new snapshot for "\(syntaxDescriptor.trailingClosureLabel)". + guard let expected + else { + recordIssue( """ + No expected value to assert against. + """, + file: file, + line: line + ) + return } - if let difference = snapshotting.diffing.diff(expected ?? "", actual ?? "")?.0 { - failure += " Difference: …\n\n\(difference.indenting(by: 2))" - } - XCTFail( + guard + let difference = snapshotting.diffing.diff(expected, actual ?? "")?.0 + else { return } + + let message = message() + syntaxDescriptor.fail( """ - \(failure) + \(message.isEmpty ? "Snapshot did not match. Difference: …" : message) - Re-run "\(function)" to assert against the newly-recorded snapshot. + \(difference.indenting(by: 2)) """, file: file, - line: line + line: line, + column: column ) - return + } catch { + recordIssue("Threw error: \(error)", file: file, line: line) } - guard let difference = snapshotting.diffing.diff(expected, actual ?? "")?.0 - else { return } - - let message = message() - syntaxDescriptor.fail( - """ - \(message.isEmpty ? "Snapshot did not match. Difference: …" : message) - - \(difference.indenting(by: 2)) - """, - file: file, - line: line, - column: column - ) - } catch { - XCTFail("Threw error: \(error)", file: file, line: line) } } #else @@ -197,6 +218,8 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable { /// Initializes an inline snapshot syntax descriptor. /// /// - Parameters: + /// - deprecatedTrailingClosureLabels: An array of deprecated labels to consider for the inline + /// snapshot. /// - trailingClosureLabel: The label of the trailing closure that returns the inline snapshot. /// - trailingClosureOffset: The offset of the trailing closure that returns the inline /// snapshot, relative to the first trailing closure. @@ -242,7 +265,7 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable { visitor.walk(testSource.sourceFile) trailingClosureLine = visitor.trailingClosureLine } - XCTFail( + recordIssue( message(), file: file, line: trailingClosureLine.map(UInt.init) ?? line @@ -386,7 +409,7 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable { ) { self.file = file self.line = snapshots.first?.line - self.wasRecording = snapshots.first?.wasRecording ?? isRecording + self.wasRecording = snapshots.first?.wasRecording ?? false self.indent = String( sourceLocationConverter.sourceLines .first { $0.first?.isWhitespace == true && $0.contains { !$0.isWhitespace } }? diff --git a/Sources/SnapshotTesting/AssertSnapshot.swift b/Sources/SnapshotTesting/AssertSnapshot.swift index 772a3568..737f607b 100644 --- a/Sources/SnapshotTesting/AssertSnapshot.swift +++ b/Sources/SnapshotTesting/AssertSnapshot.swift @@ -1,54 +1,42 @@ import XCTest /// Enhances failure messages with a command line diff tool expression that can be copied and pasted -/// into a terminal. For more complex difftool needs, see `diffToolCommand`. -/// -/// ```swift -/// diffTool = "ksdiff" -/// ``` -public var diffTool: String? { - get { diffToolCommand?("", "").trimmingCharacters(in: .whitespaces) } - set { - diffToolCommand = newValue.map { value in - { [value, $0, $1].joined(separator: " ") } - } - } +/// into a terminal. +@available( + *, + deprecated, + message: + "Use 'withSnapshotTesting' to customize the diff tool. See the documentation for more information." +) +public var diffTool: SnapshotTestingConfiguration.DiffTool { + get { _diffTool } + set { _diffTool = newValue } } -/// Enhances failure messages with a diff tool expression created by the closure, such as an clickable -/// URL or a complex command. The closure will receive the existing screenshot path and the failed -/// screenshot path as arguments. -/// -/// ```swift -/// diffToolCommand = { "compare \"\($0)\" \"\($1)\" png: | open -f -a Preview.app" } -/// ``` -public var diffToolCommand: ((String, String) -> String)? +@_spi(Internals) +public var _diffTool: SnapshotTestingConfiguration.DiffTool = .default /// Whether or not to record all new references. -public var isRecording: Bool = { - let args = ProcessInfo.processInfo.arguments - if let index = args.firstIndex(of: "-co.pointfree.SnapshotTesting.IsRecording"), - index < args.count - 1, - args[index + 1] == "1" +@available( + *, deprecated, + message: + "Use 'withSnapshotTesting' to customize the record mode. See the documentation for more information." +) +public var isRecording: Bool { + get { SnapshotTestingConfiguration.current?.record ?? _record == .all } + set { _record = newValue ? .all : .missing } +} + +@_spi(Internals) +public var _record: SnapshotTestingConfiguration.Record = { + if let value = ProcessInfo.processInfo.environment["SNAPSHOT_TESTING_RECORD"], + let record = SnapshotTestingConfiguration.Record(rawValue: value) { - return true + return record } - return false + return .missing }() -/// Whether or not to create snapshots if they aren't found. -/// It's recommended to set this to `false` in CI to avoid false positives if the test is retried. -public var canGenerateNewSnapshots = true - -/// Whether or not to record all new references. -/// -/// Due to a name clash in Xcode 12, this has been renamed to `isRecording`. -@available(*, deprecated, renamed: "isRecording") -public var record: Bool { - get { isRecording } - set { isRecording = newValue } -} - /// Asserts that a given value matches a reference on disk. /// /// - Parameters: @@ -67,7 +55,7 @@ public func assertSnapshot( of value: @autoclosure () throws -> Value, as snapshotting: Snapshotting, named name: String? = nil, - record recording: Bool = false, + record recording: Bool? = nil, timeout: TimeInterval = 5, file: StaticString = #file, testName: String = #function, @@ -84,7 +72,7 @@ public func assertSnapshot( line: line ) guard let message = failure else { return } - XCTFail(message, file: file, line: line) + recordIssue(message, file: file, line: line) } /// Asserts that a given value matches references on disk. @@ -104,7 +92,7 @@ public func assertSnapshot( public func assertSnapshots( of value: @autoclosure () throws -> Value, as strategies: [String: Snapshotting], - record recording: Bool = false, + record recording: Bool? = nil, timeout: TimeInterval = 5, file: StaticString = #file, testName: String = #function, @@ -140,7 +128,7 @@ public func assertSnapshots( public func assertSnapshots( of value: @autoclosure () throws -> Value, as strategies: [Snapshotting], - record recording: Bool = false, + record recording: Bool? = nil, timeout: TimeInterval = 5, file: StaticString = #file, testName: String = #function, @@ -214,174 +202,182 @@ public func verifySnapshot( of value: @autoclosure () throws -> Value, as snapshotting: Snapshotting, named name: String? = nil, - record recording: Bool = false, + record recording: Bool? = nil, snapshotDirectory: String? = nil, timeout: TimeInterval = 5, file: StaticString = #file, testName: String = #function, line: UInt = #line ) -> String? { - CleanCounterBetweenTestCases.registerIfNeeded() - let recording = recording || isRecording - - do { - let fileUrl = URL(fileURLWithPath: "\(file)", isDirectory: false) - let fileName = fileUrl.deletingPathExtension().lastPathComponent - - let snapshotDirectoryUrl = - snapshotDirectory.map { URL(fileURLWithPath: $0, isDirectory: true) } - ?? fileUrl - .deletingLastPathComponent() - .appendingPathComponent("__Snapshots__") - .appendingPathComponent(fileName) - - let identifier: String - if let name = name { - identifier = sanitizePathComponent(name) - } else { - let counter = counterQueue.sync { () -> Int in - let key = snapshotDirectoryUrl.appendingPathComponent(testName) - counterMap[key, default: 0] += 1 - return counterMap[key]! - } - identifier = String(counter) - } - let testName = sanitizePathComponent(testName) - let snapshotFileUrl = - snapshotDirectoryUrl - .appendingPathComponent("\(testName).\(identifier)") - .appendingPathExtension(snapshotting.pathExtension ?? "") - let fileManager = FileManager.default - try fileManager.createDirectory(at: snapshotDirectoryUrl, withIntermediateDirectories: true) - - let tookSnapshot = XCTestExpectation(description: "Took snapshot") - var optionalDiffable: Format? - snapshotting.snapshot(try value()).run { b in - optionalDiffable = b - tookSnapshot.fulfill() - } - let result = XCTWaiter.wait(for: [tookSnapshot], timeout: timeout) - switch result { - case .completed: - break - case .timedOut: - return """ - Exceeded timeout of \(timeout) seconds waiting for snapshot. + let record = + (recording == true ? .all : recording == false ? .missing : nil) + ?? SnapshotTestingConfiguration.current?.record + ?? _record + return withSnapshotTesting(record: record) { () -> String? in + do { + let fileUrl = URL(fileURLWithPath: "\(file)", isDirectory: false) + let fileName = fileUrl.deletingPathExtension().lastPathComponent + + let snapshotDirectoryUrl = + snapshotDirectory.map { URL(fileURLWithPath: $0, isDirectory: true) } + ?? fileUrl + .deletingLastPathComponent() + .appendingPathComponent("__Snapshots__") + .appendingPathComponent(fileName) + + let identifier: String + if let name = name { + identifier = sanitizePathComponent(name) + } else { + let counter = counterQueue.sync { () -> Int in + let key = snapshotDirectoryUrl.appendingPathComponent(testName) + counterMap[key, default: 0] += 1 + return counterMap[key]! + } + identifier = String(counter) + } - This can happen when an asynchronously rendered view (like a web view) has not loaded. \ - Ensure that every subview of the view hierarchy has loaded to avoid timeouts, or, if a \ - timeout is unavoidable, consider setting the "timeout" parameter of "assertSnapshot" to \ - a higher value. - """ - case .incorrectOrder, .invertedFulfillment, .interrupted: - return "Couldn't snapshot value" - @unknown default: - return "Couldn't snapshot value" - } + let testName = sanitizePathComponent(testName) + let snapshotFileUrl = + snapshotDirectoryUrl + .appendingPathComponent("\(testName).\(identifier)") + .appendingPathExtension(snapshotting.pathExtension ?? "") + let fileManager = FileManager.default + try fileManager.createDirectory(at: snapshotDirectoryUrl, withIntermediateDirectories: true) + + let tookSnapshot = XCTestExpectation(description: "Took snapshot") + var optionalDiffable: Format? + snapshotting.snapshot(try value()).run { b in + optionalDiffable = b + tookSnapshot.fulfill() + } + let result = XCTWaiter.wait(for: [tookSnapshot], timeout: timeout) + switch result { + case .completed: + break + case .timedOut: + return """ + Exceeded timeout of \(timeout) seconds waiting for snapshot. + + This can happen when an asynchronously rendered view (like a web view) has not loaded. \ + Ensure that every subview of the view hierarchy has loaded to avoid timeouts, or, if a \ + timeout is unavoidable, consider setting the "timeout" parameter of "assertSnapshot" to \ + a higher value. + """ + case .incorrectOrder, .invertedFulfillment, .interrupted: + return "Couldn't snapshot value" + @unknown default: + return "Couldn't snapshot value" + } - guard var diffable = optionalDiffable else { - return "Couldn't snapshot value" - } + guard var diffable = optionalDiffable else { + return "Couldn't snapshot value" + } - guard !recording, canGenerateNewSnapshots, fileManager.fileExists(atPath: snapshotFileUrl.path) - else { - try snapshotting.diffing.toData(diffable).write(to: snapshotFileUrl) - #if !os(Linux) && !os(Windows) - if ProcessInfo.processInfo.environment.keys.contains("__XCODE_BUILT_PRODUCTS_DIR_PATHS") { - XCTContext.runActivity(named: "Attached Recorded Snapshot") { activity in - let attachment = XCTAttachment(contentsOfFile: snapshotFileUrl) - activity.add(attachment) + func recordSnapshot() throws { + try snapshotting.diffing.toData(diffable).write(to: snapshotFileUrl) + #if !os(Linux) && !os(Windows) + if ProcessInfo.processInfo.environment.keys.contains("__XCODE_BUILT_PRODUCTS_DIR_PATHS") { + XCTContext.runActivity(named: "Attached Recorded Snapshot") { activity in + let attachment = XCTAttachment(contentsOfFile: snapshotFileUrl) + activity.add(attachment) + } } - } - #endif - - return recording - ? """ - Record mode is on. Automatically recorded snapshot: … + #endif + } - open "\(snapshotFileUrl.absoluteString)" + guard + record != .all, + (record != .missing && record != .failed) + || fileManager.fileExists(atPath: snapshotFileUrl.path) + else { + try recordSnapshot() - Turn record mode off and re-run "\(testName)" to assert against the newly-recorded snapshot - """ - : """ - No reference was found on disk. Automatically recorded snapshot: … + return SnapshotTestingConfiguration.current?.record == .all + ? """ + Record mode is on. Automatically recorded snapshot: … - open "\(snapshotFileUrl.absoluteString)" + open "\(snapshotFileUrl.absoluteString)" - Re-run "\(testName)" to assert against the newly-recorded snapshot. - """ - } + Turn record mode off and re-run "\(testName)" to assert against the newly-recorded snapshot + """ + : """ + No reference was found on disk. Automatically recorded snapshot: … - let data = try Data(contentsOf: snapshotFileUrl) - let reference = snapshotting.diffing.fromData(data) + open "\(snapshotFileUrl.absoluteString)" - #if os(iOS) || os(tvOS) - // If the image generation fails for the diffable part and the reference was empty, use the reference - if let localDiff = diffable as? UIImage, - let refImage = reference as? UIImage, - localDiff.size == .zero && refImage.size == .zero - { - diffable = reference + Re-run "\(testName)" to assert against the newly-recorded snapshot. + """ } - #endif - guard let (failure, attachments) = snapshotting.diffing.diff(reference, diffable) else { - return nil - } + let data = try Data(contentsOf: snapshotFileUrl) + let reference = snapshotting.diffing.fromData(data) - let artifactsUrl = URL( - fileURLWithPath: ProcessInfo.processInfo.environment["SNAPSHOT_ARTIFACTS"] - ?? NSTemporaryDirectory(), isDirectory: true - ) - let artifactsSubUrl = artifactsUrl.appendingPathComponent(fileName) - try fileManager.createDirectory(at: artifactsSubUrl, withIntermediateDirectories: true) - let failedSnapshotFileUrl = artifactsSubUrl.appendingPathComponent( - snapshotFileUrl.lastPathComponent) - try snapshotting.diffing.toData(diffable).write(to: failedSnapshotFileUrl) - - if !attachments.isEmpty { - #if !os(Linux) && !os(Windows) - if ProcessInfo.processInfo.environment.keys.contains("__XCODE_BUILT_PRODUCTS_DIR_PATHS") { - XCTContext.runActivity(named: "Attached Failure Diff") { activity in - attachments.forEach { - activity.add($0) - } - } + #if os(iOS) || os(tvOS) + // If the image generation fails for the diffable part and the reference was empty, use the reference + if let localDiff = diffable as? UIImage, + let refImage = reference as? UIImage, + localDiff.size == .zero && refImage.size == .zero + { + diffable = reference } #endif - } - let diffMessage = - diffToolCommand?(snapshotFileUrl.path, failedSnapshotFileUrl.path) - ?? """ - @\(minus) - "\(snapshotFileUrl.absoluteString)" - @\(plus) - "\(failedSnapshotFileUrl.absoluteString)" + guard let (failure, attachments) = snapshotting.diffing.diff(reference, diffable) else { + return nil + } - To configure output for a custom diff tool, like Kaleidoscope: + let artifactsUrl = URL( + fileURLWithPath: ProcessInfo.processInfo.environment["SNAPSHOT_ARTIFACTS"] + ?? NSTemporaryDirectory(), isDirectory: true + ) + let artifactsSubUrl = artifactsUrl.appendingPathComponent(fileName) + try fileManager.createDirectory(at: artifactsSubUrl, withIntermediateDirectories: true) + let failedSnapshotFileUrl = artifactsSubUrl.appendingPathComponent( + snapshotFileUrl.lastPathComponent) + try snapshotting.diffing.toData(diffable).write(to: failedSnapshotFileUrl) + + if !attachments.isEmpty { + #if !os(Linux) && !os(Windows) + if ProcessInfo.processInfo.environment.keys.contains("__XCODE_BUILT_PRODUCTS_DIR_PATHS") { + XCTContext.runActivity(named: "Attached Failure Diff") { activity in + attachments.forEach { + activity.add($0) + } + } + } + #endif + } - SnapshotTesting.diffTool = "ksdiff" - """ + let diffMessage = (SnapshotTestingConfiguration.current?.diffTool ?? _diffTool)( + currentFilePath: snapshotFileUrl.path, + failedFilePath: failedSnapshotFileUrl.path + ) - let failureMessage: String - if let name = name { - failureMessage = "Snapshot \"\(name)\" does not match reference." - } else { - failureMessage = "Snapshot does not match reference." - } + var failureMessage: String + if let name = name { + failureMessage = "Snapshot \"\(name)\" does not match reference." + } else { + failureMessage = "Snapshot does not match reference." + } + + if record == .failed { + try recordSnapshot() + failureMessage += " A new snapshot was automatically recorded." + } - return """ - \(failureMessage) + return """ + \(failureMessage) - \(diffMessage) + \(diffMessage) - \(failure.trimmingCharacters(in: .whitespacesAndNewlines)) - """ - } catch { - return error.localizedDescription + \(failure.trimmingCharacters(in: .whitespacesAndNewlines)) + """ + } catch { + return error.localizedDescription + } } } diff --git a/Sources/SnapshotTesting/Async.swift b/Sources/SnapshotTesting/Async.swift index bf1ddc29..2c16adda 100644 --- a/Sources/SnapshotTesting/Async.swift +++ b/Sources/SnapshotTesting/Async.swift @@ -20,7 +20,6 @@ public struct Async { /// /// - Parameters: /// - run: A function that, when called, can hand a value to a callback. - /// - callback: A function that can be called with a value. public init(run: @escaping (_ callback: @escaping (Value) -> Void) -> Void) { self.run = run } diff --git a/Sources/SnapshotTesting/Diffing.swift b/Sources/SnapshotTesting/Diffing.swift index cf850b7b..357159ff 100644 --- a/Sources/SnapshotTesting/Diffing.swift +++ b/Sources/SnapshotTesting/Diffing.swift @@ -14,16 +14,11 @@ public struct Diffing { public var diff: (Value, Value) -> (String, [XCTAttachment])? /// Creates a new `Diffing` on `Value`. - /// + /// /// - Parameters: /// - toData: A function used to convert a value _to_ data. - /// - value: A value to convert into data. /// - fromData: A function used to produce a value _from_ data. - /// - data: Data to convert into a value. /// - diff: A function used to compare two values. If the values do not match, returns a failure - /// message and artifacts describing the failure. - /// - lhs: A value to compare. - /// - rhs: Another value to compare. public init( toData: @escaping (_ value: Value) -> Data, fromData: @escaping (_ data: Data) -> Value, diff --git a/Sources/SnapshotTesting/Documentation.docc/CustomStrategies.md b/Sources/SnapshotTesting/Documentation.docc/Articles/CustomStrategies.md similarity index 100% rename from Sources/SnapshotTesting/Documentation.docc/CustomStrategies.md rename to Sources/SnapshotTesting/Documentation.docc/Articles/CustomStrategies.md diff --git a/Sources/SnapshotTesting/Documentation.docc/Articles/IntegratingWithTestFrameworks.md b/Sources/SnapshotTesting/Documentation.docc/Articles/IntegratingWithTestFrameworks.md new file mode 100644 index 00000000..2f702077 --- /dev/null +++ b/Sources/SnapshotTesting/Documentation.docc/Articles/IntegratingWithTestFrameworks.md @@ -0,0 +1,87 @@ +# Intergrating with test frameworks + +Learn how to use snapshot testing in the two main testing frameworks: Xcode's XCTest and Swift's +native testing framework. + +## Overview + +The Apple ecosystem currently has two primary testing frameworks, and unfortunately they are not +compatible with each other. There is the XCTest framework, which is a private framework provided +by Apple and heavily integrated into Xcode. And now there is Swift Testing, an open source testing +framework built in Swift that is capable of integrating into a variety of environments. + +These two frameworks are not compatible in the sense that an assertion made in one framework +from a test in the other framework will not trigger a test failure. So, if you are writing a test +with the new `@Test` macro style, and you use a test helper that ultimately calls `XCTFail` under +the hood, that will not bubble up to an actual test failure when tests are run. And similarly, if +you have a test case inheriting from `XCTestCase` that ultimiately invokes the new style `#expect` +macro, that too will not actually trigger a test failure. + +However, these details have all been hidden away in the SnapshotTesting library. You can simply +use ``assertSnapshot(of:as:named:record:timeout:file:testName:line:)`` in either an `XCTestCase` +subclass _or_ `@Test`, and it will dynamically detect what context it is running in and trigger +the correct test failure: + +```swift +class FeatureTests: XCTestCase { + func testFeature() { + assertSnapshot(of: MyView(), as: .image) // ✅ + } +} + +@Test +func testFeature() { + assertSnapshot(of: MyView(), as: .image) // ✅ +} +``` + +### Configuring snapshots + +For the most part, asserting on snapshots works the same whether you are using XCTest or Swift +Testing. There is one major difference, and that is how snapshot configuration works. There are +two major ways snapshots can be configured: ``SnapshotTestingConfiguration/diffTool-swift.property`` +and ``SnapshotTestingConfiguration/record-swift.property``. + +The `diffTool` property allows you to customize how a command is printed to the test failure +message that allows you to quickly open a diff of two files, such as +[Kaleidoscope](http://kaleidoscope.app). The `record` property allows you to change the mode of +assertion so that new snapshots are generated and saved to disk. + +These properties can be overridden for a scope of an operation using the +``withSnapshotTesting(record:diffTool:operation:)-2kuyr`` function. In an XCTest context the +simplest way to do this is to override the `invokeTest` method on `XCTestCase` and wrap it in +`withSnapshotTesting`: + +```swift +class FeatureTests: XCTestCase { + override func invokeTest() { + withSnapshotTesting( + record: .missing, + diffTool: .ksdiff + ) { + super.invokeTest() + } + } +} +``` + +This will override the `diffTool` and `record` properties for each test function. + +Swift's new testing framework does not currently have a public API for this kind of customization. +There is an experimental feature, called `CustomExecutionTrait`, that does gives us this ability, +and the library provides such a trait called ``Testing/Trait/snapshots(diffTool:record:)``. It can +be attached to any `@Test` or `@Suite` to configure snapshot testing: + +```swift +@_spi(Experimental) import SnapshotTesting + +@Suite(.snapshots(record: .all, diffTool: .ksdiff)) +struct FeatureTests { + … +} +``` + +> Important: You must import SnapshotTesting with the `@_spi(Experimental)` attribute to get access +to this functionality because Swift Testing's own `CustomExecutionTrait` is hidden behind the same +SPI flag. This means this API is subject to change in the future, but hopefully Apple will +publicize this tool soon. diff --git a/Sources/SnapshotTesting/Documentation.docc/Articles/MigrationGuides/MigratingTo1.17.md b/Sources/SnapshotTesting/Documentation.docc/Articles/MigrationGuides/MigratingTo1.17.md new file mode 100644 index 00000000..48951630 --- /dev/null +++ b/Sources/SnapshotTesting/Documentation.docc/Articles/MigrationGuides/MigratingTo1.17.md @@ -0,0 +1,175 @@ +# Migrating to 1.17 + +Learn how to use the new `withSnapshotTesting` tool for customizing how snapshots are generated and +diffs displayed. + +## Overview + +This library is under constant development, and we are always looking for ways to simplify the +library, and make it more powerful. This version of the library has deprecated some APIs, +introduced a new APIs, and includes beta support for Swift's new native testing library. + +## Customizing snapshots + +Currently there are two global variables in the library for customizing snapshot testing: + + * ``isRecording`` determines whether new snapshots are generated and saved to disk when the test + runs. + + * ``diffTool`` determines the command line tool that is used to inspect the diff of two files on + disk. + +These customization options have a few downsides currently. + + * First, because they are globals they can easily bleed over from test to test in unexpected ways. + And further, Swift's new testing library runs parallel tests in the same process, which is in + stark contrast to XCTest, which runs parallel tests in separate processes. This means there are + even more chances for these globals to bleed from one test to another. + + * And second, these options aren't as granular as some of our users wanted. When ``isRecording`` + is true snapshots are generated and written to disk, and when it is false snapshots are not + generated, _unless_ a file is not present on disk. The a snapshot _is_ generated. Some of our + users wanted an option between these two extremes, where snapshots would not be generated if the + file does not exist on disk. + + And the ``diffTool`` variable allows one to specify a command line tool to use for visualizing + diffs of files, but only works when the command line tool accepts a very narrow set of + arguments, _e.g.: + + ```sh + ksdiff /path/to/file1.png /path/to/file2.png + ``` + +Because of these reasons, the globals ``isRecording`` and ``diffTool`` are now deprecated, and we +have introduced a new tool that greatly improves upon all of these problems. There is now a function +called ``withSnapshotTesting(record:diffTool:operation:)-2kuyr`` for customizing snapshots. It +allows you to customize how the `assertSnapshot` tool behaves for a well-defined scope. + +Rather than overriding `isRecording` or `diffTool` directly in your tests, you can wrap your test in +`withSnapshotTesting`: + +@Row { + @Column { + ```swift + // Before + + func testFeature() { + isRecording = true + diffTool = "ksdiff" + assertSnapshot(…) + } + ``` + } + @Column { + ```swift + // After + + func testFeature() { + withSnapshotTesting(record: .all, diffTool: .ksdiff) { + assertSnapshot(…) + } + } + ``` + } +} + +If you want to override the options for an entire test class, you can override the `invokeTest` +method of `XCTestCase`: + +@Row { + @Column { + ```swift + // Before + + class FeatureTests: XCTestCase { + override func invokeTest() { + isRecording = true + diffTool = "ksdiff" + defer { + isRecording = false + diffTool = nil + } + super.invokeTest() + } + } + ``` + } + @Column { + ```swift + // After + + class FeatureTests: XCTestCase { + override func invokeTest() { + withSnapshotTesting(record: .all, diffTool: .ksdiff) { + super.invokeTest() + } + } + } + ``` + } +} + +And if you want to override these settings for _all_ tests, then you can implement a base +`XCTestCase` subclass and have your tests inherit from it. + +Further, the `diffTool` and `record` arguments have extra customization capabilities: + + * `diffTool` is now a [function]() + `(String, String) -> String` that is handed the current snapshot file and the failed snapshot + file. It can return the command that one can run to display a diff. For example, to use + ImageMagick's `compare` command and open the result in Preview.app: + + ```swift + extension SnapshotTestingConfiguration.DiffTool { + static let compare = Self { + "compare \"\($0)\" \"\($1)\" png: | open -f -a Preview.app" + } + } + ``` + + * `record` is now a [type]() with 4 + choices: `all`, `missing`, `never`, `failed`: + * `all`: All snapshots will be generated and saved to disk. + * `missing`: only the snapshots that are missing from the disk will be generated + and saved. + * `never`: No snapshots will be generated, even if they are missing. This option is appropriate + when running tests on CI so that re-tries of tests do not surprisingly pass after snapshots are + unexpectedly generated. + * `failed`: Snapshots only for failing tests will be generated. This can be useful for tests + that use precision thresholds so that passing tests do not re-record snapshots that are + subtly different but still within the threshold. + +## Beta support for Swift Testing + +This release of the library provides beta support for Swift's native Testing library. Prior to this +release, using `assertSnapshot` in a `@Test` would result in a passing test no matter what. That is +because under the hood `assertSnapshot` uses `XCTFail` to trigger test failures, but that does not +cause test failures when using Swift Testing. + +In version 1.17 the `assertSnapshot` helper will now intelligently figure out if tests are running +in an XCTest context or a Swift Testing context, and will determine if it should invoke `XCTFail` or +`Issue.record` to trigger a test failure. + +For the most part you can write tests for Swift Testing exactly as you would for XCTest. However, +there is one major difference. Swift Testing does not (yet) have a substitute for `invokeTest`, +which we used alongside `withSnapshotTesting` to customize snapshotting for a full test class. + +There is an experimental version of this tool in Swift Testing, called `CustomExecutionTrait`, and +this library provides such a trait called ``Testing/Trait/snapshots(diffTool:record:)``. It allows +you to customize snapshots for a `@Test` or `@Suite`, but to get access to it you must perform an +`@_spi(Experimental)` import of snapshot testing: + +```swift +@_spi(Experimental) import SnapshotTesting + +@Suite(.snapshots(record: .all, diffTool: .ksdiff)) +struct FeatureTests { + … +} +``` + +That will override the `diffTool` and `record` options for the entire `FeatureTests` suite. + +> Important: As evident by the usage of `@_spi(Experimental)` this API is subject to change. As +soon as the Swift Testing library finalizes its API for `CustomExecutionTrait` we will update +the library accordingly and remove the `@_spi` annotation. diff --git a/Sources/SnapshotTesting/Documentation.docc/Articles/MigrationGuides/MigrationGuides.md b/Sources/SnapshotTesting/Documentation.docc/Articles/MigrationGuides/MigrationGuides.md new file mode 100644 index 00000000..17e3f450 --- /dev/null +++ b/Sources/SnapshotTesting/Documentation.docc/Articles/MigrationGuides/MigrationGuides.md @@ -0,0 +1,17 @@ +# Migration guides + +Learn how to upgrade your application to the newest version of this library. + +## Overview + +This library is under constant development, and we are always looking for ways to simplify the +library, and make it more powerful. As such, we often need to deprecate certain APIs in favor of +newer ones. We recommend people update their code as quickly as possible to the newest APIs, and +these guides contain tips to do so. + +> Important: Before following any particular migration guide be sure you have followed all the +> preceding migration guides. + +### Guides + +- diff --git a/Sources/SnapshotTesting/Documentation.docc/Deprecations.md b/Sources/SnapshotTesting/Documentation.docc/Deprecations.md deleted file mode 100644 index f5460227..00000000 --- a/Sources/SnapshotTesting/Documentation.docc/Deprecations.md +++ /dev/null @@ -1,11 +0,0 @@ -# Deprecations - -## Topics - -### Configuration - -- ``record`` - -### Supporting types - -- ``SnapshotTestCase`` diff --git a/Sources/SnapshotTesting/Documentation.docc/AssertSnapshot.md b/Sources/SnapshotTesting/Documentation.docc/Extensions/AssertSnapshot.md similarity index 76% rename from Sources/SnapshotTesting/Documentation.docc/AssertSnapshot.md rename to Sources/SnapshotTesting/Documentation.docc/Extensions/AssertSnapshot.md index 8b2bd482..bb10bb9a 100644 --- a/Sources/SnapshotTesting/Documentation.docc/AssertSnapshot.md +++ b/Sources/SnapshotTesting/Documentation.docc/Extensions/AssertSnapshot.md @@ -4,8 +4,8 @@ ### Multiple snapshots -- ``assertSnapshots(of:as:record:timeout:file:testName:line:)-98vsq`` -- ``assertSnapshots(of:as:record:timeout:file:testName:line:)-4upll`` +- ``assertSnapshots(of:as:record:timeout:file:testName:line:)-6mdbp`` +- ``assertSnapshots(of:as:record:timeout:file:testName:line:)-6c4fe`` ### Custom assertions @@ -14,6 +14,6 @@ ### Deprecations - ``assertSnapshot(matching:as:named:record:timeout:file:testName:line:)`` -- ``assertSnapshots(matching:as:record:timeout:file:testName:line:)-3i804`` -- ``assertSnapshots(matching:as:record:timeout:file:testName:line:)-6bvvj`` +- ``assertSnapshots(matching:as:record:timeout:file:testName:line:)-4fz7d`` +- ``assertSnapshots(matching:as:record:timeout:file:testName:line:)-wq4j`` - ``verifySnapshot(matching:as:named:record:snapshotDirectory:timeout:file:testName:line:)`` diff --git a/Sources/SnapshotTesting/Documentation.docc/Extensions/Deprecations/SnapshotTestingDeprecations.md b/Sources/SnapshotTesting/Documentation.docc/Extensions/Deprecations/SnapshotTestingDeprecations.md new file mode 100644 index 00000000..6a162462 --- /dev/null +++ b/Sources/SnapshotTesting/Documentation.docc/Extensions/Deprecations/SnapshotTestingDeprecations.md @@ -0,0 +1,17 @@ +# Deprecations + +## Topics + +### Assert helpers + +- ``assertSnapshots(matching:as:record:timeout:file:testName:line:)-4fz7d`` +- ``assertSnapshots(matching:as:record:timeout:file:testName:line:)-wq4j`` + +### Configuration + +- ``isRecording`` +- ``diffTool`` + +### Supporting types + +- ``SnapshotTestCase`` diff --git a/Sources/SnapshotTesting/Documentation.docc/Extensions/Deprecations/diffTool-property-deprecation.md b/Sources/SnapshotTesting/Documentation.docc/Extensions/Deprecations/diffTool-property-deprecation.md new file mode 100644 index 00000000..f112fcc0 --- /dev/null +++ b/Sources/SnapshotTesting/Documentation.docc/Extensions/Deprecations/diffTool-property-deprecation.md @@ -0,0 +1,6 @@ +# ``SnapshotTesting/diffTool`` + +@DeprecationSummary { + Use ``withSnapshotTesting(record:diffTool:operation:)-2kuyr`` to customize the diff tool, instead. + See for more information. +} diff --git a/Sources/SnapshotTesting/Documentation.docc/Extensions/Deprecations/isRecording-property-deprecation.md b/Sources/SnapshotTesting/Documentation.docc/Extensions/Deprecations/isRecording-property-deprecation.md new file mode 100644 index 00000000..8c8852bc --- /dev/null +++ b/Sources/SnapshotTesting/Documentation.docc/Extensions/Deprecations/isRecording-property-deprecation.md @@ -0,0 +1,6 @@ +# ``SnapshotTesting/isRecording`` + +@DeprecationSummary { + Use ``withSnapshotTesting(record:diffTool:operation:)-2kuyr`` to customize the record mode, + instead. See for more information. +} diff --git a/Sources/SnapshotTesting/Documentation.docc/Extensions/SnapshotsTrait.md b/Sources/SnapshotTesting/Documentation.docc/Extensions/SnapshotsTrait.md new file mode 100644 index 00000000..532f53ca --- /dev/null +++ b/Sources/SnapshotTesting/Documentation.docc/Extensions/SnapshotsTrait.md @@ -0,0 +1,5 @@ +# ``SnapshotTesting/Testing/Trait/snapshots(diffTool:record:)`` + +### Configuration + +- ``Testing/Trait/snapshots(_:)`` diff --git a/Sources/SnapshotTesting/Documentation.docc/Snapshotting.md b/Sources/SnapshotTesting/Documentation.docc/Extensions/Snapshotting.md similarity index 100% rename from Sources/SnapshotTesting/Documentation.docc/Snapshotting.md rename to Sources/SnapshotTesting/Documentation.docc/Extensions/Snapshotting.md diff --git a/Sources/SnapshotTesting/Documentation.docc/Extensions/WithSnapshotTesting.md b/Sources/SnapshotTesting/Documentation.docc/Extensions/WithSnapshotTesting.md new file mode 100644 index 00000000..397b4b08 --- /dev/null +++ b/Sources/SnapshotTesting/Documentation.docc/Extensions/WithSnapshotTesting.md @@ -0,0 +1,7 @@ +# ``SnapshotTesting/withSnapshotTesting(record:diffTool:operation:)-2kuyr`` + +## Topics + +### Overloads + +- ``withSnapshotTesting(record:diffTool:operation:)-6bsqw`` diff --git a/Sources/SnapshotTesting/Documentation.docc/SnapshotTesting.md b/Sources/SnapshotTesting/Documentation.docc/SnapshotTesting.md index 473bee90..8704d920 100644 --- a/Sources/SnapshotTesting/Documentation.docc/SnapshotTesting.md +++ b/Sources/SnapshotTesting/Documentation.docc/SnapshotTesting.md @@ -7,6 +7,8 @@ Powerfully flexible snapshot testing. ### Essentials - ``assertSnapshot(of:as:named:record:timeout:file:testName:line:)`` +- +- ### Strategies @@ -17,10 +19,10 @@ Powerfully flexible snapshot testing. ### Configuration -- ``isRecording`` -- ``canGenerateNewSnapshots`` -- ``diffTool`` +- ``Testing/Trait/snapshots(diffTool:record:)`` +- ``withSnapshotTesting(record:diffTool:operation:)-2kuyr`` +- ``SnapshotTestingConfiguration`` ### Deprecations -- +- diff --git a/Sources/SnapshotTesting/Internal/Deprecations.swift b/Sources/SnapshotTesting/Internal/Deprecations.swift index 8e2472cf..debb08da 100644 --- a/Sources/SnapshotTesting/Internal/Deprecations.swift +++ b/Sources/SnapshotTesting/Internal/Deprecations.swift @@ -30,7 +30,7 @@ public func _assertInlineSnapshot( line: line ) guard let message = failure else { return } - XCTFail(message, file: file, line: line) + recordIssue(message, file: file, line: line) } @available( @@ -316,15 +316,12 @@ private var recordings: Recordings = [:] // Deprecated after 1.11.1: -@available(iOS, deprecated: 10000, message: "Use `assertSnapshot(of:…:)` instead.") -@available(macOS, deprecated: 10000, message: "Use `assertSnapshot(of:…:)` instead.") -@available(tvOS, deprecated: 10000, message: "Use `assertSnapshot(of:…:)` instead.") -@available(watchOS, deprecated: 10000, message: "Use `assertSnapshot(of:…:)` instead.") +@available(*, deprecated, renamed: "assertSnapshot(of:as:named:record:timeout:file:testName:line:)") public func assertSnapshot( matching value: @autoclosure () throws -> Value, as snapshotting: Snapshotting, named name: String? = nil, - record recording: Bool = false, + record recording: Bool? = nil, timeout: TimeInterval = 5, file: StaticString = #file, testName: String = #function, @@ -342,14 +339,13 @@ public func assertSnapshot( ) } -@available(iOS, deprecated: 10000, message: "Use `assertSnapshots(of:…:)` instead.") -@available(macOS, deprecated: 10000, message: "Use `assertSnapshots(of:…:)` instead.") -@available(tvOS, deprecated: 10000, message: "Use `assertSnapshots(of:…:)` instead.") -@available(watchOS, deprecated: 10000, message: "Use `assertSnapshots(of:…:)` instead.") +@available( + *, deprecated, renamed: "assertSnapshots(of:as:named:record:timeout:file:testName:line:)" +) public func assertSnapshots( matching value: @autoclosure () throws -> Value, as strategies: [String: Snapshotting], - record recording: Bool = false, + record recording: Bool? = nil, timeout: TimeInterval = 5, file: StaticString = #file, testName: String = #function, @@ -366,14 +362,13 @@ public func assertSnapshots( ) } -@available(iOS, deprecated: 10000, message: "Use `assertSnapshots(of:…:)` instead.") -@available(macOS, deprecated: 10000, message: "Use `assertSnapshots(of:…:)` instead.") -@available(tvOS, deprecated: 10000, message: "Use `assertSnapshots(of:…:)` instead.") -@available(watchOS, deprecated: 10000, message: "Use `assertSnapshots(of:…:)` instead.") +@available( + *, deprecated, renamed: "assertSnapshots(of:as:named:record:timeout:file:testName:line:)" +) public func assertSnapshots( matching value: @autoclosure () throws -> Value, as strategies: [Snapshotting], - record recording: Bool = false, + record recording: Bool? = nil, timeout: TimeInterval = 5, file: StaticString = #file, testName: String = #function, @@ -390,15 +385,15 @@ public func assertSnapshots( ) } -@available(iOS, deprecated: 10000, message: "Use `verifySnapshot(of:…:)` instead.") -@available(macOS, deprecated: 10000, message: "Use `verifySnapshot(of:…:)` instead.") -@available(tvOS, deprecated: 10000, message: "Use `verifySnapshot(of:…:)` instead.") -@available(watchOS, deprecated: 10000, message: "Use `verifySnapshot(of:…:)` instead.") +@available( + *, deprecated, + renamed: "verifySnapshot(of:as:named:record:snapshotDirectory:timeout:file:testName:line:)" +) public func verifySnapshot( matching value: @autoclosure () throws -> Value, as snapshotting: Snapshotting, named name: String? = nil, - record recording: Bool = false, + record recording: Bool? = nil, snapshotDirectory: String? = nil, timeout: TimeInterval = 5, file: StaticString = #file, @@ -420,3 +415,9 @@ public func verifySnapshot( @available(*, deprecated, renamed: "XCTestCase") public typealias SnapshotTestCase = XCTestCase + +@available(*, deprecated, renamed: "isRecording") +public var record: Bool { + get { isRecording } + set { isRecording = newValue } +} diff --git a/Sources/SnapshotTesting/Internal/RecordIssue.swift b/Sources/SnapshotTesting/Internal/RecordIssue.swift new file mode 100644 index 00000000..76f0a4c8 --- /dev/null +++ b/Sources/SnapshotTesting/Internal/RecordIssue.swift @@ -0,0 +1,26 @@ +import XCTest + +#if canImport(Testing) + import Testing +#endif + +@_spi(Internals) +public func recordIssue( + _ message: @autoclosure () -> String, + file: StaticString = #filePath, + line: UInt = #line +) { + #if canImport(Testing) + if Test.current != nil { + Issue.record( + Comment(rawValue: message()), + filePath: file.description, + line: Int(line) + ) + } else { + XCTFail(message(), file: file, line: line) + } + #else + XCTFail(message(), file: file, line: line) + #endif +} diff --git a/Sources/SnapshotTesting/SnapshotTestingConfiguration.swift b/Sources/SnapshotTesting/SnapshotTestingConfiguration.swift new file mode 100644 index 00000000..93c2e712 --- /dev/null +++ b/Sources/SnapshotTesting/SnapshotTestingConfiguration.swift @@ -0,0 +1,236 @@ +/// Customizes `assertSnapshot` for the duration of an operation. +/// +/// Use this operation to customize how the `assertSnapshot` function behaves in a test. It is most +/// convenient to use in the context of XCTest where you can wrap `invokeTest` of an `XCTestCase` +/// subclass so that the configuration applies to every test method. +/// +/// > Note: To customize tests when using Swift's native Testing library, use the +/// > ``Testing/Trait/snapshots(diffTool:record:)`` trait. +/// +/// For example, to specify to put an entire test class in record mode you do the following: +/// +/// ```swift +/// class FeatureTests: XCTestCase { +/// override func invokeTest() { +/// withSnapshotTesting(record: .all) { +/// super.invokeTest() +/// } +/// } +/// } +/// ``` +/// +/// - Parameters: +/// - record: The record mode to use while asserting snapshots. +/// - diffTool: The diff tool to use while asserting snapshots. +/// - operation: The operation to perform. +public func withSnapshotTesting( + record: SnapshotTestingConfiguration.Record? = nil, + diffTool: SnapshotTestingConfiguration.DiffTool? = nil, + operation: () throws -> R +) rethrows -> R { + try SnapshotTestingConfiguration.$current.withValue( + SnapshotTestingConfiguration( + record: record ?? SnapshotTestingConfiguration.current?.record ?? _record, + diffTool: diffTool ?? SnapshotTestingConfiguration.current?.diffTool + ?? SnapshotTesting._diffTool + ) + ) { + try operation() + } +} + +/// Customizes `assertSnapshot` for the duration of an asynchronous operation. +/// +/// See ``withSnapshotTesting(record:diffTool:operation:)-2kuyr`` for more information. +public func withSnapshotTesting( + record: SnapshotTestingConfiguration.Record? = nil, + diffTool: SnapshotTestingConfiguration.DiffTool? = nil, + operation: () async throws -> R +) async rethrows -> R { + try await SnapshotTestingConfiguration.$current.withValue( + SnapshotTestingConfiguration( + record: record ?? SnapshotTestingConfiguration.current?.record ?? _record, + diffTool: diffTool ?? SnapshotTestingConfiguration.current?.diffTool ?? _diffTool + ) + ) { + try await operation() + } +} + +/// The configuration for a snapshot test. +public struct SnapshotTestingConfiguration: Sendable { + @_spi(Internals) + @TaskLocal public static var current: Self? + + /// The diff tool use to print helpful test failure messages. + /// + /// See ``DiffTool-swift.struct`` for more information. + public var diffTool: DiffTool? + + /// The recording strategy to use while running snapshot tests. + /// + /// See ``Record-swift.struct`` for more information. + public var record: Record? + + public init( + record: Record?, + diffTool: DiffTool? + ) { + self.diffTool = diffTool + self.record = record + } + + /// The record mode of the snapshot test. + /// + /// There are 4 primary strategies for recording: ``Record-swift.struct/all``, + /// ``Record-swift.struct/missing``, ``Record-swift.struct/never`` and + /// ``Record-swift.struct/failed`` + public struct Record: Equatable, Sendable { + private let storage: Storage + + public init?(rawValue: String) { + switch rawValue { + case "all": + self.storage = .all + case "failed": + self.storage = .failed + case "missing": + self.storage = .missing + case "never": + self.storage = .never + default: + return nil + } + } + + /// Records all snapshots to disk, no matter what. + public static let all = Self(storage: .all) + + /// Records snapshots for assertions that fail. This can be useful for tests that use precision + /// thresholds so that passing tests do not re-record snapshots that are subtly different but + /// still within the threshold. + public static let failed = Self(storage: .failed) + + /// Records only the snapshots that are missing from disk. + public static let missing = Self(storage: .missing) + + /// Does not record any snapshots. If a snapshot is missing a test failure will be raised. This + /// option is appropriate when running tests on CI so that re-tries of tests do not + /// surprisingly pass after snapshots are unexpectedly generated. + public static let never = Self(storage: .never) + + private init(storage: Storage) { + self.storage = storage + } + + private enum Storage: Equatable, Sendable { + case all + case failed + case missing + case never + } + } + + /// Describes the diff command used to diff two files on disk. + /// + /// This type can be created with a closure that takes two arguments: the first argument is + /// is a file path to the currently recorded snapshot on disk, and the second argument is the + /// file path to a _failed_ snapshot that was recorded to a temporary location on disk. You can + /// use these two file paths to construct a command that can be used to compare the two files. + /// + /// For example, to use ImageMagick's `compare` tool and pipe the result into Preview.app, you + /// could create the following `DiffTool`: + /// + /// ```swift + /// extension SnapshotTestingConfiguration.DiffTool { + /// static let compare = Self { + /// "compare \"\($0)\" \"\($1)\" png: | open -f -a Preview.app" + /// } + /// } + /// ``` + /// + /// `DiffTool` also comes with two values: ``DiffTool-swift.struct/ksdiff`` for printing a + /// command for opening [Kaleidoscope](https://kaleidoscope.app), and + /// ``DiffTool-swift.struct/default`` for simply printing the two URLs to the test failure + /// message. + public struct DiffTool: Sendable, ExpressibleByStringLiteral { + var tool: @Sendable (_ currentFilePath: String, _ failedFilePath: String) -> String + + public init( + _ tool: @escaping @Sendable (_ currentFilePath: String, _ failedFilePath: String) -> String + ) { + self.tool = tool + } + + public init(stringLiteral value: StringLiteralType) { + self.tool = { "\(value) \($0) \($1)" } + } + + /// The [Kaleidoscope](http://kaleidoscope.app) diff tool. + public static let ksdiff = Self { + "ksdiff \($0) \($1)" + } + + /// The default diff tool. + public static let `default` = Self { + """ + @\(minus) + "file://\($0)" + @\(plus) + "file://\($1)" + + To configure output for a custom diff tool, use 'withSnapshotTesting'. For example: + + withSnapshotTesting(diffTool: .ksdiff) { + // ... + } + """ + } + public func callAsFunction(currentFilePath: String, failedFilePath: String) -> String { + self.tool(currentFilePath, failedFilePath) + } + } +} + +@available( + iOS, + deprecated: 9999, + message: "Use '.all' instead of 'true', and '.missing' instead of 'false'." +) +@available( + macOS, + deprecated: 9999, + message: "Use '.all' instead of 'true', and '.missing' instead of 'false'." +) +@available( + tvOS, + deprecated: 9999, + message: "Use '.all' instead of 'true', and '.missing' instead of 'false'." +) +@available( + watchOS, + deprecated: 9999, + message: "Use '.all' instead of 'true', and '.missing' instead of 'false'." +) +@available( + visionOS, + deprecated: 9999, + message: "Use '.all' instead of 'true', and '.missing' instead of 'false'." +) +extension SnapshotTestingConfiguration.Record: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: BooleanLiteralType) { + self = value ? .all : .missing + } +} + +@available( + *, + deprecated, + renamed: "SnapshotTestingConfiguration.DiffTool.default", + message: "Use '.default' instead of a 'nil' value for 'diffTool'." +) +extension SnapshotTestingConfiguration.DiffTool: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { + self = .default + } +} diff --git a/Sources/SnapshotTesting/SnapshotsTestTrait.swift b/Sources/SnapshotTesting/SnapshotsTestTrait.swift new file mode 100644 index 00000000..4a392ff1 --- /dev/null +++ b/Sources/SnapshotTesting/SnapshotsTestTrait.swift @@ -0,0 +1,52 @@ +#if canImport(Testing) + @_spi(Experimental) import Testing + + @_spi(Experimental) + extension Trait where Self == _SnapshotsTestTrait { + /// Configure snapshot testing in a suite or test. + /// + /// - Parameters: + /// - record: The record mode of the test. + /// - diffTool: The diff tool to use in failure messages. + public static func snapshots( + record: SnapshotTestingConfiguration.Record? = nil, + diffTool: SnapshotTestingConfiguration.DiffTool? = nil + ) -> Self { + _SnapshotsTestTrait( + configuration: SnapshotTestingConfiguration( + record: record, + diffTool: diffTool + ) + ) + } + + /// Configure snapshot testing in a suite or test. + /// + /// - Parameter configuration: The configuration to use. + public static func snapshots( + _ configuration: SnapshotTestingConfiguration + ) -> Self { + _SnapshotsTestTrait(configuration: configuration) + } + } + + /// A type representing the configuration of snapshot testing. + @_spi(Experimental) + public struct _SnapshotsTestTrait: CustomExecutionTrait, SuiteTrait, TestTrait { + public let isRecursive = true + let configuration: SnapshotTestingConfiguration + + public func execute( + _ function: @escaping () async throws -> Void, + for test: Test, + testCase: Test.Case? + ) async throws { + try await withSnapshotTesting( + record: configuration.record, + diffTool: configuration.diffTool + ) { + try await function() + } + } + } +#endif diff --git a/Sources/SnapshotTesting/Snapshotting.swift b/Sources/SnapshotTesting/Snapshotting.swift index 02c65540..4e984f78 100644 --- a/Sources/SnapshotTesting/Snapshotting.swift +++ b/Sources/SnapshotTesting/Snapshotting.swift @@ -20,7 +20,6 @@ public struct Snapshotting { /// - diffing: How to diff and convert the snapshot format to and from data. /// - asyncSnapshot: An asynchronous transform function from a value into a diffable snapshot /// format. - /// - value: A value to be converted. public init( pathExtension: String?, diffing: Diffing, @@ -37,7 +36,6 @@ public struct Snapshotting { /// - pathExtension: The path extension applied to references saved to disk. /// - diffing: How to diff and convert the snapshot format to and from data. /// - snapshot: A transform function from a value into a diffable snapshot format. - /// - value: A snapshot value to be converted. public init( pathExtension: String?, diffing: Diffing, @@ -76,7 +74,6 @@ public struct Snapshotting { /// /// - Parameters: /// - transform: A transform function from `NewValue` into `Value`. - /// - otherValue: A value to be transformed. public func pullback(_ transform: @escaping (_ otherValue: NewValue) -> Value) -> Snapshotting { @@ -93,7 +90,6 @@ public struct Snapshotting { /// /// - Parameters: /// - transform: A transform function from `NewValue` into `Async`. - /// - otherValue: A value to be transformed. public func asyncPullback( _ transform: @escaping (_ otherValue: NewValue) -> Async ) -> Snapshotting { diff --git a/Sources/SnapshotTesting/Snapshotting/CGPath.swift b/Sources/SnapshotTesting/Snapshotting/CGPath.swift index 1e8c3044..cd48ec40 100644 --- a/Sources/SnapshotTesting/Snapshotting/CGPath.swift +++ b/Sources/SnapshotTesting/Snapshotting/CGPath.swift @@ -25,8 +25,11 @@ /// match. 98-99% mimics /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the /// human eye. + /// - drawingMode: The drawing mode. public static func image( - precision: Float = 1, perceptualPrecision: Float = 1, drawingMode: CGPathDrawingMode = .eoFill + precision: Float = 1, + perceptualPrecision: Float = 1, + drawingMode: CGPathDrawingMode = .eoFill ) -> Snapshotting { return SimplySnapshotting.image( precision: precision, perceptualPrecision: perceptualPrecision diff --git a/Tests/InlineSnapshotTestingTests/AssertInlineSnapshotSwiftTests.swift b/Tests/InlineSnapshotTestingTests/AssertInlineSnapshotSwiftTests.swift new file mode 100644 index 00000000..15a1f2eb --- /dev/null +++ b/Tests/InlineSnapshotTestingTests/AssertInlineSnapshotSwiftTests.swift @@ -0,0 +1,307 @@ +#if canImport(Testing) +import Testing +import Foundation +import InlineSnapshotTesting +@_spi(Experimental) import SnapshotTesting + +@Suite( + .snapshots( + record: .missing + ) +) +struct AssertInlineSnapshotTests { + @Test func inlineSnapshot() { + assertInlineSnapshot(of: ["Hello", "World"], as: .dump) { + """ + ▿ 2 elements + - "Hello" + - "World" + + """ + } + } + + @Test func inlineSnapshot_NamedTrailingClosure() { + assertInlineSnapshot( + of: ["Hello", "World"], as: .dump, + matches: { + """ + ▿ 2 elements + - "Hello" + - "World" + + """ + }) + } + + @Test func inlineSnapshot_Escaping() { + assertInlineSnapshot(of: "Hello\"\"\"#, world", as: .lines) { + ##""" + Hello"""#, world + """## + } + } + + @Test func customInlineSnapshot() { + assertCustomInlineSnapshot { + "Hello" + } is: { + """ + - "Hello" + + """ + } + } + + @Test func customInlineSnapshot_Multiline() { + assertCustomInlineSnapshot { + """ + "Hello" + "World" + """ + } is: { + #""" + - "\"Hello\"\n\"World\"" + + """# + } + } + + @Test func customInlineSnapshot_SingleTrailingClosure() { + assertCustomInlineSnapshot(of: { "Hello" }) { + """ + - "Hello" + + """ + } + } + + @Test func customInlineSnapshot_MultilineSingleTrailingClosure() { + assertCustomInlineSnapshot( + of: { "Hello" } + ) { + """ + - "Hello" + + """ + } + } + + @Test func customInlineSnapshot_NoTrailingClosure() { + assertCustomInlineSnapshot( + of: { "Hello" }, + is: { + """ + - "Hello" + + """ + } + ) + } + + @Test func argumentlessInlineSnapshot() { + func assertArgumentlessInlineSnapshot( + expected: (() -> String)? = nil, + file: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column + ) { + assertInlineSnapshot( + of: "Hello", + as: .dump, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "is", + trailingClosureOffset: 1 + ), + matches: expected, + file: file, + function: function, + line: line, + column: column + ) + } + + assertArgumentlessInlineSnapshot { + """ + - "Hello" + + """ + } + } + + @Test func multipleInlineSnapshots() { + func assertResponse( + of url: () -> String, + head: (() -> String)? = nil, + body: (() -> String)? = nil, + file: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column + ) { + assertInlineSnapshot( + of: """ + HTTP/1.1 200 OK + Content-Type: text/html; charset=utf-8 + """, + as: .lines, + message: "Head did not match", + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "head", + trailingClosureOffset: 1 + ), + matches: head, + file: file, + function: function, + line: line, + column: column + ) + assertInlineSnapshot( + of: """ + + + + + Point-Free + + + +

What's the point?

+ + + """, + as: .lines, + message: "Body did not match", + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "body", + trailingClosureOffset: 2 + ), + matches: body, + file: file, + function: function, + line: line, + column: column + ) + } + + assertResponse { + """ + https://www.pointfree.co/ + """ + } head: { + """ + HTTP/1.1 200 OK + Content-Type: text/html; charset=utf-8 + """ + } body: { + """ + + + + + Point-Free + + + +

What's the point?

+ + + """ + } + } + + @Test func asyncThrowing() async throws { + func assertAsyncThrowingInlineSnapshot( + of value: () -> String, + is expected: (() -> String)? = nil, + file: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column + ) async throws { + assertInlineSnapshot( + of: value(), + as: .dump, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "is", + trailingClosureOffset: 1 + ), + matches: expected, + file: file, + function: function, + line: line, + column: column + ) + } + + try await assertAsyncThrowingInlineSnapshot { + "Hello" + } is: { + """ + - "Hello" + + """ + } + } + + @Test func nestedInClosureFunction() { + func withDependencies(operation: () -> Void) { + operation() + } + + withDependencies { + assertInlineSnapshot(of: "Hello", as: .dump) { + """ + - "Hello" + + """ + } + } + } + + @Test func carriageReturnInlineSnapshot() { + assertInlineSnapshot(of: "This is a line\r\nAnd this is a line\r\n", as: .lines) { + """ + This is a line\r + And this is a line\r + + """ + } + } + + @Test func carriageReturnRawInlineSnapshot() { + assertInlineSnapshot(of: "\"\"\"#This is a line\r\nAnd this is a line\r\n", as: .lines) { + ##""" + """#This is a line\##r + And this is a line\##r + + """## + } + } +} + +private func assertCustomInlineSnapshot( + of value: () -> String, + is expected: (() -> String)? = nil, + file: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column +) { + assertInlineSnapshot( + of: value(), + as: .dump, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "is", + trailingClosureOffset: 1 + ), + matches: expected, + file: file, + function: function, + line: line, + column: column + ) +} + +#endif diff --git a/Tests/InlineSnapshotTestingTests/InlineSnapshotTestingTests.swift b/Tests/InlineSnapshotTestingTests/InlineSnapshotTestingTests.swift index 7292f589..70bace93 100644 --- a/Tests/InlineSnapshotTestingTests/InlineSnapshotTestingTests.swift +++ b/Tests/InlineSnapshotTestingTests/InlineSnapshotTestingTests.swift @@ -5,13 +5,9 @@ import XCTest final class InlineSnapshotTestingTests: XCTestCase { override func invokeTest() { - SnapshotTesting.diffTool = "ksdiff" - // SnapshotTesting.isRecording = true - defer { - SnapshotTesting.diffTool = nil - SnapshotTesting.isRecording = false + withSnapshotTesting(record: .missing, diffTool: .ksdiff) { + super.invokeTest() } - super.invokeTest() } func testInlineSnapshot() { diff --git a/Tests/SnapshotTestingTests/DeprecationTests.swift b/Tests/SnapshotTestingTests/DeprecationTests.swift new file mode 100644 index 00000000..f3725a65 --- /dev/null +++ b/Tests/SnapshotTestingTests/DeprecationTests.swift @@ -0,0 +1,13 @@ +import SnapshotTesting +import XCTest + +final class DeprecationTests: XCTestCase { + @available(*, deprecated) + func testIsRecordingProxy() { + SnapshotTesting.record = true + XCTAssertEqual(isRecording, true) + + SnapshotTesting.record = false + XCTAssertEqual(isRecording, false) + } +} diff --git a/Tests/SnapshotTestingTests/RecordTests.swift b/Tests/SnapshotTestingTests/RecordTests.swift new file mode 100644 index 00000000..cfe40e83 --- /dev/null +++ b/Tests/SnapshotTestingTests/RecordTests.swift @@ -0,0 +1,204 @@ +import SnapshotTesting +import XCTest + +class RecordTests: XCTestCase { + var snapshotURL: URL! + + override func setUp() { + super.setUp() + + let testName = String( + self.name + .split(separator: " ") + .flatMap { String($0).split(separator: ".") } + .last! + ) + .prefix(while: { $0 != "]" }) + let fileURL = URL(fileURLWithPath: #file, isDirectory: false) + let testClassName = fileURL.deletingPathExtension().lastPathComponent + let testDirectory = + fileURL + .deletingLastPathComponent() + .appendingPathComponent("__Snapshots__") + .appendingPathComponent(testClassName) + snapshotURL = + testDirectory + .appendingPathComponent("\(testName).1.json") + try? FileManager.default + .removeItem(at: snapshotURL.deletingLastPathComponent()) + try? FileManager.default + .createDirectory(at: testDirectory, withIntermediateDirectories: true) + } + + override func tearDown() { + super.tearDown() + try? FileManager.default + .removeItem(at: snapshotURL.deletingLastPathComponent()) + } + + #if canImport(Darwin) + func testRecordNever() { + XCTExpectFailure { + withSnapshotTesting(record: .never) { + assertSnapshot(of: 42, as: .json) + } + } issueMatcher: { + $0.compactDescription == """ + failed - The file “testRecordNever.1.json” couldn’t be opened because there is no such file. + """ + } + + XCTAssertEqual( + FileManager.default.fileExists(atPath: snapshotURL.path), + false + ) + } + #endif + + #if canImport(Darwin) + func testRecordMissing() { + XCTExpectFailure { + withSnapshotTesting(record: .missing) { + assertSnapshot(of: 42, as: .json) + } + } issueMatcher: { + $0.compactDescription.hasPrefix( + """ + failed - No reference was found on disk. Automatically recorded snapshot: … + """) + } + + try XCTAssertEqual( + String(decoding: Data(contentsOf: snapshotURL), as: UTF8.self), + "42" + ) + } + #endif + + #if canImport(Darwin) + func testRecordMissing_ExistingFile() throws { + try Data("999".utf8).write(to: snapshotURL) + + XCTExpectFailure { + withSnapshotTesting(record: .missing) { + assertSnapshot(of: 42, as: .json) + } + } issueMatcher: { + $0.compactDescription.hasPrefix( + """ + failed - Snapshot does not match reference. + """) + } + + try XCTAssertEqual( + String(decoding: Data(contentsOf: snapshotURL), as: UTF8.self), + "999" + ) + } + #endif + + #if canImport(Darwin) + func testRecordAll_Fresh() throws { + XCTExpectFailure { + withSnapshotTesting(record: .all) { + assertSnapshot(of: 42, as: .json) + } + } issueMatcher: { + $0.compactDescription.hasPrefix( + """ + failed - Record mode is on. Automatically recorded snapshot: … + """) + } + + try XCTAssertEqual( + String(decoding: Data(contentsOf: snapshotURL), as: UTF8.self), + "42" + ) + } + #endif + + #if canImport(Darwin) + func testRecordAll_Overwrite() throws { + try Data("999".utf8).write(to: snapshotURL) + + XCTExpectFailure { + withSnapshotTesting(record: .all) { + assertSnapshot(of: 42, as: .json) + } + } issueMatcher: { + $0.compactDescription.hasPrefix( + """ + failed - Record mode is on. Automatically recorded snapshot: … + """) + } + + try XCTAssertEqual( + String(decoding: Data(contentsOf: snapshotURL), as: UTF8.self), + "42" + ) + } + #endif + + #if canImport(Darwin) + func testRecordFailed_WhenFailure() throws { + try Data("999".utf8).write(to: snapshotURL) + + XCTExpectFailure { + withSnapshotTesting(record: .failed) { + assertSnapshot(of: 42, as: .json) + } + } issueMatcher: { + $0.compactDescription.hasPrefix( + """ + failed - Snapshot does not match reference. A new snapshot was automatically recorded. + """) + } + + try XCTAssertEqual( + String(decoding: Data(contentsOf: snapshotURL), as: UTF8.self), + "42" + ) + } + #endif + + func testRecordFailed_NoFailure() throws { + try Data("42".utf8).write(to: snapshotURL) + let modifiedDate = + try FileManager.default + .attributesOfItem(atPath: snapshotURL.path)[FileAttributeKey.modificationDate] as! Date + + withSnapshotTesting(record: .failed) { + assertSnapshot(of: 42, as: .json) + } + + try XCTAssertEqual( + String(decoding: Data(contentsOf: snapshotURL), as: UTF8.self), + "42" + ) + XCTAssertEqual( + try FileManager.default + .attributesOfItem(atPath: snapshotURL.path)[FileAttributeKey.modificationDate] as! Date, + modifiedDate + ) + } + + #if canImport(Darwin) + func testRecordFailed_MissingFile() throws { + XCTExpectFailure { + withSnapshotTesting(record: .failed) { + assertSnapshot(of: 42, as: .json) + } + } issueMatcher: { + $0.compactDescription.hasPrefix( + """ + failed - No reference was found on disk. Automatically recorded snapshot: … + """) + } + + try XCTAssertEqual( + String(decoding: Data(contentsOf: snapshotURL), as: UTF8.self), + "42" + ) + } + #endif +} diff --git a/Tests/SnapshotTestingTests/SnapshotTestingTests.swift b/Tests/SnapshotTestingTests/SnapshotTestingTests.swift index 07aa425a..95a66b18 100644 --- a/Tests/SnapshotTestingTests/SnapshotTestingTests.swift +++ b/Tests/SnapshotTestingTests/SnapshotTestingTests.swift @@ -21,15 +21,13 @@ import XCTest #endif final class SnapshotTestingTests: XCTestCase { - override func setUp() { - super.setUp() - diffTool = "ksdiff" - // isRecording = true - } - - override func tearDown() { - isRecording = false - super.tearDown() + override func invokeTest() { + withSnapshotTesting( + record: .missing, + diffTool: .ksdiff + ) { + super.invokeTest() + } } func testAny() { @@ -1316,15 +1314,6 @@ final class SnapshotTestingTests: XCTestCase { assertSnapshot(of: view, as: .image(layout: .device(config: .tv)), named: "device") } #endif - - @available(*, deprecated) - func testIsRecordingProxy() { - SnapshotTesting.record = true - XCTAssertEqual(isRecording, true) - - SnapshotTesting.record = false - XCTAssertEqual(isRecording, false) - } } #if os(iOS) @@ -1344,30 +1333,3 @@ final class SnapshotTestingTests: XCTestCase { "accessibility-extra-extra-extra-large": .accessibilityExtraExtraExtraLarge, ] #endif - -#if os(Linux) || os(Windows) - extension SnapshotTestingTests { - static var allTests: [(String, (SnapshotTestingTests) -> () throws -> Void)] { - return [ - ("testAny", testAny), - ("testAnySnapshotStringConvertible", testAnySnapshotStringConvertible), - ("testAutolayout", testAutolayout), - ("testDeterministicDictionaryAndSetSnapshots", testDeterministicDictionaryAndSetSnapshots), - ("testEncodable", testEncodable), - ("testMixedViews", testMixedViews), - ("testMultipleSnapshots", testMultipleSnapshots), - ("testNamedAssertion", testNamedAssertion), - ("testPrecision", testPrecision), - ("testSCNView", testSCNView), - ("testSKView", testSKView), - ("testTableViewController", testTableViewController), - ("testTraits", testTraits), - ("testTraitsEmbeddedInTabNavigation", testTraitsEmbeddedInTabNavigation), - ("testTraitsWithView", testTraitsWithView), - ("testUIView", testUIView), - ("testURLRequest", testURLRequest), - ("testWebView", testWebView), - ] - } - } -#endif diff --git a/Tests/SnapshotTestingTests/SnapshotsTraitTests.swift b/Tests/SnapshotTestingTests/SnapshotsTraitTests.swift new file mode 100644 index 00000000..a3a7ad2f --- /dev/null +++ b/Tests/SnapshotTestingTests/SnapshotsTraitTests.swift @@ -0,0 +1,54 @@ +#if compiler(>=6) && canImport(Testing) +@_spi(Experimental) import Testing +@_spi(Experimental) @_spi(Internals) import SnapshotTesting + + +struct SnapshotsTraitTests { + @Test(.snapshots(diffTool: "ksdiff")) + func testDiffTool() { + #expect( + SnapshotTestingConfiguration.current? + .diffTool?(currentFilePath: "old.png", failedFilePath: "new.png") + == "ksdiff old.png new.png" + ) + } + + @Suite(.snapshots(diffTool: "ksdiff")) + struct OverrideDiffTool { + @Test(.snapshots(diffTool: "difftool")) + func testDiffToolOverride() { + #expect( + SnapshotTestingConfiguration.current? + .diffTool?(currentFilePath: "old.png", failedFilePath: "new.png") + == "difftool old.png new.png" + ) + } + + @Suite(.snapshots(record: .all)) + struct OverrideRecord { + @Test + func config() { + #expect( + SnapshotTestingConfiguration.current? + .diffTool?(currentFilePath: "old.png", failedFilePath: "new.png") + == "ksdiff old.png new.png" + ) + #expect(SnapshotTestingConfiguration.current?.record == .all) + } + + @Suite(.snapshots(record: .failed, diffTool: "diff")) + struct OverrideDiffToolAndRecord { + @Test + func config() { + #expect( + SnapshotTestingConfiguration.current? + .diffTool?(currentFilePath: "old.png", failedFilePath: "new.png") + == "diff old.png new.png" + ) + #expect(SnapshotTestingConfiguration.current?.record == .failed) + } + } + } + } +} +#endif diff --git a/Tests/SnapshotTestingTests/WithSnapshotTestingTests.swift b/Tests/SnapshotTestingTests/WithSnapshotTestingTests.swift new file mode 100644 index 00000000..b6f6c6f5 --- /dev/null +++ b/Tests/SnapshotTestingTests/WithSnapshotTestingTests.swift @@ -0,0 +1,35 @@ +import XCTest + +@_spi(Internals) @testable import SnapshotTesting + +class WithSnapshotTestingTests: XCTestCase { + func testNesting() { + withSnapshotTesting(record: .all) { + XCTAssertEqual( + SnapshotTestingConfiguration.current? + .diffTool?(currentFilePath: "old.png", failedFilePath: "new.png"), + """ + @− + "file://old.png" + @+ + "file://new.png" + + To configure output for a custom diff tool, use 'withSnapshotTesting'. For example: + + withSnapshotTesting(diffTool: .ksdiff) { + // ... + } + """ + ) + XCTAssertEqual(SnapshotTestingConfiguration.current?.record, .all) + withSnapshotTesting(diffTool: "ksdiff") { + XCTAssertEqual( + SnapshotTestingConfiguration.current? + .diffTool?(currentFilePath: "old.png", failedFilePath: "new.png"), + "ksdiff old.png new.png" + ) + XCTAssertEqual(SnapshotTestingConfiguration.current?.record, .all) + } + } + } +} diff --git a/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testCGPath.macOS.png b/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testCGPath.macOS.png index e6e25b5b..0731e431 100644 Binary files a/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testCGPath.macOS.png and b/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testCGPath.macOS.png differ diff --git a/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testNSBezierPath.macOS.png b/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testNSBezierPath.macOS.png index cd649f4e..6876bd00 100644 Binary files a/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testNSBezierPath.macOS.png and b/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testNSBezierPath.macOS.png differ diff --git a/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testNSView.1.png b/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testNSView.1.png index 2c9a5ce5..5c2d5782 100644 Binary files a/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testNSView.1.png and b/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testNSView.1.png differ diff --git a/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testNSView.2.txt b/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testNSView.2.txt index a9694f78..5880103b 100644 --- a/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testNSView.2.txt +++ b/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testNSView.2.txt @@ -1,4 +1,4 @@ -[ AF ! wLU ] h=--- v=--- NSButton "Push Me" f=(0,0,83,32) b=(-) => <_NSViewBackingLayer> - [ A ! wLU ] h=--- v=--- NSButtonBezelView f=(0,0,83,32) b=(-) => <_NSViewBackingLayer> - [ AF ! wLU ] h=--- v=--- NSButtonTextField "Push Me" f=(11,7,61,15) b=(-) => +[ AF ! wLU ] h=--- v=--- NSButton "Push Me" f=(0,0,87,32) b=(-) => + [ A ! wLU ] h=--- v=--- NSButtonBezelView f=(0,0,87,32) b=(-) => + [ AF ! wLU ] h=--- v=--- NSButtonTextField "Push Me" f=(11,6,65,16) b=(-) => A=autoresizesSubviews, C=canDrawConcurrently, D=needsDisplay, F=flipped, G=gstate, H=hidden (h=by ancestor), L=needsLayout (l=child needsLayout), U=needsUpdateConstraints (u=child needsUpdateConstraints), O=opaque, P=preservesContentDuringLiveResize, S=scaled/rotated, W=wantsLayer (w=ancestor wantsLayer), V=needsVibrancy (v=allowsVibrancy), #=has surface diff --git a/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testNSViewWithLayer.2.txt b/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testNSViewWithLayer.2.txt index d5f30a13..c4763d5c 100644 --- a/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testNSViewWithLayer.2.txt +++ b/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testNSViewWithLayer.2.txt @@ -1,2 +1,2 @@ -[ A ! W U ] h=--- v=--- NSView f=(0,0,10,10) b=(-) => <_NSViewBackingLayer> +[ A ! W U ] h=--- v=--- NSView f=(0,0,10,10) b=(-) => A=autoresizesSubviews, C=canDrawConcurrently, D=needsDisplay, F=flipped, G=gstate, H=hidden (h=by ancestor), L=needsLayout (l=child needsLayout), U=needsUpdateConstraints (u=child needsUpdateConstraints), O=opaque, P=preservesContentDuringLiveResize, S=scaled/rotated, W=wantsLayer (w=ancestor wantsLayer), V=needsVibrancy (v=allowsVibrancy), #=has surface diff --git a/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testPrecision.macos.png b/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testPrecision.macos.png index d0f149a5..e13d0130 100644 Binary files a/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testPrecision.macos.png and b/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testPrecision.macos.png differ diff --git a/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testWebView.macos.png b/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testWebView.macos.png index f6fd0490..ab2073c8 100644 Binary files a/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testWebView.macos.png and b/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testWebView.macos.png differ diff --git a/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testWebViewWithManipulatingNavigationDelegate.macos.png b/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testWebViewWithManipulatingNavigationDelegate.macos.png index f6fd0490..ab2073c8 100644 Binary files a/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testWebViewWithManipulatingNavigationDelegate.macos.png and b/Tests/SnapshotTestingTests/__Snapshots__/SnapshotTestingTests/testWebViewWithManipulatingNavigationDelegate.macos.png differ