Ответ 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: Есть что-нибудь для рефакторинга? Когда я впервые написал это, я сказал: "Не совсем". Но, посмотрев его после просмотра
- (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;
}
static NSString *const currentReminderIdKey = @"currentReminderId";
Заключение
Добавьте один неудачный тест Напишите простейший код, который проходит, даже если он выглядит немым Рефактор (как производственный код, так и тестовый код)вы не просто оказываетесь в одном месте. Вы получаете:
хорошо изолированный код, поддерживающий инъекцию зависимостей, минималистский код, который реализует только те, которые были протестированы, тесты для каждого случая (с проверкой самих тестов), squeaky-clean code с небольшими, легко читаемыми методами.Для примера с полным приложением, получите книгу Test-Driven iOS Development. Здесь .