Test iOS search debounce with virtual time on a cloud Mac

Test iOS search debounce with virtual time on a cloud Mac

Test iOS search debounce with virtual time on a cloud Mac

The search field has a 300-millisecond debounce, but the test says, “Wait half a second after typing, then check the request count.” In parallel jobs on a cloud Mac, that makes the test both slow and vulnerable to scheduling delays. What actually needs checking is the deadline after the last input, replacement of earlier input, and the absence of delivery after the field is cleared. Separate those rules from the timer, and the tests no longer need to wait for real time to pass.

Define the search timing contract first

This article uses trailing-edge debounce: every nonempty input resets the deadline, and only the latest search term is delivered, once, when that deadline is reached. Leading and trailing whitespace is trimmed first; whitespace-only input and an empty string both cancel the pending delivery. Entering the same term again also restarts the delay. That is an explicit product rule, not the default behavior of every implementation.

Time is represented as integer milliseconds elapsed since the start of the test, not as a system date. In production, obtain elapsed time from a monotonic clock, not a wall clock that can jump when the system adjusts its time. The 300-millisecond delay is an example policy, not a service performance metric.

Debounce determines only when a search term is delivered. Whether an in-flight network request is canceled, and whether an older response may update the UI, are two separate contracts. “The timer was canceled” is not an acceptance check for either.

Extract the timing rules into a standalone state machine

Create a small verification package from the command line on a cloud Mac. The currently selected developer toolchain must include Swift; no simulator or graphical session is required.

mkdir SearchTiming
cd SearchTiming
swift package init --type library --name SearchTiming

Replace Sources/SearchTiming/SearchTiming.swift with the following. It creates no threads or timers. The caller is responsible for supplying timestamps in nondecreasing order and accessing the state serially.

import Foundation

struct SearchDebounce {
    let delay: Int64
    private(set) var deadline: Int64?
    private var pending: String?

    init(delay: Int64 = 300) {
        precondition(delay > 0)
        self.delay = delay
    }

    mutating func input(_ text: String, at now: Int64) {
        let query = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !query.isEmpty else {
            pending = nil
            deadline = nil
            return
        }
        pending = query
        deadline = now + delay
    }

    mutating func takeDue(at now: Int64) -> String? {
        guard let limit = deadline, now >= limit else {
            return nil
        }
        let query = pending
        pending = nil
        deadline = nil
        return query
    }
}

takeDue checks the deadline and consumes the pending value in the same operation. Repeated callbacks therefore cannot deliver the same value twice, and an early callback does not clear it. This example assumes timestamps remain within a normal operating range. If the clock adapter accepts external values, it should also check for negative values, time moving backward, and addition overflow.

Advance virtual time instead of calling sleep

Replace Tests/SearchTimingTests/SearchTimingTests.swift with the tests below. Each test creates fresh state to avoid sharing a clock or pending input.

import XCTest
@testable import SearchTiming

final class SearchTimingTests: XCTestCase {
    func testDeadlineAndSingleDelivery() {
        var state = SearchDebounce()
        state.input(" swift ", at: 0)
        XCTAssertNil(state.takeDue(at: 299))
        XCTAssertEqual(state.takeDue(at: 300), "swift")
        XCTAssertNil(state.takeDue(at: 301))
    }

    func testLatestInputMovesDeadline() {
        var state = SearchDebounce()
        state.input("s", at: 0)
        state.input("sw", at: 100)
        state.input("swift", at: 220)
        XCTAssertEqual(state.deadline, 520)
        XCTAssertNil(state.takeDue(at: 300))
        XCTAssertNil(state.takeDue(at: 519))
        XCTAssertEqual(state.takeDue(at: 520), "swift")
    }

    func testBlankCancelsPendingInput() {
        var state = SearchDebounce()
        state.input("swift", at: 0)
        state.input(" \n ", at: 100)
        XCTAssertNil(state.deadline)
        XCTAssertNil(state.takeDue(at: 1000))
    }

    func testSameInputRestartsDelay() {
        var state = SearchDebounce()
        state.input("swift", at: 0)
        state.input("swift", at: 200)
        XCTAssertNil(state.takeDue(at: 300))
        XCTAssertEqual(state.takeDue(at: 500), "swift")
    }
}

Run swift test. A failed assertion must make the command exit with a nonzero status; the pipeline must not simply archive the output and continue. These tests cover the instant just before the deadline, the deadline itself, and repeated consumption. Add a case well past the deadline to verify that a scheduling delay cannot cause the pending value to be lost permanently.

Guard against two cancellation errors when wiring up real tasks

The production adapter can use ContinuousClock to wait until the deadline. The state machine owns the rules; the adapter handles wakeups. On each input, update the state first, then cancel the previous waiting task and create a new one for the new deadline. Do not create another task after the field is cleared.

The first error is swallowing cancellation errors. If a try? wait is followed immediately by delivery logic, execution continues down the delivery path even after cancellation. Exit when cancellation is caught; do not treat cancellation as “the deadline has arrived.”

The second error is a late callback from an old task. Increment a generation counter for every input, and have each waiting task capture the generation in which it was created. When it wakes, compare that generation with the current one on the same serial executor, and allow it to consume the state only if they match. Checking only whether the task has been canceled is not enough, because cancellation and wakeup can happen at the same time.

UI input, generation updates, and result delivery can all run on the main executor. Do not read the state in the background and then asynchronously hop back to the main executor to modify it: that leaves a gap between checking and consuming the state. Milliseconds make assertions convenient in virtual-time tests; real waits should use the clock’s deadline and duration types to avoid accumulated conversion errors.

Finish with a few adapter integration tests

Once the pure logic tests pass, check the real adapter rather than converting every test back to a long wait. Allow a sufficiently generous test timeout, but still verify that delivery never happens early and happens only once in the end. The timeout is there to detect a hang, not to serve as a precise timing assertion.

The acceptance checklist should include at least the following: rapid successive inputs deliver only the last term; clearing the field immediately cancels the pending delivery; cancellation errors never enter the delivery path; callbacks from older generations are discarded; no further UI updates occur after the UI is released; and repeated runs produce consistent results. If the application needs to deduplicate identical search terms, add a separate deduplication layer and explicitly decide whether clearing the field and reentering a term should allow another search.

Keep the commit revision, toolchain version, and test exit code; there is no need to log users’ actual search terms. The result is not a script that “probably passes after waiting half a second,” but a set of assertions that identifies exactly which timing rule was broken.

Frequently asked questions

Can virtual time replace every real-timer test?

No. It verifies the state machine. Keep a few integration tests to check monotonic time, timer cancellation and delivery on the intended executor.

Does clearing the field cancel a request already in flight?

No. This state machine cancels only pending query delivery. Cancel active network requests separately and reject stale responses using a request generation.

A dedicated Mac for your projects

Mac mini physical nodes, not virtual machines. Rent by the day, week, month, or quarter. Choose your use case and specs, then check real-time availability in the console.

Choose a model and rental term View full pricing