Как проверить дебаунс поиска iOS на облачном Mac

Как проверить дебаунс поиска iOS на облачном Mac

Как проверить дебаунс поиска iOS на облачном Mac

Для поискового поля настроен дебаунс на 300 миллисекунд, а тест устроен так: «после ввода подождать полсекунды и проверить число запросов». При параллельном выполнении задач на облачном Mac такой тест не только медленный, но и чувствительный к задержкам планировщика. Проверять нужно другое: момент срабатывания после последнего ввода, замену предыдущего текста новым и отсутствие отправки после очистки поля. Если отделить эти правила от таймера, тестам не придётся ждать в реальном времени.

Сначала определим временной контракт поиска

В этой статье используется дебаунс по заднему фронту: каждый непустой ввод заново задаёт срок срабатывания, а по его наступлении передаётся только последний поисковый запрос — ровно один раз. Сначала удаляются пробельные символы по краям строки; пустая строка и строка из одних пробельных символов отменяют ожидающий отправки запрос. Повторный ввод того же текста тоже запускает отсчёт заново. Это явно заданное продуктовое правило, а не поведение по умолчанию во всех реализациях.

Время представлено целым числом миллисекунд от начала теста, а не системной датой. В рабочем приложении прошедшее время следует получать из монотонных часов, а не из часов реального времени, показания которых могут скачкообразно меняться при синхронизации. 300 миллисекунд — лишь пример настройки, а не показатель производительности сервиса.

Дебаунс определяет только момент передачи поискового запроса. Нужно ли отменять уже отправленный сетевой запрос и может ли старый ответ обновлять интерфейс — два отдельных контракта. Проверка того, что «таймер отменён», их не заменяет.

Вынесем правила времени в отдельный автомат состояний

Создадим небольшой пакет для проверки в командной строке облачного Mac. Выбранный набор инструментов разработки должен включать Swift; симулятор и графический сеанс здесь не нужны.

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

Замените содержимое Sources/SearchTiming/SearchTiming.swift следующим кодом. Он не создаёт ни потоков, ни таймеров. Вызывающая сторона должна передавать время в неубывающем порядке и обращаться к состоянию последовательно.

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 одновременно проверяет срок срабатывания и забирает ожидающий запрос, удаляя его из состояния. Поэтому повторный вызов не приводит к повторной передаче, а преждевременный вызов не удаляет запрос. В примере предполагается, что значения времени находятся в нормальном рабочем диапазоне. Если адаптер часов принимает значения извне, нужно также проверять отрицательные значения, движение времени назад и переполнение при сложении.

Продвигаем виртуальное время без sleep

Замените содержимое Tests/SearchTimingTests/SearchTimingTests.swift следующими тестами. Каждый тест создаёт новое состояние, чтобы не делить с другими тестами часы и ожидающий ввод.

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")
    }
}

Запустите swift test. При провале проверки команда обязана завершаться с ненулевым кодом: пайплайн не должен просто сохранять вывод и продолжать работу. Тесты охватывают момент непосредственно перед сроком срабатывания, сам срок и повторное получение уже выданного запроса. Добавьте ещё один сценарий с моментом заметно позже срока, чтобы проверить, что задержка планировщика не приводит к безвозвратной потере запроса.

При подключении реальных задач защитимся от двух ошибок отмены

В реальном приложении для ожидания срока срабатывания можно использовать ContinuousClock. Автомат состояний отвечает за правила, адаптер — за пробуждение. При каждом вводе сначала обновляется состояние, затем отменяется предыдущая задача ожидания и создаётся новая для нового срока. После очистки поля задача больше не создаётся.

Первая ошибка — подавление исключения отмены. Если после ожидания через try? сразу вызвать логику передачи запроса, выполнение продолжится и при отмене. При перехвате отмены нужно выйти, а не трактовать её как «время уже наступило».

Вторая ошибка — запоздалое выполнение старой задачи. Каждому вводу следует присваивать новый номер поколения, а задача ожидания должна сохранять номер, с которым была создана. После пробуждения она сравнивает его с текущим номером на том же последовательном исполнителе и может забрать запрос из состояния только при совпадении. Проверять лишь факт отмены задачи недостаточно: отмена и пробуждение могут произойти одновременно.

Ввод из интерфейса, обновление поколения и передачу результата можно выполнять на главном исполнителе. Не стоит читать состояние в фоне, а затем асинхронно возвращаться на главный исполнитель для его изменения: иначе между проверкой и получением запроса появится разрыв. Миллисекунды в виртуальных тестах нужны только для удобства проверок. При реальном ожидании следует использовать типы срока и длительности, предоставляемые часами, чтобы не накапливать ошибки преобразования.

Завершим проверку несколькими интеграционными тестами

После успешного прохождения тестов чистой логики проверьте реальный адаптер, а не переводите все сценарии обратно на длительное ожидание. Задайте достаточно большой тайм-аут теста, но по-прежнему проверяйте, что запрос не передаётся раньше срока и в итоге передаётся ровно один раз. Тайм-аут должен лишь выявлять зависание, а не служить точной проверкой времени.

Минимальный список приёмочных проверок: при быстром последовательном вводе передаётся только последний вариант; очистка поля сразу отменяет ожидающий запрос; исключение отмены не приводит к вызову логики передачи; обратные вызовы старого поколения отбрасываются; после освобождения интерфейса обновлений больше нет; несколько последовательных прогонов дают одинаковый результат. Если бизнес-логика требует устранять повторы одинаковых поисковых запросов, добавьте отдельный слой дедупликации и явно определите, допустим ли повторный поиск после очистки поля и повторного ввода.

Достаточно сохранить версию коммита, версию набора инструментов и код завершения тестов; записывать реальные поисковые запросы пользователей не нужно. В результате получится не скрипт, который «обычно проходит, если подождать полсекунды», а набор проверок, способный указать, какое именно временное правило нарушено.

Часто задаваемые вопросы

Заменяют ли тесты виртуального времени проверку настоящего таймера?

Нет. Они проверяют правила автомата. Отдельные интеграционные тесты должны подтвердить использование монотонных часов, отмену таймера и передачу результата нужному исполнителю.

Отменяет ли очистка поля уже отправленный запрос?

Нет. Автомат отменяет только ещё не переданную поисковую строку. Активный сетевой запрос отменяется отдельно; устаревшие ответы нужно отбрасывать по поколению запроса.

Выделенный Mac для ваших проектов

Физические узлы Mac mini, не виртуальные машины. Арендуйте на день, неделю, месяц или квартал: выберите задачу и конфигурацию, затем проверьте актуальную доступность в консоли.

Выбрать модель и срок аренды Посмотреть все цены