Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add AsyncPredicate - Matchers with AsyncExpressions #1056

Merged
merged 8 commits into from
Jul 13, 2023
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 @@
isClosure: isClosure
)
}

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

Check warning on line 123 in Sources/Nimble/AsyncExpression.swift

View workflow job for this annotation

GitHub Actions / lint

Trailing Newline Violation: Files should have a single trailing newline (trailing_newline)
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
Loading