В прошлых статьях я рассматривал unit-тесты, в этот раз речь пойдет о интеграционных тестах.
Чтобы пример не вышел слишком большим, но и содержал материал, я решил написать на примере части RSS Reader’а.
Будет рассмотрена подделка ответа от сервера для проверки вариантов работы.
Будет рассмотрено тестирование с CoreData.
Пара слов теории:
Unit tests — проверка работы одного элемента в системе в изоляции.
Integration tests — проверка работы части системы вместе.
Если вы не знакомы с XCT, то здесь я про это писал.
Будем использовать SOA (Service Oriented Architecture), где будет заключена основная логика взаимодействия. Собственно сервисы — это первоочередные цели для тестирования.
Так же внесены изменение в main.m, чтобы запускать тесты независимо от работы основного таргета.
int main(int argc, char * argv[]) { @autoreleasepool { Class appDelegateClass = (NSClassFromString(@"XCTestCase") ? [RSTestingAppDelegate class] : [RSAppDelegate class]); return UIApplicationMain(argc, argv, nil, NSStringFromClass(appDelegateClass)); } }
И создан базовый класс RSTestCase для тестирования включив туда удобный способ тестировать асинхронный код.
typedef void (^RSTestCaseAsync)(XCTestExpectation *expectation); ... ... ... - (void)asyncTest:(RSTestCaseAsync)async { [self asyncTest:async timeout:5.0]; } - (void)asyncTest:(RSTestCaseAsync)async timeout:(NSTimeInterval)timeout { XCTestExpectation *expectation = [self expectationWithDescription:@"block not call"]; XCTAssertNotNil(async, @"don't send async block!"); async(expectation); [self waitForExpectationsWithTimeout:timeout handler:nil]; }
Интеграционные тесты я храню в группе IT, а классы с окончанием IT.
Модульные тесты я храню в группе Unit, а классы с окончанием Test.
А теперь возьмемся за практику
Начнем с менеджера зависимостей CocoaPods
use_frameworks!
pod ‘AFNetworking’, ‘~> 2.5.4’
pod ‘XMLDictionary’, ‘~> 1.4’
pod ‘ReactiveCocoa’, ‘~> 2.5’
pod ‘BlocksKit’, ‘~> 2.2.5’
pod ‘MagicalRecord’, ‘~> 2.3.0’
pod ‘MWFeedParser/NSDate+InternetDateTime’
target ‘RSReaderTests’ do
pod ‘OHHTTPStubs’, ‘~> 4.6.0’
pod ‘OCMock’, ‘~> 3.2’
end
Создадим файл RSFeedServiceIT.m и класс RSFeedServiceIT для тестирование сервиса ленты новостей.
#import "RSTestCase.h" @interface RSFeedServiceIT : RSTestCaseIT @end @implementation RSFeedServiceIT - (void)setUp { [super setUp]; // Put setup code here. This method is called before the invocation of each test method in the class. } - (void)tearDown { // Put teardown code here. This method is called after the invocation of each test method in the class. [super tearDown]; } @end
Нас интересуют следующие случаи
1) Получить RSS
2) Ошибка соединения
3) Сервер не найден
И того 3 интеграционных теста.
Для нашего тестового класса нужны 2 поля. Тестируемый сервис и url. Будем перед каждым тестом задавать это.
@interface RSFeedServiceIT : RSTestCaseIT @property (strong, nonatomic) RSFeedService *service; @property (strong, nonatomic) NSString *url; @end ... ... ... - (void)setUp { [super setUp]; // Put setup code here. This method is called before the invocation of each test method in the class. self.service = [RSFeedService sharedInstance]; self.url = @"http://images.apple.com/main/rss/hotnews/hotnews.rss"; }
Тест1: Получить RSS
OHHTTPStubs — позволит подделать ответ на запрос. Говорим что на любой запрос нужно выдать данные из файла rss_news.xml, Content-Type будет application/xml, а код ответа 200 (OK).
При получении ответа в тесте, проверяем что данные пришли, а сервис успешно обработал ответ и выдал 20 новостей.
Вызов блока failure должен привести к ошибки теста.
#pragma mark test - (void)testFeedFromURL { [self stubXmlFeed]; [self asyncTest:^(XCTestExpectation *expectation) { @weakify(self); [self.service feedFromStringUrl:self.url success:^(NSArray *itemNews) { @strongify(self); [expectation fulfill]; XCTAssertNotNil(itemNews); XCTAssertEqual([itemNews count], 20); } failure:^(NSError *error) { @strongify(self); [expectation fulfill]; XCTFail(@"%@", error); }]; }]; } - (void)stubXmlFeed { [OHHTTPStubs stubRequestsPassingTest:^BOOL(NSURLRequest *request) { return YES; } withStubResponse:^OHHTTPStubsResponse*(NSURLRequest *request) { NSString *xmlFeed = OHPathForFile(@"rss_news.xml", [self class]); NSDictionary *headers = @{ @"Content-Type" : @"application/xml" }; return [OHHTTPStubsResponse responseWithFileAtPath:xmlFeed statusCode:200 headers:headers]; }]; }
Добавим в родительский класс RSTestCaseIT (наследник от RSTestCase) метод для сброса установки стаба на запрос после каждого теста.
- (void)tearDown { [OHHTTPStubs removeAllStubs]; // Put teardown code here. This method is called after the invocation of each test method in the class. [super tearDown]; }
Так же добавим в RSTestCaseIT метод для генерации ошибки на сетевой запрос.
- (void)stubHttpErrorDomain:(NSString *)domain code:(NSInteger)code userInfo:(NSDictionary *)userInfo { [OHHTTPStubs stubRequestsPassingTest:^BOOL(NSURLRequest *request) { return YES; } withStubResponse:^OHHTTPStubsResponse*(NSURLRequest *request) { NSError *error = [NSError errorWithDomain:domain code:code userInfo:userInfo]; return [OHHTTPStubsResponse responseWithError:error]; }]; }
Тест2: Ошибка соединения
Сервис должен вызвать блок failure, передать ошибку с кодом NSURLErrorNotConnectedToInternet и доменом NSURLErrorDomain. Вызов блока success должен привести к ошибки теста.
#pragma mark test - (void)testFeedFromURLErrorInternet { [self stubHttpErrorDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:nil]; [self asyncTest:^(XCTestExpectation *expectation) { @weakify(self); [self.service feedFromStringUrl:self.url success:^(NSArray *itemNews) { @strongify(self); [expectation fulfill]; XCTFail(@"this is error"); } failure:^(NSError *error) { @strongify(self); [expectation fulfill]; XCTAssertEqualObjects([error domain], NSURLErrorDomain); XCTAssertEqual([error code], NSURLErrorNotConnectedToInternet); }]; }]; }
Тест3: Сервер не найден
#pragma mark test - (void)testFeedFromURLErrorServerNotFound { [self stubHttpErrorDomain:NSURLErrorDomain code:NSURLErrorCannotFindHost userInfo:nil]; [self asyncTest:^(XCTestExpectation *expectation) { @weakify(self); [self.service feedFromStringUrl:self.url success:^(NSArray *itemNews) { @strongify(self); [expectation fulfill]; XCTFail(@"this is error"); } failure:^(NSError *error) { @strongify(self); [expectation fulfill]; XCTAssertEqualObjects([error domain], NSURLErrorDomain); XCTAssertEqual([error code], NSURLErrorCannotFindHost); }]; }]; }
Как видно, здесь не рассматриваются случаи, когда при вызове метода не передан ulr, либо не передан блок, смотрится именно часть требования к системе.
А теперь немного кода. Код упрощен, а именно — нет выделенного транспортного уровня, чтобы не раздувать код.
#import <Foundation/Foundation.h> @interface RSFeedService : NSObject + (instancetype)sharedInstance; - (void)feedFromStringUrl:(NSString *)url success:(RSItemsBlock)success failure:(RSErrorBlock)failure; @end
#import "RSFeedService.h" #import "RSFeedParser.h" @interface RSFeedService () @property (strong, nonatomic) RSFeedParser *parser; @property (strong, nonatomic) AFHTTPRequestOperationManager *transportLayer; @end @implementation RSFeedService + (instancetype)sharedInstance { static dispatch_once_t onceToken; static RSFeedService *instance; dispatch_once(&onceToken, ^{ instance = [[self alloc] init]; instance.parser = [RSFeedParser sharedInstance]; instance.transportLayer = [self createSimpleOperationManager]; }); return instance; } + (AFHTTPRequestOperationManager *)createSimpleOperationManager { AFHTTPRequestOperationManager *manager = [AFHTTPRequestOperationManager manager]; manager.responseSerializer = [[AFXMLParserResponseSerializer alloc] init]; manager.responseSerializer.acceptableContentTypes = [NSSet setWithArray:@[@"application/xml", @"text/xml",@"application/rss+xml"]]; return manager; } - (void)feedFromStringUrl:(NSString *)url success:(RSItemsBlock)success failure:(RSErrorBlock)failure { @weakify(self); [self.transportLayer GET:url parameters:nil success:^(AFHTTPRequestOperation *operation, id responseObject) { @strongify(self); NSDictionary *dom = [NSDictionary dictionaryWithXMLParser:responseObject]; NSArray *items = [self.parser itemFeed:dom]; success(items); } failure:^(AFHTTPRequestOperation *operation, NSError *error) { failure(error); }]; } @end
#import <Foundation/Foundation.h> @interface RSFeedParser : NSObject + (instancetype)sharedInstance; - (NSArray *)itemFeed:(NSDictionary *)dom; @end
#import "RSFeedParser.h" #import <MWFeedParser/NSDate+InternetDateTime.h> #import "RSFeedItem.h" NSString * const RSFeedParserChannel = @"channel"; NSString * const RSFeedParserItem = @"item"; NSString * const RSFeedParserTitle = @"title"; NSString * const RSFeedParserPubDate = @"pubDate"; NSString * const RSFeedParserDescription = @"description"; NSString * const RSFeedParserLink = @"link"; @implementation RSFeedParser + (instancetype)sharedInstance { static dispatch_once_t onceToken; static RSFeedParser *instance; dispatch_once(&onceToken, ^{ instance = [[self alloc] init]; }); return instance; } - (NSArray *)itemFeed:(NSDictionary *)dom { NSDictionary *channel = dom[RSFeedParserChannel]; NSArray *items = channel[RSFeedParserItem]; return [items bk_map:^id(NSDictionary *item) { NSString *title = item[RSFeedParserTitle]; NSString *description = item[RSFeedParserDescription]; NSString *pubDateString = item[RSFeedParserPubDate]; NSString *linkString = item[RSFeedParserLink]; NSDate *pubDate = [NSDate dateFromInternetDateTimeString:pubDateString formatHint:DateFormatHintRFC822]; NSURL *link = [NSURL URLWithString:linkString]; return [RSFeedItem initWithTitle:title descriptionNews:description pubDate:pubDate link:link]; }]; } @end
@interface RSFeedItem : NSObject @property (copy, nonatomic, readonly) NSString *title; @property (copy, nonatomic, readonly) NSString *descriptionNews; @property (strong, nonatomic, readonly) NSDate *pubDate; @property (strong, nonatomic, readonly) NSURL *link; + (instancetype)initWithTitle:(NSString *)title descriptionNews:(NSString *)descriptionNews pubDate:(NSDate *)pubDate link:(NSURL *)link; - (instancetype)initWithTitle:(NSString *)title descriptionNews:(NSString *)descriptionNews pubDate:(NSDate *)pubDate link:(NSURL *)link; @end
#import "RSFeedItem.h" @interface RSFeedItem () @property (copy, nonatomic, readwrite) NSString *title; @property (copy, nonatomic, readwrite) NSString *descriptionNews; @property (strong, nonatomic, readwrite) NSDate *pubDate; @property (strong, nonatomic, readwrite) NSURL *link; @end @implementation RSFeedItem + (instancetype)initWithTitle:(NSString *)title descriptionNews:(NSString *)descriptionNews pubDate:(NSDate *)pubDate link:(NSURL *)link { return [[self alloc] initWithTitle:title descriptionNews:descriptionNews pubDate:pubDate link:link]; } - (instancetype)initWithTitle:(NSString *)title descriptionNews:(NSString *)descriptionNews pubDate:(NSDate *)pubDate link:(NSURL *)link { self = [super init]; if (self != nil) { self.title = title; self.descriptionNews = descriptionNews; self.pubDate = pubDate; self.link = link; } return self; } @end
А где CoreData?
Теперь рассмотрим другую часть системы — работа со списком RSS.
1) Получить список RSS
2) Добавить RSS
3) Удалить RSS
4) При первом запуске приложения имеются 2 RSS источника.
Как вам последний пункт? А тестировать надо… На самом деле как раз это абсолютно не сложно (спасибо OCMock).
Намного интереснее остальные 3 пункта, здесь нам отлично поможет ReactiveCocoa
В методе setUp устанавливаем режим для MagicalRecord ‘in-memory’, так нам не придется задумываться о повреждении рабочих данных.
Так же для выполнения 4го пункта делаем частичное мокирование.
В методе tearDown чистим MagicalRecord, и чистим частичное мокирование.
@interface RSLinkServiceIT : RSTestCaseIT @property (strong, nonatomic) RSLinkService *service; @property (strong, nonatomic) id mockUserDefaults; @end @implementation RSLinkServiceIT - (void)setUp { [super setUp]; // Put setup code here. This method is called before the invocation of each test method in the class. [MagicalRecord setupCoreDataStackWithInMemoryStore]; self.service = [RSLinkService sharedInstance]; id userDefaults = [NSUserDefaults standardUserDefaults]; [userDefaults setBool:YES forKey:RSHasBeenAddStandardLink]; self.mockUserDefaults = OCMPartialMock(userDefaults); } - (void)tearDown { [MagicalRecord cleanUp]; [self.mockUserDefaults stopMocking]; // Put teardown code here. This method is called after the invocation of each test method in the class. [super tearDown]; }
Тест для проверки пункта 4
#pragma mark test - (void)testOnFirstRunHave2Link { OCMStub([self.mockUserDefaults boolForKey:RSHasBeenAddStandardLink]).andReturn(NO); [self asyncTest:^(XCTestExpectation *expectation) { @weakify(self); [self.service list:^(NSArray *items) { @strongify(self); [expectation fulfill]; XCTAssertEqual([items count], 2); } failure:^{ @strongify(self); [expectation fulfill]; XCTFail(@"error"); }]; } timeout:0.1]; }
А теперь самое интересное — проверка добавление/удаление/получение RSS ссылок.
Проверим как это все работает вместе. Добавим пару ссылок, удалим одну и запросим список тех, что имеем. Сервис имеет асинхронный интерфейс (что позволит проще подключить сервер в случае необходимости), а операции зависят друг от друга. По этому воспользуемся ReactiveCocoa для работы с подобным кодом.
#pragma mark test - (void)testList { [self asyncTest:^(XCTestExpectation *expectation) { [self asyncTestList:expectation]; } timeout:0.1]; } - (void)asyncTestList:(XCTestExpectation *)expectation { NSString *rss1 = @"http://news.rambler.ru/rss/scitech1/"; NSString *rss2 = @"http://news.rambler.ru/rss/scitech2/"; RACSignal *signalAdd1 = [self createSignalAddRSS:rss1]; RACSignal *signalAdd2 = [self createSignalAddRSS:rss2]; RACSignal *signalRemove = [self createSignalRemove:rss1]; RACSignal *signalList = [self createSignalList]; [[[[signalAdd1 flattenMap:^RACStream *(id _) { return signalAdd2; }] flattenMap:^RACStream *(id _) { return signalRemove; }] flattenMap:^RACStream *(id _) { return signalList; }] subscribeNext:^(NSArray *items) { [expectation fulfill]; XCTAssertEqual([items count], 1); XCTAssertEqualObjects(items[0], rss2); } error:^(NSError *error) { [expectation fulfill]; XCTFail(@"%@", error); }]; } - (RACSignal *)createSignalAddRSS:(NSString *)rss { @weakify(self); return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) { @strongify(self); [self.service add:rss success:^{ [subscriber sendNext:nil]; [subscriber sendCompleted]; } failure:^(NSError *error) { @strongify(self); XCTFail(@"%@", error); }]; return nil; }]; } - (RACSignal *)createSignalRemove:(NSString *)rss { @weakify(self); return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) { @strongify(self); [self.service remove:rss success:^{ [subscriber sendNext:nil]; [subscriber sendCompleted]; } failure:^(NSError *error) { @strongify(self); XCTFail(@"%@", error); }]; return nil; }]; } - (RACSignal *)createSignalList { @weakify(self); return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) { @strongify(self); [self.service list:^(NSArray *items) { [subscriber sendNext:items]; [subscriber sendCompleted]; } failure:^{ [subscriber sendError:nil]; [subscriber sendCompleted]; }]; return nil; }]; }
Остальной код
#import <Foundation/Foundation.h> @interface RSLinkService : NSObject + (instancetype)sharedInstance; - (void)add:(NSString *)link success:(RSEmptyBlock)success failure:(RSErrorBlock)failure; - (void)list:(RSItemsBlock)callback failure:(RSEmptyBlock)failure; - (void)remove:(NSString *)link success:(RSEmptyBlock)success failure:(RSErrorBlock)failure; @end
#import "RSLinkService.h" #import "RSLinkDAO.h" @interface RSLinkService () @property (strong, nonatomic) RSLinkDAO *dao; @end @implementation RSLinkService + (instancetype)sharedInstance { static dispatch_once_t onceToken; static RSLinkService *instance; dispatch_once(&onceToken, ^{ instance = [[self alloc] init]; instance.dao = [RSLinkDAO sharedInstance]; }); return instance; } - (void)add:(NSString *)link success:(RSEmptyBlock)success failure:(RSErrorBlock)failure { [self.dao add:link]; success(); } - (void)list:(RSItemsBlock)callback failure:(RSEmptyBlock)failure { NSArray *list = [self.dao list]; callback(list); } - (void)remove:(NSString *)link success:(RSEmptyBlock)success failure:(RSErrorBlock)failure { [self.dao remove:link]; success(); } @end
#import <Foundation/Foundation.h> @interface RSLinkDAO : NSObject + (instancetype)sharedInstance; - (void)add:(NSString *)link; - (NSArray *)list; - (void)remove:(NSString *)link; @end
#import "RSLinkDAO.h" #import "RSLinkEntity.h" #import <MagicalRecord/MagicalRecord.h> #import "NSString+RS_RSS.h" @interface RSLinkDAO () @end @implementation RSLinkDAO + (instancetype)sharedInstance { static dispatch_once_t onceToken; static RSLinkDAO *instance; dispatch_once(&onceToken, ^{ instance = [[self alloc] init]; }); return instance; } - (void)add:(NSString *)link { NSString *url = [link convertToBaseHttp]; RSLinkEntity *entity = [self linkToLinkEntity:url]; [entity.managedObjectContext MR_saveToPersistentStoreAndWait]; } - (NSArray *)list { NSUserDefaults *standardUserDefaults = [NSUserDefaults standardUserDefaults]; if (![standardUserDefaults boolForKey:RSHasBeenAddStandardLink]) { [self addStandartLink]; [standardUserDefaults setBool:YES forKey:RSHasBeenAddStandardLink]; [standardUserDefaults synchronize]; } NSArray *all = [RSLinkEntity MR_findAll]; return [self linkEntityToLink:all]; } - (void)addStandartLink { RSLinkEntity *entity = [self linkToLinkEntity:@"http://developer.apple.com/news/rss/news.rss"]; [entity.managedObjectContext MR_saveToPersistentStoreAndWait]; RSLinkEntity *entity1 = [self linkToLinkEntity:@"http://news.rambler.ru/rss/world"]; [entity1.managedObjectContext MR_saveToPersistentStoreAndWait]; } - (void)remove:(NSString *)link { RSLinkEntity *entity = [self entityWithLink:link]; [entity MR_deleteEntity]; [entity.managedObjectContext MR_saveToPersistentStoreAndWait]; } #pragma mark - convert - (NSArray *)linkEntityToLink:(NSArray *)entitys { return [entitys bk_map:^id(RSLinkEntity *entity) { return entity.link; }]; } - (RSLinkEntity *)linkToLinkEntity:(NSString *)link { RSLinkEntity *entity = [RSLinkEntity MR_createEntity]; entity.link = link; return entity; } - (RSLinkEntity *)entityWithLink:(NSString *)link { return [RSLinkEntity MR_findFirstByAttribute:@"link" withValue:link]; } @end
#import <Foundation/Foundation.h> @interface NSString (RS_RSS) - (instancetype)convertToBaseHttp; @end
#import "NSString+RS_RSS.h" @implementation NSString (RS_RSS) - (instancetype)convertToBaseHttp { NSRange rangeHttp = [self rangeOfString:@"http://"]; NSRange rangeHttps = [self rangeOfString:@"https://"]; if (rangeHttp.location != NSNotFound || rangeHttps.location != NSNotFound) { return self; } return [NSString stringWithFormat:@"http://%@", self]; } @end
Как уже писал, старался убрать лишний код, чтобы сосредоточить внимание на тестах.
Интеграционные тесты позволят проверить, правильно ли сцеплены ваши части системы.
Исходники здесь.
ссылка на оригинал статьи http://habrahabr.ru/post/272967/
Добавить комментарий