Sunday, January 8, 2012

Unit Testing

This report shows an example of unit testing in Objective-C using iPhone Unit Testing framework for a map view based application. Unit tests target your code at the lowest possible level - the input/output of a single method (a method is often the smallest testable unit of code). If every single method behaves as you expect, so will your program. What percentage of your code is covered by unit tests is called unit test coverage. 100% coverage is not only rare, but unrealistic.  You should determine which classes and methods are to get the most attention from your tests. This can be achieved by answering a few questions like how often the implementation changes or if it takes input from an unpredictable source, like a user or a network connection.

External data sources are less reliable than, for example, a static read-only file on the file system. When applications rely on external data sources such as the Internet and device hardware, availability and validity of data are undoubtedly major risks. This is due to the fact that the likelihood of incidents such as information has been moved, information not being in expected format or network disconnection is high. Therefore, checking if an application handles data related matters properly can be the centre of focus in tests.

Example map view application
The example map view application in question here consists of two view controllers. First, there is a table view controller which loads some coordinates from a CSV file. By selecting any of the coordinates from the table view, the second view, the map view, appears showing the chosen point. Screen shots of the application are presented below (source code of the application is provided at the end of the report).

Figure 1: example map view application

The goal here is to make sure a proper error is raised when coordinates, either latitude or longitude, are out of range. The code snippet below shows data validation in the map view controller. Note that all data handling and validation methods are supposed to be in a separate class in model layer. However, as the goal here is not to demonstrate MVC pattern and to make it quicker, data related matters are being handled in view controllers.

- (void)setMapDetailsWithLatitude:(double) latitude andWithLongitude:(double)longitude
{
    @try {
       
        if ( latitude > 90 || latitude < -90 ) {
            NSException* e = [NSException exceptionWithName:@"Bad Location" 
                                          reason:@"Invalid latitude" 
                                          userInfo:nil];
            @throw e;
        }

        if ( longitude > 180 || longitude < -180 ) {
            NSException* e = [NSException exceptionWithName:@"Bad Location" 
                                          reason:@"Invalid longtitude" 
                                          userInfo:nil];
            @throw e;
        }
       
        MKCoordinateSpan coordinationSpan;
        coordinationSpan.latitudeDelta = 0.1;
        coordinationSpan.longitudeDelta = 0.1;
       
        CLLocationCoordinate2D locationCoordinate;
        locationCoordinate.latitude = latitude;
        locationCoordinate.longitude = longitude;
       
        MKCoordinateRegion region;
        region.span=coordinationSpan;
        region.center=locationCoordinate;
       
        mapView.mapType = MKMapTypeStandard;
        [mapView setShowsUserLocation:YES];
        [mapView setRegion:region animated:TRUE];     
    }
    @catch (NSException *exception) {
        NSException* e = [NSException exceptionWithName:@"Bad Location" 
                                      reason:@"Invalid Location Parameters" 
                                      userInfo:[exception userInfo]];
        @throw e;
    }
}
Code snippet 1: data validation

Fortunately, XCODE makes it fairly easy to add unit tests to applications. What needs to be done in the beginning is to make sure Include Unit Tests checkbox is checked when creating a new application.

Figure 2: including unit tests when creating a new project

Then all needs to be done is clicking and holding Run button in the upper left of XCODE IDE, and choose test from the popup menu.

Figure 3: build for testing

If you forgot to include unit tests in an application, or you want to add unit tests to an existing application, you simply add a new target to the project, and add a “Cocoa Touch Unit Testing Bundle” to your app.

Figure 4: adding a new target to project

Figure 5: add a Cocoa Touch Unit Testing Bundle to project

Next is to write appropriate test cases to see if data validation code works properly and effectively. There are 4 test cases in the example map view application.
  • testInvalidLatitude – Tests if an invalid latitude would cause an exception or not (latitude should be between -90 and +90).
  • testInvalidLongitude - Tests if an invalid longitude would cause an exception or not (longitude should be between -180 and +180).
  • testValidCoords – Tests if valid latitude and longitude would not cause an exception.
  • testAllCoordsFromFile – Tests all the values provided in the gps_coords.csv file.
Note that test cases names should follow the above format, which is “test” plus some other words as in testInvalidLatitude, to be considered as test cases and get executed. Below, the code for test cases mentioned above is presented.

- (void)testInvalidLatitude
{
    MapViewController* mapVC = [[MapViewController alloc] initWithNibName:@"MapViewController" bundle:nil];
   
    STAssertThrowsSpecificNamed([mapVC setMapDetailsWithLatitude:91 andWithLongitude:0],NSException, @"Bad Location", @"Invalid latitude or longitude values should raise an exception");
   
    [mapVC release];
}

- (void)testInvalidLongitude
{
    MapViewController* mapVC = [[MapViewController alloc] initWithNibName:@"MapViewController" bundle:nil];
  
    STAssertThrowsSpecificNamed([mapVC setMapDetailsWithLatitude:0 andWithLongitude:181],NSException, @"Bad Location", @"Invalid latitude or longitude values should raise an exception");
   
    [mapVC release];
}

- (void)testValidCoords
{
    MapViewController* mapVC = [[MapViewController alloc] initWithNibName:@"MapViewController" bundle:nil];
   
    STAssertNoThrow([mapVC setMapDetailsWithLatitude:27 andWithLongitude:153],@"Valid latitude or longitude values should NOT raise an exception");
   
    [mapVC release];
}

- (void)testAllCoordsFromFile
{
    NSString* filePath = [[NSBundle mainBundle] pathForResource:@"gps_coords"
                                                ofType:@"csv"];
    NSString* gpsCoords = [NSString stringWithContentsOfFile:filePath
                                    usedEncoding:nil
                                    error:nil];
    NSMutableArray* mutableArray = [[NSMutableArray alloc] initWithArray:[gpsCoords componentsSeparatedByString:@"\n"]];
   
    MapViewController* mapVC = [[MapViewController alloc] initWithNibName:@"MapViewController" bundle:nil];
   
    double lat;  
    double lng;
   
    for (NSInteger i=0; i<[mutableArray count]; i++) {
        @try {
            lat = [[[[mutableArray objectAtIndex:i] componentsSeparatedByString:@";"] objectAtIndex:0] doubleValue];       
            lng = [[[[mutableArray objectAtIndex:i] componentsSeparatedByString:@";"] objectAtIndex:1] doubleValue];

            STAssertNoThrow([mapVC setMapDetailsWithLatitude:lat andWithLongitude:lng],@"Valid latitude or longitude values should NOT raise an exception");
        }
        @catch (NSException* e){
            NSLog([NSString stringWithFormat:@"line %d is not in expected format",i]);
        }      
    }   
   
    [mapVC release];
    [mutableArray release];
}
Code snippet 2: test cases in the test class
The results of tests are shown in XCODE output window (as shown in figure 6 bolew). Tests marked with passed keyword are successful tests. It means that, for instance, expected exception was thrown, or no exception was thrown when it was not meant to, or the return value of some method are as expected and so on.

Figure 6: test results

Conclusion
Variety of tests can be implemented such as STAssertNotNil, STAssertTrue, STAssertThrows, STAssertThrowsSpecific, STAssertThrowsSpecificNamed and STAssertEquals. In addition, tests are debug-able using XCODE which is very helpful. Taking advantage of unit testing, to some extent, can guarantee the functionality of any application. Having a variety of test cases that are designed and implemented accurately can help to minimize the risk of failure. However, unit testing is absolutely not a substitute for pressure tests or tests including real users whatsoever. Unit testing is specifically useful to test the desired functionality of the application in known dangerous circumstances, where you know your code might break.

Sunday, January 1, 2012

Automating Recovery after Failure

A Simple Download Manager
Introduction
This report is aimed at discussing a design for an iOS application, which downloads files from the internet. Based on this design, the application is able to recover itself from failures, which disrupt the download process and make it stop. The discussion in this report covers the areas related to how the download states are saved in case of a failure, and how the application uses the saved information to resume previously in progress downloads. The example application uses ASIHTTPRequest library to perform network activities (in this case, simply downloading files), and uses ASINetworkQueue to manage multiple downloads. (A link to the source of the project discussed in this report is provided at the end of the report)

Application general description
The example download manager application uses two buttons to initiate downloads for a 5MB and a 10MB files. To enable an ASIHTTPRequest object to be able to resume not finished downloads, temporary download path can be set for partial downloads. Figure 1, shows the application in 3 different states.

Figure 1: The download manager application in 3 states: No downloads in queue, 1 download in progress and 2 download in progress

Note that in figure 1, when downloads are in progress, network activity indicator is animating on the status bar. Figure 2 shows the partially downloaded files on the hard drive. Note that the .download extension indicates that downloads are not completed.

Figure 2: partially downloaded files (download is still in progress)

To provide the ability to recover in the download manager application, information of download requests is kept in an array of dictionaries, called dlTracker (NSMutableArray* dlTracker). Every request has a unique identifier so that it would be recognizable in the entire application. For this purpose, the tag property of ASIHTTPRequest objects is used. Download buttons code snippets are provided below. (A link to the source of the project is provided at the end of the report)

- (IBAction)startDownloadFiveMBBtnPressed:(id)sender
{
    if (file5MBDownloadInProgress)
        return;
    NSURL *url = [NSURL URLWithString:@"http://download.thinkbroadband.com/5MB.zip"];
                 
    ASIHTTPRequest *request = [ASIHTTPRequest requestWithURL:url];

    NSString *downloadPath = [path stringByAppendingString:@"/5MB.zip"];   
    [request setDownloadDestinationPath:downloadPath];
   
    [request setTemporaryFileDownloadPath:[path stringByAppendingString:@"/5MB.zip.download"]];
    [request setAllowResumeForFileDownloads:YES];
   
    [request setDelegate:self];
    [request setDownloadProgressDelegate:progressViewFive];
    [request setShowAccurateProgress:YES];
   
    request.tag = 5;
    //[request startAsynchronous];
   
    [self takeRequestInfo:request];
    [queue addOperation:request];
    [queue go];
}

- (IBAction)startDownloadTenMBBtnPressed:(id)sender
{
    if (file10MBDownloadInProgress)
        return;
    NSURL *url = [NSURL URLWithString:@"http://download.thinkbroadband.com/10MB.zip"];   
   
    ASIHTTPRequest *request = [ASIHTTPRequest requestWithURL:url];
   
    NSString *downloadPath = [path stringByAppendingString:@"/10MB.zip"];   
    [request setDownloadDestinationPath:downloadPath];
   
    [request setTemporaryFileDownloadPath:[path stringByAppendingString:@"/10MB.zip.download"]];
    [request setAllowResumeForFileDownloads:YES];
   
    [request setDelegate:self];
    [request setDownloadProgressDelegate:progressViewTen];
    [request setShowAccurateProgress:YES];
   
    request.tag = 10;
    //[request startAsynchronous];
   
    [self takeRequestInfo:request];
    [queue addOperation:request];
    [queue go];
}
Code snippet 1: IBAction methods for initiating downloads
The application going to the background, network getting disconnected, and force stop by user; will be discussed as failure scenarios in the following. It should be mentioned that ASIHTTPRequest does not stop its file transferring process in the background. However, in this design, downloads are forced to stop in background, to better show the functionality of the application. A better design would be downloads remain in progress in background and be saved and stopped when the application is about to terminate.


First failure scenario: Application going to the background
When the application is about to enter background, downloads are stopped and the data being kept in dlTracker array is written to a file called “dlTracker.if”. Information kept for each download request includes, URL, download path, download temporary path and request unique identifier. Figures 3, shows that the dlTracker.if file comes into existence when the application enters background, and figure 4, shows the dlTracker file format.

Figure 3: dlTracker.if file is created as the application enters background

Figure 4: dlTracker file format

It should be mentioned that dlTracker.if file is only created when there are download requests in download queue. Therefore, when re-entering foreground, the application uses this condition, which is the existence of dlTracker.if file, to determine if there were any download requests in progress when the application entered background. So, on changing state from background to foreground, if dlTracker.if file exists, user is asked for confirmation on resuming downloads. If user chooses to continue downloads, the requests are recreated based on their information in dlTracker.if file, and are resumed from the point indicated by partially downloaded files. As downloads begin to continue, dlTracker.if file is also removed.

Figure 5: dlTracker.if file is erased if user decides to continue.

If any of downloads finishes, its data will be removed from dlTracker array. Therefore, in case of any interruption, the finished process information will not be written to file. This is how not attempting to resume completed processes by the application is handled.

Figure 6: one of the download requests has been completed

Figure 7: dlTracker.if file does not contain any information about the finished process

Code snippets for parts discussed in this section are provided below. (A link to the source of the project is provided at the end of the report)

- (void)takeRequestInfo:(ASIHTTPRequest*)request
{
    NSString* requestTag = [[NSString alloc] initWithFormat:@"%d",request.tag];
    NSString* url = [[NSString alloc] initWithString:[[request url] description]] ;
   
    NSArray* objects = [[NSArray alloc] initWithObjects:
                        requestTag,url,
                        [request downloadDestinationPath],
                        [request temporaryFileDownloadPath],nil];

    NSArray* keys = [[NSArray alloc] initWithObjects:
                        @"tag",@"url",
                        @"downloadPath",
                        @"tmpDownloadPath",nil];
   
    [requestTag release];                       
    [url release];
                       
    NSDictionary* requestInfo = [[NSDictionary alloc] initWithObjects:objects forKeys:keys];
   
    [objects release];
    [keys release];
   
    [dlTracker addObject:requestInfo];
    [requestInfo release];
}
Code snippet 2: Tracking requests in an NSMutableArray.

- (void)willResignActive:(NSNotification*)notification
{
    [self performSelector:@selector(saveRequestInfo)];
}

- (void)saveRequestInfo
{
    if ([dlTracker count]>0)
    {
        if (queue != nil)
            [queue cancelAllOperations];
        [dlTracker writeToFile:[path stringByAppendingString:@"/dlTracker.if"] atomically:YES];
        [dlTracker removeAllObjects];   
    }
}

- (void)willEnterForeground:(NSNotification*)notification
{
    [self performSelector:@selector(initializeNetworkOperation)];
}
Code snippet 3: Application changing state between background and foreground.

- (void)initializeNetworkOperation
{
    if (reachability == nil)
        reachability = [[Reachability reachabilityForInternetConnection] retain];
    [reachability stopNotifier];
    [reachability startNotifier];

    if ([reachability currentReachabilityStatus] == NotReachable)
    {
        [self performSelector:@selector(updateNetworkStatus:)
                              withObject:@"No Internet availability"];
        return;
    }
    [self performSelector:@selector(updateNetworkStatus:)
                              withObject:@"Internet available"];
   
    [[NSNotificationCenter defaultCenter] removeObserver:self];
    [[NSNotificationCenter defaultCenter] addObserver:self       
                             selector:@selector(reachabilityChanged:)
                             name:kReachabilityChangedNotification object:nil];   
   
    if (queue == nil)
    {
        queue = [[ASINetworkQueue alloc] init];
        [queue setDelegate:self];
        [queue setDownloadProgressDelegate:progressViewQ];
        [queue setShowAccurateProgress:YES];
        [queue setQueueDidFinishSelector:@selector(queueComplete:)];   
        [queue setRequestDidStartSelector:@selector(queueInProgress:)];
    }
   
    [[NSNotificationCenter defaultCenter] addObserver:self
                    selector:@selector(willEnterForeground:)                                                 
                    name:@"UIApplicationWillEnterForegroundNotification" 
                    object:nil];   
    [[NSNotificationCenter defaultCenter] addObserver:self
                    selector:@selector(willResignActive:)
                    name:@"UIApplicationWillResignActiveNotification"
                    object:nil];
   
    if (dlTracker == nil)
        dlTracker = [[NSMutableArray alloc] init];
   
    if ([self performSelector:@selector(checkForIncompleteRequests)])
    {
        UIAlertView* alertView = [[UIAlertView alloc]
                                   initWithTitle:@"Incomplete Downloads"
                                   message:@"Continue incomplete downloads?"
                                   delegate:self
                                   cancelButtonTitle:@"No"
                                   otherButtonTitles:@"Yes",nil];
        [alertView show];
        [alertView release];
    }   
}

- (void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex
{
    if (buttonIndex == 1)
        [self performSelector:@selector(recoverRequests)];
}

- (void)recoverRequests
{
    NSMutableArray* mArray = [[NSMutableArray alloc] initWithContentsOfFile:[path stringByAppendingString:@"/dlTracker.if"]];
   
    NSFileManager* fileManager = [NSFileManager defaultManager];
    [fileManager removeItemAtPath:[path stringByAppendingString:@"/dlTracker.if"] error:nil];
   
    for (NSDictionary* requestInfo in mArray)
    {
        NSURL *url = [[NSURL alloc] initWithString:[requestInfo objectForKey:@"url"]];
        ASIHTTPRequest *request = [[ASIHTTPRequest alloc] initWithURL:url];
        [url release];
       
        [request setDownloadDestinationPath:[requestInfo objectForKey:@"downloadPath"]];
        [request setTemporaryFileDownloadPath:[requestInfo objectForKey:@"tmpDownloadPath"]];
       
        NSNumber* requestTag = [requestInfo objectForKey:@"tag"];
        request.tag = [requestTag intValue];
       
        [request setDelegate:self];
        [request setAllowResumeForFileDownloads:YES];
       
        if (request.tag==5)
            [request setDownloadProgressDelegate:progressViewFive];
        else if (request.tag == 10)
            [request setDownloadProgressDelegate:progressViewTen];
        
        [request setShowAccurateProgress:YES];
       
        [self takeRequestInfo:request];
        [queue addOperation:request];
        [request release];
    }
   
    [mArray release];
   
    if ( [queue operationCount]>0 )
        [queue go];
}
Code snippet 4: Network operation initialization including checking for incomplete downloads and initiating downloads recovery.

Second failure scenario: Internet is not available
When the application is initialized, an object of type Reachability is created, which is constantly observing Internet connection. By every change in network status, Reachability object notifies the application of the change. This is where network failures are actually handled. As soon as the application is notified by a change in network status, and if it determines that the internet is not available anymore, dlTracker.if file is generated; containing the data related to active requests. When internet connection is back, saved requests are recovered through the same process as discussed before. Figure 8, illustrates the screenshots when internet is disconnected and connected again. Note that internet availability is shown on the application’s screen, and as mentioned before network activity indicator animates when requests are in progress. Code snippets for parts discussed in this section are provided below. (A link to the source of the project is provided at the end of the report)

- (void)reachabilityChanged: (NSNotification* )note
{
    Reachability* curReach = [note object];
    NSParameterAssert([curReach isKindOfClass: [Reachability class]]);
    if ([curReach currentReachabilityStatus]==NotReachable)
        [self performSelector:@selector(internetDisconnected)];
    else
        [self performSelector:@selector(internetConnected)];
}

- (void)internetDisconnected
{
    NSLog(@"Damn it network's gone");
    [self performSelector:@selector(updateNetworkStatus:) withObject:@"No Internet availability"];
    [self saveRequestInfo];
}

- (void)internetConnected
{
    NSLog(@"Phew, network's back");
    [self performSelector:@selector(updateNetworkStatus:) withObject:@"Internet availabe"];
    [self initializeNetworkOperation];
}
Code snippet 5: Reachability object delegates

Figure 8: Internet connection availability

Third failure scenario: force stop by user
Two buttons, stop and resume, are provided for users, so that they can manage their download requests manually. The functionality of these buttons and what that happens in their IBAction methods is exactly the same as previous scenarios; except for the fact that unlike previous scenarios, when touching resume button, user is not asked for confirmation. This is in accordance with iOS Human Interface Guidelines 2011, which says it is not considered a good practice to take advantage of alert views to take users’ confirmation for tasks they have initiated themselves.

Conclusion
This download manager application is able to recover itself from almost any type of interruption. However, it may not be a good practice to keep track of requests similar to the way used here. Specially relying on existence of a file to determine if there are any downloads to resume, can be quite risky. Finally, as mentioned before, it would be better if downloads remained in progress in background and were saved and stopped when the application was about to terminate.
References