📜 ⬆️ ⬇️

Objective-C integration testing on the example of part of the RSS reader

In previous articles I considered unit-tests, this time we will talk about integration tests.
So that the example did not come out too large, but also contained material, I decided to use the example of the RSS Reader part.
A counterfeit response from the server will be considered to check the options for operation.
Testing with CoreData will be considered.



A few words of theory:


Unit tests - testing the operation of a single element in the system in isolation.
Integration tests - test the work of the system together.

If you are not familiar with XCT, then I wrote about it here .
')
We will use SOA (Service Oriented Architecture) , where the main interaction logic will be concluded. Actually services are the primary goals for testing.

Also, a change was made in main.m to run tests regardless of the operation of the main target.
int main(int argc, char * argv[]) { @autoreleasepool { Class appDelegateClass = (NSClassFromString(@"XCTestCase") ? [RSTestingAppDelegate class] : [RSAppDelegate class]); return UIApplicationMain(argc, argv, nil, NSStringFromClass(appDelegateClass)); } } 

And the RSTestCase base class has been created for testing by including a convenient way to test asynchronous code.
How?
 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]; } 
Unhandled exception in the setUp method
The main purpose of this class is to provide information about a fall inside the setUp method. After all, this method is called before the execution of any test and the fall here means the failure of the subsequent test. However, the method itself is not a test and the fall in it will not write an error in the table of tests. Therefore, in this class there is a test testInitAfterSetUp. This test will succeed and will be called (in random order) for each child class upon successful execution of the setUp method. The failure of this test signals a fall inside the setUp method.

Integration tests I store in the IT group, and classes with the end of IT.
I store the unit tests in the Unit group, and the classes with the Test end.

Now take up the practice


Let's start with CocoaPods dependency manager
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

Create an RSFeedServiceIT.m file and RSFeedServiceIT class to test the news feed service.
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 


We are interested in the following cases
1) Get RSS
2) Connection error
3) Server not found

And that 3 integration test.
And if you have your own server and all requests go to him?
If it is written for your server, then you can write 1 test for receiving RSS from the server and exclude from the testing list (but do you have to run with your hands - is everything fine after another feature on you or on the server?). To do this, simply find the test in the list of tests and turn it off.


For our test class, we need 2 fields. Tested service and url. We will ask this before each test.
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"; } 


Test1: Get RSS


OHHTTPStubs - allows you to fake a response to the request. We say that for any request you need to output data from the rss_news.xml file, the Content-Type will be application / xml, and the response code is 200 (OK).
When receiving the answer in the test, we check that the data came, and the service successfully processed the answer and issued 20 news items.
Calling the failure block should result in a test error.
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]; }]; } 


Add to the parent class RSTestCaseIT (derived from RSTestCase) a method for resetting the stub installation to the request after each test.
 - (void)tearDown { [OHHTTPStubs removeAllStubs]; // Put teardown code here. This method is called after the invocation of each test method in the class. [super tearDown]; } 

Also add to RSTestCaseIT a method for generating an error on a network request.
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]; }]; } 


Test2: Connection Error


The service should call the failure block, pass an error with the code NSURLErrorNotConnectedToInternet and the domain NSURLErrorDomain. Calling the success block should result in a test error.
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); }]; }]; } 


Test3: Server not found


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


As you can see, cases are not considered here when ulr is not passed when the method is called, or the block is not transferred, it looks exactly part of the system requirements.

And now some code. The code is simplified, namely - there is no dedicated transport level in order not to inflate the code.
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 



And where is CoreData?


Now consider another part of the system - working with the list of RSS.
1) Get a list of RSS
2) Add RSS
3) Delete RSS
4) When you first start the application there are 2 RSS source.

How do you like the last item? And you need to test ... In fact, this is absolutely not difficult at all (thanks to OCMock ).
The remaining 3 points are much more interesting, here ReactiveCocoa will help us very well

In the setUp method, we set the mode for MagicalRecord to 'in-memory', so we don’t have to worry about corrupting working data.
We also do partial mocking for the 4th point.
In the tearDown method, clean the MagicalRecord, and clean the partial mocking.

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


Test to check item 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]; } 


And now the most interesting thing is checking add / delete / get RSS links.
Check out how it all works together. Add a couple of links, remove one and request a list of those that we have. The service has an asynchronous interface (which makes it easier to connect the server if necessary), and the operations depend on each other. Therefore, we will use ReactiveCocoa to work with such code.
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; }]; } 


The rest of the code


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 



As already mentioned, I tried to remove the extra code in order to focus on the tests.
Integration tests will verify that your system parts are correctly linked.
Sources here .

Source: https://habr.com/ru/post/272967/


All Articles