Pull to refresh

Unit тесты для RxSwift кода

Reading time18 min
Views3.6K

Привет, Хабр! Представляю вашему вниманию перевод статьи "Testing Your RxSwift Code" автора Shai Mishali с сайта raywenderlich.com.


Написание реактивных приложений с помощью RxSwift концептуально отличается от написания приложений "обычным путем". Оно отличается в том смысле, что объекты в вашем приложении обычно не будут иметь четкого значения, вместо этого они будут представлены потоком значений на оси времени, известным в RxSwift как Observable. Эта статья даст вам ключ к тестированию RxSwift кода.


Observable — мощный механизм, который позволяет вам, как разработчику, реагировать на изменения и быть уверенным что состояние вашего приложения всегда актуально. Вместе со всеми преимуществами которые это дает, тестирование Observable — не такая тривиальная задача, как простое использование XCTAssert для обычных значений. Но не переживайте — эта статья наставит вас на путь становления экспертом в тестировании RxSwift!


Эта статья научит вас создавать unit тесты для Observable потоков. Вы научитесь некоторым доступным техникам для тестирования RxSwift кода, а также некоторым советам. Давайте начнем.


Внимание: Эта статья подразумевает что вы уже знакомы как с RxSwift, так и с тем как писать простые тесты с помощью XCTest

Начинаем


Реактивные приложения прекрасно показывают себя, когда надо работать с изменяющимся контентом, так что мы будем работать как раз с таким приложением.


Загрузите стартовый проект. Вы найдете стартовый проект для этого урока: Raytronome — приложение-метроном, которым вы бы могли воспользоваться для занятий музыкой. Как вы можете понять, из-за того что метрономы работают со временем, вам предстоит увидеть много логики, которую можно протестировать.


Откройте Raytronome.xcworkspace. После откройте Main.storyboard. Вы увидите, что это очень простое приложение с всего одним экраном.


Соберите и запустите приложение. Нажмите на кнопку Play чтобы запустить метроном. Вы также можете изменить тактовый размер (Signature) и темп (Tempo).



Приложение состоит из одного UIViewController — MetronomeViewController.swift, а MetronomeViewModel.swift содержит всю бизнес-логику, для которой вы и будете писать тесты.


Проблема тестирования Observable


Быстро повторим основы RxSwift и Observable потоков.


Работа с потоками кардинально отличается от работы с простыми значениями и объектами, задача тестирования потоков отличается соответственно.


Простые значения единственны и независимы; у них нет никого представления во времени. Observable потоки, с другой стороны, выдают элементы (значения) во времени.



Это означает, что при тестировании потоков значений вам часто придется тестировать одну из двух вещей:


  • Некоторый поток выдает правильные элементы, вне зависимости от времени когда это происходит
  • Некоторый поток выдает правильные элементы в правильное время. В таком случае вам понадобится "записывать" эти элементы вместе с временем когда поток их выдал

Определяем что тестировать


Обычно хорошей идеей будет немного подумать о том, что именно вы хотите тестировать. Как было упомянуто ранее, вы будете тестировать MetronomeViewModel, ViewModel которая содержит бизнес-логику относящуюся к вашему метроному.


Откройте MetronomeViewModel.swift. Смотря на ViewModel вы можете увидеть выходы, которые отвечают за разные части логики: числитель, знаменатель (тактового размера), тактовый размер, темп в виде строк, числовое значение числителя, максимальное значение числителя, а также потоки отвечающие за бит



Давайте подумаем о том что нам захочется протестировать в UI. Мы хотим протестировать что:


  • Числитель и знаменатель имеют начальное значение 4
  • Тактовый размер по умолчанию 4/4
  • Темп имеет начальное значение 120
  • Нажатие кнопки Play/Pause меняет состояние isPlaying метронома
  • Изменение числителя, знаменателя, либо темпа вызывает правильные изменения текстов.
  • Удары метронома "бьют" в соответствии с размером.
  • Бит меняется между состояниями .even и .odd — приложение использует эти состояния для изменения картинки сверху экрана

