Написание unit test для проверки запуска NSTimer

Я использую XCTest и OCMock для написания модульных тестов для приложения iOS, и мне нужно руководство о том, как наилучшим образом разработать unit test, который проверяет, что метод приводит к запуску NSTimer.

Проверяемый код:

- (void)start {
    ...
    self.timer = [NSTimer timerWithTimeInterval:1.0
                                         target:self
                                       selector:@selector(tick:)
                                       userInfo:nil
                                        repeats:YES];
    NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
    [runLoop addTimer:self.timer forMode:NSDefaultRunLoopMode];
    ...
}

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

Я подумал о следующих вариантах, которые мне не нравятся:

  • На самом деле дождитесь срабатывания таймера. (Причина мне не нравится: ужасная практика тестирования модулей. Это больше похоже на медленный тест интеграции.)
  • Извлеките стартовый код таймера в частный метод, покажите этот закрытый метод в файле расширения класса для тестирования модуля и используйте макетное ожидание, чтобы проверить, вызван ли частный метод. (Причина мне не нравится: он проверяет, вызван ли метод, но не тот метод, который фактически устанавливает правильную работу таймера. Также, разоблачение частных методов не является хорошей практикой.)
  • Предоставить макет NSTimer тестируемому коду. (Причина мне не нравится: не удается проверить, что на самом деле она запускается по расписанию, потому что таймеры запускаются через цикл запуска, а не из некоторого метода запуска NSTimer.)
  • Предоставьте макет NSRunLoop и убедитесь, что addTimer:forMode: вызывается. (Причина мне не нравится: я должен был бы предоставить интерфейс в цикл цикла? Это кажется дурацким.)

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

Ответ 1

Хорошо! Понадобилось время, чтобы понять, как это сделать. Я полностью объясню свой мыслительный процесс. Извините за долговечность.

Сначала я должен был выяснить, что я тестировал. В моем коде есть две вещи: он запускает повторяющийся таймер, а затем таймер имеет обратный вызов, который делает мой код что-то еще. Это два отдельных поведения, что означает два разных модульных теста.

Итак, как вы пишете unit test, чтобы убедиться, что ваш код правильно запускает повторяющийся таймер? Есть три вещи, которые вы можете проверить в unit test:

  • Возвращаемое значение метода
  • Изменение состояния или поведения системы (желательно доступное через открытый интерфейс)
  • Взаимодействие вашего кода с другим кодом, который вы не контролируете

С NSTimer и NSRunLoop мне пришлось протестировать взаимодействие, потому что нет возможности проверить, правильно ли настроен таймер. Серьезно, нет свойства repeats. Вы должны перехватить вызов метода, который создает сам таймер.

Затем я понял, что мне вообще не придется прикоснуться к NSRunLoop, если я создам таймер с +scheduledTimerWithTimeInterval:target:selector:userInfo:repeats, который автоматически запускает таймер. Это меньшее взаимодействие, которое я должен проверить.

Наконец, чтобы создать ожидание вызова +scheduledTimerWithTimeInterval:target:selector:userInfo:repeats, вы должны высмеять класс NSTimer, который, к счастью, OCMock может сделать сейчас. Вот как выглядел тест:

id mockTimer = [OCMockObject mockForClass:[NSTimer class]];
[[mockTimer expect] scheduledTimerWithTimeInterval:1.0
                                            target:[OCMArg any]
                                          selector:[OCMArg anySelector]
                                          userInfo:[OCMArg any]
                                           repeats:YES];

<your code that should create/schedule NSTimer>

[mockTimer verify];

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

Это приводит нас ко второму unit test: делает ли таймер то, что мне нужно? Чтобы проверить это, к счастью NSTimer имеет -fire, что заставляет таймер выполнять селектор цели. Таким образом, вам даже не нужно создавать поддельные NSTimer, или делать извлечение и переопределение для создания настраиваемого таймера макета, все, что вам нужно сделать, это разрешить его:

id mockObserver = [OCMockObject observerMock];
[[NSNotificationCenter defaultCenter] addMockObserver:mockObserver
                                                 name:@"SomeNotificationName"
                                               object:nil];
[[mockObserver expect] notificationWithName:@"SomeNotificationName"
                                     object:[OCMArg any]];
[myCode startTimer];

[myCode.timer fire];

[mockObserver verify];
[[NSNotificationCenter defaultCenter] removeObserver:mockObserver];

Несколько комментариев об этом тесте:

  • Когда срабатывает таймер, тест ожидает, что a NSNotification будет отправлено по умолчанию NSNotificationCenter. OCMock удалось не разочаровать: широковещательное тестирование уведомлений - это легко.
  • Чтобы запустить триггер для запуска таймера, вам нужна ссылка на таймер. Мой тестируемый класс не раскрывает NSTimer в открытом интерфейсе, так что я сделал это, создав расширение класса, которое раскрыло личное свойство NSTimer для моих тестов как описано в этом SO post.

Ответ 2

Мне очень понравился подход Ричарда, я немного расширил код, чтобы использовать вызовы блоков, чтобы не ссылаться на частное свойство NSTimer.

[[mockAPIClient expect] someMethodSuccess:[OCMIsNotNilConstraint constraint]
                                  failure:[OCMIsNotNilConstraint constraint];

id mockTimer = [OCMockObject mockForClass:[NSTimer class]];
[[[mockTimer expect] andDo:^(NSInvocation *invocation) {
    SEL selector = nil;
    [invocation getArgument:&selector atIndex:4];
    [testSubject performSelector:selector];
}] scheduledTimerWithTimeInterval:10.0
                           target:testSubject
                         selector:[OCMArg anySelector]
                         userInfo:nil
                          repeats:YES];

[testSubject viewWillAppear:YES];

[mockTimer verify];
[mockAPIClient verify];