Пример тестирования устройства с помощью OCUnit

Я действительно пытаюсь понять модульное тестирование. Я понимаю важность TDD, но все примеры модульного тестирования, которые я читал, кажутся чрезвычайно простыми и тривиальными. Например, тестирование, чтобы убедиться, что свойство установлено или выделено память массиву. Зачем? Если я код ..alloc] init], мне действительно нужно убедиться, что он работает?

Я новичок в разработке, поэтому я уверен, что здесь что-то не хватает, особенно со всем увлечением TDD.

Я думаю, что моя главная проблема - я не могу найти практических примеров. Вот метод setReminderId, который кажется хорошим кандидатом для тестирования. Как бы выглядел полезный unit test, чтобы убедиться, что это работает? (используя OCUnit)

- (NSNumber *)setReminderId: (NSDictionary *)reminderData
{
    NSNumber *currentReminderId = [[NSUserDefaults standardUserDefaults] objectForKey:@"currentReminderId"];
    if (currentReminderId) {
        // Increment the last reminderId
        currentReminderId = @(currentReminderId.intValue + 1);
    }
    else {
        // Set to 0 if it doesn't already exist
        currentReminderId = @0;
    }
    // Update currentReminderId to model
    [[NSUserDefaults standardUserDefaults] setObject:currentReminderId forKey:@"currentReminderId"];

    return currentReminderId;
}

Ответ 1

Обновление: я улучшил этот ответ двумя способами: теперь это screencast, и я переключился с вставки свойств на инъекцию конструктора. См. Как начать работу с Objective-C TDD

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

Есть несколько разных способов сделать это. Один из них заключается в передаче его в качестве дополнительного аргумента в метод. Другой - сделать его переменной экземпляра класса. И есть разные способы настройки этого ивара. Там "инжектор конструктора", где он указан в аргументах инициализатора. Или там "инъекция свойств". Для стандартных объектов из iOS SDK я предпочитаю сделать это свойством со значением по умолчанию.

Итак, начнем с теста, что по умолчанию это свойство NSUserDefaults. Мой набор инструментов, кстати, представляет собой встроенный OCUnit Xcode, плюс OCHamcrest для утверждений и OCMockito для макетов объектов. Есть и другие варианты, но то, что я использую.

Первый тест: Пользовательские значения по умолчанию

Из-за отсутствия лучшего имени класс будет называться Example. Экземпляр будет называться sut для "тестируемой системы". Свойство будет называться userDefaults. Здесь первый тест, чтобы определить, каково его значение по умолчанию, в ExampleTests.m:

#import <SenTestingKit/SenTestingKit.h>

#define HC_SHORTHAND
#import <OCHamcrestIOS/OCHamcrestIOS.h>

@interface ExampleTests : SenTestCase
@end

@implementation ExampleTests

- (void)testDefaultUserDefaultsShouldBeSet
{
    Example *sut = [[Example alloc] init];
    assertThat([sut userDefaults], is(instanceOf([NSUserDefaults class])));
}

@end

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

Давайте напишем простейший код, который мы можем получить, чтобы выполнить этот тест для компиляции и запуска - и сбой. Здесь Example.h:

#import <Foundation/Foundation.h>

@interface Example : NSObject
@property (strong, nonatomic) NSUserDefaults *userDefaults;
@end

И впечатляющий пример .m:

#import "Example.h"

@implementation Example
@end

Нам нужно добавить строку к самому началу ExampleTests.m:

#import "Example.h"

Тестирование выполняется и не выполняется с сообщением "Ожидается экземпляр NSUserDefaults, но он равен нулю". Именно то, что мы хотели. Мы достигли первого шага нашего первого теста.

Шаг 2 - написать простейший код, который мы можем передать этому тесту. Как насчет этого:

- (id)init
{
    self = [super init];
    if (self)
        _userDefaults = [NSUserDefaults standardUserDefaults];
    return self;
}

Проходит! Шаг 2 завершен.

Шаг 3 - это код рефакторинга для включения всех изменений как в производственный код, так и в тестовый код. Но на самом деле ничего не почистить. Мы закончили с нашим первым тестом. Что у нас есть? Начало класса, который может получить доступ к NSUserDefaults, но также переопределить его для тестирования.

Второй тест: без соответствующей клавиши, вернуть 0

Теперь напишите тест для метода. Что мы хотим сделать? Если пользовательские значения по умолчанию не имеют соответствующего ключа, мы хотим, чтобы он возвращал 0.

Когда вы начинаете сначала с макетных объектов, я сначала рекомендую сделать их вручную, чтобы вы поняли, для чего они нужны. Затем начните использовать фреймворк. Но я собираюсь продвинуться вперед и использовать OCMockito, чтобы ускорить работу. Мы добавляем эти строки в ExampleTest.m:

#define MOCKITO_SHORTHAND
#import <OCMockitoIOS/OCMockitoIOS.h>

