From a1dbd10264e80d69cdf569b8c835cd1540170599 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 7 May 2024 18:49:28 -0700 Subject: [PATCH] Add `assert` and `precondition` (#213) * Add `assert` and `precondition` Lightweight ways of writing assertions and preconditions in a testable fashion. * wip * wip * wip * Update Sources/Dependencies/DependencyValues/Assert.swift * wip * wip * wip * wip --- .../xcshareddata/swiftpm/Package.resolved | 39 ++-- Package.resolved | 20 +- .../DependencyValues/Assert.swift | 196 ++++++++++++++++++ .../Extensions/DependencyValues.md | 5 +- .../Extensions/DependencyValuesAssert.md | 12 ++ Tests/DependenciesTests/AssertTests.swift | 53 +++++ 6 files changed, 299 insertions(+), 26 deletions(-) create mode 100644 Sources/Dependencies/DependencyValues/Assert.swift create mode 100644 Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesAssert.md create mode 100644 Tests/DependenciesTests/AssertTests.swift diff --git a/Dependencies.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Dependencies.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9fad66b3..356c6c59 100644 --- a/Dependencies.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Dependencies.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "fddd1c00396eed152c45a46bea9f47b98e59301d", - "version" : "1.2.0" + "revision" : "46989693916f56d1186bd59ac15124caef896560", + "version" : "1.3.1" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "d1fd837326aa719bee979bdde1f53cd5797443eb", - "version" : "1.0.0" + "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", + "version" : "1.0.2" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "ea631ce892687f5432a833312292b80db238186a", - "version" : "1.0.0" + "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", + "version" : "1.1.0" } }, { @@ -50,7 +50,16 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-docc-plugin", "state" : { - "revision" : "3303b164430d9a7055ba484c8ead67a52f7b74f6", + "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", "version" : "1.0.0" } }, @@ -59,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-macro-testing", "state" : { - "revision" : "35acd9468d40ae87e75991a18af6271e8124c261", - "version" : "0.2.1" + "revision" : "5c4a1b9d7c23cd5c08ea50677d8e89080365cb00", + "version" : "0.4.0" } }, { @@ -68,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "bb0ea08db8e73324fe6c3727f755ca41a23ff2f4", - "version" : "1.14.2" + "revision" : "625ccca8570773dd84a34ee51a81aa2bc5a4f97a", + "version" : "1.16.0" } }, { @@ -77,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax", "state" : { - "revision" : "ffa3cd6fc2aa62adbedd31d3efaf7c0d86a9f029", - "version" : "509.0.1" + "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", + "version" : "510.0.2" } }, { @@ -86,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "b13b1d1a8e787a5ffc71ac19dcaf52183ab27ba2", - "version" : "1.1.1" + "revision" : "6f30bdba373bbd7fbfe241dddd732651f2fbd1e2", + "version" : "1.1.2" } } ], diff --git a/Package.resolved b/Package.resolved index 295b4a31..356c6c59 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "c8ed701b513cf5177118a175d85fbbbcd707ab41", - "version" : "1.3.0" + "revision" : "46989693916f56d1186bd59ac15124caef896560", + "version" : "1.3.1" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-macro-testing", "state" : { - "revision" : "90e38eec4bf661ec0da1bbfd3ec507d0f0c05310", - "version" : "0.3.0" + "revision" : "5c4a1b9d7c23cd5c08ea50677d8e89080365cb00", + "version" : "0.4.0" } }, { @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "5b0c434778f2c1a4c9b5ebdb8682b28e84dd69bd", - "version" : "1.15.4" + "revision" : "625ccca8570773dd84a34ee51a81aa2bc5a4f97a", + "version" : "1.16.0" } }, { @@ -86,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax", "state" : { - "revision" : "08a2f0a9a30e0f705f79c9cfaca1f68b71bdc775", - "version" : "510.0.0" + "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", + "version" : "510.0.2" } }, { @@ -95,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "b13b1d1a8e787a5ffc71ac19dcaf52183ab27ba2", - "version" : "1.1.1" + "revision" : "6f30bdba373bbd7fbfe241dddd732651f2fbd1e2", + "version" : "1.1.2" } } ], diff --git a/Sources/Dependencies/DependencyValues/Assert.swift b/Sources/Dependencies/DependencyValues/Assert.swift new file mode 100644 index 00000000..ae04c91d --- /dev/null +++ b/Sources/Dependencies/DependencyValues/Assert.swift @@ -0,0 +1,196 @@ +extension DependencyValues { + /// A dependency for handling assertions. + /// + /// Useful as a controllable and testable substitute for Swift's `assert` function that calls + /// `XCTFail` in tests instead of terminating the executable. + /// + /// ```swift + /// func operate(_ n: Int) { + /// @Dependency(\.assert) var assert + /// assert(n > 0, "Number must be greater than zero") + /// // ... + /// } + /// ``` + /// + /// Tests can assert against this precondition using `XCTExpectFailure`: + /// + /// ```swift + /// XCTExpectFailure { + /// operate(n) + /// } issueMatcher: { + /// $0.compactDescription = "Number must be greater than zero" + /// } + /// ``` + public var assert: any AssertionEffect { + get { self[AssertKey.self] } + set { self[AssertKey.self] = newValue } + } + + /// A dependency for failing an assertion. + /// + /// Equivalent to passing a `false` condition to ``DependencyValues/assert``. + public var assertionFailure: any AssertionFailureEffect { + AssertionFailure(base: self.assert) + } + + /// A dependency for handling preconditions. + /// + /// Useful as a controllable and testable substitute for Swift's `precondition` function that + /// calls `XCTFail` in tests instead of terminating the executable. + /// + /// ```swift + /// func operate(_ n: Int) { + /// @Dependency(\.precondition) var precondition + /// precondition(n > 0, "Number must be greater than zero") + /// // ... + /// } + /// ``` + /// + /// Tests can assert against this precondition using `XCTExpectFailure`: + /// + /// ```swift + /// XCTExpectFailure { + /// operate(n) + /// } issueMatcher: { + /// $0.compactDescription = "Number must be greater than zero" + /// } + /// ``` + public var precondition: any AssertionEffect { + get { self[PreconditionKey.self] } + set { self[PreconditionKey.self] = newValue } + } +} + +/// A type for creating an assertion or precondition. +/// +/// See ``DependencyValues/assert`` or ``DependencyValues/precondition`` for more information. +public protocol AssertionEffect: Sendable { + func callAsFunction( + _ condition: @autoclosure () -> Bool, + _ message: @autoclosure () -> String, + file: StaticString, + line: UInt + ) +} + +extension AssertionEffect { + @_disfavoredOverload + @_transparent + public func callAsFunction( + _ condition: @autoclosure () -> Bool, + _ message: @autoclosure () -> String = "", + file: StaticString = #file, + line: UInt = #line + ) { + self.callAsFunction(condition(), message(), file: file, line: line) + } +} + +private struct LiveAssertionEffect: AssertionEffect { + @_transparent + func callAsFunction( + _ condition: @autoclosure () -> Bool, + _ message: @autoclosure () -> String, + file: StaticString, + line: UInt + ) { + Swift.assert(condition(), message(), file: file, line: line) + } +} + +private struct LivePreconditionEffect: AssertionEffect { + @_transparent + func callAsFunction( + _ condition: @autoclosure () -> Bool, + _ message: @autoclosure () -> String, + file: StaticString, + line: UInt + ) { + Swift.precondition(condition(), message(), file: file, line: line) + } +} + +private struct TestAssertionEffect: AssertionEffect { + @_transparent + func callAsFunction( + _ condition: @autoclosure () -> Bool, + _ message: @autoclosure () -> String, + file: StaticString, + line: UInt + ) { + guard condition() else { return XCTFail(message(), file: file, line: line) } + } +} + +public protocol AssertionFailureEffect: Sendable { + func callAsFunction( + _ message: @autoclosure () -> String, + file: StaticString, + line: UInt + ) +} + +extension AssertionFailureEffect { + @_disfavoredOverload + @_transparent + public func callAsFunction( + _ message: @autoclosure () -> String = "", + file: StaticString = #file, + line: UInt = #line + ) { + self.callAsFunction(message(), file: file, line: line) + } +} + +private struct AssertionFailure: AssertionFailureEffect { + let base: any AssertionEffect + + @_transparent + func callAsFunction( + _ message: @autoclosure () -> String, + file: StaticString, + line: UInt + ) { + self.base(false, message(), file: file, line: line) + } +} + +private enum AssertKey: DependencyKey { + public static let liveValue: any AssertionEffect = LiveAssertionEffect() + public static let testValue: any AssertionEffect = TestAssertionEffect() +} + +private enum PreconditionKey: DependencyKey { + public static let liveValue: any AssertionEffect = LivePreconditionEffect() + public static let testValue: any AssertionEffect = TestAssertionEffect() +} + +/// An ``AssertionEffect`` that invokes the given closure. +public struct AnyAssertionEffect: AssertionEffect { + private let assert: @Sendable ( + _ condition: @autoclosure () -> Bool, + _ message: @autoclosure () -> String, + _ file: StaticString, + _ line: UInt + ) -> Void + + public init( + _ assert: @escaping @Sendable ( + _ condition: @autoclosure () -> Bool, + _ message: @autoclosure () -> String, + _ file: StaticString, + _ line: UInt + ) -> Void + ) { + self.assert = assert + } + + public func callAsFunction( + _ condition: @autoclosure () -> Bool, + _ message: @autoclosure () -> String, + file: StaticString, + line: UInt + ) { + self.assert(condition(), message(), file, line) + } +} diff --git a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValues.md b/Sources/Dependencies/Documentation.docc/Extensions/DependencyValues.md index e61c206a..8126b78e 100644 --- a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValues.md +++ b/Sources/Dependencies/Documentation.docc/Extensions/DependencyValues.md @@ -5,7 +5,7 @@ ### Creating and accessing values - ``init()`` -- ``subscript(_:_:_:_:)`` +- ``subscript(key:file:function:line:) ### Overriding values @@ -18,6 +18,8 @@ ### Dependency values +- ``assert`` +- ``assertionFailure`` - ``calendar`` - ``context`` - ``continuousClock`` @@ -27,6 +29,7 @@ - ``mainQueue`` - ``mainRunLoop`` - ``openURL`` +- ``precondition`` - ``suspendingClock`` - ``timeZone`` - ``urlSession`` diff --git a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesAssert.md b/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesAssert.md new file mode 100644 index 00000000..a5b37d30 --- /dev/null +++ b/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesAssert.md @@ -0,0 +1,12 @@ +# ``Dependencies/DependencyValues/assert`` + +## Topics + +### Dependency values + +- ``AssertionEffect`` +- ``AssertionFailureEffect`` + +### Custom assertions + +- ``AnyAssertionEffect`` diff --git a/Tests/DependenciesTests/AssertTests.swift b/Tests/DependenciesTests/AssertTests.swift new file mode 100644 index 00000000..9a4ca3cc --- /dev/null +++ b/Tests/DependenciesTests/AssertTests.swift @@ -0,0 +1,53 @@ +import Dependencies +import XCTest + +final class AssertTests: XCTestCase { + @Dependency(\.assert) var assert + @Dependency(\.assertionFailure) var assertionFailure + @Dependency(\.precondition) var precondition + + func testPass() { + assert(true) + assert(true, "Must be true") + precondition(true) + precondition(true, "Must be true") + } + + #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) + func testFail() { + XCTExpectFailure { + assert(false) + } + XCTExpectFailure { + assert(false, "Must be true") + } issueMatcher: { + $0.compactDescription == "Must be true" + } + XCTExpectFailure { + assertionFailure("Failure") + } issueMatcher: { + $0.compactDescription == "Failure" + } + XCTExpectFailure { + precondition(false) + } + XCTExpectFailure { + precondition(false, "Must be true") + } issueMatcher: { + $0.compactDescription == "Must be true" + } + } + #endif + + func testCustom() { + let expectation = self.expectation(description: "assert") + withDependencies { + $0.assert = AnyAssertionEffect { condition, message, file, line in + expectation.fulfill() + } + } operation: { + assert(true) + self.wait(for: [expectation], timeout: 0) + } + } +}