В процессе написания тестов вы будете пользоваться двумя дополнительными библиотеками вместе с RxSwift, которые называются RxBlocking и RxTest. Каждая из них предлагает разные возможности и концепты для тестирования ваших потоков. Эти библиотеки уже входят в ваш стартовый проект


Использование RxBlocking


Стартовый проект включает в себя полупустой тестовый таргет с файлом RaytronomeTests.swift.


Откройте его и осмотритесь; он импортирует RxSwift, RxCocoa, RxTest и RxBlocking, а также включает в себя поле viewModel и простую функцию setUp() для создания нового экземпляра MetronomeViewModel перед каждым тестом


Ваши первые тесты будут проверять, что числитель и знаменатель начинаются со значения 4. Это означает, что вы будете думать только о первом значении, которое выдаст каждый из потоков. Звучит как идеальная задача для RxBlocking!


RxBlocking — это одна из двух библиотек для тестирования доступных с RxSwift, и он следует простой идее: он позволяет вам перевести ваш Observable поток в BlockingObservable, особый Observable который блокирует текущий поток, ожидая некоторого условия в зависимости от оператора.



Он полезен в ситуациях, когда вы либо имеете дело с конечной последовательностью — то есть той, которая отдает событие completed или error — либо хотите протестировать конечное число элементов


RxBlocking дает несколько операторов, из которых самые полезные:


  • toArray(): Ждет конца последовательности и возвращает все элементы массивом.
  • first(): Ждет первого элемента и возвращает его.
  • last(): Ждет конца последовательности и возвращает последний элемент из нее.

Очевидно, что для нашей ситуцаии наиболее подходящим будет оператор first().


Добавьте следующие тесты к классу RaytronomeTests:


func testNumeratorStartsAt4() throws {
  XCTAssertEqual(try viewModel.numeratorText.toBlocking().first(), "4")
  XCTAssertEqual(try viewModel.numeratorValue.toBlocking().first(), 4)
}

func testDenominatorStartsAt4() throws {
  XCTAssertEqual(try viewModel.denominatorText.toBlocking().first(), "4")
}

Вы используете toBlocking() для того чтобы конвертировать свой обычный поток в BlockingObservable, а потом используете first() чтобы подождать и вернуть первый выданный элемент. Далее вы можете использовать XCTAssert как и в любом обычном тесте.


Обратите внимание, что методы включают в себя throws, так как операторы RxBlocking могут возвращать ошибки. Добавление аннотации throws помогает избежать конструкции вида try! и корректно обработать падающий тест.


Нажмите Command-U для запуска тестов



В качестве упражнения попробуйте написать еще два теста которые проверят что signatureText имеет начальное значение 4/4, а tempoText имеет начальное значение 120 BPM. Эти тесты будут очень похожи на тесты выше


Если вам не удается сделать это самостоятельно, можете посмотреть решение под спойлером ниже


Решение
func testSignatureStartsAt4By4() throws {
  XCTAssertEqual(try viewModel.signatureText.toBlocking().first(), "4/4")
}

func testTempoStartsAt120() throws {
  XCTAssertEqual(try viewModel.tempoText.toBlocking().first(), "120 BPM")
}

Плюсы и минусы RxBlocking


Как вы могли заметить, RxBlocking эффективен и прост в освоении, так как он в некотором роде "оборачивает" реактивные концепты в хорошо известные структуры. К сожалению, это накладывает некоторые ограничения о которых вам стоит знать:


  1. Он направлен на тестирование конечного числа элементов, что означает, что если вы хотите протестировать первый элемент, или все элементы конечного потока, то RxBlocking покажет себя отлично. Однако, в более частом случае — при взаимодействии с бесконечными потоками — использование RxBlocking не даст вам достаточной гибкости
  2. RxBlocking работает блокируя текущий поток и блокирует выполнение кода. Если ваш Observable выдает элементы сравнительно долгие промежутки времени, ваш BlockingObservable будет их ждать соответствующее время.
  3. Когда вам необходимо протестировать события, для которых важно время, RxBlocking не поможет, так как он захватывает лишь элементы без их времени.
  4. При тестировании событий которые зависят от асинхронных событий RxBlocking не будет полезным, так как он блокирует текущий поток.

