Pull to refresh

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

Reading time13 min
Views8.3K
В прошлых статьях я рассматривал 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



Как уже писал, старался убрать лишний код, чтобы сосредоточить внимание на тестах.
Интеграционные тесты позволят проверить, правильно ли сцеплены ваши части системы.
Исходники здесь.
Tags:
Hubs:
Total votes 11: ↑10 and ↓1+9
Comments1

Articles