From db8206d0839488826e324f6effb96c9b78d43097 Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Wed, 29 Nov 2023 23:28:07 -0800 Subject: [PATCH] Introduce the require-dsl. expect, but it returns the result of the expression As implied, this is basically a copy-paste of the functionality of expect. It's not complete, and it's not well-tested. But it gets the idea across. Future work is cleaning this up, backfilling tests for features (such as the unwrap function), and adding support for polling requirements (i.e. toEventually). Other than functionality, require also files the errorThrown issue type with XCTest, whereas expect files the assertionFailed issue type. This has minor differences that are mostly semantics. Also, in addition to the require dsl, this also adds unwrap, which is a shorthand for `require(...).toNot(beNil())`. --- Nimble.xcodeproj/project.pbxproj | 8 + .../Nimble/Adapters/AdapterProtocols.swift | 1 + .../Nimble/Adapters/AssertionDispatcher.swift | 6 + .../Nimble/Adapters/AssertionRecorder.swift | 16 +- .../Nimble/Adapters/NimbleXCTestHandler.swift | 40 ++++- Sources/Nimble/DSL+Require.swift | 50 ++++++ Sources/Nimble/Requirement.swift | 149 ++++++++++++++++++ Tests/NimbleTests/DSLTest.swift | 16 ++ 8 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 Sources/Nimble/DSL+Require.swift create mode 100644 Sources/Nimble/Requirement.swift diff --git a/Nimble.xcodeproj/project.pbxproj b/Nimble.xcodeproj/project.pbxproj index 9c387bc9d..b6680e535 100644 --- a/Nimble.xcodeproj/project.pbxproj +++ b/Nimble.xcodeproj/project.pbxproj @@ -125,6 +125,8 @@ 857D1849253610A900D8693A /* BeWithin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 857D1848253610A900D8693A /* BeWithin.swift */; }; 857D184F2536124400D8693A /* BeWithinTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 857D184D2536123F00D8693A /* BeWithinTest.swift */; }; 8913649429E6925F00AD535E /* utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F14FB63194180C5009F2A08 /* utils.swift */; }; + 891729D52B1842D6005CC866 /* DSL+Require.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891729D42B1842D6005CC866 /* DSL+Require.swift */; }; + 891729D72B18431D005CC866 /* Requirement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891729D62B18431D005CC866 /* Requirement.swift */; }; 891A04712AB0164500B46613 /* AsyncTimerSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891A04702AB0164500B46613 /* AsyncTimerSequence.swift */; }; 892FDF1329D3EA7700523A80 /* AsyncExpression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FDF1229D3EA7700523A80 /* AsyncExpression.swift */; }; 896962412A5FABD000A7929D /* AsyncAllPass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896962402A5FABD000A7929D /* AsyncAllPass.swift */; }; @@ -310,6 +312,8 @@ 7B5358C11C39155600A23FAA /* ObjCSatisfyAnyOfTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ObjCSatisfyAnyOfTest.m; sourceTree = ""; }; 857D1848253610A900D8693A /* BeWithin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeWithin.swift; sourceTree = ""; }; 857D184D2536123F00D8693A /* BeWithinTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeWithinTest.swift; sourceTree = ""; }; + 891729D42B1842D6005CC866 /* DSL+Require.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DSL+Require.swift"; sourceTree = ""; }; + 891729D62B18431D005CC866 /* Requirement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Requirement.swift; sourceTree = ""; }; 891A04702AB0164500B46613 /* AsyncTimerSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncTimerSequence.swift; sourceTree = ""; }; 892FDF1229D3EA7700523A80 /* AsyncExpression.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsyncExpression.swift; sourceTree = ""; }; 896962402A5FABD000A7929D /* AsyncAllPass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAllPass.swift; sourceTree = ""; }; @@ -447,6 +451,8 @@ 1FD8CD041968AB07008ED995 /* Adapters */, 892FDF1229D3EA7700523A80 /* AsyncExpression.swift */, 1FD8CD081968AB07008ED995 /* DSL.swift */, + 891729D62B18431D005CC866 /* Requirement.swift */, + 891729D42B1842D6005CC866 /* DSL+Require.swift */, 899441F32902EF0900C1FAF9 /* DSL+AsyncAwait.swift */, DA9E8C811A414BB9002633C2 /* DSL+Wait.swift */, 1FD8CD091968AB07008ED995 /* Expectation.swift */, @@ -959,6 +965,7 @@ 1FD8CD451968AB07008ED995 /* BeginWith.swift in Sources */, 1FD8CD4B1968AB07008ED995 /* BeIdenticalTo.swift in Sources */, 1FD8CD431968AB07008ED995 /* BeEmpty.swift in Sources */, + 891729D52B1842D6005CC866 /* DSL+Require.swift in Sources */, 1F1871D41CA89EEE00A34BF2 /* NMBStringify.m in Sources */, A8F6B5BD2070186D00FCB5ED /* SatisfyAllOf.swift in Sources */, 1FD8CD531968AB07008ED995 /* BeNil.swift in Sources */, @@ -968,6 +975,7 @@ 1FD8CD351968AB07008ED995 /* DSL.swift in Sources */, 7B5358BF1C38479700A23FAA /* SatisfyAnyOf.swift in Sources */, 896962412A5FABD000A7929D /* AsyncAllPass.swift in Sources */, + 891729D72B18431D005CC866 /* Requirement.swift in Sources */, 1FD8CD391968AB07008ED995 /* Expression.swift in Sources */, 891A04712AB0164500B46613 /* AsyncTimerSequence.swift in Sources */, 89EEF5A52A03293100988224 /* AsyncMatcher.swift in Sources */, diff --git a/Sources/Nimble/Adapters/AdapterProtocols.swift b/Sources/Nimble/Adapters/AdapterProtocols.swift index a28bf2be5..55471229a 100644 --- a/Sources/Nimble/Adapters/AdapterProtocols.swift +++ b/Sources/Nimble/Adapters/AdapterProtocols.swift @@ -1,6 +1,7 @@ /// Protocol for the assertion handler that Nimble uses for all expectations. public protocol AssertionHandler { func assert(_ assertion: Bool, message: FailureMessage, location: SourceLocation) + func require(_ passed: Bool, message: FailureMessage, location: SourceLocation) } /// Global backing interface for assertions that Nimble creates. diff --git a/Sources/Nimble/Adapters/AssertionDispatcher.swift b/Sources/Nimble/Adapters/AssertionDispatcher.swift index 94a9030eb..30b245f20 100644 --- a/Sources/Nimble/Adapters/AssertionDispatcher.swift +++ b/Sources/Nimble/Adapters/AssertionDispatcher.swift @@ -16,4 +16,10 @@ public class AssertionDispatcher: AssertionHandler { handler.assert(assertion, message: message, location: location) } } + + public func require(_ passed: Bool, message: FailureMessage, location: SourceLocation) { + for handler in handlers { + handler.require(passed, message: message, location: location) + } + } } diff --git a/Sources/Nimble/Adapters/AssertionRecorder.swift b/Sources/Nimble/Adapters/AssertionRecorder.swift index 239393eda..6ceeb7869 100644 --- a/Sources/Nimble/Adapters/AssertionRecorder.swift +++ b/Sources/Nimble/Adapters/AssertionRecorder.swift @@ -11,8 +11,10 @@ public struct AssertionRecord: CustomStringConvertible { /// The source location the expectation occurred on. public let location: SourceLocation + public let issueType: IssueType + public var description: String { - return "AssertionRecord { success=\(success), message='\(message.stringValue)', location=\(location) }" + return "AssertionRecord { success=\(success), message='\(message.stringValue)', location=\(location), issueType=\(issueType) }" } } @@ -31,7 +33,17 @@ public class AssertionRecorder: AssertionHandler { AssertionRecord( success: assertion, message: message, - location: location)) + location: location, + issueType: .assertionFailure)) + } + + public func require(_ passed: Bool, message: FailureMessage, location: SourceLocation) { + assertions.append( + AssertionRecord( + success: passed, + message: message, + location: location, + issueType: .thrownError)) } } diff --git a/Sources/Nimble/Adapters/NimbleXCTestHandler.swift b/Sources/Nimble/Adapters/NimbleXCTestHandler.swift index 697dc8d80..2b3f3dd57 100644 --- a/Sources/Nimble/Adapters/NimbleXCTestHandler.swift +++ b/Sources/Nimble/Adapters/NimbleXCTestHandler.swift @@ -9,6 +9,12 @@ public class NimbleXCTestHandler: AssertionHandler { recordFailure("\(message.stringValue)\n", location: location) } } + + public func require(_ passed: Bool, message: FailureMessage, location: SourceLocation) { + if !passed { + recordFailure("\(message.stringValue)\n", issueType: .thrownError, location: location) + } + } } /// Alternative handler for Nimble. This assertion handler passes failures along @@ -25,6 +31,18 @@ public class NimbleShortXCTestHandler: AssertionHandler { recordFailure("\(msg)\n", location: location) } } + + public func require(_ passed: Bool, message: FailureMessage, location: SourceLocation) { + if !passed { + let msg: String + if let actual = message.actualValue { + msg = "got: \(actual) \(message.postfixActual)" + } else { + msg = "expected \(message.to) \(message.postfixMessage)" + } + recordFailure("\(msg)\n", issueType: .thrownError, location: location) + } + } } /// Fallback handler in case XCTest is unavailable. This assertion handler will abort @@ -33,6 +51,10 @@ class NimbleXCTestUnavailableHandler: AssertionHandler { func assert(_ assertion: Bool, message: FailureMessage, location: SourceLocation) { fatalError("XCTest is not available and no custom assertion handler was configured. Aborting.") } + + func require(_ passed: Bool, message: FailureMessage, location: SourceLocation) { + fatalError("XCTest is not available and no custom assertion handler was configured. Aborting.") + } } #if canImport(Darwin) @@ -72,7 +94,21 @@ func isXCTestAvailable() -> Bool { #endif } -public func recordFailure(_ message: String, location: SourceLocation) { +public enum IssueType { + case assertionFailure + case thrownError + + #if canImport(Darwin) + var xctIssueType: XCTIssueReference.IssueType { + switch self { + case .assertionFailure: return .assertionFailure + case .thrownError: return .thrownError + } + } + #endif +} + +public func recordFailure(_ message: String, issueType: IssueType = .assertionFailure, location: SourceLocation) { #if !canImport(Darwin) XCTFail("\(message)", file: location.file, line: location.line) #else @@ -80,7 +116,7 @@ public func recordFailure(_ message: String, location: SourceLocation) { let line = Int(location.line) let location = XCTSourceCodeLocation(filePath: location.file, lineNumber: line) let sourceCodeContext = XCTSourceCodeContext(location: location) - let issue = XCTIssue(type: .assertionFailure, compactDescription: message, sourceCodeContext: sourceCodeContext) + let issue = XCTIssue(type: issueType.xctIssueType, compactDescription: message, sourceCodeContext: sourceCodeContext) testCase.record(issue) } else { let msg = """ diff --git a/Sources/Nimble/DSL+Require.swift b/Sources/Nimble/DSL+Require.swift new file mode 100644 index 000000000..0ef15cf31 --- /dev/null +++ b/Sources/Nimble/DSL+Require.swift @@ -0,0 +1,50 @@ +/// Make a ``Requirement`` on a given actual value. The value given is lazily evaluated. +public func require(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure @escaping () throws -> T?) -> SyncRequirement { + return SyncRequirement( + expression: Expression( + expression: expression, + location: SourceLocation(file: file, line: line), + isClosure: true)) +} + +/// Make a ``Requirement`` on a given actual value. The closure is lazily invoked. +public func require(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure () -> (() throws -> T)) -> SyncRequirement { + return SyncRequirement( + expression: Expression( + expression: expression(), + location: SourceLocation(file: file, line: line), + isClosure: true)) +} + +/// Make a ``Requirement`` on a given actual value. The closure is lazily invoked. +public func require(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure () -> (() throws -> T?)) -> SyncRequirement { + return SyncRequirement( + expression: Expression( + expression: expression(), + location: SourceLocation(file: file, line: line), + isClosure: true)) +} + +/// Make a ``Requirement`` on a given actual value. The closure is lazily invoked. +public func require(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure () -> (() throws -> Void)) -> SyncRequirement { + return SyncRequirement( + expression: Expression( + expression: expression(), + location: SourceLocation(file: file, line: line), + isClosure: true)) +} + +// MARK: - Unwrap + +/// Makes sure that the expression evaluates to a non-nil value, otherwise throw an error. +/// As you can tell, this is a much less verbose equivalent to `require(expression).toNot(beNil())` +public func unwrap(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure @escaping () throws -> T?) throws -> T { + try require(file: file, line: line, expression()).toNot(beNil()) +} + +/// Makes sure that the expression evaluates to a non-nil value, otherwise throw an error. +/// As you can tell, this is a much less verbose equivalent to `require(expression).toNot(beNil())` +@discardableResult +public func unwrap(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure () -> (() throws -> T?)) throws -> T { + try require(file: file, line: line, expression()).toNot(beNil()) +} diff --git a/Sources/Nimble/Requirement.swift b/Sources/Nimble/Requirement.swift new file mode 100644 index 000000000..c1f29f28e --- /dev/null +++ b/Sources/Nimble/Requirement.swift @@ -0,0 +1,149 @@ +public struct RequirementError: Error, CustomNSError { + let message: String + let location: SourceLocation + + var localizedDescription: String { message } + public var errorUserInfo: [String: Any] { + // Required to prevent Xcode from reporting that we threw an error. + // The default assertionHandlers will report this to XCode for us. + ["XCTestErrorUserInfoKeyShouldIgnore": true] + } + + static func unknown(_ location: SourceLocation) -> RequirementError { + RequirementError(message: "Nimble error - file a bug if you see this!", location: location) + } +} + +public enum RequireError: Error { + case requirementFailed + case exceptionRaised(name: String, reason: String?, userInfo: [AnyHashable: Any]?) +} + +internal func executeRequire(_ expression: Expression, _ style: ExpectationStyle, _ matcher: Matcher, to: String, description: String?, captureExceptions: Bool = true) -> (Bool, FailureMessage, T?) { + func run() -> (Bool, FailureMessage, T?) { + let msg = FailureMessage() + msg.userDescription = description + msg.to = to + do { + let result = try matcher.satisfies(expression) + let value = try expression.evaluate() + result.message.update(failureMessage: msg) + if msg.actualValue == "" { + msg.actualValue = "<\(stringify(value))>" + } + return (result.toBoolean(expectation: style), msg, value) + } catch let error { + msg.stringValue = "unexpected error thrown: <\(error)>" + return (false, msg, nil) + } + } + + var result: (Bool, FailureMessage, T?) = (false, FailureMessage(), nil) + if captureExceptions { + let capture = NMBExceptionCapture(handler: ({ exception -> Void in + let msg = FailureMessage() + msg.stringValue = "unexpected exception raised: \(exception)" + result = (false, msg, nil) + }), finally: nil) + capture.tryBlock { + result = run() + } + } else { + result = run() + } + + return result +} + +internal func executeRequire(_ expression: AsyncExpression, _ style: ExpectationStyle, _ matcher: AsyncMatcher, to: String, description: String?) async -> (Bool, FailureMessage, T?) { + let msg = FailureMessage() + msg.userDescription = description + msg.to = to + do { + let result = try await matcher.satisfies(expression) + let value = try await expression.evaluate() + result.message.update(failureMessage: msg) + if msg.actualValue == "" { + msg.actualValue = "<\(stringify(value))>" + } + return (result.toBoolean(expectation: style), msg, value) + } catch let error { + msg.stringValue = "unexpected error thrown: <\(error)>" + return (false, msg, nil) + } +} + +import XCTest + +public struct SyncRequirement { + public let expression: Expression + public let status: ExpectationStatus + + public var location: SourceLocation { expression.location } + + private init(expression: Expression, status: ExpectationStatus) { + self.expression = expression + self.status = status + } + + public init(expression: Expression) { + self.init(expression: expression, status: .pending) + } + + @discardableResult + public func verify(_ pass: Bool, _ message: FailureMessage, _ value: Value?) throws -> Value { + let handler = NimbleEnvironment.activeInstance.assertionHandler + handler.require(pass, message: message, location: expression.location) + guard pass, let value else { + throw RequirementError(message: message.stringValue, location: self.location) + } + return value + +// return try value.get() + } + + /// Tests the actual value using a matcher to match. + @discardableResult + public func to(_ matcher: Matcher, description: String? = nil) throws -> Value { + let (pass, msg, result) = executeRequire(expression, .toMatch, matcher, to: "to", description: description) + return try verify(pass, msg, result) + } + + /// Tests the actual value using a matcher to not match. + @discardableResult + public func toNot(_ matcher: Matcher, description: String? = nil) throws -> Value { + let (pass, msg, result) = executeRequire(expression, .toNotMatch, matcher, to: "to not", description: description) + return try verify(pass, msg, result) + } + + /// Tests the actual value using a matcher to not match. + /// + /// Alias to toNot(). + @discardableResult + public func notTo(_ matcher: Matcher, description: String? = nil) throws -> Value { + try toNot(matcher, description: description) + } + + // MARK: - AsyncMatchers + /// Tests the actual value using a matcher to match. + @discardableResult + public func to(_ matcher: AsyncMatcher, description: String? = nil) async throws -> Value { + let (pass, msg, result) = await executeRequire(expression.toAsyncExpression(), .toMatch, matcher, to: "to", description: description) + return try verify(pass, msg, result) + } + + /// Tests the actual value using a matcher to not match. + @discardableResult + public func toNot(_ matcher: AsyncMatcher, description: String? = nil) async throws -> Value { + let (pass, msg, result) = await executeRequire(expression.toAsyncExpression(), .toNotMatch, matcher, to: "to not", description: description) + return try verify(pass, msg, result) + } + + /// Tests the actual value using a matcher to not match. + /// + /// Alias to toNot(). + @discardableResult + public func notTo(_ matcher: AsyncMatcher, description: String? = nil) async throws -> Value { + try await toNot(matcher, description: description) + } +} diff --git a/Tests/NimbleTests/DSLTest.swift b/Tests/NimbleTests/DSLTest.swift index 09b63f8f2..15a6fe478 100644 --- a/Tests/NimbleTests/DSLTest.swift +++ b/Tests/NimbleTests/DSLTest.swift @@ -71,4 +71,20 @@ final class DSLTest: XCTestCase { expect { nonThrowingInt() }.to(equal(1)) expects { nonThrowingInt() }.to(equal(1)) } + + func testRequire() throws { + expect { try require(1).to(equal(1)) }.toNot(throwError()) + + let records = gatherExpectations(silently: true) { + do { + try require(1).to(equal(2)) + } catch { + expect(error).to(matchError(RequirementError.self)) + } + } + + expect(records).to(haveCount(2)) + expect(records.first?.success).to(beFalse()) + expect(records.last?.success).to(beTrue()) + } }