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()) + } }