Следующие тесты которые вам предстоит написать упираются почти во все из этих ограничений. Например: Нажатие кнопки Play/Pause должно вызывать событие в потоке isPlaying, а это требует асинхронного вызова (tappedPlayPause вход). Также следует проверить количество вызовов.


RxTest


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


Для разрешения этих и других проблем приходит RxTest!


RxTest кардинально отличается от RxBlocking, главным образом тем, что он гораздо более гибок в своих возможностях, и той информацией которую он дает о ваших потоках. Это возможно из-за того, что он дает свой планировщик — TestScheduler.



Перед тем как начинать писать код стоит разобраться в том что же такое планировщик


Понимание планировщиков


Планировщик — немного более низкоуровневый концепт RxSwift, но тем не менее очень важно понимать что это такое, как они работают, для того чтобы лучше понимать их роль в ваших тестах


RxSwift использует планировщики как абстракцию для описания выполнения задач, а также для упорядочивания событий появившихся в результате выполнения этих задач.


Вы можете задать вопрос: "Зачем об этом знать?"


RxTest дает свой кастомный планировщик — TestScheduler — исключительно для тестирования. Он упрощает тестирование событий зависимых от времени, позволяя вам создать мокнутые Observable и Observer, так что вы сможете "записать" их и протестировать.


Если вы хотите глубже разобраться в планировщиках — официальная документация предлагает много полезной информации на эту тему.


Тестируем события зависимые от времени


Перед тем как писать тесты вам нужно создать экземпляр объекта TestScheduler. Вы также добавите DisposeBag в ваш класс для управления Disposable которые создаются вашими тестами. Ниже поля viewModel добавьте следующие поля:


var scheduler: TestScheduler!
var disposeBag: DisposeBag!

Потом, в конце метода setUp(), добавьте следующие строки для создания новых TestScheduler и DisposeBag перед каждым тестом:


scheduler = TestScheduler(initialClock: 0)
disposeBag = DisposeBag()

Инициализатор TestScheduler принимает аргумент initialClock, который определяет "стартовое время" вашего потока. Новый DisposeBag позаботится о том чтобы избавиться от всех подписок. оставленных прошлым тестом.


Давайте писать тесты!


Ваш первый тест "нажмет" кнопку Play/Pause несколько раз и убедится, что выход isPlaying реагирует соответствующе.


Для этого вам необходимо:


  1. Создать мок Observable потока выдающий фейковые "нажатия" во вход tappedPlayPause.
  2. Создать мок Observer'а для записи событий выхода isPlaying.
  3. Убедиться что записанные события соответствуют ожидаемым.

Это может казаться сложным, но вы удивитесь как просто это на самом деле!


Некоторые вещи проще обьяснить на примере. Начните с добавления первого RxTest-теста:


func testTappedPlayPauseChangesIsPlaying() {
  // 1
  let isPlaying = scheduler.createObserver(Bool.self)

  // 2
  viewModel.isPlaying
    .drive(isPlaying)
    .disposed(by: disposeBag)

  // 3
  scheduler.createColdObservable([.next(10, ()),
                                  .next(20, ()),
                                  .next(30, ())])
           .bind(to: viewModel.tappedPlayPause)
           .disposed(by: disposeBag)

  // 4
  scheduler.start()

  // 5
  XCTAssertEqual(isPlaying.events, [
    .next(0, false),
    .next(10, true),
    .next(20, false),
    .next(30, true)
  ])
}

