From 4e09e91293932e15f9178af8f91b09ea04195e09 Mon Sep 17 00:00:00 2001 From: Omar Valiev Date: Sun, 14 Jun 2026 15:29:55 +0400 Subject: [PATCH 1/2] Defer error message evaluation until expectaion fails --- Sources/Nimble/Expectation.swift | 22 +++--- Sources/Nimble/Matchers/Matcher.swift | 44 ++++++++---- Sources/Nimble/Polling+AsyncAwait.swift | 11 +-- Sources/Nimble/Requirement.swift | 22 +++--- .../NimbleTests/AsyncAwaitTest+Require.swift | 21 ++++++ Tests/NimbleTests/AsyncAwaitTest.swift | 21 ++++++ Tests/NimbleTests/Matchers/MatchTest.swift | 71 +++++++++++++++++++ Tests/NimbleTests/SynchronousTest.swift | 64 +++++++++++++++++ 8 files changed, 244 insertions(+), 32 deletions(-) diff --git a/Sources/Nimble/Expectation.swift b/Sources/Nimble/Expectation.swift index 732e0c573..ef5e99bad 100644 --- a/Sources/Nimble/Expectation.swift +++ b/Sources/Nimble/Expectation.swift @@ -5,11 +5,14 @@ internal func execute(_ expression: Expression, _ style: ExpectationStyle, msg.to = to do { let result = try matcher.satisfies(expression) - result.message.update(failureMessage: msg) - if msg.actualValue == "" { - msg.actualValue = "<\(stringify(try expression.evaluate()))>" + let pass = result.toBoolean(expectation: style) + if !pass { + result.message.update(failureMessage: msg) + if msg.actualValue == "" { + msg.actualValue = "<\(stringify(try expression.evaluate()))>" + } } - return (result.toBoolean(expectation: style), msg) + return (pass, msg) } catch let error { msg.stringValue = "unexpected error thrown: <\(error)>" return (false, msg) @@ -39,11 +42,14 @@ internal func execute(_ expression: AsyncExpression, _ style: ExpectationS msg.to = to do { let result = try await matcher.satisfies(expression) - result.message.update(failureMessage: msg) - if msg.actualValue == "" { - msg.actualValue = "<\(stringify(try await expression.evaluate()))>" + let pass = result.toBoolean(expectation: style) + if !pass { + result.message.update(failureMessage: msg) + if msg.actualValue == "" { + msg.actualValue = "<\(stringify(try await expression.evaluate()))>" + } } - return (result.toBoolean(expectation: style), msg) + return (pass, msg) } catch let error { msg.stringValue = "unexpected error thrown: <\(error)>" return (false, msg) diff --git a/Sources/Nimble/Matchers/Matcher.swift b/Sources/Nimble/Matchers/Matcher.swift index 375419e4c..a202905ee 100644 --- a/Sources/Nimble/Matchers/Matcher.swift +++ b/Sources/Nimble/Matchers/Matcher.swift @@ -70,9 +70,15 @@ extension Matcher { /// error message. /// /// Also ensures the matcher's actual value cannot pass with `nil` given. - public static func simple(_ message: String = "match", matcher: @escaping (Expression) throws -> MatcherStatus) -> Matcher { + public static func simple( + _ messageProvider: @escaping @autoclosure () -> String = "match", + matcher: @escaping (Expression) throws -> MatcherStatus + ) -> Matcher { return Matcher { actual in - return MatcherResult(status: try matcher(actual), message: .expectedActualValueTo(message)) + return MatcherResult( + status: try matcher(actual), + message: .expectedActualValueTo(messageProvider()) + ) }.requireNonNil } @@ -80,9 +86,15 @@ extension Matcher { /// error message. /// /// Unlike `simple`, this allows nil values to succeed if the given closure chooses to. - public static func simpleNilable(_ message: String = "match", matcher: @escaping (Expression) throws -> MatcherStatus) -> Matcher { + public static func simpleNilable( + _ messageProvider: @escaping @autoclosure () -> String = "match", + matcher: @escaping (Expression) throws -> MatcherStatus + ) -> Matcher { return Matcher { actual in - return MatcherResult(status: try matcher(actual), message: .expectedActualValueTo(message)) + return MatcherResult( + status: try matcher(actual), + message: .expectedActualValueTo(messageProvider()) + ) } } } @@ -97,19 +109,26 @@ public enum ExpectationStyle { public struct MatcherResult { /// Status indicates if the matcher matches, does not match, or fails. public var status: MatcherStatus - /// The error message that can be displayed if it does not match - public var message: ExpectationMessage + private var messageProvider: () -> ExpectationMessage - /// Constructs a new MatcherResult with a given status and error message - public init(status: MatcherStatus, message: ExpectationMessage) { + /// The error message that can be displayed if it does not match. + /// Evaluated lazily — only computed when accessed (i.e. when a failure is reported). + public var message: ExpectationMessage { + get { messageProvider() } + set { messageProvider = { newValue } } + } + + /// Constructs a new MatcherResult with a given status and error message. + /// The message expression is wrapped in an autoclosure and evaluated lazily. + public init(status: MatcherStatus, message: @autoclosure @escaping () -> ExpectationMessage) { self.status = status - self.message = message + self.messageProvider = message } /// Shorthand to MatcherResult(status: MatcherStatus(bool: bool), message: message) - public init(bool: Bool, message: ExpectationMessage) { + public init(bool: Bool, message: @autoclosure @escaping () -> ExpectationMessage) { self.status = MatcherStatus(bool: bool) - self.message = message + self.messageProvider = message } /// Converts the result to a boolean based on what the expectation intended @@ -254,8 +273,9 @@ final public class NMBMatcherResult: NSObject { } public func toSwift() -> MatcherResult { + let message = self.message.toSwift() return MatcherResult(status: status.toSwift(), - message: message.toSwift()) + message: message) } } diff --git a/Sources/Nimble/Polling+AsyncAwait.swift b/Sources/Nimble/Polling+AsyncAwait.swift index 5123fd0e3..5b0511778 100644 --- a/Sources/Nimble/Polling+AsyncAwait.swift +++ b/Sources/Nimble/Polling+AsyncAwait.swift @@ -10,11 +10,14 @@ internal func execute(_ expression: AsyncExpression, style: ExpectationSty msg.to = to do { let result = try await matcherExecutor() - result.message.update(failureMessage: msg) - if msg.actualValue == "" { - msg.actualValue = "<\(stringify(try await expression.evaluate()))>" + let pass = result.toBoolean(expectation: style) + if !pass { + result.message.update(failureMessage: msg) + if msg.actualValue == "" { + msg.actualValue = "<\(stringify(try await expression.evaluate()))>" + } } - return (result.toBoolean(expectation: style), msg) + return (pass, msg) } catch let error { msg.stringValue = "unexpected error thrown: <\(error)>" return (false, msg) diff --git a/Sources/Nimble/Requirement.swift b/Sources/Nimble/Requirement.swift index 91c8487da..8191815be 100644 --- a/Sources/Nimble/Requirement.swift +++ b/Sources/Nimble/Requirement.swift @@ -24,11 +24,14 @@ internal func executeRequire(_ expression: Expression, _ style: Expectatio let cachedExpression = expression.withCaching() let result = try matcher.satisfies(cachedExpression) let value = try cachedExpression.evaluate() - result.message.update(failureMessage: msg) - if msg.actualValue == "" { - msg.actualValue = "<\(stringify(value))>" + let pass = result.toBoolean(expectation: style) + if !pass { + result.message.update(failureMessage: msg) + if msg.actualValue == "" { + msg.actualValue = "<\(stringify(value))>" + } } - return (result.toBoolean(expectation: style), msg, value) + return (pass, msg, value) } catch let error { msg.stringValue = "unexpected error thrown: <\(error)>" return (false, msg, nil) @@ -60,11 +63,14 @@ internal func executeRequire(_ expression: AsyncExpression, _ style: Expec let cachedExpression = expression.withCaching() let result = try await matcher.satisfies(cachedExpression) let value = try await cachedExpression.evaluate() - result.message.update(failureMessage: msg) - if msg.actualValue == "" { - msg.actualValue = "<\(stringify(value))>" + let pass = result.toBoolean(expectation: style) + if !pass { + result.message.update(failureMessage: msg) + if msg.actualValue == "" { + msg.actualValue = "<\(stringify(value))>" + } } - return (result.toBoolean(expectation: style), msg, value) + return (pass, msg, value) } catch let error { msg.stringValue = "unexpected error thrown: <\(error)>" return (false, msg, nil) diff --git a/Tests/NimbleTests/AsyncAwaitTest+Require.swift b/Tests/NimbleTests/AsyncAwaitTest+Require.swift index f6dfd712d..5417b3454 100644 --- a/Tests/NimbleTests/AsyncAwaitTest+Require.swift +++ b/Tests/NimbleTests/AsyncAwaitTest+Require.swift @@ -283,6 +283,27 @@ final class AsyncAwaitRequireTest: XCTestCase { // swiftlint:disable:this type_b try await require(nil).toAlways(equal(0)) } } + + // MARK: Deffered error evaluation + func testAsyncRequireCallsStringifyOnlyWhenFails() async throws { + let object1 = ObjectToDescribe(id: 1) + let object2 = ObjectToDescribe(id: 2) + let object1Provider: () async -> ObjectToDescribe = { object1 } + + try await require { await object1Provider() }.to(equal(objectToDescribe: object1)) + await expect(object1.descriptionCallCount).toAlways(equal(0)) + await expect(object2.descriptionCallCount).toAlways(equal(0)) + + try await require { await object1Provider() }.notTo(equal(objectToDescribe: object2)) + await expect(object1.descriptionCallCount).toAlways(equal(0)) + await expect(object2.descriptionCallCount).toAlways(equal(0)) + + await failsWithErrorMessage("expected to match, got ") { + try await require { await object1Provider() }.to(equal(objectToDescribe: object2)) + } + await expect(object1.descriptionCallCount).toEventually(equal(1)) + await expect(object2.descriptionCallCount).toAlways(equal(0)) + } } #endif diff --git a/Tests/NimbleTests/AsyncAwaitTest.swift b/Tests/NimbleTests/AsyncAwaitTest.swift index c3e94d72d..a85e007d7 100644 --- a/Tests/NimbleTests/AsyncAwaitTest.swift +++ b/Tests/NimbleTests/AsyncAwaitTest.swift @@ -457,6 +457,27 @@ final class AsyncAwaitTest: XCTestCase { // swiftlint:disable:this type_body_len await expect(nil).toAlways(equal(0)) } } + + // MARK: Deffered error evaluation + func testAsyncExpectationCallsStringifyOnlyWhenFails() async { + let object1 = ObjectToDescribe(id: 1) + let object2 = ObjectToDescribe(id: 2) + let object1Provider: () async -> ObjectToDescribe = { object1 } + + await expect { await object1Provider() }.to(equal(objectToDescribe: object1)) + await expect(object1.descriptionCallCount).toAlways(equal(0)) + await expect(object2.descriptionCallCount).toAlways(equal(0)) + + await expect { await object1Provider() }.notTo(equal(objectToDescribe: object2)) + await expect(object1.descriptionCallCount).toAlways(equal(0)) + await expect(object2.descriptionCallCount).toAlways(equal(0)) + + await failsWithErrorMessage("expected to match, got ") { + await expect { await object1Provider() }.to(equal(objectToDescribe: object2)) + } + await expect(object1.descriptionCallCount).toEventually(equal(1)) + await expect(object2.descriptionCallCount).toAlways(equal(0)) + } } #endif diff --git a/Tests/NimbleTests/Matchers/MatchTest.swift b/Tests/NimbleTests/Matchers/MatchTest.swift index 8aaa2785f..53c648eee 100644 --- a/Tests/NimbleTests/Matchers/MatchTest.swift +++ b/Tests/NimbleTests/Matchers/MatchTest.swift @@ -36,4 +36,75 @@ final class MatchTest: XCTestCase { expect(nil as String?).toNot(match("\\d{2}:\\d{2}")) } } + + func testMatcherDefersErrorMessageEvaluation() { + var errorEvaluationCount = 0 + func makeErrorMessage() -> String { + errorEvaluationCount += 1 + return "create error message #\(errorEvaluationCount)" + } + + let match: Matcher = Matcher.define { expression in + _ = try expression.evaluate() + return MatcherResult( + status: .matches, + message: .expectedTo(makeErrorMessage()).prepended(expectation: "not") + ) + } + expect(1).to(match) + expect(errorEvaluationCount).to(equal(0)) + + let doesNotMatch: Matcher = Matcher.define { expression in + _ = try expression.evaluate() + return MatcherResult( + status: .doesNotMatch, + message: .expectedTo(makeErrorMessage()) + ) + } + expect(1).toNot(doesNotMatch) + expect(errorEvaluationCount).to(equal(0)) + + let fail: Matcher = Matcher.define { expression in + _ = try expression.evaluate() + return MatcherResult( + status: .fail, + message: .expectedTo(makeErrorMessage()) + ) + } + failsWithErrorMessage("expected to not create error message #1") { + expect(1).toNot(fail) + } + expect(errorEvaluationCount).to(equal(1)) + } + + func testSimpleMatcherDefersErrorMessageEvaluation() { + var errorEvaluationCount = 0 + func makeErrorMessage() -> String { + errorEvaluationCount += 1 + return "create error message #\(errorEvaluationCount)" + } + + let match: Matcher = Matcher.simple(makeErrorMessage()) { expression in + _ = try expression.evaluate() + return .matches + } + expect(1).to(match) + expect(errorEvaluationCount).to(equal(0)) + + let doesNotMatch: Matcher = Matcher.simple(makeErrorMessage()) { expression in + _ = try expression.evaluate() + return .doesNotMatch + } + expect(1).toNot(doesNotMatch) + expect(errorEvaluationCount).to(equal(0)) + + let fail: Matcher = Matcher.simple(makeErrorMessage()) { expression in + _ = try expression.evaluate() + return .fail + } + failsWithErrorMessage("expected to not create error message #1, got <1>") { + expect(1).toNot(fail) + } + expect(errorEvaluationCount).to(equal(1)) + } } diff --git a/Tests/NimbleTests/SynchronousTest.swift b/Tests/NimbleTests/SynchronousTest.swift index 98171f961..794b621a8 100644 --- a/Tests/NimbleTests/SynchronousTest.swift +++ b/Tests/NimbleTests/SynchronousTest.swift @@ -128,4 +128,68 @@ final class SynchronousTest: XCTestCase { expect(2).toNot(equal(1)).toNot(equal(2)).to(equal(3)) } } + + // MARK: Deffered error evaluation + func testSyncExpectationCallsStringifyOnlyWhenFails() { + let object1 = ObjectToDescribe(id: 1) + let object2 = ObjectToDescribe(id: 2) + + expect(object1).to(equal(objectToDescribe: object1)) + expect(object1.descriptionCallCount).to(equal(0)) + expect(object2.descriptionCallCount).to(equal(0)) + + expect(object1).notTo(equal(objectToDescribe: object2)) + expect(object1.descriptionCallCount).to(equal(0)) + expect(object2.descriptionCallCount).to(equal(0)) + + failsWithErrorMessage("expected to match, got ") { + expect(object1).to(equal(objectToDescribe: object2)) + } + expect(object1.descriptionCallCount).to(equal(1)) + expect(object2.descriptionCallCount).to(equal(0)) + } + + func testSyncRequirementCallsStringifyOnlyWhenFails() throws { + let object1 = ObjectToDescribe(id: 1) + let object2 = ObjectToDescribe(id: 2) + + try require(object1).to(equal(objectToDescribe: object1)) + expect(object1.descriptionCallCount).to(equal(0)) + expect(object2.descriptionCallCount).to(equal(0)) + + try require(object1).notTo(equal(objectToDescribe: object2)) + expect(object1.descriptionCallCount).to(equal(0)) + expect(object2.descriptionCallCount).to(equal(0)) + + failsWithErrorMessage("expected to match, got ") { + try require(object1).to(equal(objectToDescribe: object2)) + } + expect(object1.descriptionCallCount).to(equal(1)) + expect(object2.descriptionCallCount).to(equal(0)) + } +} + +final class ObjectToDescribe: CustomStringConvertible, Equatable { + let id: Int + private(set) var descriptionCallCount: Int = 0 + + init(id: Int) { + self.id = id + } + + var description: String { + descriptionCallCount += 1 + return "id: \(id)" + } + + static func == (lhs: ObjectToDescribe, rhs: ObjectToDescribe) -> Bool { + lhs.id == rhs.id + } +} + +func equal(objectToDescribe expected: ObjectToDescribe) -> Matcher { + Matcher.simple { expression in + guard let actual = try expression.evaluate() else { return .fail } + return actual == expected ? .matches : .doesNotMatch + } } From 048390213126ff8b6841f1095f8758eb7e8089f6 Mon Sep 17 00:00:00 2001 From: Omar Valiev Date: Sun, 14 Jun 2026 16:40:21 +0400 Subject: [PATCH 2/2] Update matchers to defer error message evaluation until expectation fails --- Sources/Nimble/Matchers/AllPass.swift | 34 +++++++---- Sources/Nimble/Matchers/AsyncAllPass.swift | 34 +++++++---- Sources/Nimble/Matchers/BeAKindOf.swift | 38 ++++++------ Sources/Nimble/Matchers/BeAnInstanceOf.swift | 22 +++---- Sources/Nimble/Matchers/BeCloseTo.swift | 15 +++-- Sources/Nimble/Matchers/BeLessThan.swift | 6 +- Sources/Nimble/Matchers/ElementsEqual.swift | 36 ++++++++--- Sources/Nimble/Matchers/Equal.swift | 20 +++---- Sources/Nimble/Matchers/HaveCount.swift | 20 +++---- Sources/Nimble/Matchers/MatchError.swift | 45 +++++++------- Sources/Nimble/Matchers/Matcher.swift | 2 +- Sources/Nimble/Matchers/ThrowError.swift | 59 +++++++++---------- Tests/NimbleTests/Matchers/AllPassTest.swift | 21 +++++++ .../Matchers/AsyncAllPassTest.swift | 29 +++++++++ Tests/NimbleTests/SynchronousTest.swift | 15 ++++- 15 files changed, 246 insertions(+), 150 deletions(-) diff --git a/Sources/Nimble/Matchers/AllPass.swift b/Sources/Nimble/Matchers/AllPass.swift index 133c21ec4..c0c8d45cb 100644 --- a/Sources/Nimble/Matchers/AllPass.swift +++ b/Sources/Nimble/Matchers/AllPass.swift @@ -36,7 +36,9 @@ private func createMatcher(_ elementMatcher: Matcher) -> ) } - var failure: ExpectationMessage = .expectedTo("all pass") + var messageProvider: () -> ExpectationMessage = { + .expectedTo("all pass") + } for currentElement in actualValue { let exp = Expression( expression: { currentElement }, @@ -44,22 +46,28 @@ private func createMatcher(_ elementMatcher: Matcher) -> ) let matcherResult = try elementMatcher.satisfies(exp) if matcherResult.status == .matches { - failure = matcherResult.message.prepended(expectation: "all ") + messageProvider = { + matcherResult.message.prepended(expectation: "all ") + } } else { - failure = matcherResult.message - .replacedExpectation({ .expectedTo($0.expectedMessage) }) - .wrappedExpectation( - before: "all ", - after: ", but failed first at element <\(stringify(currentElement))>" - + " in <\(stringify(actualValue))>" + return MatcherResult( + status: .doesNotMatch, + message: matcherResult.message + .replacedExpectation({ .expectedTo($0.expectedMessage) }) + .wrappedExpectation( + before: "all ", + after: ", but failed first at element <\(stringify(currentElement))>" + + " in <\(stringify(actualValue))>" + ) ) - return MatcherResult(status: .doesNotMatch, message: failure) } } - failure = failure.replacedExpectation({ expectation in - return .expectedTo(expectation.expectedMessage) - }) - return MatcherResult(status: .matches, message: failure) + messageProvider = { [messageProvider] in + messageProvider().replacedExpectation({ expectation in + return .expectedTo(expectation.expectedMessage) + }) + } + return MatcherResult(status: .matches, message: messageProvider()) } } diff --git a/Sources/Nimble/Matchers/AsyncAllPass.swift b/Sources/Nimble/Matchers/AsyncAllPass.swift index ec04f9ebe..96bedee2c 100644 --- a/Sources/Nimble/Matchers/AsyncAllPass.swift +++ b/Sources/Nimble/Matchers/AsyncAllPass.swift @@ -36,7 +36,9 @@ private func createMatcher(_ elementMatcher: AsyncMatcher ExpectationMessage = { + .expectedTo("all pass") + } for currentElement in actualValue { let exp = AsyncExpression( expression: { currentElement }, @@ -44,21 +46,27 @@ private func createMatcher(_ elementMatcher: AsyncMatcher" - + " in <\(stringify(actualValue))>" + return MatcherResult( + status: .doesNotMatch, + message: matcherResult.message + .replacedExpectation({ .expectedTo($0.expectedMessage) }) + .wrappedExpectation( + before: "all ", + after: ", but failed first at element <\(stringify(currentElement))>" + + " in <\(stringify(actualValue))>" + ) ) - return MatcherResult(status: .doesNotMatch, message: failure) } } - failure = failure.replacedExpectation({ expectation in - return .expectedTo(expectation.expectedMessage) - }) - return MatcherResult(status: .matches, message: failure) + messageProvider = { [messageProvider] in + messageProvider().replacedExpectation({ expectation in + return .expectedTo(expectation.expectedMessage) + }) + } + return MatcherResult(status: .matches, message: messageProvider()) } } diff --git a/Sources/Nimble/Matchers/BeAKindOf.swift b/Sources/Nimble/Matchers/BeAKindOf.swift index 48261b3e0..3a35233a4 100644 --- a/Sources/Nimble/Matchers/BeAKindOf.swift +++ b/Sources/Nimble/Matchers/BeAKindOf.swift @@ -8,21 +8,20 @@ private func matcherMessage(forClass expectedClass: AnyClass) -> String { /// A Nimble matcher that succeeds when the actual value is an instance of the given class. public func beAKindOf(_ expectedType: T.Type) -> Matcher { return Matcher.define { actualExpression in - let message: ExpectationMessage - let instance = try actualExpression.evaluate() guard let validInstance = instance else { - message = .expectedCustomValueTo(matcherMessage(forType: expectedType), actual: "") - return MatcherResult(status: .fail, message: message) + return MatcherResult( + status: .fail, + message: .expectedCustomValueTo(matcherMessage(forType: expectedType), actual: "") + ) } - message = .expectedCustomValueTo( - "be a kind of \(String(describing: expectedType))", - actual: "<\(String(describing: type(of: validInstance))) instance>" - ) return MatcherResult( bool: validInstance is T, - message: message + message: .expectedCustomValueTo( + "be a kind of \(String(describing: expectedType))", + actual: "<\(String(describing: type(of: validInstance))) instance>" + ) ) } } @@ -34,25 +33,28 @@ import class Foundation.NSObject /// @see beAnInstanceOf if you want to match against the exact class public func beAKindOf(_ expectedClass: AnyClass) -> Matcher { return Matcher.define { actualExpression in - let message: ExpectationMessage let status: MatcherStatus let instance = try actualExpression.evaluate() if let validInstance = instance { status = MatcherStatus(bool: instance != nil && instance!.isKind(of: expectedClass)) - message = .expectedCustomValueTo( - matcherMessage(forClass: expectedClass), - actual: "<\(String(describing: type(of: validInstance))) instance>" + return MatcherResult( + status: status, + message: .expectedCustomValueTo( + matcherMessage(forClass: expectedClass), + actual: "<\(String(describing: type(of: validInstance))) instance>" + ) ) } else { status = .fail - message = .expectedCustomValueTo( - matcherMessage(forClass: expectedClass), - actual: "" + return MatcherResult( + status: status, + message: .expectedCustomValueTo( + matcherMessage(forClass: expectedClass), + actual: "" + ) ) } - - return MatcherResult(status: status, message: message) } } diff --git a/Sources/Nimble/Matchers/BeAnInstanceOf.swift b/Sources/Nimble/Matchers/BeAnInstanceOf.swift index f36d6689e..4769d46f1 100644 --- a/Sources/Nimble/Matchers/BeAnInstanceOf.swift +++ b/Sources/Nimble/Matchers/BeAnInstanceOf.swift @@ -2,21 +2,21 @@ import Foundation /// A Nimble matcher that succeeds when the actual value is an _exact_ instance of the given class. public func beAnInstanceOf(_ expectedType: T.Type) -> Matcher { - let errorMessage = "be an instance of \(String(describing: expectedType))" return Matcher.define { actualExpression in let instance = try actualExpression.evaluate() guard let validInstance: Any = instance else { return MatcherResult( status: .doesNotMatch, - message: .expectedActualValueTo(errorMessage) + message: .expectedActualValueTo("be an instance of \(String(describing: expectedType))") ) } - let actualString = "<\(String(describing: type(of: validInstance))) instance>" - return MatcherResult( status: MatcherStatus(bool: type(of: validInstance) == expectedType), - message: .expectedCustomValueTo(errorMessage, actual: actualString) + message: .expectedCustomValueTo( + "be an instance of \(String(describing: expectedType))", + actual: "<\(String(describing: type(of: validInstance))) instance>" + ) ) } } @@ -24,15 +24,8 @@ public func beAnInstanceOf(_ expectedType: T.Type) -> Matcher { /// A Nimble matcher that succeeds when the actual value is an instance of the given class. /// @see beAKindOf if you want to match against subclasses public func beAnInstanceOf(_ expectedClass: AnyClass) -> Matcher { - let errorMessage = "be an instance of \(String(describing: expectedClass))" return Matcher.define { actualExpression in let instance = try actualExpression.evaluate() - let actualString: String - if let validInstance = instance { - actualString = "<\(String(describing: type(of: validInstance))) instance>" - } else { - actualString = "" - } #if canImport(Darwin) let matches = instance != nil && instance!.isMember(of: expectedClass) #else @@ -40,7 +33,10 @@ public func beAnInstanceOf(_ expectedClass: AnyClass) -> Matcher { #endif return MatcherResult( status: MatcherStatus(bool: matches), - message: .expectedCustomValueTo(errorMessage, actual: actualString) + message: .expectedCustomValueTo( + "be an instance of \(String(describing: expectedClass))", + actual: instance.map { "<\(String(describing: type(of: $0))) instance>" } ?? "" + ) ) } } diff --git a/Sources/Nimble/Matchers/BeCloseTo.swift b/Sources/Nimble/Matchers/BeCloseTo.swift index 36dc0e00f..508dec87a 100644 --- a/Sources/Nimble/Matchers/BeCloseTo.swift +++ b/Sources/Nimble/Matchers/BeCloseTo.swift @@ -10,11 +10,13 @@ internal func isCloseTo( expectedValue: Value, delta: Value ) -> MatcherResult { - let errorMessage = "be close to <\(stringify(expectedValue))> (within \(stringify(delta)))" return MatcherResult( bool: actualValue != nil && abs(actualValue! - expectedValue) < delta, - message: .expectedCustomValueTo(errorMessage, actual: "<\(stringify(actualValue))>") + message: .expectedCustomValueTo( + "be close to <\(stringify(expectedValue))> (within \(stringify(delta)))", + actual: "<\(stringify(actualValue))>" + ) ) } @@ -23,11 +25,13 @@ internal func isCloseTo( expectedValue: NMBDoubleConvertible, delta: Double ) -> MatcherResult { - let errorMessage = "be close to <\(stringify(expectedValue))> (within \(stringify(delta)))" return MatcherResult( bool: actualValue != nil && abs(actualValue!.doubleValue - expectedValue.doubleValue) < delta, - message: .expectedCustomValueTo(errorMessage, actual: "<\(stringify(actualValue))>") + message: .expectedCustomValueTo( + "be close to <\(stringify(expectedValue))> (within \(stringify(delta)))", + actual: "<\(stringify(actualValue))>" + ) ) } @@ -100,8 +104,7 @@ public func beCloseTo( _ expectedValues: Values, within delta: Value = defaultDelta() ) -> Matcher where Values.Element == Value { - let errorMessage = "be close to <\(stringify(expectedValues))> (each within \(stringify(delta)))" - return Matcher.simple(errorMessage) { actualExpression in + return Matcher.simple("be close to <\(stringify(expectedValues))> (each within \(stringify(delta)))") { actualExpression in guard let actualValues = try actualExpression.evaluate() else { return .doesNotMatch } diff --git a/Sources/Nimble/Matchers/BeLessThan.swift b/Sources/Nimble/Matchers/BeLessThan.swift index 4be83c973..585a9c053 100644 --- a/Sources/Nimble/Matchers/BeLessThan.swift +++ b/Sources/Nimble/Matchers/BeLessThan.swift @@ -1,7 +1,6 @@ /// A Nimble matcher that succeeds when the actual value is less than the expected value. public func beLessThan(_ expectedValue: T?) -> Matcher { - let message = "be less than <\(stringify(expectedValue))>" - return Matcher.simple(message) { actualExpression in + return Matcher.simple("be less than <\(stringify(expectedValue))>") { actualExpression in guard let actual = try actualExpression.evaluate(), let expected = expectedValue else { return .fail } return MatcherStatus(bool: actual < expected) @@ -21,8 +20,7 @@ import enum Foundation.ComparisonResult /// A Nimble matcher that succeeds when the actual value is less than the expected value. public func beLessThan(_ expectedValue: T?) -> Matcher { - let message = "be less than <\(stringify(expectedValue))>" - return Matcher.simple(message) { actualExpression in + return Matcher.simple("be less than <\(stringify(expectedValue))>") { actualExpression in let actualValue = try actualExpression.evaluate() let matches = actualValue != nil && actualValue!.NMB_compare(expectedValue) == ComparisonResult.orderedAscending return MatcherStatus(bool: matches) diff --git a/Sources/Nimble/Matchers/ElementsEqual.swift b/Sources/Nimble/Matchers/ElementsEqual.swift index 39b2dbf21..b14f6afdb 100644 --- a/Sources/Nimble/Matchers/ElementsEqual.swift +++ b/Sources/Nimble/Matchers/ElementsEqual.swift @@ -5,16 +5,26 @@ public func elementsEqual( _ expectedValue: Seq2? ) -> Matcher where Seq1.Element: Equatable, Seq1.Element == Seq2.Element { - return Matcher.define("elementsEqual <\(stringify(expectedValue))>") { (actualExpression, msg) in + return Matcher.define { actualExpression in let actualValue = try actualExpression.evaluate() switch (expectedValue, actualValue) { case (nil, _?): - return MatcherResult(status: .fail, message: msg.appendedBeNilHint()) + return MatcherResult( + status: .fail, + message: .expectedActualValueTo("elementsEqual <\(stringify(expectedValue))>") + .appendedBeNilHint() + ) case (nil, nil), (_, nil): - return MatcherResult(status: .fail, message: msg) + return MatcherResult( + status: .fail, + message: .expectedActualValueTo("elementsEqual <\(stringify(expectedValue))>") + ) case (let expected?, let actual?): let matches = expected.elementsEqual(actual) - return MatcherResult(bool: matches, message: msg) + return MatcherResult( + bool: matches, + message: .expectedActualValueTo("elementsEqual <\(stringify(expectedValue))>") + ) } } } @@ -27,16 +37,26 @@ public func elementsEqual( _ expectedValue: Seq2?, by areEquivalent: @escaping (Seq1.Element, Seq2.Element) -> Bool ) -> Matcher { - return Matcher.define("elementsEqual <\(stringify(expectedValue))>") { (actualExpression, msg) in + return Matcher.define { actualExpression in let actualValue = try actualExpression.evaluate() switch (expectedValue, actualValue) { case (nil, _?): - return MatcherResult(status: .fail, message: msg.appendedBeNilHint()) + return MatcherResult( + status: .fail, + message: .expectedActualValueTo("elementsEqual <\(stringify(expectedValue))>") + .appendedBeNilHint() + ) case (nil, nil), (_, nil): - return MatcherResult(status: .fail, message: msg) + return MatcherResult( + status: .fail, + message: .expectedActualValueTo("elementsEqual <\(stringify(expectedValue))>") + ) case (let expected?, let actual?): let matches = actual.elementsEqual(expected, by: areEquivalent) - return MatcherResult(bool: matches, message: msg) + return MatcherResult( + bool: matches, + message: .expectedActualValueTo("elementsEqual <\(stringify(expectedValue))>") + ) } } } diff --git a/Sources/Nimble/Matchers/Equal.swift b/Sources/Nimble/Matchers/Equal.swift index 4ec21e37d..83e6d2ced 100644 --- a/Sources/Nimble/Matchers/Equal.swift +++ b/Sources/Nimble/Matchers/Equal.swift @@ -2,16 +2,16 @@ internal func equal( _ expectedValue: T?, by areEquivalent: @escaping (T, T) -> Bool ) -> Matcher { - Matcher.define("equal <\(stringify(expectedValue))>") { actualExpression, msg in + Matcher.define { actualExpression in let actualValue = try actualExpression.evaluate() switch (expectedValue, actualValue) { case (nil, _?): - return MatcherResult(status: .fail, message: msg.appendedBeNilHint()) + return MatcherResult(status: .fail, message: .expectedActualValueTo("equal <\(stringify(expectedValue))>").appendedBeNilHint()) case (_, nil): - return MatcherResult(status: .fail, message: msg) + return MatcherResult(status: .fail, message: .expectedActualValueTo("equal <\(stringify(expectedValue))>")) case (let expected?, let actual?): let matches = areEquivalent(expected, actual) - return MatcherResult(bool: matches, message: msg) + return MatcherResult(bool: matches, message: .expectedActualValueTo("equal <\(stringify(expectedValue))>")) } } } @@ -26,16 +26,16 @@ public func equal(_ expectedValue: T) -> Matcher { /// A Nimble matcher allowing comparison of collection with optional type public func equal(_ expectedValue: [T?]) -> Matcher<[T?]> { - Matcher.define("equal <\(stringify(expectedValue))>") { actualExpression, msg in + Matcher.define { actualExpression in guard let actualValue = try actualExpression.evaluate() else { return MatcherResult( status: .fail, - message: msg.appendedBeNilHint() + message: .expectedActualValueTo("equal <\(stringify(expectedValue))>").appendedBeNilHint() ) } let matches = expectedValue == actualValue - return MatcherResult(bool: matches, message: msg) + return MatcherResult(bool: matches, message: .expectedActualValueTo("equal <\(stringify(expectedValue))>")) } } @@ -118,16 +118,16 @@ private func equal(_ expectedValue: Set?, stringify: @escaping (Set?) - /// A Nimble matcher that succeeds when the actual dictionary is equal to the expected dictionary public func equal(_ expectedValue: [K: V?]) -> Matcher<[K: V]> { - Matcher.define("equal <\(stringify(expectedValue))>") { actualExpression, msg in + Matcher.define { actualExpression in guard let actualValue = try actualExpression.evaluate() else { return MatcherResult( status: .fail, - message: msg.appendedBeNilHint() + message: .expectedActualValueTo("equal <\(stringify(expectedValue))>").appendedBeNilHint() ) } let matches = expectedValue == actualValue - return MatcherResult(bool: matches, message: msg) + return MatcherResult(bool: matches, message: .expectedActualValueTo("equal <\(stringify(expectedValue))>")) } } diff --git a/Sources/Nimble/Matchers/HaveCount.swift b/Sources/Nimble/Matchers/HaveCount.swift index 7b476d738..530a6d028 100644 --- a/Sources/Nimble/Matchers/HaveCount.swift +++ b/Sources/Nimble/Matchers/HaveCount.swift @@ -8,15 +8,15 @@ public func haveCount(_ expectedValue: Int) -> Matcher { return Matcher.define { actualExpression in if let actualValue = try actualExpression.evaluate() { - let message = ExpectationMessage - .expectedCustomValueTo( + let result = expectedValue == actualValue.count + return MatcherResult( + bool: result, + message: ExpectationMessage.expectedCustomValueTo( "have \(prettyCollectionType(actualValue)) with count \(stringify(expectedValue))", actual: "\(actualValue.count)" ) .appended(details: "Actual Value: \(stringify(actualValue))") - - let result = expectedValue == actualValue.count - return MatcherResult(bool: result, message: message) + ) } else { return MatcherResult(status: .fail, message: .fail("")) } @@ -28,14 +28,14 @@ public func haveCount(_ expectedValue: Int) -> Matcher { public func haveCount(_ expectedValue: Int) -> Matcher { return Matcher { actualExpression in if let actualValue = try actualExpression.evaluate() { - let message = ExpectationMessage - .expectedCustomValueTo( + let result = expectedValue == actualValue.count + return MatcherResult( + bool: result, + message: ExpectationMessage.expectedCustomValueTo( "have \(prettyCollectionType(actualValue)) with count \(stringify(expectedValue))", actual: "\(actualValue.count). Actual Value: \(stringify(actualValue))" ) - - let result = expectedValue == actualValue.count - return MatcherResult(bool: result, message: message) + ) } else { return MatcherResult(status: .fail, message: .fail("")) } diff --git a/Sources/Nimble/Matchers/MatchError.swift b/Sources/Nimble/Matchers/MatchError.swift index 10ed86b83..21254ddc7 100644 --- a/Sources/Nimble/Matchers/MatchError.swift +++ b/Sources/Nimble/Matchers/MatchError.swift @@ -7,18 +7,19 @@ public func matchError(_ error: T) -> Matcher { return Matcher.define { actualExpression in let actualError = try actualExpression.evaluate() - let message = messageForError( - postfixMessageVerb: "match", - actualError: actualError, - error: error - ) - var matches = false if let actualError = actualError, errorMatchesExpectedError(actualError, expectedError: error) { matches = true } - return MatcherResult(bool: matches, message: message) + return MatcherResult( + bool: matches, + message: messageForError( + postfixMessageVerb: "match", + actualError: actualError, + error: error + ) + ) } } @@ -31,18 +32,19 @@ public func matchError(_ error: T) -> Matcher { return Matcher.define { actualExpression in let actualError = try actualExpression.evaluate() - let message = messageForError( - postfixMessageVerb: "match", - actualError: actualError, - error: error - ) - var matches = false if let actualError = actualError as? T, error == actualError { matches = true } - return MatcherResult(bool: matches, message: message) + return MatcherResult( + bool: matches, + message: messageForError( + postfixMessageVerb: "match", + actualError: actualError, + error: error + ) + ) } } @@ -52,17 +54,18 @@ public func matchError(_ errorType: T.Type) -> Matcher { return Matcher.define { actualExpression in let actualError = try actualExpression.evaluate() - let message = messageForError( - postfixMessageVerb: "match", - actualError: actualError, - errorType: errorType - ) - var matches = false if actualError as? T != nil { matches = true } - return MatcherResult(bool: matches, message: message) + return MatcherResult( + bool: matches, + message: messageForError( + postfixMessageVerb: "match", + actualError: actualError, + errorType: errorType + ) + ) } } diff --git a/Sources/Nimble/Matchers/Matcher.swift b/Sources/Nimble/Matchers/Matcher.swift index a202905ee..6e962d1be 100644 --- a/Sources/Nimble/Matchers/Matcher.swift +++ b/Sources/Nimble/Matchers/Matcher.swift @@ -109,7 +109,7 @@ public enum ExpectationStyle { public struct MatcherResult { /// Status indicates if the matcher matches, does not match, or fails. public var status: MatcherStatus - private var messageProvider: () -> ExpectationMessage + var messageProvider: () -> ExpectationMessage /// The error message that can be displayed if it does not match. /// Evaluated lazily — only computed when accessed (i.e. when a failure is reported). diff --git a/Sources/Nimble/Matchers/ThrowError.swift b/Sources/Nimble/Matchers/ThrowError.swift index 32c2f6c1d..39c37f28b 100644 --- a/Sources/Nimble/Matchers/ThrowError.swift +++ b/Sources/Nimble/Matchers/ThrowError.swift @@ -52,13 +52,6 @@ public func throwError(_ error: T, closure: ((Error) -> Void)? = actualError = error } - let message = messageForError( - actualError: actualError, - error: error, - errorType: nil, - closure: closure - ) - var matches = false if let actualError = actualError, errorMatchesExpectedError(actualError, expectedError: error) { matches = true @@ -74,7 +67,15 @@ public func throwError(_ error: T, closure: ((Error) -> Void)? = } } - return MatcherResult(bool: matches, message: message) + return MatcherResult( + bool: matches, + message: messageForError( + actualError: actualError, + error: error, + errorType: nil, + closure: closure + ) + ) } } @@ -98,13 +99,6 @@ public func throwError(_ error: T, closure: ((T) -> V actualError = error } - let message = messageForError( - actualError: actualError, - error: error, - errorType: nil, - closure: closure - ) - var matches = false if let actualError = actualError as? T, error == actualError { matches = true @@ -120,7 +114,15 @@ public func throwError(_ error: T, closure: ((T) -> V } } - return MatcherResult(bool: matches, message: message) + return MatcherResult( + bool: matches, + message: messageForError( + actualError: actualError, + error: error, + errorType: nil, + closure: closure + ) + ) } } @@ -147,13 +149,6 @@ public func throwError( actualError = error } - let message = messageForError( - actualError: actualError, - error: nil, - errorType: errorType, - closure: closure - ) - var matches = false if let actualError = actualError { matches = true @@ -186,7 +181,15 @@ public func throwError( } } - return MatcherResult(bool: matches, message: message) + return MatcherResult( + bool: matches, + message: messageForError( + actualError: actualError, + error: nil, + errorType: errorType, + closure: closure + ) + ) } } @@ -206,8 +209,6 @@ public func throwError(closure: @escaping ((Error) -> Void)) -> Matcher(closure: @escaping ((Error) -> Void)) -> Matcher(closure: @escaping ((T) -> Void)) -> Match actualError = error } - let message = messageForError(actualError: actualError, closure: closure) - var matches = false if let actualError = actualError as? T { matches = true @@ -256,6 +255,6 @@ public func throwError(closure: @escaping ((T) -> Void)) -> Match } } - return MatcherResult(bool: matches, message: message) + return MatcherResult(bool: matches, message: messageForError(actualError: actualError, closure: closure)) } } diff --git a/Tests/NimbleTests/Matchers/AllPassTest.swift b/Tests/NimbleTests/Matchers/AllPassTest.swift index 561849b5e..1b668f09f 100644 --- a/Tests/NimbleTests/Matchers/AllPassTest.swift +++ b/Tests/NimbleTests/Matchers/AllPassTest.swift @@ -107,4 +107,25 @@ final class AllPassTest: XCTestCase { expect(nil as [Int]?).to(allPass(beLessThan(5))) } } + + func testAllPassDefersErrorEvaluation() { + let objects = [ObjectToDescribe(id: 1), ObjectToDescribe(id: 2), ObjectToDescribe(id: 3)] + expect(objects).to(allPass { $0.id < 4 }) + for object in objects { + expect(object.descriptionCallCount).to(equal(0)) + } + + expect(objects).toNot(allPass { $0.id > 2 }) + for object in objects { + expect(object.descriptionCallCount).to(equal(0)) + } + + failsWithErrorMessage( + "expected to all pass a condition, but failed first at element in <[id: 1, id: 2, id: 3]>") { + expect(objects).to(allPass { $0.id < 3 }) + } + expect(objects[0].descriptionCallCount).to(equal(1)) + expect(objects[1].descriptionCallCount).to(equal(1)) + expect(objects[2].descriptionCallCount).to(equal(2)) + } } diff --git a/Tests/NimbleTests/Matchers/AsyncAllPassTest.swift b/Tests/NimbleTests/Matchers/AsyncAllPassTest.swift index 5c4ec502e..6b902ebce 100644 --- a/Tests/NimbleTests/Matchers/AsyncAllPassTest.swift +++ b/Tests/NimbleTests/Matchers/AsyncAllPassTest.swift @@ -142,4 +142,33 @@ final class AsyncAllPassTest: XCTestCase { await expect(nil as [Int]?).to(allPass(asyncBeLessThan(5))) } } + + func testAllPassDefersErrorEvaluation() async { + let objects = [ObjectToDescribe(id: 1), ObjectToDescribe(id: 2), ObjectToDescribe(id: 3)] + let objectsProvider: () async -> [ObjectToDescribe] = { objects } + + await expect { await objectsProvider() }.to(allPass { value in + await asyncCheck { value.id < 4 } + }) + for object in objects { + expect(object.descriptionCallCount).to(equal(0)) + } + + await expect { await objectsProvider() }.toNot(allPass { value in + await asyncCheck { value.id > 2 } + }) + for object in objects { + expect(object.descriptionCallCount).to(equal(0)) + } + + await failsWithErrorMessage( + "expected to all pass a condition, but failed first at element in <[id: 1, id: 2, id: 3]>") { + await expect { await objectsProvider() }.to(allPass { value in + await asyncCheck { value.id < 3 } + }) + } + expect(objects[0].descriptionCallCount).to(equal(1)) + expect(objects[1].descriptionCallCount).to(equal(1)) + expect(objects[2].descriptionCallCount).to(equal(2)) + } } diff --git a/Tests/NimbleTests/SynchronousTest.swift b/Tests/NimbleTests/SynchronousTest.swift index 794b621a8..b7e1245d4 100644 --- a/Tests/NimbleTests/SynchronousTest.swift +++ b/Tests/NimbleTests/SynchronousTest.swift @@ -169,16 +169,25 @@ final class SynchronousTest: XCTestCase { } } -final class ObjectToDescribe: CustomStringConvertible, Equatable { +final class ObjectToDescribe: CustomStringConvertible, Equatable, @unchecked Sendable { let id: Int - private(set) var descriptionCallCount: Int = 0 + private let lock: NSLock + private var descriptionCalls: Int = 0 + var descriptionCallCount: Int { + lock.withLock { + descriptionCalls + } + } init(id: Int) { self.id = id + self.lock = NSLock() } var description: String { - descriptionCallCount += 1 + lock.withLock { + descriptionCalls += 1 + } return "id: \(id)" }