Internship/2020 year-end summer internship

[Long post] Create a unit test for asynchronous method (NSURLSessionDataTask) in Objective C

hajinny 2021. 1. 28. 06:34

You would ideally want to test some methods so you are sure that the functionalities have a robust implementation. Here's how to create unit test, and test a method that makes an asynchronous request using NSURLSessionDataTask. I'll set everything up from scratch, so you or I can follow it in the future.

 

First, set up a new project.

This framework just means that you aim to create a .framework file, which is equivalent to .jar file in Java (or generally speaking, a binary library file). Once you create it, it will conveniently create everything you see below (except Request.h, Request.m, which I just created).

Now, firstly set up a method that makes a POST request. I made header and class file for Request (.h, .m), as below:

#import <Foundation/Foundation.h>

@interface Request : NSObject

+ (void)makeRequest;

@end
#import <Foundation/Foundation.h>
#import "Request.h"
@implementation Request

+ (void)makeRequest{
    NSMutableDictionary *requestMap = [[NSMutableDictionary alloc] initWithCapacity:32];
    requestMap[@"type"] = @"purchase";
    requestMap[@"amount"] = @"10.00";
    requestMap[@"currency"] = @"NZD";
    requestMap[@"card"] = @{
            @"cardNumber": @"4111111111111111",
            @"dateExpiryYear": @"23",
            @"dateExpiryMonth": @"10",
            @"cvc2": @"000"
    };

    NSData *requestBodyData = [NSJSONSerialization dataWithJSONObject:requestMap options:0 error:nil];

    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"https://dev.windcave.com/api/v1/transactions"] cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:60.0];

    [request setHTTPMethod: @"POST"];
    [request setValue: @"application/json" forHTTPHeaderField: @"content-type"];
    [request setValue: @"Basic SGFqaW5LOjVlZGYzNzIyZmFjMjE1M2IwYzRmZjQyOGM2MDM5MjYwNjE5ZDI0N2M3Yzc3YWQ0Mzc5MThkZDUwNDMyMTc1MmU=" forHTTPHeaderField:@"Authorization"];
    [request setValue:@"helo" forHTTPHeaderField:@"X-ID"];
    [request setHTTPBody: requestBodyData];

    NSURLSession *session = [NSURLSession sharedSession];
    NSURLSessionDataTask *dataTask = [session
                                      dataTaskWithRequest:request
                                      completionHandler:^(NSData *data, NSURLResponse *response, NSError *error){
        dispatch_sync(dispatch_get_main_queue(), ^{
            NSLog(@"reached here as well");
            NSDictionary *dictionary = [NSJSONSerialization JSONObjectWithData:data options:kNilOptions error:nil];
                            
            NSHTTPURLResponse *res = (NSHTTPURLResponse *)response;
            NSDictionary *resHeaders = res.allHeaderFields;
            NSLog(@"%@", resHeaders);
            NSLog(@"%@", [resHeaders valueForKey:@"X-ID"]);
            NSLog(@"%@", [resHeaders valueForKey:@"X-Duplicate"]);
            NSLog(@"%ld",(long)res.statusCode);
            NSLog(@"%@", dictionary);
        });
        
    }];
    [dataTask resume];
}

@end

The above request body is just a standard way of making a POST request in objective C.

Since we created this file in Framework project, we don't really have a 'main' file that allows us to run it straight away. In usual commandline applications that has main.m, you would simply put:

[Request makeRequest];

under the main function. But you will probably need to also include:

while(true){}

Just to easily wait for POST request to be completed.

 

 

Let's say we want to check if the callback function passed as a completion handler (which is just an anonymous block:

^{
	dispatch_sync(dispatch_get_main_queue(), ^{...});
}

) is successfully executed. Well, there's probably several intended behaviours to check, but the first thing we would check is whether POST request even gets called successfully. The measurand for that would be to check that the above anonymous block (the callback passed to the async method) is called.

 

Before we even think any further, we need to understand that Apple made a tool that allows you to do exactly that. It's better to understand what tools we have, than to simply try to give you intuitions around how that can be done.

 

If you go to RequestFrameworkTests.m, you will see something like this:

 

Except, the two green rhombus you see here won't be present on your side (also you wouldn't have @property XCTestExpectation *expectation;, we are meant to add that later.). You will instead have a blank rhombus. Currently, testExample is one test case you can execute. setUp and tearDown are specifically for setting up common objects and removing it at the end of each test method. Try press that rhombus, you will get green. It's very much similar to JUnit.

 

