Асинхронный код синхронно: как устроено юнит-тестирование в СберМаркете +5


Привет! Меня зовут Владислав Сединкин, я работаю iOS-разработчиком в СберМаркете. Сегодня я расскажу, как мы проводим юнит-тестирование, с какими сложностями сталкивались при написании тестов и как их решали.

Я выступал с этим докладом на iOS Meetup | СберМаркет Tech, здесь его сжатая версия.

Стили юнит-тестов

Юнит-тесты проверяют, правильно ли работает фрагмент кода. Они быстро выполняются и не зависят друг от друга. Хорошие юнит-тесты:

  • интегрированы в цикл разработки — в идеале должны запускаться на CI;

  • проверяют бизнес-логику, а не простые вещи;

  • защищают от багов;

  • устойчивы к рефакторингу.

В зависимости от подхода к изоляции и разбиению кода на небольшие фрагменты выделяют два стиля юнит-тестирования.

Классические юнит-тесты проверяют связанные объекты. Такие тесты изолируются друг от друга.

Лондонские юнит-тесты проверяют один объект, который изолирован от зависимостей. Для этого все зависимости в тесте заменяются на заглушки test doubles. Есть пять видов test doubles:

  • dummy-объект — обычная заглушка, которая не нарушает сигнатуру вызова функции. Не несёт полезной нагрузки для теста, поэтому в передаваемой функции его можно заменить на nil;

  • stub-объект предоставляет данные из теста, полностью заменяет исходную реализацию;

  • fake-объект похож на stub. Он подменяет и упрощает сложную реализацию. Логика и и выход при этом остаются прежними;

  • spy-объект записывает количество вызовов тестируемой функции и полученные аргументы;

  • mock-объект вбирает в себя особенности stub и spy, но при этом может завалить тест, если условие не удовлетворяется. Например, если функция вызывается большее количество раз, чем необходимо, или аргументы приходят не такими, какими нужно.

СберМаркет использует лондонский стиль юнит-тестирования.

Какой способ юнит-тестирования используем

Основных способов юнит-тестирования два: проверять выходные значения или детали реализации.

В СберМаркете мы обычно тестируем выходные значения. Для этого используем метод «черный ящик»: разработчик пишет тесты, не зная устройство функции. Главное, чтобы её выходные значения совпали с ожидаемым результатом. Так мы проверяем, что, а не как должна делать система.

Тесты по методу «черного ящика» разбивают на три блока:

  1. В Given устанавливают моки, начальные и ожидаемые значения. Обычно это самый большой блок.

  2. В When вызывают функцию, которую нужно протестировать.

  3. В Then проверяют результаты. Это может быть несколько проверок — главное, чтобы все они относились к этой функции и к этому сценарию.

Вот как это выглядит на практике:

func test_sumCalculation() {
    // given
    let aValue = 7
    let bValue = 3
    let expectedValue = 10
    // sut.setMocks 
  
    // when
    let result = sut.calculate(a: aValue: b: bValue)

    // then
    XCTAssertEqual(result, expectedValue)
}

Какой инструмент используем для юнит-тестирования

Наш основной инструмент — фреймворк SwiftyMocky, который генерирует заглушки для зависимостей. С его помощью тестировщики проверяют результаты вызовов и настраивают тестовые значения для свойств и методов. Полное описание функций есть в GitHub-репозитории библиотеки.

Обычно для зависимостей нужно составлять сложные графы и долго их настраивать. SwiftyMocky позволяет помечать зависимости протоколом AutoMockable — это проще и быстрее. Пример юнит-теста с использованием SwiftyMocky:

func test_foo() {
    // given
    let storeId = 5
    // задаём ожидаемое значение методом given
    dependency1.given(.currentStoreId(willReturn: storeId))

    // when	
    sut.fooMethod(storeId: storeId)

    // then 
    // проверяем, сколько раз вызывалась функция
    // и какие аргументы она получила, методом verify 
    dependency1.verify( .currentStoreId(), count: .once) 
}