Не переживайте, если это выглядит сложным. Давайте разберем по частям:


  1. ВоспользуемсяTestScheduler для создания TestableObserver с элементами того типа, которые будет выдавать Observable который вы хотите записать — в этом случае это Bool. Одно из главных преимуществ этого Observaer это то — что он имеет поле events, которое вы можете использовать для проверки любых элементов которые туда попадают.
  2. drive() "копирует" все события которые произойдут в поле viewModel.isPlaying в новый TestableObserver. Это то место где вы "записываете" события.
  3. Создаем мок Observable, который выдает выход от трех "нажатий" в вход tappedPlayPause. Еще раз, это специальный тип Observable, называемый TestableObservable, который использует ваш TestScheduler для вызова событий на его "виртуальной" временной линии.
  4. Вызываем start() на вашем тестовом планировщике. Этот метов вызывает ожидающие подписки созданные на предыдущих шагах
  5. Используем специальный экземпляр метода XCTAssertEqual содержащийся в RxTest, который позволит вам убедиться, что события в isPlaying идентичны ожидаемым как в значениях, так и по времени. 10, 20 и 30 соответствуют событиям, которые вызывает ваш вход, а 0 — начальных выход isPlaying.

Запутались? Подумайте об этом таким образом: вы создаете мок потока событий и скармливаете его во вход viewModel в конкретные моменты времени. Потом вы проверяете, что выход отдал ожидаемые события в ожидаемое время.



Прогоните свои тесты еще раз нажатием Command-u. Вы должны увидеть 5 успешных тестов.



Понимаем значения времени


Возможно вы заметили, что ранее мы использовали 0, 10, 20 и 30 в качестве времени и задумались о том что эти значения показывают, и как они соотносятся с реальным временем.


RxTest использует внутренний механизм для перевода реального времени (Date) в то, что называется VirtualTimeUnit (выраженный через Int).


При планировании событий с RxSwift время которые вы используете может быть любым — они совершенно случайны и TestScheduler использует их для планирования событий как любой другой планировщик.


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


Теперь, когда у вас есть более глубокое понимание времени в TestScheduler, почему бы вам не вернуться к добавлению тестов?


Добавьте эти три теста сразу после предыдущего:


func testModifyingNumeratorUpdatesNumeratorText() {
  let numerator = scheduler.createObserver(String.self)

  viewModel.numeratorText
           .drive(numerator)
           .disposed(by: disposeBag)

  scheduler.createColdObservable([.next(10, 3),
                                  .next(15, 1)])
           .bind(to: viewModel.steppedNumerator)
           .disposed(by: disposeBag)

  scheduler.start()

  XCTAssertEqual(numerator.events, [
    .next(0, "4"),
    .next(10, "3"),
    .next(15, "1")
  ])
}

func testModifyingDenominatorUpdatesNumeratorText() {
  let denominator = scheduler.createObserver(String.self)

  viewModel.denominatorText
           .drive(denominator)
           .disposed(by: disposeBag)

  // Denominator (числитель) - это 2 в степени `steppedDenominator + 1`.
  // f(1, 2, 3, 4) = 4, 8, 16, 32
  scheduler.createColdObservable([.next(10, 2),
                                  .next(15, 4),
                                  .next(20, 3),
                                  .next(25, 1)])
          .bind(to: viewModel.steppedDenominator)
          .disposed(by: disposeBag)

  scheduler.start()

  XCTAssertEqual(denominator.events, [
    .next(0, "4"),
    .next(10, "8"),
    .next(15, "32"),
    .next(20, "16"),
    .next(25, "4")
  ])
}

func testModifyingTempoUpdatesTempoText() {
  let tempo = scheduler.createObserver(String.self)

  viewModel.tempoText
           .drive(tempo)
           .disposed(by: disposeBag)

  scheduler.createColdObservable([.next(10, 75),
                                  .next(15, 90),
                                  .next(20, 180),
                                  .next(25, 60)])
           .bind(to: viewModel.tempo)
           .disposed(by: disposeBag)

  scheduler.start()

  XCTAssertEqual(tempo.events, [
    .next(0, "120 BPM"),
    .next(10, "75 BPM"),
    .next(15, "90 BPM"),
    .next(20, "180 BPM"),
    .next(25, "60 BPM")
  ])
}