Firstly, add @property XCTestExpectation *expectation; as done on line 13 above. Then, under testExample, add the following code:

- (void)testExample {
    _expectation = [self expectationWithDescription:@"just some testing"];
    [self->_expectation fulfill];
    [self waitForExpectationsWithTimeout:5 handler:nil];
}

This is basically everything we need to test an async method. Friendly reminder that property set up in interface can be accessed with underscore within the implementation, hence why _expectation is there. Further, self->_expectation just gets the object called '_expectation' which simply references the property declared in the interface (and that property has been initialised to the result of the call, [self expectationWithDescription:@"just some testing"].

 

If you run the test case by pressing the rhombus beside -(void)testExample, it will say test run successfully. What does the code above mean, though?

 

In the first line, expectation object (of type XCTestExpectation) is set up. Expectation object is nothing but an abstract object which has a state 'fulfiled'. When you first set it up, 'fulflled' would be false. You have to call 'fulfill' method of the expectation object somewhere in the code to turn that 'fulfilled' state into true. The last line will simply wait for 5 seconds until the expectation object's 'fulfilled' state is set to true. You can see that it sort of acts in the same way that we would think of the word 'expectation' in general.

 

Now, this is very much applicable to our scenario, as we simply need to call [self->_expectation fulfill] in the callback method of the post request:

^{
	dispatch_sync(dispatch_get_main_queue(), ^{...});
}

and the test case will simply wait for 5 seconds, and as long as the above block was called (the block which now contains [self->_expectation fulfill];), the test will say it ran successfully. By the way, if it's not fulfilled in 5 seconds, it will simply say test failed.

 

Ok, having this in mind, here's the full code before I explain it any further.

 

Request.h

#import <Foundation/Foundation.h>

@interface Request : NSObject

+ (void)makeRequestwithCompletionHandler:(void (^)(BOOL))handler;

@end

Request.m

#import <Foundation/Foundation.h>
#import "Request.h"
@implementation Request

+ (void)makeRequestwithCompletionHandler:(void (^)(BOOL))handler{
    NSMutableDictionary *requestMap = [[NSMutableDictionary alloc] initWithCapacity:32];
    requestMap[@"type"] = @"purchase";
    requestMap[@"amount"] = @"10.00";
    requestMap[@"currency"] = @"NZD";
    requestMap[@"card"] = @{
            @"cardNumber": @"4111111111111111",
            @"dateExpiryYear": @"23",
            @"dateExpiryMonth": @"10",
            @"cvc2": @"000"
    };

    NSData *requestBodyData = [NSJSONSerialization dataWithJSONObject:requestMap options:0 error:nil];

    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"https://dev.windcave.com/api/v1/transactions"] cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:60.0];

    [request setHTTPMethod: @"POST"];
    [request setValue: @"application/json" forHTTPHeaderField: @"content-type"];
    [request setValue: @"Basic SGFqaW5LOjVlZGYzNzIyZmFjMjE1M2IwYzRmZjQyOGM2MDM5MjYwNjE5ZDI0N2M3Yzc3YWQ0Mzc5MThkZDUwNDMyMTc1MmU=" forHTTPHeaderField:@"Authorization"];
    [request setValue:@"helo" forHTTPHeaderField:@"X-ID"];
    [request setHTTPBody: requestBodyData];

    NSURLSession *session = [NSURLSession sharedSession];
    NSURLSessionDataTask *dataTask = [session
                                      dataTaskWithRequest:request
                                      completionHandler:^(NSData *data, NSURLResponse *response, NSError *error){
        dispatch_sync(dispatch_get_main_queue(), ^{
            NSLog(@"reached here as well");
            NSDictionary *dictionary = [NSJSONSerialization JSONObjectWithData:data options:kNilOptions error:nil];
                            
            NSHTTPURLResponse *res = (NSHTTPURLResponse *)response;
            NSDictionary *resHeaders = res.allHeaderFields;
            NSLog(@"%@", resHeaders);
            NSLog(@"%@", [resHeaders valueForKey:@"X-ID"]);
            NSLog(@"%@", [resHeaders valueForKey:@"X-Duplicate"]);
            NSLog(@"%ld",(long)res.statusCode);
            NSLog(@"%@", dictionary);
            handler(YES);
        });
        
    }];
    [dataTask resume];
}

@end

RequestFrameworkTests.m