Что тестируем

В СберМаркете основной пласт тестов связан с бизнес-логикой. Например, мы пишем юнит-тесты для протоколов Presenter и Interactor.

Presenter — это протокол, который обрабатывает события от пользовательского интерфейса. Например, можно подписать вьюшку на свойство ViewState у Presenter. View будет перерисовываться после каждого обновления свойства. В тестах мы проверяем, что ViewState корректно взаимодействует с зависимостями, например с output или координатором.

Вот пример такого теста для Presenter:

func test_ifModuleIsConfigured_viewStateIsConfigured() {
    // given
    let codeLength = 6
    let resendLimit = 3
    let phone = "9991234567"
    let formattedPhone = "+7 (999) 123 45 67"
		
    // when
    presenter.configure(
        isPromoTermsAccepted: true,
        isB2B: true,
        phoneNumber: phone,
        source: .addressModal
    )

    // then
    XCTAssertEqual(viewState.codeFieldModel.codeLength, codeLength)
    XCTAssertEqual(viewState.phoneNumber, formattedPhone)
    XCTAssertEqual(viewState. isResendCodeButtonEnabled, false)
    XCTAssertEqual(viewState.resendInSeconds, resendLimit)
}

Interactor — протокол, который готовит данные для передачи в Presenter. Поэтому в тестах для Interactor мы проверяем, что данные получены корректно. Вот пример такого теста для Interactor:

func test_updateStoreld() {
    // given
    let oldStoreId = 5
    let newStoreId = 6
    storeSelectionService.given(.currentStoreId(willReturn: oldStoreId))
		
    // when
    interactor.updateStoreId(storeId: newStoreId)

    // then
    storagesCleaner.verify(.cleanStoreData(), count: .once)
    storeSelectionService.verify(
        .setCurrentStoreId(.value(newStoreId), nil, completion: nil),
        count: .once
    )
}

Как решили проблему с асинхронным кодом из-за Combine

Первая сложность, с которой мы столкнулись при юнит-тестировании, связана с фреймворком Combine. Практически весь код на Combine выполняется асинхронно — это затрудняет тестирование.

Если код асинхронный, то тесты ждут, пока выполнится тестируемый метод. Из-за этого растет время проверки и код: например, приходится писать условие завершения метода.

Простое решение — использовать expectation, но оно также увеличивает размер и время теста. Замедление особенно заметно, если использовать операторы delay, debounce или throttle.

Пример метода, который нерационально тестировать с помощью expectation:

viewState.fieldModel
    .$text
    .debounce(for: 0.3, scheduler: scheduler)
    .sink { [weak self] text in
    …
    }
    .store(in: &textSubscriptions)

Представим, что у нас есть published-свойство text. Оно изменяется, когда пользователь набирает текст в поле ввода. Мы получаем это событие и обрабатываем текст через 300 миллисекунд после ввода. Тест для этого метода выглядел бы следующим образом:

func test_viewDidAppear_textTypingHandled_requestSuggests_delayed() {
    // given
    let testText = "test"
    interactor.given(
        .getSuggests(
        for: .value(testText),
            willReturn: Result.Publisher([]).eraseToAnyPublisher())
    )
    // Создаём expectation
    let expectation = expectation(description: #function)
    presenter.viewDidAppear()

    // when
    // Триггерим изменение свойства text
    viewState.fteldModel.updateText("test")

    // Ждём, пока пройдёт debounce
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: expectation.fulfill) 

    // then
    wait(for: expectation, timeout: 0.4)
    interactor.verify( .getSuggests(for: .value(testText)))
}

Итог: тест никогда не выполняется быстрее, чем за 350 миллисекунд. Это долго.

Более удачное решение — Combine Schedulers. Они позволяют синхронно выполнять асинхронный код без временных потерь.