По умолчанию макет-объект на основе OCMockito возвращает nil для любого метода. Но я напишу дополнительный код, чтобы сделать ожидание явным, сказав: "учитывая, что он запросил objectForKey:@"currentReminderId", он вернет nil". И учитывая все это, мы хотим, чтобы метод возвращал NSNumber 0. (Я не собираюсь передавать аргумент, потому что я не знаю, для чего это нужно. И я назову метод nextReminderId.)

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldReturnZero
{
    Example *sut = [[Example alloc] init];
    NSUserDefaults *mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:nil];

    assertThat([sut nextReminderId], is(equalTo(@0)));
}

Это еще не компилируется. Определим метод nextReminderId в примере .h:

- (NSNumber *)nextReminderId;

И вот первая реализация в примере .m. Я хочу, чтобы тест потерпел неудачу, поэтому я собираюсь вернуть фиктивный номер:

- (NSNumber *)nextReminderId
{
    return @-1;
}

Тест завершился неудачей с сообщением "Ожидаемое < 0 > , но было < -1 > ". Важно, чтобы тест завершился неудачно, потому что это наш способ тестирования теста и обеспечение того, что код, который мы пишем, переводит его из состояния отказа в состояние передачи. Шаг 1 завершен.

Шаг 2: Позвольте пройти тест. Но помните, нам нужен простейший код, который проходит тест. Это будет выглядеть ужасно глупо.

- (NSNumber *)nextReminderId
{
    return @0;
}

Удивительно, он проходит! Но мы еще не закончили этот тест. Теперь мы переходим к этапу 3: рефакторинг. В тестах повторяется код. Пусть вытащите sut, тестируемую систему, в ivar. Мы будем использовать метод -setUp для его настройки, а -tearDown - очистить его (уничтожить).

@interface ExampleTests : SenTestCase
{
    Example *sut;
}
@end

@implementation ExampleTests

- (void)setUp
{
    [super setUp];
    sut = [[Example alloc] init];
}

- (void)tearDown
{
    sut = nil;
    [super tearDown];
}

- (void)testDefaultUserDefaultsShouldBeSet
{
    assertThat([sut userDefaults], is(instanceOf([NSUserDefaults class])));
}

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldReturnZero
{
    NSUserDefaults *mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:nil];

    assertThat([sut nextReminderId], is(equalTo(@0)));
}

@end

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

Третий тест: без подходящей клавиши, сохраните 0 в настройках пользователя по умолчанию

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

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldSaveZeroInUserDefaults
{
    // given
    NSUserDefaults *mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:nil];

    // when
    [sut nextReminderId];

    // then
    [verify(mockUserDefaults) setObject:@0 forKey:@"currentReminderId"];
}

Оператор verify - это способ OCMockito: "Этот макет-объект должен был так называться один раз". Мы запускаем тесты и получаем отказ: "Ожидаемое 1 совпадение с вызовом, но получено 0". Шаг 1 завершен.

Шаг 2: простейший код, который проходит. Готов? Здесь:

- (NSNumber *)nextReminderId
{
    [_userDefaults setObject:@0 forKey:@"currentReminderId"];
    return @0;
}

"Но почему вы сохраняете @0 в пользовательских значениях по умолчанию вместо переменной с этим значением?" ты спрашиваешь. Потому что, насколько мы тестировали. Подождите, мы доберемся туда.

Шаг 3: рефакторинг. Опять же, у нас есть дубликат кода в тестах. Выдвиньте mockUserDefaults как ivar.

@interface ExampleTests : SenTestCase
{
    Example *sut;
    NSUserDefaults *mockUserDefaults;
}
@end

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

    NSNumber *current = nil;
    mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:current];

Теперь выберите последние 3 строки, нажмите контекстное меню и выберите "Рефакторинг" ▶ "Извлечь". Мы создадим новый метод под названием setUpUserDefaultsWithCurrentReminderId:

- (void)setUpUserDefaultsWithCurrentReminderId:(NSNumber *)current
{
    mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:current];
}

Обратный код, который вызывает это, теперь выглядит следующим образом:

    NSNumber *current = nil;
    [self setUpUserDefaultsWithCurrentReminderId:current];

Единственная причина для этой переменной заключалась в том, чтобы помочь нам в автоматическом рефакторинге. Позвольте встроить его:

    [self setUpUserDefaultsWithCurrentReminderId:nil];

Тесты все еще проходят. Поскольку автоматическое рефакторинг Xcode не заменило все экземпляры этого кода вызовом нового вспомогательного метода, мы должны сделать это сами. Итак, теперь тесты выглядят так:

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldReturnZero
{
    [self setUpUserDefaultsWithCurrentReminderId:nil];

    assertThat([sut nextReminderId], is(equalTo(@0)));
}

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldSaveZeroInUserDefaults
{
    // given
    [self setUpUserDefaultsWithCurrentReminderId:nil];

    // when
    [sut nextReminderId];

    // then
    [verify(mockUserDefaults) setObject:@0 forKey:@"currentReminderId"];
}

Посмотрите, как мы постоянно чистим, когда мы идем? Тесты стали легче читать!

Четвертый тест: с помощью ключа соответствия возвращаемое значение увеличивается

