XCTestExpectation
iOS testing
XCTest
Swift
test automation

How to know XCTestExpectation current fulfillment count

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

XCTestExpectation lets you describe how many asynchronous events must happen before a test can finish, but it is not designed as a general-purpose counter you can query at any time. In practice, if you need the current fulfillment count during a test, the reliable approach is to track that count yourself next to the expectation.

What XCTest Gives You Directly

XCTestExpectation exposes a few useful pieces of state, including the description and the expectedFulfillmentCount. That tells XCTest how many times fulfill() must be called before the expectation is satisfied.

Example:

swift
1import XCTest
2
3final class CounterTests: XCTestCase {
4    func testExpectationNeedsThreeEvents() async {
5        let expectation = expectation(description: "three callbacks")
6        expectation.expectedFulfillmentCount = 3
7        expectation.assertForOverFulfill = true
8
9        expectation.fulfill()
10        expectation.fulfill()
11        expectation.fulfill()
12
13        await fulfillment(of: [expectation], timeout: 1.0)
14    }
15}

This sets the target count, but it still does not give you a convenient public API for "how many fulfillments have happened so far." That is the gap most people run into.

Track The Current Count Yourself

If the test logic needs to inspect the current number of callbacks, keep your own counter alongside the expectation.

swift
1import XCTest
2
3final class CounterTests: XCTestCase {
4    func testTrackFulfillmentManually() async {
5        let expectation = expectation(description: "two callbacks")
6        expectation.expectedFulfillmentCount = 2
7
8        var currentCount = 0
9
10        func markFulfilled() {
11            currentCount += 1
12            expectation.fulfill()
13        }
14
15        markFulfilled()
16        XCTAssertEqual(currentCount, 1)
17
18        markFulfilled()
19        XCTAssertEqual(currentCount, 2)
20
21        await fulfillment(of: [expectation], timeout: 1.0)
22    }
23}

This works because your test is now explicit about the state it cares about. XCTest handles waiting. Your own counter handles inspection and assertions.

For older synchronous-style tests, the equivalent pattern is:

swift
1import XCTest
2
3final class LegacyCounterTests: XCTestCase {
4    func testManualCounterWithWait() {
5        let expectation = expectation(description: "done twice")
6        expectation.expectedFulfillmentCount = 2
7
8        var currentCount = 0
9
10        for _ in 0..<2 {
11            currentCount += 1
12            expectation.fulfill()
13        }
14
15        XCTAssertEqual(currentCount, 2)
16        wait(for: [expectation], timeout: 1.0)
17    }
18}

Why XCTest Does Not Center This API

An expectation is mainly a synchronization primitive. XCTest cares whether the expectation was fulfilled enough times, fulfilled too many times, or timed out. It does not encourage business logic that polls internal expectation state while the async work is still happening.

That design is sensible because tests are easier to maintain when they assert observable results:

  • data was returned
  • callback fired the expected number of times
  • timeout did not occur

If you find yourself constantly needing the expectation's internal count, it is often a sign that your test should expose that state through a stub, spy, or helper object instead.

A Better Pattern For Repeated Callbacks

For more complex tests, use a small recorder object. That keeps the count and payloads together.

swift
1import XCTest
2
3final class EventRecorder {
4    private(set) var values: [String] = []
5
6    func record(_ value: String) {
7        values.append(value)
8    }
9}
10
11final class RecorderTests: XCTestCase {
12    func testRecorderAndExpectation() async {
13        let expectation = expectation(description: "receive three values")
14        expectation.expectedFulfillmentCount = 3
15
16        let recorder = EventRecorder()
17
18        ["A", "B", "C"].forEach { value in
19            recorder.record(value)
20            expectation.fulfill()
21        }
22
23        await fulfillment(of: [expectation], timeout: 1.0)
24        XCTAssertEqual(recorder.values.count, 3)
25        XCTAssertEqual(recorder.values, ["A", "B", "C"])
26    }
27}

This gives you a current count through recorder.values.count and a full record of what happened.

Use assertForOverFulfill

If you expect a fixed number of callbacks, enable:

swift
expectation.assertForOverFulfill = true

That helps catch accidental extra fulfill() calls. It is not a counter, but it makes the test stricter and easier to debug.

Common Pitfalls

  • Expecting expectedFulfillmentCount to tell you how many times fulfill() has already been called. It only tells XCTest the target number.
  • Polling expectation state instead of tracking callback state in your test double or helper object.
  • Forgetting assertForOverFulfill when duplicate callbacks would indicate a bug.
  • Mixing old wait(for:timeout:) style and new async test style inconsistently in the same test logic.
  • Using expectations when a direct async return value would make the test simpler.

Summary

  • 'XCTestExpectation is built for synchronization, not for exposing a live fulfillment counter.'
  • Use expectedFulfillmentCount to define the target number of callbacks.
  • If you need the current count, track it yourself in a variable, spy, or recorder object.
  • 'assertForOverFulfill helps catch extra callback invocations.'
  • Prefer assertions on observable test state rather than trying to inspect expectation internals.

Course illustration
Course illustration

All Rights Reserved.