Skip to content

Commit

Permalink
New matcher: map, for running a function on a value and running a mat…
Browse files Browse the repository at this point in the history
…cher against the return value of that function (#1112)
  • Loading branch information
younata committed Jan 6, 2024
1 parent 437d2a6 commit 1b75ed0
Show file tree
Hide file tree
Showing 5 changed files with 163 additions and 5 deletions.
10 changes: 9 additions & 1 deletion Nimble.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -314,6 +316,8 @@
892282892B2833B7002DA355 /* PollingTest+Require.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollingTest+Require.swift"; sourceTree = "<group>"; };
8922828B2B2837E1002DA355 /* Polling+Require.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Polling+Require.swift"; sourceTree = "<group>"; };
8922828E2B283956002DA355 /* AsyncAwaitTest+Require.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncAwaitTest+Require.swift"; sourceTree = "<group>"; };
8923E60C2B47CE7E00F3961A /* Map.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Map.swift; sourceTree = "<group>"; };
8923E60E2B47D06E00F3961A /* MapTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTest.swift; sourceTree = "<group>"; };
892FDF1229D3EA7700523A80 /* AsyncExpression.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsyncExpression.swift; sourceTree = "<group>"; };
896962402A5FABD000A7929D /* AsyncAllPass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAllPass.swift; sourceTree = "<group>"; };
896962452A5FAD4500A7929D /* AsyncAllPassTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAllPassTest.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions Sources/Nimble/AsyncExpression.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,19 +91,27 @@ public struct AsyncExpression<Value> {
/// - Parameter block: The block that can cast the current Expression value to a
/// new type.
public func cast<U>(_ block: @escaping (Value?) throws -> U?) -> AsyncExpression<U> {
return AsyncExpression<U>(
AsyncExpression<U>(
expression: ({ try await block(self.evaluate()) }),
location: self.location,
isClosure: self.isClosure
)
}

public func cast<U>(_ block: @escaping (Value?) async throws -> U?) -> AsyncExpression<U> {
AsyncExpression<U>(
expression: ({ try await block(self.evaluate()) }),
location: self.location,
isClosure: self.isClosure
)
}

public func evaluate() async throws -> Value? {
return try await self._expression(_withoutCaching)
try await self._expression(_withoutCaching)
}

public func withoutCaching() -> AsyncExpression<Value> {
return AsyncExpression(
AsyncExpression(
memoizedExpression: self._expression,
location: location,
withoutCaching: true,
Expand All @@ -112,7 +120,7 @@ public struct AsyncExpression<Value> {
}

public func withCaching() -> AsyncExpression<Value> {
return AsyncExpression(
AsyncExpression(
memoizedExpression: memoizedClosure { try await self.evaluate() },
location: self.location,
withoutCaching: false,
Expand Down
27 changes: 27 additions & 0 deletions Sources/Nimble/Matchers/Map.swift
Original file line number Diff line number Diff line change
@@ -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<T, U>(_ transform: @escaping (T) throws -> U, _ matcher: Matcher<U>) -> Matcher<T> {
Matcher { (received: Expression<T>) 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<T, U>(_ transform: @escaping (T) async throws -> U, _ matcher: some AsyncableMatcher<U>) -> AsyncMatcher<T> {
AsyncMatcher { (received: AsyncExpression<T>) in
try await matcher.satisfies(received.cast { value in
guard let value else { return nil }
return try await transform(value)
})
}
}
83 changes: 83 additions & 0 deletions Tests/NimbleTests/Matchers/MapTest.swift
Original file line number Diff line number Diff line change
@@ -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"))
))
}
}

0 comments on commit 1b75ed0

Please sign in to comment.