Теперь мы хотим проверить, что, если значения по умолчанию у пользователя имеют некоторое значение, мы возвращаем один больше. Я собираюсь скопировать и изменить тест "должен вернуть ноль", используя произвольное значение 3.

- (void)testNextReminderIdWithCurrentReminderIdInUserDefaultsShouldReturnOneGreater
{
    [self setUpUserDefaultsWithCurrentReminderId:@3];

    assertThat([sut nextReminderId], is(equalTo(@4)));
}

Это не удается: "Ожидаемый < 4 > , но был < 0 > ".

Вот простой код для прохождения теста:

- (NSNumber *)nextReminderId
{
    NSNumber *reminderId = [_userDefaults objectForKey:@"currentReminderId"];
    if (reminderId)
        reminderId = @([reminderId integerValue] + 1);
    else
        reminderId = @0;
    [_userDefaults setObject:@0 forKey:@"currentReminderId"];
    return reminderId;
}

За исключением этого setObject:@0, это начинает выглядеть как ваш пример. Я пока ничего не вижу в рефакторе. (На самом деле есть, но я не заметил этого позже. Продолжайте.)

Пятый тест: с помощью соответствующего ключа сохраните увеличенное значение

Теперь мы можем установить еще один тест: при тех же условиях он должен сохранить новый идентификатор напоминания в настройках пользователя по умолчанию. Это можно сделать, скопировав предыдущий тест, изменив его и присвоив ему хорошее имя:

- (void)testNextReminderIdWithCurrentReminderIdInUserDefaultsShouldSaveOneGreaterInUserDefaults
{
    // given
    [self setUpUserDefaultsWithCurrentReminderId:@3];

    // when
    [sut nextReminderId];

    // then
    [verify(mockUserDefaults) setObject:@4 forKey:@"currentReminderId"];
}

Этот тест завершился неудачно, с "Ожидаемым 1 совпадением вызова, но полученным 0". Чтобы получить это, мы, конечно, просто меняем setObject:@0 на setObject:reminderId. Все проходит. Мы закончили!

Подождите, мы не закончили. Шаг 3: Есть что-нибудь для рефакторинга? Когда я впервые написал это, я сказал: "Не совсем". Но, посмотрев его после просмотра

Опять же, дядя Боб: "Единственный способ быть уверенным в том, что функция делает одно, чтобы извлечь" пока ты не упадешь ". Первые четыре линии работают вместе; они вычисляют фактическое значение. Позвольте выбрать их, и Refactor ▶ Извлечь. Следуя правилу определения дяди Боба из эпизода 2, мы дадим ему приятное длинное описательное имя, поскольку его объем использования очень ограничен. Вот что дает нам автоматический рефакторинг:

- (NSNumber *)determineNextReminderIdFromUserDefaults
{
    NSNumber *reminderId = [_userDefaults objectForKey:@"currentReminderId"];
    if (reminderId)
        reminderId = @([reminderId integerValue] + 1);
    else
        reminderId = @0;
    return reminderId;
}

- (NSNumber *)nextReminderId
{
    NSNumber *reminderId;
    reminderId = [self determineNextReminderIdFromUserDefaults];
    [_userDefaults setObject:reminderId forKey:@"currentReminderId"];
    return reminderId;
}

Очистите это, чтобы сделать его более плотным:

- (NSNumber *)determineNextReminderIdFromUserDefaults
{
    NSNumber *reminderId = [_userDefaults objectForKey:@"currentReminderId"];
    if (reminderId)
        return @([reminderId integerValue] + 1);
    else
        return @0;
}

- (NSNumber *)nextReminderId
{
    NSNumber *reminderId = [self determineNextReminderIdFromUserDefaults];
    [_userDefaults setObject:reminderId forKey:@"currentReminderId"];
    return reminderId;
}

Теперь каждый метод очень плотный, и любому легко читать три строки основного метода, чтобы увидеть, что он делает. Но мне неудобно, что ключевое слово пользователя по умолчанию используется по двум методам. Пусть извлеките это в константу в главе примера .m:

static NSString *const currentReminderIdKey = @"currentReminderId";

Я буду использовать эту константу везде, где этот ключ появится в производственном коде. Но тестовый код продолжает использовать литералы. Это защищает нас от кого-то, случайно меняющего этот постоянный ключ.

Заключение

Итак, у вас это есть. В пяти тестах у меня есть TDD'd мой путь к коду, который вы просили. Надеюсь, это даст вам более четкое представление о том, как TDD, и почему это того стоит. Следуя трехэтапному вальсу

Добавьте один неудачный тест Напишите простейший код, который проходит, даже если он выглядит немым Рефактор (как производственный код, так и тестовый код)

вы не просто оказываетесь в одном месте. Вы получаете:

хорошо изолированный код, поддерживающий инъекцию зависимостей, минималистский код, который реализует только те, которые были протестированы, тесты для каждого случая (с проверкой самих тестов), squeaky-clean code с небольшими, легко читаемыми методами.

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

Для примера с полным приложением, получите книгу Test-Driven iOS Development. Здесь .