Самый известный Scheduler — ImmediateScheduler, который Combine предоставляет из коробки. Для нашей задачи он не подойдёт, поэтому мы будем использовать кастомный объект — TestScheduler.

TestScheduler более продвинутый, чем стандартный ImmediateScheduler. Он позволяет управлять цепочкой Combine — например, запускать выполнение в нужный момент.

Чтобы использовать Scheduler, его нужно внедрить напрямую в тестируемый объект через инициализатор. Если тестируемый метод возвращает Publisher, можно использовать оператор receive(on:) с нужным Scheduler.

Вот как тест из примера выше выглядит с TestScheduler:

func test_viewDidAppear_textTypingHandled_requestSuggests_delayed() {
    // given
    let testText = "test"
    …
    presenter.viewDidAppear()

    // when
    // Триггерим изменение свойства text 
    viewState.fieldModel.updateText("test")
    // Сдвигаем виртуальное время на 100 миллисекунд
    scheduler.advance(by: .milliseconds(100))
    // Проверяем, не произошла ли обработка
    interactor.verify(.getSuggests(for: .value(testText), count: 0)
    // Сдвигаем виртуальное время ещё на 100 миллисекунд
    scheduler.advance(by: .milliseconds(100))
    // Снова проверяем, не произошла ли обработка
    interactor.verify(.getSuggests(for: .value(testText), count: 0)
    // Сдвигаем виртуальное время ещё на 100 миллисекунд
    // Общий сдвиг — 300 миллисекунд
    scheduler.advance(by: .milliseconds(100)) 

    // then
    // По истечении 300 секунд обработка должна произойти
    interactor.verify(.getSuggests(for: .value(testText)), count: 1) 
}

Итог: тест выполняется практически мгновенно, а не за 300 секунд.

Как мы решили проблему с асинхронным кодом из-за замыканий

Вторая сложность связана с замыканиями (closures). Проблема всё та же: из-за асинхронного кода необходимо использовать expectation. Есть два варианта, как немного упростить себе жизнь: использовать метод sync или сделать обёртку над expectation.

Первый подход — вызывать метод sync на background queue. Он заблокирует вызываемый поток до тех пор, пока все задачи на background queue не завершатся. Это позволит достигнуть линейности выполнения. Чтобы воспользоваться этим подходом, понадобится протокол BackgroundTestable:

public protocol BackgroundTestable {
    var backgroundQueue: DispatchQueue { get }
}

public extension RealmBackgroundTestable {
    func waitForBackgroundOperations() {
        // Ждём выполнения всех операций на background потоке
        backgroundQueue.sync {}
    }
}

BackgroundTestable реализует нужный тест-кейс, который встраивает свойство backgroundQueue в тестируемый объект. Вот как в итоге выглядит тест:

func test_create0rUpdateOrder_create() {
    // given опущен
  
    // when
    // Вызываем тестируемую функцию
    sut.invokeMethod(object, completion: nil)
    // После выполнения главный поток заблокируется до тех пор,
    // пока все задачи на background-потоке не завершатся 
    // Затем управление вернётся главному потоку и выполнение теста продолжится
    waitForBackgroundOperations() 

    // then опущен
}

Минус решения: если completion block вызывается не на background-потоке, тест продолжится до выполнения блока. Так случится, потому что главный поток освободится раньше, чем сработает completion block.

Это будет выглядеть так:

  1. Задачи на background-потоке завершатся.

  2. Главный поток разблокируется.

  3. Выполнение теста продолжится.

  4. Completion асинхронно вызовется на главном потоке на следующей итерации runloop.

Другое решение — обёртка над expectation. Полностью уйти от expectation всё равно не получится, но обёртка минимизирует шаблонный код для его создания. Вот как сделать обёртку над expectation:

public extension XCTestCase {
  
    // name — название функции, из которой происходит вызов
    // timeout — время, чтобы тестируемая функция могла завершиться
    // action — замыкание, с помощью которого вызывается тестируемый код
    func wattAsyncActton(
        _ name: String = #function, 
        timeout: TimeInterval = 1, 
        action: (@escaping () -> Void
    ) throws -> Void) rethrows { 

        let expectation = self.expectation(description: name)

        try action(expectation.fulfill)

        waitForExpectations(timeout: timeout) 
    }
}

Плюсы решения:

  • код лаконичный;

  • код быстро пишется, потому что XCode помогает автонаписанием;

  • не нужно постоянно писать дублирующийся код для создания expectation вручную.

Полезные материалы


Мы завели соцсети с новостями и анонсами Tech-команды. Если хотите узнать, что под капотом высоконагруженного e-commerce, следите за нами там, где вам удобнее всего: Telegram, VK.




Комментарии (2):

  1. yolondie
    /#24348576

    Хорошая тема затронута в статье, важная.

    Как решили проблему с асинхронным кодом из-за Combine

    Актуальный кейс с интересным решением. Только код асинхронный не из-за combine, а из-за реализации. Код мог бы быть синхронным и с combine.

    Как мы решили проблему с асинхронным кодом из-за замыканий

    Тоже самое. Так же бросаются в глаза фразы "задачи на background-потоке", при том что работаете вы с очередями.

    Он заблокирует вызываемый поток до тех пор, пока все задачи на background queue не завершатся.

    Это утверждение верно лишь до тех пор, пока этот вызов происходит в главной очереди (и на главном потоке соответственно). По-этому хотелось бы больше детерминированности в терминологии)

    Может в вашем проекте не актуально, но так же можно было бы привести варианты тестирования кода который работает с таймерами.

    Но вообще, по хорошему в тестах нужно стремится свести к нулю любую асинхронность. Для этого нужно концентрировать силы не на combine или queues, а на месте где она возникает. Как правило, это те самые зависимости которые вы заменяете заглушками.

    Как с комбайном, так и с замыканиями тестовая реализация может оставаться синхронной.
    На мой взгляд это самый важный момент в поднятом вопросе, но вы об этом ничего не сказали.

    • Tap0k
      /#24348682

      Хорошие замечания, спасибо.

      Только код асинхронный не из-за combine, а из-за реализации. Код мог бы быть синхронным и с combine.

      В случае с combine код будет синхронный только в двух случаях:

      1. если не указать receive(on:)/subscribe(on:)

      2. передать ImmediatedScheduler.shared в качестве scheduler'а в функции receive(on:)/subscribe(on:)

      В остальных случаях так или иначе код будет асинхронный. Эта асинхронность может быть вызвана использованием операторов debounce/throttle либо из-за использования в качестве scheduler какой-то приватной очереди. Да даже использование DispatchQueue.main либо RunLoop.main в качестве scheduler заставит код выполняться асинхронно. Соответсвенно и в тесте эту асинхронность придётся бы обрабатывать.

      Это утверждение верно лишь до тех пор, пока этот вызов происходит в главной очереди (и на главном потоке соответственно).

      Не совсем так. Если код вызывается на очереди q1 и на этой очереди мы вызываем q2.sync {}, то q1 будет ждать пока все задачи на q2 завершатся. Другими словами это верно для того потока с которого произошел вызов q.sync {}.

      Но вообще, по хорошему в тестах нужно стремится свести к нулю любую асинхронность. Для этого нужно концентрировать силы не на combine или queues, а на месте где она возникает. Как правило, это те самые зависимости которые вы заменяете заглушками.

      Согласен, но это не всегда возможно. Бывают случаи, когда, чтобы полностью избавиться от асинхронности нужно переписать не мало кода. А бывает, что избавиться вовсе невозможно, как в примере с debounce. Ну и не нужно забывать, что иногда нужно тестировать непосредственно асинхронное поведение, например, если используется параллельная очередь.