//
//  RequestFrameworkTests.m
//  RequestFrameworkTests
//
//  Created by Developer on 27/01/21.
//  Copyright © 2021 Developer. All rights reserved.
//

#import <XCTest/XCTest.h>
#import "../RequestFramework/Request.h"
@interface RequestFrameworkTests : XCTestCase

@property XCTestExpectation *expectation;

@end

@implementation RequestFrameworkTests

- (void)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.
}

- (void)testExample {
    NSLog(@"hello we started testing!");
    _expectation = [self expectationWithDescription:@"hello!"];
    [Request makeRequestwithCompletionHandler:^(BOOL finished){
        if(finished){
            [self->_expectation fulfill];
        }
    }];

    [self waitForExpectationsWithTimeout:5 handler:^(NSError *error) {

        if(error)
        {
            XCTFail(@"Expectation Failed with error: %@", error);
        }

    }];
}

@end

Let me note a few things that may be strange or is not something that was mentioned earlier.

First and foremost, I've changed the makeRequest method of Request class to makeRequestwithCompletionHandler:(void (^) BOOL)handler, and I pass in a corresponding anonymous block when calling that method in our test case:

    [Request makeRequestwithCompletionHandler:^(BOOL finished){
        if(finished){
            [self->_expectation fulfill];
        }
    }];

Note that the implemenation of makeRequestwithCompletionHandler also has handler(YES); line added to it.

Let's understand what we are doing. Start from the test case we have:

//
//  RequestFrameworkTests.m
//  RequestFrameworkTests
//
//  Created by Developer on 27/01/21.
//  Copyright © 2021 Developer. All rights reserved.
//

#import <XCTest/XCTest.h>
#import "../RequestFramework/Request.h"
@interface RequestFrameworkTests : XCTestCase

@property XCTestExpectation *expectation;

@end

@implementation RequestFrameworkTests

- (void)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.
}

- (void)testExample {
    NSLog(@"hello we started testing!");
    _expectation = [self expectationWithDescription:@"hello!"];
    [Request makeRequestwithCompletionHandler:^(BOOL finished){
        if(finished){
            [self->_expectation fulfill];
        }
    }];

    [self waitForExpectationsWithTimeout:5 handler:^(NSError *error) {

        if(error)
        {
            XCTFail(@"Expectation Failed with error: %@", error);
        }

    }];
}

@end

1. we firstly set up an expectation

2. we call the asynchronous POST request method of Request. We also pass in an anonymous function (block) that can be invoked with one boolean (BOOL) parameter, such that if that boolean parameter is true (or, YES), the expectation object will be set to fulfilled.

3. we wait for expectation to be fulfilled for 5 seconds. We also additionally pass a callback method, so that if there is any error, the test case will fail.

 

In the step 2, we are expecting that the async method will call the anonymous function (block) with BOOL parameter YES, and the call will be made from

^{
	dispatch_sync(dispatch_get_main_queue(), ^{...});
}

which is only called when POST request is made successfully (notwithstanding whether the status code of https request was 2xx, 4xx or 5xx). And this indeed is the case with our code!

snippet from Request.m

    NSURLSession *session = [NSURLSession sharedSession];
    NSURLSessionDataTask *dataTask = [session
                                      dataTaskWithRequest:request
                                      completionHandler:^(NSData *data, NSURLResponse *response, NSError *error){
        dispatch_sync(dispatch_get_main_queue(), ^{
            NSLog(@"reached here as well");
            NSDictionary *dictionary = [NSJSONSerialization JSONObjectWithData:data options:kNilOptions error:nil];
                            
            NSHTTPURLResponse *res = (NSHTTPURLResponse *)response;
            NSDictionary *resHeaders = res.allHeaderFields;
            NSLog(@"%@", resHeaders);
            NSLog(@"%@", [resHeaders valueForKey:@"X-ID"]);
            NSLog(@"%@", [resHeaders valueForKey:@"X-Duplicate"]);
            NSLog(@"%ld",(long)res.statusCode);
            NSLog(@"%@", dictionary);
            handler(YES);
        });
        
    }];

(note handler(YES) in dispatch_sync)

 

That's it!

If you run the test case, you will get

If you are curious if it's actually working, you can try handler(NO); instead of handler(YES);, in which case expectation won't ever be fulfilled, and you will get a test fail.