Эти три теста делают следующее:


  • testModifyingNumeratorUpdatesNumeratorText: проверяет, что при изменении числителя текст меняется верно.
  • testModifyingDenominatorUpdatesNumeratorText: проверяет, что при изменении знаменателя текст меняется верно.
  • testModifyingTempoUpdatesTempoText: проверяет, что при изменении темпа текст меняется верно

Надеюсь, вы уже чувствуете себя комфортно с этим к кодом, так как он довольно сильно похож на предыдущий тест. Вы отправляете события о том, что числитель изменился на 3, а затем на 1. И вы проверяете, что numeratorText отдает "4" (стартовое значение), "3", и, в итоге, "1".


Похожим образом вы проверяете, что изменение значения знаменателя меняет denominatorText соответствующе


Наконец, вы проверяете что изменение темпа правильно меняет строковое отображение с концом BPM


Запустите тесты нажатием Command-U, вы увидите 8 прошедших тестов. Отлично!



Хорошо, кажется вы все понимаете!


Время немного все сложнить. Добавьте следующий тест:


func testModifyingSignatureUpdatesSignatureText() {
  // 1
  let signature = scheduler.createObserver(String.self)

  viewModel.signatureText
           .drive(signature)
           .disposed(by: disposeBag)

  // 2
  scheduler.createColdObservable([.next(5, 3),
                                  .next(10, 1),

                                  .next(20, 5),
                                  .next(25, 7),

                                  .next(35, 12),

                                  .next(45, 24),
                                  .next(50, 32)
                                ])
           .bind(to: viewModel.steppedNumerator)
           .disposed(by: disposeBag)

  // Denominator (знаменатель) - это 2 в степени `steppedDenominator + 1`.
  // f(1, 2, 3, 4) = 4, 8, 16, 32
  scheduler.createColdObservable([.next(15, 2), // switch to 8ths
                                  .next(30, 3), // switch to 16ths
                                  .next(40, 4)  // switch to 32nds
                                ])
           .bind(to: viewModel.steppedDenominator)
           .disposed(by: disposeBag)

  // 3
  scheduler.start()

  // 4
  XCTAssertEqual(signature.events, [
    .next(0, "4/4"),
    .next(5, "3/4"),
    .next(10, "1/4"),

    .next(15, "1/8"),
    .next(20, "5/8"),
    .next(25, "7/8"),

    .next(30, "7/16"),
    .next(35, "12/16"),

    .next(40, "12/32"),
    .next(45, "24/32"),
    .next(50, "32/32")
  ])
}

Глубоко вздохните! Тут на самом деле нет ничего ужасного или пугающего, это лишь чуть более длинная версия тестов, которые вы уже писали. Вы добавляете элементы сразу во входы steppedNumerator и steppedDenominator по очереди для создания разных видов тактов, а далее вы проверяете что выход signatureText выдает правильно форматированные такты.


Это становится более понятно если посмотреть на тест визуально:



Не стесняйтесь запустить все тесты еще раз. Теперь у вас 9 рабочих тестов!


Далее, вы победите еще более сложный кейс.


Подумайте о следующем сценарии:


  1. Приложение начинается с такта 4/4
  2. Вы переключаетесь на такт 24/32.
  3. Далее вы нажимаете кнопку "-" на знаменателе; это должно заставить такт упасть до 16/16, потом 8/8, и, наконец, 4/4, потому что 24/16, 24/8 и 24/4 — это не валидные значения для вашего метронома.

Обратите внимание: Несмотря на то, что некоторые из этих тактов имеют место быть в музыке, мы будем считать их неверными для нашего метронома


Добавьте тест для такого сценария:


