Trang chủ

Kiểm thử một pipeline

Điều khiển thời gian để một bài test debounce không mất ba giây

Combine kiểm thử được nhiều hơn vẻ ngoài của nó, vì đúng một lý do: scheduler là một phụ thuộc được tiêm vào, và một scheduler là một cái đồng hồ. Điều khiển được đồng hồ thì các toán tử dựa trên thời gian thôi chậm chạp.

Hình dạng cơ bản

Một pipeline bất đồng bộ đòi bài test phải chờ, và với Swift Testing thì đó là một confirmation:

import Testing
import Combine

@Test func searchDebouncesInput() async throws {
    var cancellables: Set<AnyCancellable> = []
    let subject = PassthroughSubject<String, Never>()
    var received: [String] = []

    await confirmation { confirmed in
        subject
            .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
            .sink { value in
                received.append(value)
                confirmed()
            }
            .store(in: &cancellables)

        subject.send("s")
        subject.send("sw")
        subject.send("swift")

        try? await Task.sleep(for: .milliseconds(500))
    }

    #expect(received == ["swift"])
}

Cách đó chạy được và mất nửa giây, con số nhân lên trên cả bộ test chính là lý do người ta thôi viết những bài test kiểu này.

Tiêm scheduler vào

Hãy biến scheduler thành một tham số và độ trễ biến mất:

final class SearchModel {
    private let scheduler: any Scheduler

    init(scheduler: some Scheduler = DispatchQueue.main) {
        self.scheduler = scheduler
    }
}

Trong bản chạy thật nó là DispatchQueue.main; trong test nó là ImmediateScheduler.shared, thứ chạy mọi thứ đồng bộ mà không trễ chút nào. Một debounce ba giây hoàn tất tức thì, và bài test không cần chờ, không cần confirmation và không bị chập chờn.

Cảnh báo

ImmediateScheduler xóa sạch độ trễ, nên nó chứng minh được pipeline nối đúng nhưng không chứng minh được thời lượng có đúng không. Một bài test dùng nó sẽ pass dù debounce là 300ms hay 30s. Với hành vi phụ thuộc vào khoảng thời gian, bạn cần một scheduler có đồng hồ điều khiển được — Apple không cung cấp cái nào, nên đây là chỗ một test scheduler từ combine-schedulers xứng đáng có mặt, hoặc bạn tự viết hai mươi dòng ấy.

Thu thập đầu ra

Đoạn lặp đi lặp lại là “chạy publisher này rồi đưa tôi mọi thứ nó tạo ra”. Đáng viết một lần:

extension Publisher {
    func collectValues(
        timeout: TimeInterval = 1
    ) async throws -> [Output] where Failure == Never {
        var values: [Output] = []
        for await value in values(timeout: timeout) {
            values.append(value)
        }
        return values
    }
}

Trong thực tế, phiên bản đơn giản nhất dùng .values, cây cầu sang AsyncSequence được nói ở chương sau, thứ biến một bài test Combine thành một bài test async bình thường:

@Test func loadsItems() async throws {
    let model = ItemsModel(api: StubAPI(items: [.sample]))
    var results: [LoadState] = []

    for await state in model.state.values.prefix(2).values {
        results.append(state)
    }

    #expect(results.count == 2)
}

prefix(2) là thứ khiến đoạn này kết thúc được. Một publisher vô hạn duyệt bằng for await không bao giờ xong, và bài test sẽ treo chứ không fail — hãy luôn chặn giới hạn cho chuỗi.

Kiểm thử các nhánh lỗi

Chương về lỗi đã lập luận rằng bắt lỗi ở đâu là chuyện quan trọng. Khẳng định đó kiểm thử được, và đáng có một bài test vì cái lỗi mà nó ngăn chặn thì vô hình:

@Test func searchSurvivesAFailedRequest() async throws {
    let api = StubAPI(results: [.failure(APIError.offline), .success([.sample])])
    let model = SearchModel(api: api, scheduler: ImmediateScheduler.shared)

    model.search("first")            // hỏng
    model.search("second")           // vẫn phải chạy được

    #expect(model.results == [.sample])
}

Nếu catch nằm ngoài publisher con, lần tìm kiếm thứ hai không trả về gì và bài test này fail. Đó chính xác là cái lỗi sản xuất mà nếu không có test thì sẽ được phát hành và bị báo cáo là “thỉnh thoảng tìm kiếm ngừng hoạt động”.

Làm cho các phụ thuộc thay thế được

Không có gì ở trên khả thi nếu một mô hình tự dựng URLSession.shared bên trong nó. Pipeline phải nhận các đầu vào của mình:

protocol SearchAPI {
    func search(_ term: String) -> AnyPublisher<[Result], APIError>
}

struct StubAPI: SearchAPI {
    var results: [Swift.Result<[Result], APIError>]
    func search(_ term: String) -> AnyPublisher<[Result], APIError> {
        // trả về câu trả lời đóng hộp kế tiếp
    }
}

AnyPublisher ở ranh giới protocol chính là cách dùng xóa kiểu đúng đắn — kiểu cụ thể của chuỗi thì không viết ra được, còn bản stub trả về một kiểu hoàn toàn khác.

Thứ gì đáng kiểm thử

Không phải các toán tử. debounce chạy đúng; Apple đã test nó rồi. Thứ đáng kiểm thử là phần bạn viết:

  • Hình dạng của đầu ra — rằng một lượt tìm kiếm phát ra .loading rồi .loaded, đúng thứ tự đó.
  • Rằng lỗi được giam lại — bài test ở trên, và là bài giá trị nhất ở đây.
  • Rằng việc hủy có xảy ra — một lượt tìm kiếm mới hủy request trước đó, kiểm chứng bằng cách khẳng định bản stub đã thấy một lần hủy.
  • Phần logic biến đổi — bất cứ thứ gì bên trong một map hay scan đủ phức tạp để có thể sai.

Nếu một bài test đang khẳng định rằng combineLatest ghép các giá trị mới nhất, thì nó đang kiểm thử framework của Apple, và nó sẽ tiếp tục pass trong khi logic của chính bạn hỏng.