You will also see logs like this: (if you don't, press shift+command+c)

Test Suite 'Selected tests' started at 2021-01-28 10:22:42.740
Test Suite 'RequestFrameworkTests.xctest' started at 2021-01-28 10:22:42.741
Test Suite 'RequestFrameworkTests' started at 2021-01-28 10:22:42.741
Test Case '-[RequestFrameworkTests testExample]' started.
2021-01-28 10:22:42.796853+1300 xctest[1575:34718] hello we started testing!
2021-01-28 10:22:42.966600+1300 xctest[1575:34718] reached here as well
2021-01-28 10:22:42.966940+1300 xctest[1575:34718] {
    "Cache-Control" = "no-cache,no-store";
    "Content-Length" = 1544;
    "Content-Security-Policy" = "default-src 'none'; frame-ancestors 'none';";
    "Content-Type" = "application/json; charset=utf-8";
    Date = "Wed, 27 Jan 2021 21:22:42 GMT";
    Expires = 0;
    "Frame-Options" = deny;
    Pragma = "no-cache";
    Server = "";
    "Strict-Transport-Security" = "max-age=16070400; includeSubDomains";
    "X-ASPNET-VERSION" = "";
    "X-Content-Security-Policy" = "default-src 'none'; frame-ancestors 'none';";
    "X-Content-Type-Options" = nosniff;
    "X-Duplicate" = 1;
    "X-Frame-Options" = deny;
    "X-ID" = helo;
    "X-POWERED-BY" = "";
    "X-XSS-Protection" = "1; mode=block";
    p3p = "CP=\"This site does not have a P3P policy.\"";
}
2021-01-28 10:22:42.967067+1300 xctest[1575:34718] helo
2021-01-28 10:22:42.967130+1300 xctest[1575:34718] 1
2021-01-28 10:22:42.967164+1300 xctest[1575:34718] 200
2021-01-28 10:22:42.967359+1300 xctest[1575:34718] {
    acquirer =     {
        mid = 1000000;
        name = Undefined;
        tid = 10000001;
    };
    allowRetry = 0;
    amount = "10.00";
    authCode = 102237;
    authorised = 1;
    avs =     {
        avsAction = 0;
        avsActionName = DontCheck;
        avsResultCode = U;
        avsResultDescription = "U - address information not available, or AVS is unavailable";
        postCode = "";
        streetAddress = "";
    };
    balanceAmount = "0.00";
    card =     {
        cardNumber = "411111........11";
        dateExpiryMonth = 10;
        dateExpiryYear = 23;
        type = visa;
    };
    clientType = internet;
    currency = NZD;
    currencyNumeric = 554;
    cvc2ResultCode = S;
    dateTimeLocal = "2021-01-28T08:22:37+11:00";
    dateTimeUtc = "2021-01-27T21:22:37Z";
    id = 0000005f01625ba9;
    isSurcharge = 0;
    liabilityIndicator = standard;
    links =     (
                {
            href = "https://dev.windcave.com/api/v1/transactions/0000005f01625ba9";
            method = GET;
            rel = self;
        },
                {
            href = "https://dev.windcave.com/api/v1/transactions";
            method = POST;
            rel = refund;
        }
    );
    localTimeZone = AEST;
    method = card;
    reCo = 00;
    responseText = "APPROVED (00)";
    settlementDate = "2021-01-28";
    storedCardIndicator = single;
    type = purchase;
    username = HajinK;
}
Test Case '-[RequestFrameworkTests testExample]' passed (0.227 seconds).
Test Suite 'RequestFrameworkTests' passed at 2021-01-28 10:22:42.969.
	 Executed 1 test, with 0 failures (0 unexpected) in 0.227 (0.228) seconds
Test Suite 'RequestFrameworkTests.xctest' passed at 2021-01-28 10:22:42.969.
	 Executed 1 test, with 0 failures (0 unexpected) in 0.227 (0.228) seconds
Test Suite 'Selected tests' passed at 2021-01-28 10:22:42.969.
	 Executed 1 test, with 0 failures (0 unexpected) in 0.227 (0.229) seconds
Program ended with exit code: 0

You can sort of inspect where the 'testExample' test case started executing. You can put further NSLog() to increase observability of what's going on.

 

And one last thing is that when you are referencing the file from RequestFramework folder from the test file of RequestFrameworkTests, i.e.,

You would probably need to import it like this:

since they are in different folders. I heard you can make the 'virtual folders', which means that you can simply reference differnet files without having to do that sort of navigation (eg, just do #import "Request.h"), but I can't really find a way to set that up, and the above solution is working so it's fine.