Skip to content

Commit

Permalink
Add AsyncPredicate - Matchers with AsyncExpressions (#1056)
Browse files Browse the repository at this point in the history
* Create AsyncPredicate, for allowing async functions in predicates.

This will not ever replace the standard Predicates, but is meant to be a companion to it.

* Allow satisfyAnyOf and satisfyAllOf to take in both Predicates and AsyncPredicates

* Change the some AsyncablePredicate<T> in satisfyAnyOf/satisfyAllOf operators to any AsyncablePredicate<T>

This is a workaround for a compiler bug in swift 5.7.

* just require swift 5.8 for satisfyAny/AllOf with async predicates

* xcode 14.3 requires macos 13

* Be more discerning when trying to find watchOS and macOS SDK versions

* Use a more recent iPhone for testing

* Add an async version of allPass. Update documentation to mention async predicates
  • Loading branch information
younata authored Jul 13, 2023
1 parent 371f7d2 commit 7f0621b
Show file tree
Hide file tree
Showing 22 changed files with 1,346 additions and 47 deletions.
15 changes: 14 additions & 1 deletion .github/workflows/ci-swiftpm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ on:
- "*"

jobs:
swiftpm_darwin:
swiftpm_darwin_monterey:
name: SwiftPM, Darwin, Xcode ${{ matrix.xcode }}
runs-on: macos-12
strategy:
Expand All @@ -23,13 +23,26 @@ jobs:
- uses: actions/checkout@v3
- run: ./test swiftpm

swiftpm_darwin_ventura:
name: SwiftPM, Darwin, Xcode ${{ matrix.xcode }}
runs-on: macos-13
strategy:
matrix:
xcode: ["14.3.1"]
env:
DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app"
steps:
- uses: actions/checkout@v3
- run: ./test swiftpm

swiftpm_linux:
name: SwiftPM, Linux
runs-on: ubuntu-latest
strategy:
matrix:
container:
- swift:5.7
- swift:5.8
# - swiftlang/swift:nightly
fail-fast: false
container: ${{ matrix.container }}
Expand Down
18 changes: 17 additions & 1 deletion .github/workflows/ci-xcode.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ on:
- "*"

jobs:
xcode:
xcode_monterey:
name: Xcode ${{ matrix.xcode }} (Xcode Project)
runs-on: macos-12
strategy:
Expand All @@ -27,6 +27,22 @@ jobs:
- run: ./test tvos
- run: ./test watchos

xcode_ventura:
name: Xcode ${{ matrix.xcode }} (Xcode Project)
runs-on: macos-13
strategy:
matrix:
xcode: ["14.3.1"]
fail-fast: false
env:
DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app"
steps:
- uses: actions/checkout@v3
- run: ./test macos
- run: ./test ios
- run: ./test tvos
- run: ./test watchos

xcode_spm:
name: Xcode ${{ matrix.xcode }} (Swift Package)
runs-on: macos-12
Expand Down
50 changes: 50 additions & 0 deletions Nimble.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

70 changes: 69 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ To avoid a compiler errors when using synchronous `expect` in asynchronous conte

```swift
// Swift
await expect(await aFunctionReturning1()).to(equal(1)))
await expecta(await aFunctionReturning1()).to(equal(1)))
```

Similarly, if you're ever in a situation where you want to force the compiler to
Expand All @@ -338,6 +338,22 @@ expects(someNonAsyncFunction()).to(equal(1)))
expects(await someAsyncFunction()).to(equal(1)) // Compiler error: 'async' call in an autoclosure that does not support concurrency
```

### Async Matchers

In addition to asserting on async functions prior to passing them to a
synchronous predicate, you can also write matchers that directly take in an
async value. These are called `AsyncPredicate`s. This is most obviously useful
when directly asserting against an actor. In addition to writing your own
async matchers, Nimble currently ships with async versions of the following
predicates:

- `allPass`
- `containElementSatisfying`
- `satisfyAllOf` and the `&&` operator overload accept both `AsyncPredicate` and
synchronous `Predicate`s.
- `satisfyAnyOf` and the `||` operator overload accept both `AsyncPredicate` and
synchronous `Predicate`s.

Note: Async/Await support is different than the `toEventually`/`toEventuallyNot`
feature described below.

Expand Down Expand Up @@ -1193,6 +1209,9 @@ expect(turtles).to(containElementSatisfying({ turtle in
// should it fail
```

Note: in Swift, `containElementSatisfying` also has a variant that takes in an
async function.

```objc
// Objective-C

Expand Down Expand Up @@ -1287,6 +1306,19 @@ expect([1, 2, 3, 4]).to(allPass { $0 < 5 })
expect([1, 2, 3, 4]).to(allPass(beLessThan(5)))
```

