Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Sources/Nimble/Nimble.docc/Guides/PollingExpectations.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ In Nimble, it's easy to make expectations on values that are updated
asynchronously. These are called Polling Expectations, because they work by
continuously polling the Expectation.

The expression is evaluated again for each polling attempt. Within an attempt,
the matcher reuses the same evaluated value, including when it builds a failure
message. This keeps the result and its diagnostic consistent without preventing
later attempts from observing changes.

## Forms of Polling Expectations

There are 4 forms of polling expectations: `toEventually`,
Expand Down
24 changes: 12 additions & 12 deletions Sources/Nimble/Polling+AsyncAwait.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ extension SyncExpectation {
timeout: timeout,
poll: pollInterval,
fnName: "toEventually") { @MainActor in
try matcher.satisfies(expression.withoutCaching())
try matcher.satisfies(expression.withoutCaching().withCaching())
}
}
return verify(pass, msg)
Expand All @@ -132,7 +132,7 @@ extension SyncExpectation {
timeout: timeout,
poll: pollInterval,
fnName: "toEventuallyNot") { @MainActor in
try matcher.satisfies(expression.withoutCaching())
try matcher.satisfies(expression.withoutCaching().withCaching())
}
}
return verify(pass, msg)
Expand Down Expand Up @@ -166,7 +166,7 @@ extension SyncExpectation {
timeout: until,
poll: pollInterval,
fnName: "toNever") { @MainActor in
try matcher.satisfies(expression.withoutCaching())
try matcher.satisfies(expression.withoutCaching().withCaching())
}
}
return verify(pass, msg)
Expand Down Expand Up @@ -200,7 +200,7 @@ extension SyncExpectation {
timeout: until,
poll: pollInterval,
fnName: "toAlways") { @MainActor in
try matcher.satisfies(expression.withoutCaching())
try matcher.satisfies(expression.withoutCaching().withCaching())
}
}
return verify(pass, msg)
Expand Down Expand Up @@ -236,7 +236,7 @@ extension SyncExpectation {
timeout: timeout,
poll: pollInterval,
fnName: "toEventually") { @MainActor in
try await matcher.satisfies(expression.withoutCaching().toAsyncExpression())
try await matcher.satisfies(expression.withoutCaching().withCaching().toAsyncExpression())
}
}
return verify(pass, msg)
Expand All @@ -262,7 +262,7 @@ extension SyncExpectation {
timeout: timeout,
poll: pollInterval,
fnName: "toEventuallyNot") { @MainActor in
try await matcher.satisfies(expression.withoutCaching().toAsyncExpression())
try await matcher.satisfies(expression.withoutCaching().withCaching().toAsyncExpression())
}
}
return verify(pass, msg)
Expand Down Expand Up @@ -296,7 +296,7 @@ extension SyncExpectation {
timeout: until,
poll: pollInterval,
fnName: "toNever") { @MainActor in
try await matcher.satisfies(expression.withoutCaching().toAsyncExpression())
try await matcher.satisfies(expression.withoutCaching().withCaching().toAsyncExpression())
}
}
return verify(pass, msg)
Expand Down Expand Up @@ -330,7 +330,7 @@ extension SyncExpectation {
timeout: until,
poll: pollInterval,
fnName: "toAlways") { @MainActor in
try await matcher.satisfies(expression.withoutCaching().toAsyncExpression())
try await matcher.satisfies(expression.withoutCaching().withCaching().toAsyncExpression())
}
}
return verify(pass, msg)
Expand Down Expand Up @@ -490,7 +490,7 @@ extension AsyncExpectation {
timeout: timeout,
poll: pollInterval,
fnName: "toEventually") {
try await matcher.satisfies(expression.withoutCaching())
try await matcher.satisfies(expression.withoutCaching().withCaching())
}
}
return verify(pass, msg)
Expand All @@ -514,7 +514,7 @@ extension AsyncExpectation {
timeout: timeout,
poll: pollInterval,
fnName: "toEventuallyNot") {
try await matcher.satisfies(expression.withoutCaching())
try await matcher.satisfies(expression.withoutCaching().withCaching())
}
}
return verify(pass, msg)
Expand Down Expand Up @@ -547,7 +547,7 @@ extension AsyncExpectation {
timeout: until,
poll: pollInterval,
fnName: "toNever") {
try await matcher.satisfies(expression.withoutCaching())
try await matcher.satisfies(expression.withoutCaching().withCaching())
}
}
return verify(pass, msg)
Expand Down Expand Up @@ -580,7 +580,7 @@ extension AsyncExpectation {
timeout: until,
poll: pollInterval,
fnName: "toAlways") {
try await matcher.satisfies(expression.withoutCaching())
try await matcher.satisfies(expression.withoutCaching().withCaching())
}
}
return verify(pass, msg)
Expand Down
24 changes: 12 additions & 12 deletions Sources/Nimble/Polling+Require.swift
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ extension SyncRequirement {
timeout: timeout,
poll: pollInterval,
fnName: "toEventually") { @MainActor in
try matcher.satisfies(expression.withoutCaching())
try matcher.satisfies(expression.withoutCaching().withCaching())
}
}
return try verify(pass, msg, try await asyncExpression.evaluate())
Expand All @@ -237,7 +237,7 @@ extension SyncRequirement {
timeout: timeout,
poll: pollInterval,
fnName: "toEventuallyNot") { @MainActor in
try matcher.satisfies(expression.withoutCaching())
try matcher.satisfies(expression.withoutCaching().withCaching())
}
}
return try verify(pass, msg, try await asyncExpression.evaluate())
Expand Down Expand Up @@ -271,7 +271,7 @@ extension SyncRequirement {
timeout: until,
poll: pollInterval,
fnName: "toNever") { @MainActor in
try matcher.satisfies(expression.withoutCaching())
try matcher.satisfies(expression.withoutCaching().withCaching())
}
}
return try verify(pass, msg, try await asyncExpression.evaluate())
Expand Down Expand Up @@ -305,7 +305,7 @@ extension SyncRequirement {
timeout: until,
poll: pollInterval,
fnName: "toAlways") { @MainActor in
try matcher.satisfies(expression.withoutCaching())
try matcher.satisfies(expression.withoutCaching().withCaching())
}
}
return try verify(pass, msg, try await asyncExpression.evaluate())
Expand Down Expand Up @@ -341,7 +341,7 @@ extension SyncRequirement {
timeout: timeout,
poll: pollInterval,
fnName: "toEventually") { @MainActor in
try await matcher.satisfies(expression.withoutCaching().toAsyncExpression())
try await matcher.satisfies(expression.withoutCaching().withCaching().toAsyncExpression())
}
}
return try verify(pass, msg, try await asyncExpression.evaluate())
Expand All @@ -367,7 +367,7 @@ extension SyncRequirement {
timeout: timeout,
poll: pollInterval,
fnName: "toEventuallyNot") { @MainActor in
try await matcher.satisfies(expression.withoutCaching().toAsyncExpression())
try await matcher.satisfies(expression.withoutCaching().withCaching().toAsyncExpression())
}
}
return try verify(pass, msg, try await asyncExpression.evaluate())
Expand Down Expand Up @@ -401,7 +401,7 @@ extension SyncRequirement {
timeout: until,
poll: pollInterval,
fnName: "toNever") { @MainActor in
try await matcher.satisfies(expression.withoutCaching().toAsyncExpression())
try await matcher.satisfies(expression.withoutCaching().withCaching().toAsyncExpression())
}
}
return try verify(pass, msg, try await asyncExpression.evaluate())
Expand Down Expand Up @@ -435,7 +435,7 @@ extension SyncRequirement {
timeout: until,
poll: pollInterval,
fnName: "toAlways") { @MainActor in
try await matcher.satisfies(expression.withoutCaching().toAsyncExpression())
try await matcher.satisfies(expression.withoutCaching().withCaching().toAsyncExpression())
}
}
return try verify(pass, msg, try await asyncExpression.evaluate())
Expand Down Expand Up @@ -595,7 +595,7 @@ extension AsyncRequirement {
timeout: timeout,
poll: pollInterval,
fnName: "toEventually") {
try await matcher.satisfies(expression.withoutCaching())
try await matcher.satisfies(expression.withoutCaching().withCaching())
}
}
return try verify(pass, msg, try await expression.evaluate())
Expand All @@ -619,7 +619,7 @@ extension AsyncRequirement {
timeout: timeout,
poll: pollInterval,
fnName: "toEventuallyNot") {
try await matcher.satisfies(expression.withoutCaching())
try await matcher.satisfies(expression.withoutCaching().withCaching())
}
}
return try verify(pass, msg, try await expression.evaluate())
Expand Down Expand Up @@ -652,7 +652,7 @@ extension AsyncRequirement {
timeout: until,
poll: pollInterval,
fnName: "toNever") {
try await matcher.satisfies(expression.withoutCaching())
try await matcher.satisfies(expression.withoutCaching().withCaching())
}
}
return try verify(pass, msg, try await expression.evaluate())
Expand Down Expand Up @@ -685,7 +685,7 @@ extension AsyncRequirement {
timeout: until,
poll: pollInterval,
fnName: "toAlways") {
try await matcher.satisfies(expression.withoutCaching())
try await matcher.satisfies(expression.withoutCaching().withCaching())
}
}
return try verify(pass, msg, try await expression.evaluate())
Expand Down
2 changes: 1 addition & 1 deletion Sources/Nimble/Polling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ internal func poll<T>(
sourceLocation: actualExpression.location,
fnName: fnName,
isContinuous: matchStyle.isContinuous) {
lastMatcherResult = try matcher.satisfies(uncachedExpression)
lastMatcherResult = try matcher.satisfies(uncachedExpression.withCaching())
if lastMatcherResult!.toBoolean(expectation: style) {
if matchStyle.isContinuous {
return .incomplete
Expand Down
11 changes: 11 additions & 0 deletions Tests/NimbleTests/AsyncAwaitTest+Require.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ import NimbleSharedTestHelpers
#endif

final class AsyncAwaitRequireTest: XCTestCase { // swiftlint:disable:this type_body_length
@MainActor
func testRequireReturnsValueFromCachedPollingAttempt() async throws {
var evaluations = 0
let value = try await require {
evaluations += 1
return evaluations % 3 != 1
}.toEventually(beTrue(), timeout: .milliseconds(100), pollInterval: .milliseconds(1))
XCTAssertTrue(value)
XCTAssertEqual(evaluations, 2)
}

func testToPositiveMatches() async throws {
func someAsyncFunction() async throws -> Int {
try await Task.sleep(nanoseconds: 1_000_000) // 1 millisecond
Expand Down
32 changes: 32 additions & 0 deletions Tests/NimbleTests/AsyncAwaitTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,38 @@ import NimbleSharedTestHelpers
#endif

final class AsyncAwaitTest: XCTestCase { // swiftlint:disable:this type_body_length
@MainActor
func testSyncExpressionIsCachedWithinAsyncPollingAttempt() async {
var evaluations = 0
await expect {
evaluations += 1
return evaluations % 3 != 1
}.toEventually(beTrue(), timeout: .milliseconds(100), pollInterval: .milliseconds(1))
XCTAssertEqual(evaluations, 2)
}

func testAsyncMatcherCachesEachPollingAttempt() async {
actor Counter {
var evaluations = 0
func next() -> Int {
evaluations += 1
return evaluations
}
}
let counter = Counter()
let matcher = AsyncMatcher<Int> { expression in
let first = try await expression.evaluate()
let second = try await expression.evaluate()
XCTAssertEqual(first, second)
return MatcherResult(bool: first == 2, message: .expectedActualValueTo("equal 2"))
}
await expect { await counter.next() }.toEventually(
matcher, timeout: .milliseconds(100), pollInterval: .milliseconds(1)
)
let evaluations = await counter.evaluations
XCTAssertEqual(evaluations, 2)
}

func testToPositiveMatches() async {
func someAsyncFunction() async throws -> Int {
try await Task.sleep(nanoseconds: 1_000_000) // 1 millisecond
Expand Down
33 changes: 33 additions & 0 deletions Tests/NimbleTests/PollingTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,39 @@ final class PollingTest: XCTestCase {
expect { value }.toEventuallyNot(equal(1))
}

func testPollingMatcherEvaluatesExpressionOncePerAttempt() {
var evaluations = 0
expect {
evaluations += 1
return evaluations % 3 != 1
}.toEventually(
beTrue(), timeout: .milliseconds(100), pollInterval: .milliseconds(1)
)
XCTAssertEqual(evaluations, 2)
}

func testContinuousPollingCachesEachAttempt() {
let matcher = Matcher<Int> { expression in
let first = try expression.evaluate()
let second = try expression.evaluate()
XCTAssertEqual(first, second)
return MatcherResult(bool: true, message: .expectedActualValueTo("match"))
}
var evaluations = 0
expect {
evaluations += 1
return evaluations
}.toAlways(matcher, until: .milliseconds(20), pollInterval: .milliseconds(1))
XCTAssertGreaterThan(evaluations, 1)

evaluations = 0
expect {
evaluations += 1
return evaluations
}.toNever(not(matcher), until: .milliseconds(20), pollInterval: .milliseconds(1))
XCTAssertGreaterThan(evaluations, 1)
}

func testToEventuallyNegativeMatches() {
let value = 0
failsWithErrorMessage("expected to eventually not equal <0>, got <0>") {
Expand Down