func testModifyingDenominatorUpdatesNumeratorValueIfExceedsMaximum() {
  // 1
  let numerator = scheduler.createObserver(Double.self)

  viewModel.numeratorValue
           .drive(numerator)
           .disposed(by: disposeBag)

  // 2

  // Denominator (знаменатель) - это 2 в степени `steppedDenominator + 1`.
  // f(1, 2, 3, 4) = 4, 8, 16, 32
  scheduler.createColdObservable([
      .next(5, 4), // switch to 32nds
      .next(15, 3), // switch to 16ths
      .next(20, 2), // switch to 8ths
      .next(25, 1)  // switch to 4ths
      ])
      .bind(to: viewModel.steppedDenominator)
      .disposed(by: disposeBag)

  scheduler.createColdObservable([.next(10, 24)])
           .bind(to: viewModel.steppedNumerator)
           .disposed(by: disposeBag)

  // 3
  scheduler.start()

  // 4
  XCTAssertEqual(numerator.events, [
    .next(0, 4), // Ожидается 4/4
    .next(10, 24), // Ожидается 24/32
    .next(15, 16), // Ожидается 16/16
    .next(20, 8), // Ожидается 8/8
    .next(25, 4) // Ожидается 4/4
  ])
}

Несколько запутанно, но ничего такого, чего вы бы не поняли! Разберем по частям:


  1. Как обычно, вы начинаете с создания TestableObserver и "копирования" выхода numeratorValue в него
  2. Тут все становится несколько сложнее, но взгляд на визуальное отображение сделает все понятнее. Вы начинаете с переключения на знаменатель 32, а потом переключаетесь на числитель 24 (во втором потоке). Это создает такт 24/32. Далее вы уменьшаете знаменатель шаг за шагом, чтобы заставить модель отображать изменения на выходе numeratorValue.
  3. Начинаем schelduer
  4. Проверяем что на каждом шагу выдаются верные значения numeratorValue


Вы написали довольно сложный тест! Запустите тесты нажатием Command-U:


XCTAssertEqual failed: ("[next(4.0) @ 0, next(24.0) @ 10]") is not equal to ("[next(4.0) @ 0, next(24.0) @ 10, next(16.0) @ 15, next(8.0) @ 20, next(4.0) @ 25]") -

О нет! Тест провалился.


Смотря на результат кажется, что выход numeratorValue остается равен 24, даже когда знаменатель падает, что оставляет вас с невалидными темпами вроде 24/16 и 24/4. Запустите приложение и попробуйте сами:


  • Поднимите знаменатель, до такта в 4/8.
  • Далее поднимите числитель так, чтобы такт стал 7/8.
  • Опустите знаменатель. В теории вы должны увидеть такт 4/4, но в реальности вы видите 7/4 — неправильное значение для вашего метронома!


Похоже, вы нашли баг. :]


Конечно мы сделаем правильный выбор и поправим его.


Откройте MetronomeViewModel.swift и найдите этот кусок кода, отвечающий за выставление numeratorValue:


numeratorValue = steppedNumerator
  .distinctUntilChanged()
  .asDriver(onErrorJustReturn: 0)

Замените его на:


numeratorValue = steppedNumerator
  .distinctUntilChanged()
  .asDriver(onErrorJustReturn: 0)

Вместо того чтоб просто брать значение steppedNumerator и отдавать его, вы совмещаете последнее значение steppedNumerator и maxNumerator и возвращаете меньшее.


Запустите ваши тесты снова нажатием Command-U, и вы должны увидеть 10 прекрасных удачных тестов. Прекрасная работа!



Тестирование чувствительных ко времени событий


Вы прошли довольно далеко в тестировании viewModel. Если вы посмотрите на покрытие кода тестами, то вы увидите цифру около 78%. Время поднять его до самого верха!


Обратите внимание: Для того чтобы увидеть покрытие кода, нажмите Edit Scheme... в дроп-дауне со схемами, и, в разделе Tests, выберите вкладку Options и отметьте Code Coverage. Выберете Gather coverage for some targets и добавьте таргет Raytronome в этот список. После следующего запуска тестов в Report Navigator появится процент покрытия кода тестами


Осталось всего две вещи, которые осталось покрыть. Первая — сам бит генерируемый метрономом.


Мы хотим убедиться, что при некотором выбранном такте/скорости, бит вызывается в равные интервалы, и что сам бит правильный (первый удар каждого цикла отличается от остальных)


Вы начнете с тестирования самого быстрого знаменателя — 32. Вернитесь к RaytronomeTests.swift и добавьте следующий тест:


func testBeatBy32() {
  // 1
  viewModel = MetronomeViewModel(initialMeter: Meter(signature: "4/32"),
                                 autoplay: true,
                                 beatScheduler: scheduler)

  // 2
  let beat = scheduler.createObserver(Beat.self)
  viewModel.beat.asObservable()
    .take(8)
    .bind(to: beat)
    .disposed(by: disposeBag)

  // 3
  scheduler.start()

  XCTAssertEqual(beat.events, [])
}

Пока что этот тест не будет проходить. Но все же разобьем его на части:


  1. Для этого конкретного теста вы создадите viewModel с несколькими параметрами. Создадим счетчик с тактом 4/32, и скажем модели начать бит автоматически, что избавляет от вызова tappedPlayPause автоматически.
    Третий аргумент тоже важен. По умолчанию, viewModel использует SerialDispatchQueueScheduler для создания бита, но при тестировании бита вам понадобится передать туда TestScheduler, так вы сможете убедиться, что бит правильно вызывается на нем.
  2. Создаем TestableObserver для типа Beat и записываем 8 первых его ударов из выхода beat. 8 ударов — два цикла, которых должно быть достаточно для понимания, что все вызывается корректно.
  3. Начинаем scheduler. Обратите внимание, что вы сравниваете результат с пустым массивом, зная, что тест провалится — главным образом для того чтобы увидеть какие значения и время вы получите.

Запустите тесты нажатием Command-U. Вы увидите следующий результат сравнения:


XCTAssertEqual failed: ("[next(first) @ 1, next(regular) @ 2, next(regular) @ 3, next(regular) @ 4, next(first) @ 5, next(regular) @ 6, next(regular) @ 7, next(regular) @ 8, completed @ 8]") is not equal to ("[]") —

Судя по всему события выдают корректные значения, но время выглядит странно, не так ли? Просто список из чисел от 1 до 8.


Для понимания, что что-то не так, попробуйте изменить счетчик с 4/32 на 4/4. Это должно выдать события в другое время, как и сам бит.


Замените Meter(signature: "4/32") на Meter(signature: "4/4") и запустите тесты снова нажатием Command-U. Вы должны увидеть такую-же ошибку с идеально таким-же временем.


Воу, это странно! Обратите внимание, что вы получили события в такое-же время, как и в первый раз. Как так вышло, что разные такты выдали события в одно время? Что-ж, это связано с VirtualTimeUnit, который уже был упомянут в этой статье раньше.


Выбираем точность


Используя стандартный темп в 120 BPM, и используя знаменатель 4 (как для 4/4), вы должны получать один удар бита каждые 0.5 секунд. Используя знаменатель 32, вы должны получать удар каждые 0.0625 секунд.


Для понимаения того, почему это — проблема, вам нужно лучше разобраться в том как TestScheduler переводит реальное время, в свои внутренние VirtualTimeUnit.


Вы вычисляете виртуальное время разделяя настоящие секунды на нечто называемое resolution и округляя результат наверх. resolution — часть TestScheduler и имеет стандартное значение 1.


0.0625/1 округленное наверх равно 1, но округление 0.5/1 также дает 1, что просто недостаточно точно для теста подобного рода.


К счастью, вы можете изменить resolution, для получения большей точности для теста подобного рода.


Выше инициализации viewModel, на первой строке вашего теста, добавьте следующую строку:


scheduler = TestScheduler(initialClock: 0, resolution: 0.01)

Это уменьшит resolution и даст более высокую точность при округлении виртуального времени


Обратите внимание насколько виртуальное время меняется при уменьшении resoulution



Переключите свой счетчик обратно на 4/32 в инициализаторе viewModel и снова запустите тесты нажатием Command-U.


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


XCTAssertEqual failed: ("[next(first) @ 6, next(regular) @ 12, next(regular) @ 18, next(regular) @ 24, next(first) @ 30, next(regular) @ 36, next(regular) @ 42, next(regular) @ 48, completed @ 48]") is not equal to ("[]") —

