Objective-C integration testing на примере части RSS читалки

от автора

В прошлых статьях я рассматривал 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]; } 
Необработанное исключение в setUp методе

Основной смысл данного класса — обеспечение информации о падении внутри метода setUp. Ведь этот метод вызывается перед выполнением любого теста и падение здесь означает провал последующего теста. Однако сам метод не является тестом и падение в нем не запишет ошибку в таблицу тестов. Поэтому в данном классе имеется тест testInitAfterSetUp. Данный тест выполнится успешно и будет вызван (в произвольном порядке) у каждого дочернего класса при успешном выполнении метода setUp. Провал этого теста сигнализирует о падении внутри метода setUp.

Интеграционные тесты я храню в группе IT, а классы с окончанием IT.
Модульные тесты я храню в группе Unit, а классы с окончанием Test.

А теперь возьмемся за практику

Начнем с менеджера зависимостей CocoaPods

Podfile

platform :ios, ‘8.0’
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 для тестирование сервиса ленты новостей.

RSFeedServiceIT.m

#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 интеграционных теста.

А если имеется свой сервер и все запросы идут на него?

Если пишется для своего сервера, то можно написать 1 тест на получение RSS с сервера и исключенить из списка тестирования (но запускать руками — все ли отлично после очередной фитчи у вас или на сервере?). Для этого достаточно найти нужный тест в списке тестов и выключить.

Для нашего тестового класса нужны 2 поля. Тестируемый сервис и url. Будем перед каждым тестом задавать это.

RSFeedServiceIT.m

@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 должен привести к ошибки теста.

testFeedFromURL

#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 метод для генерации ошибки на сетевой запрос.

stubHttpErrorDomain:code:userInfo

- (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 должен привести к ошибки теста.

testFeedFromURLErrorInternet

#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: Сервер не найден

testFeedFromURLErrorServerNotFound

#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, либо не передан блок, смотрится именно часть требования к системе.

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

RSFeedService

#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 

RSFeedParser

#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 

RSFeedItem

@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, и чистим частичное мокирование.

RSLinkServiceIT.m setUp/tearDown

@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

testOnFirstRunHave2Link

#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 для работы с подобным кодом.

testList

#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;     }]; } 

Остальной код

RSLinkService

#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 

RSLinkDAO

#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 

NSString+RS_RSS

#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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *