← All daysDay 38 of 49 · Part 9: Swift concurrency
Day 38

Controlling time in tests

These are the lesson notes for this day. Its build-along tutorial and flash cards have not been written yet.

Lesson 43

Controlling time in tests

Tests must not wait on real time. A test that sleeps for 300 ms to check a debounce is slow, and it fails at random on a busy machine. Treat time as a dependency, like the network. Code that needs to wait should accept a Clock — the type is written any Clock<Duration> — and call clock.sleep(for:). Production code passes ContinuousClock(). A test passes a clock that it controls.

The swift-clocks package provides TestClock, which moves only when the test calls advance(by:), and ImmediateClock, which does not wait at all. This is the same idea as advancing a TestScheduler in RxSwift or Combine.

Exercise

Write a debounced search model that accepts a clock. Test it: advance the clock by 299 ms and assert that no search has happened; advance by 1 ms more and assert that exactly one has.

After this lesson you should be able to answer
  1. Why should a unit test not call Task.sleep?
  2. How do you make code that depends on time testable?
  3. What does TestClock.advance(by:) do?
Resources
Find the bug
Interview problem · Part 9

Two downloads for one image

“It is an actor, so it is thread-safe — and yet a scrolling list downloads the same avatar six times. Is that a data race? If not, what is it, and how do you fix it?”

actor ImageLoader {
    private var cache: [URL: Data] = [:]

    func image(for url: URL) async throws -> Data {
        if let cached = cache[url] { return cached }
        let (data, _) = try await URLSession.shared.data(from: url)
        cache[url] = data
        return data
    }
}

Where this comes from: Apple iOS interview, as reported by candidates (Prepfully) · WWDC21: Protect mutable state with Swift actors (2021 — fundamentals unchanged) — Apple, as reported: “How would you handle race conditions when dealing with threads in an iOS application?”

Show the answer

It is not a data race. The actor guarantees that no two calls touch cache at the same instant. It is reentrancy: every time the actor reaches an await, it is free to start another call. Six callers each find the cache empty, each suspends on its own download, and each writes the same bytes back. Whatever you checked before an await may no longer be true after it.

actor ImageLoaderFixed {
    private var cache: [URL: Data] = [:]
    private var inFlight: [URL: Task<Data, Error>] = [:]

    func image(for url: URL) async throws -> Data {
        if let cached = cache[url] { return cached }
        // join the download already under way
        if let task = inFlight[url] { return try await task.value }

        let task = Task { try await URLSession.shared.data(from: url).0 }
        inFlight[url] = task
        defer { inFlight[url] = nil }

        let data = try await task.value
        cache[url] = data
        return data
    }
}

Cache the work in progress, not only the result. Later callers wait for the first download instead of starting their own. Measured with six simultaneous callers, the original makes six requests and this version makes one.

What interviewers listen for: Precise vocabulary — a data race, a race condition, and reentrancy are three different things — and the habit of re-checking state after every await.