В прошлых статьях я рассматривал 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



Как уже писал, старался убрать лишний код, чтобы сосредоточить внимание на тестах.
Интеграционные тесты позволят проверить, правильно ли сцеплены ваши части системы.
Исходники здесь.

Комментарии (1)


  1. house2008
    29.12.2015 02:25
    +1

    Спасибо) Не знал про OHHTTPStubs, в процессе чтения подумал, как он определяет кого стабить — NSURLConnection или NSURLSession, оказываться там всё схвачено) Блин, круто конечно, а то я сам писал миниобертки для теста реквестов. Вместо мокито пользуюсь kiwi, как то с ним не заладилось одно время, уже забыл почему.