How do I unit test HTTP request and response using NSURLSession in iOS 7.1?
Asked Answered
I

1

26

I'd like to know how to "unit test" HTTP requests and responses using NSURLSession. Right now, my completion block code does not get called when running as a unit test. However, when the same code is executed from within the AppDelegate (didFinishWithLaunchingOptions), the code inside the completion block is called. As suggested in this thread, NSURLSessionDataTask dataTaskWithURL completion handler not getting called, the use of semaphores and/or dispatch_group is needed "to make sure the main thread is blocked until the network request finishes."

My HTTP post code looks like the following.

@interface LoginPost : NSObject
- (void) post;
@end

@implementation LoginPost
- (void) post
{
 NSURLSessionConfiguration* conf = [NSURLSessionConfiguration defaultSessionConfiguration];
 NSURLSession* session = [NSURLSession sessionWithConfiguration:conf delegate:nil delegateQueue:[NSOperationQueue mainQueue]];
 NSURL* url = [NSURL URLWithString:@"http://www.example.com/login"];
 NSString* params = @"[email protected]&password=test";
 NSMutableRequest* request = [NSMutableURLRequest requestWithURL:url];
 [request setHTTPMethod:@"POST"];
 [request setHTTPBody:[params dataUsingEncoding:NSUTF8StringEncoding]];
 NSURLSessionDataTask* task = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
  NSLog(@"Response:%@ %@\n", response, error); //code here never gets called in unit tests, break point here never is triggered as well
  //response is actually deserialized to custom object, code omitted
 }];
 [task resume];
 }
@end

The method that tests this looks like the following.

- (void) testPost
{
 LoginPost* loginPost = [LoginPost alloc];
 [loginPost post];
 //XCTest continues by operating assertions on deserialized HTTP response
 //code omitted
}

In my AppDelegate, the completion block code does work, and it looks like the following.

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
 //generated code
 LoginPost* loginPost = [LoginPost alloc];
 [loginPost post];
}

Any pointers on how to get the completion block executed when running inside a unit test? I am a newcomer to iOS so a clear example would really help.

  • Note: I realize what I am asking may also not be "unit" testing in a strict sense in that I am relying on a HTTP server as a part of my test (meaning, what I am asking is more like an integration testing).
  • Note: I realize there is another thread Unit tests with NSURLSession about unit testing with NSURLSession, but I do not want to mock responses.
Immersionism answered 14/5, 2014 at 14:26 Comment(1)
Unrelated to your original question, when building your POST request, I'd suggest percent-escaping the values (if the username or password had reserved characters, such as + or &, this would not work). Also, it's probably good practice to set the Content-type header to application/x-www-form-urlencoded. But perhaps you just were trying to spare us from some of those gory details. :)Westerfield
W
53

Xcode 6 now handles asynchronous tests withXCTestExpectation. When testing an asynchronous process, you establish the "expectation" that this process will complete asynchronously, after issuing the asynchronous process, you then wait for the expectation to be satisfied for a fixed amount of time, and when the query finishes, you will asynchronously fulfill the expectation.

For example:

- (void)testDataTask
{
    XCTestExpectation *expectation = [self expectationWithDescription:@"asynchronous request"];

    NSURL *url = [NSURL URLWithString:@"http://www.apple.com"];
    NSURLSessionTask *task = [self.session dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
        XCTAssertNil(error, @"dataTaskWithURL error %@", error);

        if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
            NSInteger statusCode = [(NSHTTPURLResponse *) response statusCode];
            XCTAssertEqual(statusCode, 200, @"status code was not 200; was %d", statusCode);
        }

        XCTAssert(data, @"data nil");

        // do additional tests on the contents of the `data` object here, if you want

        // when all done, Fulfill the expectation

        [expectation fulfill];
    }];
    [task resume];

    [self waitForExpectationsWithTimeout:10.0 handler:nil];
}

My previous answer, below, predates XCTestExpectation, but I will keep it for historical purposes.


Because your test is running on the main queue, and because your request is running asynchronously, your test will not capture the events in the completion block. You have to use a semaphore or dispatch group to make the request synchronous.

For example:

- (void)testDataTask
{
    dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);

    NSURL *url = [NSURL URLWithString:@"http://www.apple.com"];
    NSURLSessionTask *task = [self.session dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
        XCTAssertNil(error, @"dataTaskWithURL error %@", error);

        if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
            NSInteger statusCode = [(NSHTTPURLResponse *) response statusCode];
            XCTAssertEqual(statusCode, 200, @"status code was not 200; was %d", statusCode);
        }

        XCTAssert(data, @"data nil");

        // do additional tests on the contents of the `data` object here, if you want

        // when all done, signal the semaphore

        dispatch_semaphore_signal(semaphore);
    }];
    [task resume];

    long rc = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 60.0 * NSEC_PER_SEC));
    XCTAssertEqual(rc, 0, @"network request timed out");
}

The semaphore will ensure that the test won't complete until the request does.

Clearly, my above test is just making a random HTTP request, but hopefully it illustrates the idea. And my various XCTAssert statements will identify four types of errors:

  1. The NSError object was not nil.

  2. The HTTP status code was not 200.

  3. The NSData object was nil.

  4. The completion block didn't complete within 60 seconds.

You would presumably also add tests for the contents of the response (which I didn't do in this simplified example).

Note, the above test works because there is nothing in my completion block that is dispatching anything to the main queue. If you're testing this with an asynchronous operation that requires the main queue (which will happen if you are not careful using AFNetworking or manually dispatch to the main queue yourself), you can get deadlocks with the above pattern (because we're blocking the main thread waiting for the network request to finish). But in the case of NSURLSession, this pattern works great.


You asked about doing testing from the command line, independent of the simulator. There are a couple of aspects:

  1. If you want to test from the command line, you can use xcodebuild from the command line. For example, to test on a simulator from the command line, it would be (in my example, my scheme is called NetworkTest):

    xcodebuild test -scheme NetworkTest -destination 'platform=iOS Simulator,name=iPhone Retina (3.5-inch),OS=7.0'
    

    That will build the scheme and run it on the specified destination. Note, there are many reports of Xcode 5.1 issues testing apps on simulator from the command line with xcodebuild (and I can verify this behavior, because I have one machine on which the above works fine, but it freezes on another). Command line testing against simulator in Xcode 5.1 appears to be not altogether reliable.

  2. If you don't want your test to run on the simulator (and this applies whether doing it from the command line or from Xcode), then you can build a MacOS X target, and have an associated scheme for that build. For example, I added a Mac OS X target to my app and then added a scheme called NetworkTestMacOS for it.

    BTW, if you add a Mac OS X scheme to and existing iOS project, the tests might not be automatically added to the scheme, so you might have to do that manually by editing the scheme, navigate to the tests section, and add your test class there. You can then run those Mac OS X tests from Xcode by choosing the right scheme, or you can do it from the command line, too:

    xcodebuild test -scheme NetworkTestMacOS -destination 'platform=OS X,arch=x86_64'
    

    Also note, if you've already built your target, you can run those tests directly by navigating to the right DerivedData folder (in my example, it's ~/Library/Developer/Xcode/DerivedData/NetworkTest-xxx/Build/Products/Debug) and then running xctest directly from the command line:

    /Applications/Xcode.app/Contents/Developer/usr/bin/xctest -XCTest All NetworkTestMacOSTests.xctest
    
  3. Another option for isolating your tests from your Xcode session is to do your testing on a separate OS X Server. See the Continuous Integration and Testing section of the WWDC 2013 video Testing in Xcode. To go into that here is well beyond the scope of the original question, so I'll simply refer you to that video which gives a nice introduction to the topic.

Personally, I really like the testing integration in Xcode (it makes the debugging of tests infinitely easier) and by having a Mac OS X target, you bypass the simulator in that process. But if you want to do it from the command line (or OS X Server), maybe the above helps.

Westerfield answered 14/5, 2014 at 15:1 Comment(4)
Is there any way to run the test suite/cases outside of XCode and/or the simulator (e.g. from the command line)? I'm coming from the Java world where you have build tools to make testing independent of a simulator.Immersionism
@JaneWayne If you want to make testing independent of the simulator, create a Mac OS X target and test with that. If you want to make testing independent from Xcode, itself, then you can use the command line tools (but I'm not sure you really want to go there). Anyway, see my revised answer.Westerfield
This also cover this are Swift Mocking... very cool! nshipster.com/xctestcaseFootplate
Thanks for this great answer. In my case I was missing the [task resume]; call and that's why my test wasn't waiting for the completion block!Estaestablish

© 2022 - 2024 — McMap. All rights reserved.