From 1b75ed0e245b5d8a7bd19fe26ceb277ceab88572 Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Sat, 6 Jan 2024 14:01:56 -0800 Subject: [PATCH] New matcher: map, for running a function on a value and running a matcher against the return value of that function (#1112) --- Nimble.xcodeproj/project.pbxproj | 10 ++- README.md | 32 +++++++++ Sources/Nimble/AsyncExpression.swift | 16 +++-- Sources/Nimble/Matchers/Map.swift | 27 ++++++++ Tests/NimbleTests/Matchers/MapTest.swift | 83 ++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 Sources/Nimble/Matchers/Map.swift create mode 100644 Tests/NimbleTests/Matchers/MapTest.swift diff --git a/Nimble.xcodeproj/project.pbxproj b/Nimble.xcodeproj/project.pbxproj index eb324c786..84e896de8 100644 --- a/Nimble.xcodeproj/project.pbxproj +++ b/Nimble.xcodeproj/project.pbxproj @@ -131,6 +131,8 @@ 8922828A2B2833B7002DA355 /* PollingTest+Require.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892282892B2833B7002DA355 /* PollingTest+Require.swift */; }; 8922828D2B283818002DA355 /* Polling+Require.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8922828B2B2837E1002DA355 /* Polling+Require.swift */; }; 8922828F2B283956002DA355 /* AsyncAwaitTest+Require.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8922828E2B283956002DA355 /* AsyncAwaitTest+Require.swift */; }; + 8923E60D2B47CE7E00F3961A /* Map.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8923E60C2B47CE7E00F3961A /* Map.swift */; }; + 8923E6102B47D08300F3961A /* MapTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8923E60E2B47D06E00F3961A /* MapTest.swift */; }; 892FDF1329D3EA7700523A80 /* AsyncExpression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FDF1229D3EA7700523A80 /* AsyncExpression.swift */; }; 896962412A5FABD000A7929D /* AsyncAllPass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896962402A5FABD000A7929D /* AsyncAllPass.swift */; }; 8969624A2A5FAD5F00A7929D /* AsyncAllPassTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896962452A5FAD4500A7929D /* AsyncAllPassTest.swift */; }; @@ -314,6 +316,8 @@ 892282892B2833B7002DA355 /* PollingTest+Require.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollingTest+Require.swift"; sourceTree = ""; }; 8922828B2B2837E1002DA355 /* Polling+Require.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Polling+Require.swift"; sourceTree = ""; }; 8922828E2B283956002DA355 /* AsyncAwaitTest+Require.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncAwaitTest+Require.swift"; sourceTree = ""; }; + 8923E60C2B47CE7E00F3961A /* Map.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Map.swift; sourceTree = ""; }; + 8923E60E2B47D06E00F3961A /* MapTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTest.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 = ""; }; 896962452A5FAD4500A7929D /* AsyncAllPassTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAllPassTest.swift; sourceTree = ""; }; @@ -524,6 +528,7 @@ 1F925EFE195C187600ED456B /* EndWithTest.swift */, 1F925F04195C18B700ED456B /* EqualTest.swift */, 472FD1361B9E094B00C7B8DA /* HaveCountTest.swift */, + 8923E60E2B47D06E00F3961A /* MapTest.swift */, AE7ADE481C80C00D00B94CD3 /* MatchErrorTest.swift */, DDB4D5EF19FE442800E9D9FE /* MatchTest.swift */, 1FCF914E1C61C85A00B15DCB /* PostNotificationTest.swift */, @@ -583,10 +588,11 @@ C576224C2A61D3AE00BD6A8C /* Equal+TupleArray.swift */, 472FD1341B9E085700C7B8DA /* HaveCount.swift */, DDB4D5EC19FE43C200E9D9FE /* Match.swift */, + 8923E60C2B47CE7E00F3961A /* Map.swift */, 1FD8CD1D1968AB07008ED995 /* MatcherProtocols.swift */, AE7ADE441C80BF8000B94CD3 /* MatchError.swift */, - 1FCF91521C61C8A400B15DCB /* PostNotification.swift */, 1FA0C3FE1E30B14500623165 /* Matcher.swift */, + 1FCF91521C61C8A400B15DCB /* PostNotification.swift */, 1FD8CD1E1968AB07008ED995 /* RaisesException.swift */, A8F6B5BC2070186D00FCB5ED /* SatisfyAllOf.swift */, 7B5358BD1C38479700A23FAA /* SatisfyAnyOf.swift */, @@ -850,6 +856,7 @@ 29EA59671B551EE6002D767E /* ThrowError.swift in Sources */, 62FB326223B78BF90047BED9 /* BeginWithPrefix.swift in Sources */, 1FD8CD5B1968AB07008ED995 /* Equal.swift in Sources */, + 8923E60D2B47CE7E00F3961A /* Map.swift in Sources */, CDF5C57B2647B89B0036532C /* Equal+Tuple.swift in Sources */, 857D1849253610A900D8693A /* BeWithin.swift in Sources */, 1FD8CD4D1968AB07008ED995 /* BeLessThan.swift in Sources */, @@ -975,6 +982,7 @@ DD72EC651A93874A002F7651 /* AllPassTest.swift in Sources */, 1F4A569E1A3B3565009E1637 /* ObjCMatchTest.m in Sources */, 1F925EEA195C124400ED456B /* BeAnInstanceOfTest.swift in Sources */, + 8923E6102B47D08300F3961A /* MapTest.swift in Sources */, 29EA59641B551ED2002D767E /* ThrowErrorTest.swift in Sources */, 6CAEDD0B1CAEA86F003F1584 /* LinuxSupport.swift in Sources */, 1F4A566B1A3B3108009E1637 /* ObjCBeAnInstanceOfTest.m in Sources */, diff --git a/README.md b/README.md index f8f6d308d..4f5b48ab8 100644 --- a/README.md +++ b/README.md @@ -1594,6 +1594,38 @@ The `String` provided with `.failed()` is shown when the test fails. When using `toEventually()` be careful not to make state changes or run process intensive code since this closure will be ran many times. +## Mapping a Value to Another Value + +Sometimes, you only want to match against a property or group of properties. +For example, if you wanted to check that only one or a few properties of a value +are equal to something else. For this, use the `map` matcher to convert a value +to another value and check it with a matcher. + +```swift +// Swift + +expect(someValue).to(map(\.someProperty, equal(expectedProperty))) + +// or, for checking multiple different properties: + +expect(someValue).to(satisfyAllOf( + map(\.firstProperty, equal(expectedFirstProperty)), + map({ $0.secondProperty }, equal(expectedSecondProperty)) +)) +``` + +The `map` matcher takes in either a closure or a keypath literal, and a matcher +to compose with. It also works with async closures and async matchers. + +In most cases, it is simpler and easier to not use map (that is, prefer +`expect(someValue.property).to(equal(1))` to +`expect(someValue).to(map(\.property, equal(1)))`). But `map` is incredibly +useful when combined with `satisfyAllOf`/`satisfyAnyOf`, especially for checking +a value that cannot conform to `Equatable` (or you don't want to make it +conform to `Equatable`). However, if you find yourself reusing `map` many times +to do a fuzzy-equals of a given type, you will find writing a custom matcher to +be much easier to use and maintain. + # Writing Your Own Matchers In Nimble, matchers are Swift functions that take an expected diff --git a/Sources/Nimble/AsyncExpression.swift b/Sources/Nimble/AsyncExpression.swift index e5fd4bebe..b669d5a0b 100644 --- a/Sources/Nimble/AsyncExpression.swift +++ b/Sources/Nimble/AsyncExpression.swift @@ -91,7 +91,15 @@ public struct AsyncExpression { /// - Parameter block: The block that can cast the current Expression value to a /// new type. public func cast(_ block: @escaping (Value?) throws -> U?) -> AsyncExpression { - return AsyncExpression( + AsyncExpression( + expression: ({ try await block(self.evaluate()) }), + location: self.location, + isClosure: self.isClosure + ) + } + + public func cast(_ block: @escaping (Value?) async throws -> U?) -> AsyncExpression { + AsyncExpression( expression: ({ try await block(self.evaluate()) }), location: self.location, isClosure: self.isClosure @@ -99,11 +107,11 @@ public struct AsyncExpression { } public func evaluate() async throws -> Value? { - return try await self._expression(_withoutCaching) + try await self._expression(_withoutCaching) } public func withoutCaching() -> AsyncExpression { - return AsyncExpression( + AsyncExpression( memoizedExpression: self._expression, location: location, withoutCaching: true, @@ -112,7 +120,7 @@ public struct AsyncExpression { } public func withCaching() -> AsyncExpression { - return AsyncExpression( + AsyncExpression( memoizedExpression: memoizedClosure { try await self.evaluate() }, location: self.location, withoutCaching: false, diff --git a/Sources/Nimble/Matchers/Map.swift b/Sources/Nimble/Matchers/Map.swift new file mode 100644 index 000000000..7132ab3c4 --- /dev/null +++ b/Sources/Nimble/Matchers/Map.swift @@ -0,0 +1,27 @@ +/// `map` works by transforming the expression to a value that the given matcher uses. +/// +/// For example, you might only care that a particular property on a method equals some other value. +/// So, you could write `expect(myObject).to(lens(\.someIntValue, equal(3))`. +/// This is also useful in conjunction with ``satisfyAllOf`` to do a partial equality of an object. +public func map(_ transform: @escaping (T) throws -> U, _ matcher: Matcher) -> Matcher { + Matcher { (received: Expression) in + try matcher.satisfies(received.cast { value in + guard let value else { return nil } + return try transform(value) + }) + } +} + +/// `map` works by transforming the expression to a value that the given matcher uses. +/// +/// For example, you might only care that a particular property on a method equals some other value. +/// So, you could write `expect(myObject).to(lens(\.someIntValue, equal(3))`. +/// This is also useful in conjunction with ``satisfyAllOf`` to do a partial equality of an object. +public func map(_ transform: @escaping (T) async throws -> U, _ matcher: some AsyncableMatcher) -> AsyncMatcher { + AsyncMatcher { (received: AsyncExpression) in + try await matcher.satisfies(received.cast { value in + guard let value else { return nil } + return try await transform(value) + }) + } +} diff --git a/Tests/NimbleTests/Matchers/MapTest.swift b/Tests/NimbleTests/Matchers/MapTest.swift new file mode 100644 index 000000000..9b13c5f64 --- /dev/null +++ b/Tests/NimbleTests/Matchers/MapTest.swift @@ -0,0 +1,83 @@ +import XCTest +import Nimble +#if SWIFT_PACKAGE +import NimbleSharedTestHelpers +#endif + +final class MapTest: XCTestCase { + func testMap() { + expect(1).to(map({ $0 }, equal(1))) + + struct Value { + let int: Int + let string: String? + } + + expect(Value( + int: 1, + string: "hello" + )).to(satisfyAllOf( + map(\.int, equal(1)), + map(\.string, equal("hello")) + )) + + expect(Value( + int: 1, + string: "hello" + )).to(satisfyAnyOf( + map(\.int, equal(2)), + map(\.string, equal("hello")) + )) + + expect(Value( + int: 1, + string: "hello" + )).toNot(satisfyAllOf( + map(\.int, equal(2)), + map(\.string, equal("hello")) + )) + } + + func testMapAsync() async { + struct Value { + let int: Int + let string: String + } + + await expect(Value( + int: 1, + string: "hello" + )).to(map(\.int, asyncEqual(1))) + + await expect(Value( + int: 1, + string: "hello" + )).toNot(map(\.int, asyncEqual(2))) + } + + func testMapWithAsyncFunction() async { + func someOperation(_ value: Int) async -> String { + "\(value)" + } + await expect(1).to(map(someOperation, equal("1"))) + } + + func testMapWithActor() { + actor Box { + let int: Int + let string: String + + init(int: Int, string: String) { + self.int = int + self.string = string + } + } + + let box = Box(int: 3, string: "world") + + expect(box).to(satisfyAllOf( + map(\.int, equal(3)), + map(\.string, equal("world")) + )) + } +}