There are also variants of `allPass` that check against async matchers, and
that take in async functions:

```swift
// Swift

// Providing a custom function:
expect([1, 2, 3, 4]).to(allPass { await asyncFunctionReturningBool($0) })

// Composing the expectation with another matcher:
expect([1, 2, 3, 4]).to(allPass(someAsyncMatcher()))
```

### Objective-C

In Objective-C, the collection must be an instance of a type which implements
Expand Down Expand Up @@ -1414,6 +1446,9 @@ expect(6).to(satisfyAnyOf(equal(2), equal(3), equal(4), equal(5), equal(6), equa
expect(82).to(beLessThan(50) || beGreaterThan(80))
```

Note: In swift, you can mix and match synchronous and asynchronous predicates
using by `satisfyAnyOf`/`||`.

```objc
// Objective-C

Expand Down Expand Up @@ -1709,6 +1744,39 @@ For a more comprehensive message that spans multiple lines, use
.expectedActualValueTo("be true").appended(details: "use beFalse() for inverse\nor use beNil()")
```

## Asynchronous Predicates

To write predicates against async expressions, return an instance of
`AsyncPredicate`. The closure passed to `AsyncPredicate` is async, and the
expression you evaluate is also asynchronous and needs to be awaited on.

```swift
// Swift

actor CallRecorder<Arguments> {
private(set) var calls: [Arguments] = []

func record(call: Arguments) {
calls.append(call)
}
}

func beCalled<Argument: Equatable>(with arguments: Argument) -> AsyncPredicate<CallRecorder<Argument>> {
AsyncPredicate { (expression: AsyncExpression<CallRecorder<Argument>>) in
let message = ExpectationMessage.expectedActualValueTo("be called with \(arguments)")
guard let calls = try await expression.evaluate()?.calls else {
return PredicateResult(status: .fail, message: message.appendedBeNilHint())
}

return PredicateResult(bool: calls.contains(args), message: message.appended(details: "called with \(calls)"))
}
}
```

In this example, we created an actor to act as an object to record calls to an
async function. Then, we created the `beCalled(with:)` matcher to check if the
actor has received a call with the given arguments.

## Supporting Objective-C

To use a custom matcher written in Swift from Objective-C, you'll have
Expand Down
9 changes: 9 additions & 0 deletions Sources/Nimble/AsyncExpression.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,5 +110,14 @@ public struct AsyncExpression<Value> {
isClosure: isClosure
)
}

public func withCaching() -> AsyncExpression<Value> {
return AsyncExpression(
memoizedExpression: memoizedClosure { try await self.evaluate() },
location: self.location,
withoutCaching: false,
isClosure: isClosure
)
}
}

62 changes: 62 additions & 0 deletions Sources/Nimble/Expectation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ internal func execute<T>(_ expression: Expression<T>, _ style: ExpectationStyle,
return result
}

internal func execute<T>(_ expression: AsyncExpression<T>, _ style: ExpectationStyle, _ predicate: AsyncPredicate<T>, to: String, description: String?) async -> (Bool, FailureMessage) {
let msg = FailureMessage()
msg.userDescription = description
msg.to = to
do {
let result = try await predicate.satisfies(expression)
result.message.update(failureMessage: msg)
if msg.actualValue == "" {
msg.actualValue = "<\(stringify(try await expression.evaluate()))>"
}
return (result.toBoolean(expectation: style), msg)
} catch let error {
msg.stringValue = "unexpected error thrown: <\(error)>"
return (false, msg)
}
}

public enum ExpectationStatus: Equatable {

/// No predicates have been performed.
Expand Down Expand Up @@ -192,6 +209,29 @@ public struct SyncExpectation<Value>: Expectation {
toNot(predicate, description: description)
}

// MARK: - AsyncPredicates
/// Tests the actual value using a matcher to match.
@discardableResult
public func to(_ predicate: AsyncPredicate<Value>, description: String? = nil) async -> Self {
let (pass, msg) = await execute(expression.toAsyncExpression(), .toMatch, predicate, to: "to", description: description)
return verify(pass, msg)
}

/// Tests the actual value using a matcher to not match.
@discardableResult
public func toNot(_ predicate: AsyncPredicate<Value>, description: String? = nil) async -> Self {
let (pass, msg) = await execute(expression.toAsyncExpression(), .toNotMatch, predicate, to: "to not", description: description)
return verify(pass, msg)
}

/// Tests the actual value using a matcher to not match.
///
/// Alias to toNot().
@discardableResult
public func notTo(_ predicate: AsyncPredicate<Value>, description: String? = nil) async -> Self {
await toNot(predicate, description: description)
}

// see:
// - `Polling.swift` for toEventually and older-style polling-based approach to "async"
// - NMBExpectation for Objective-C interface
Expand Down Expand Up @@ -261,4 +301,26 @@ public struct AsyncExpectation<Value>: Expectation {
public func notTo(_ predicate: Predicate<Value>, description: String? = nil) async -> Self {
await toNot(predicate, description: description)
}

/// Tests the actual value using a matcher to match.
@discardableResult
public func to(_ predicate: AsyncPredicate<Value>, description: String? = nil) async -> Self {
let (pass, msg) = await execute(expression, .toMatch, predicate, to: "to", description: description)
return verify(pass, msg)
}

/// Tests the actual value using a matcher to not match.
@discardableResult
public func toNot(_ predicate: AsyncPredicate<Value>, description: String? = nil) async -> Self {
let (pass, msg) = await execute(expression, .toNotMatch, predicate, to: "to not", description: description)
return verify(pass, msg)
}

/// Tests the actual value using a matcher to not match.
///
/// Alias to toNot().
@discardableResult
public func notTo(_ predicate: AsyncPredicate<Value>, description: String? = nil) async -> Self {
await toNot(predicate, description: description)
}
}
17 changes: 13 additions & 4 deletions Sources/Nimble/Expression.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,19 +75,19 @@ public struct Expression<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?) -> Expression<U> {
return Expression<U>(
Expression<U>(
expression: ({ try block(self.evaluate()) }),
location: self.location,
isClosure: self.isClosure
)
}

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

public func withoutCaching() -> Expression<Value> {
return Expression(
Expression(
memoizedExpression: self._expression,
location: location,
withoutCaching: true,
Expand All @@ -96,11 +96,20 @@ public struct Expression<Value> {
}

public func withCaching() -> Expression<Value> {
return Expression(
Expression(
memoizedExpression: memoizedClosure { try self.evaluate() },
location: self.location,
withoutCaching: false,
isClosure: isClosure
)
}

public func toAsyncExpression() -> AsyncExpression<Value> {
AsyncExpression(
memoizedExpression: { @MainActor memoize in try _expression(memoize) },
location: location,
withoutCaching: _withoutCaching,
isClosure: isClosure
)
}
}
64 changes: 64 additions & 0 deletions Sources/Nimble/Matchers/AsyncAllPass.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
public func allPass<S: Sequence>(
_ passFunc: @escaping (S.Element) async throws -> Bool
) -> AsyncPredicate<S> {
let matcher = AsyncPredicate<S.Element>.define("pass a condition") { actualExpression, message in
guard let actual = try await actualExpression.evaluate() else {
return PredicateResult(status: .fail, message: message)
}
return PredicateResult(bool: try await passFunc(actual), message: message)
}
return createPredicate(matcher)
}

public func allPass<S: Sequence>(
_ passName: String,
_ passFunc: @escaping (S.Element) async throws -> Bool
) -> AsyncPredicate<S> {
let matcher = AsyncPredicate<S.Element>.define(passName) { actualExpression, message in
guard let actual = try await actualExpression.evaluate() else {
return PredicateResult(status: .fail, message: message)
}
return PredicateResult(bool: try await passFunc(actual), message: message)
}
return createPredicate(matcher)
}

public func allPass<S: Sequence>(_ elementPredicate: AsyncPredicate<S.Element>) -> AsyncPredicate<S> {
return createPredicate(elementPredicate)
}

private func createPredicate<S: Sequence>(_ elementMatcher: AsyncPredicate<S.Element>) -> AsyncPredicate<S> {
return AsyncPredicate { actualExpression in
guard let actualValue = try await actualExpression.evaluate() else {
return PredicateResult(
status: .fail,
message: .appends(.expectedTo("all pass"), " (use beNil() to match nils)")
)
}

var failure: ExpectationMessage = .expectedTo("all pass")
for currentElement in actualValue {
let exp = AsyncExpression(
expression: { currentElement },
location: actualExpression.location
)
let predicateResult = try await elementMatcher.satisfies(exp)
if predicateResult.status == .matches {
failure = predicateResult.message.prepended(expectation: "all ")
} else {
failure = predicateResult.message
.replacedExpectation({ .expectedTo($0.expectedMessage) })
.wrappedExpectation(
before: "all ",
after: ", but failed first at element <\(stringify(currentElement))>"
+ " in <\(stringify(actualValue))>"
)
return PredicateResult(status: .doesNotMatch, message: failure)
}
}
failure = failure.replacedExpectation({ expectation in
return .expectedTo(expectation.expectedMessage)
})
return PredicateResult(status: .matches, message: failure)
}
}
Loading

0 comments on commit 7f0621b

Please sign in to comment.