Удары наконец разделены виртуальным временным промежутком в 6. Теперь вы можете заменить существующий XCTAssertEqual с этим:


XCTAssertEqual(beat.events, [
  .next(6, .first),
  .next(12, .regular),
  .next(18, .regular),
  .next(24, .regular),
  .next(30, .first),
  .next(36, .regular),
  .next(42, .regular),
  .next(48, .regular),
  .completed(48)
])

Запустите тесты еще раз нажатием Command-U, и вы должны увидеть, что тест наконец проходит. Отлично!


Использование такой-же метод для тестирования бита 4/4 очень похоже.


Добавьте следующий тест:


func testBeatBy4() {
  scheduler = TestScheduler(initialClock: 0, resolution: 0.1)

  viewModel = MetronomeViewModel(initialMeter: Meter(signature: "4/4"),
                                 autoplay: true,
                                 beatScheduler: scheduler)

  let beat = scheduler.createObserver(Beat.self)
  viewModel.beat.asObservable()
    .take(8)
    .bind(to: beat)
    .disposed(by: disposeBag)

  scheduler.start()

  XCTAssertEqual(beat.events, [
    .next(5, .first),
    .next(10, .regular),
    .next(15, .regular),
    .next(20, .regular),
    .next(25, .first),
    .next(30, .regular),
    .next(35, .regular),
    .next(40, .regular),
    .completed(40)
  ])
}

Единственное отличие тут — это то, что вы подняли resolution, до 0.1, так как он дает достаточную точность для знаменателя 4.


Запустите свои тесты снова нажатием Command-U, и вы должны увидеть, что все 12 тестов теперь проходят!


Если вы посмотрите на покрытие кода тестами, то вы увидите, что у вас 99.25% покрытого кода для MetronomeViewModel, что прекрасно. Единственный выход который вы не протестировали: beatType.



Тестирование beatType — неплохое испытание для себя на этом этапе, так как тест должен быть очень похож на предыдущие два теста, за тем исключением, что beatType должен переключаться между .even и .odd. Попробуйте написать этот тест сами. Если вы почувствуете, что застряли, можете посмотреть ответ под спойлером:


Решение
func testBeatTypeAlternates() {
  scheduler = TestScheduler(initialClock: 0, resolution: 0.1)

  viewModel = MetronomeViewModel(initialMeter: Meter(signature: "4/4"),
                                 autoplay: true,
                                 beatScheduler: scheduler)

  let beatType = scheduler.createObserver(BeatType.self)
  viewModel.beatType.asObservable()
    .take(8)
    .bind(to: beatType)
    .disposed(by: disposeBag)

  scheduler.start()

  XCTAssertEqual(beatType.events, [
    .next(5, .even),
    .next(10, .odd),
    .next(15, .even),
    .next(20, .odd),
    .next(25, .even),
    .next(30, .odd),
    .next(35, .even),
    .next(40, .odd),
    .completed(40)
  ])
}

Что делать дальше?


Вы можете скачать законченную версию проекта, использую кнопку в начале статьи. Вы знаете все, что вам нужно для того чтобы начать тестировать приложения основанные на RxSwift. Вы узнали как полезен RxBlocking для тестирования конечных последовательностей где вы не заинтересованы во времени происходящих событий, тогда как RxTest дает вам огромную гибкость и мощь для тестирования.


И вы даже дотронулись до более низкоуровневых объектов, таких как Scheduler, а также узнали как TestScheduler рассчитывает виртуальное время.


Есть еще много того, что можно узнать как в RxSwift, так и в RxBlocking — их внутреннюю работу, операторы и так далее. Лучшее место для продолжения — официальная документация RxSwift, а также список операторов RxBlocking.


Если у вас остались вопросы, или комментарии по содержанию статьи — добро пожаловать в комментарии, или в обсуждении к оригинальной статье. Спасибо за прочтение!Внимание, данная статья является переводом статьи с сайта raywenderlich.com.

Tags:
Hubs:
Total votes 8: ↑8 and ↓0+8
Comments0

Articles