Test code with dispatch_async calls
Asked Answered
Z

2

5

Following TDD I'm developing an iPad app that downloads some info from the internet and displays it on a list, allowing the user to filter that list using a search bar.

I want to test that, as the user types in the search bar, the internal variable with the filter text is updated, the filtered list of items is updated, and finally the table view receives a "reloadData" message.

These are my tests:

- (void)testSutChangesFilterTextWhenSearchBarTextChanges
{
    // given
    sut.filterText = @"previous text";

    // when
    [sut searchBar:nil textDidChange:@"new text"];

    // then
    assertThat(sut.filterText, is(equalTo(@"new text")));
}

- (void)testSutReloadsTableViewDataAfterChangeFilterTextFromSearchBar
{
    // given
    sut.tableView = mock([UITableView class]);

    // when
    [sut searchBar:nil textDidChange:@"new text"];

    // then
    [verify(sut.tableView) reloadData];
}

NOTE: Changing the "filterText" property triggers right now the actual filtering process, which has been tested in other tests.

This works OK as my searchBar delegate code was written as follows:

- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText
{
    self.filterText = searchText;
    [self.tableView reloadData];
}

The problem is that filtering this data is becoming a heavy process that right now is being done on the main thread, so during that time the UI is blocked.

Therefore, I thought of doing something like this:

- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText
{
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        NSArray *filteredData = [self filteredDataWithText:searchText];

        dispatch_async(dispatch_get_main_queue(), ^{
            self.filteredData = filteredData;
            [self.tableView reloadData];
        });
    });
}

So that the filtering process occurs in a different thread and when it has finished, the table is asked to reload its data.

The question is... how do I test these things inside dispatch_async calls?

Is there any elegant way of doing that other than time-based solutions? (like waiting for some time and expect that those tasks have finished, not very deterministic)

Or maybe I should put my code on a different way to make it more testable?

In case you need to know, I'm using OCMockito and OCHamcrest by Jon Reid.

Thanks in advance!!

Zellner answered 12/5, 2013 at 18:29 Comment(3)
USing brakpoints and NSLogs might help you ?Sapor
For what purpose you have first two methods.Sapor
Hi @ArpitParekh ! The idea is using unit testing to automatically test my code. It's not about finding a bug, but to assure that this code behaves properly from now on. The first two methods are tests from my test suite. Check the link about unit testing for more info :)Zellner
M
5

There are two basic approaches. Either

  • Make things synchronous only while testing. Or,
  • Keep things asynchronous, but write an acceptance test that does resynchronizing.

To make things synchronous for testing only, extract the code that actually does work into their own methods. You already have -filteredDataWithText:. Here's another extraction:

- (void)updateTableWithFilteredData:(NSArray *)filteredData
{
    self.filteredData = filteredData;
    [self.tableView reloadData];
}

The real method that takes care of all the threading now looks like this:

- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText
{
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        NSArray *filteredData = [self filteredDataWithText:searchText];

        dispatch_async(dispatch_get_main_queue(), ^{
            [self updateTableWithFilteredData:filteredData];
        });
    });
}

Notice that underneath all that threading fanciness, it really just calls two methods. So now to pretend that all that threading was done, have your tests just invoke those two methods in order:

NSArray *filteredData = [self filteredDataWithText:searchText];
[self updateTableWithFilteredData:filteredData];

This does mean that -searchBar:textDidChange: won't be covered by unit tests. A single manual test can confirm that it's dispatching the right things.

If you really want an automated test on the delegate method, write an acceptance test that has its own run loop. See Pattern for unit testing async queue that calls main queue on completion. (But keep acceptance tests in a separate test target. They're too slow to include with unit tests.)

Moslemism answered 13/5, 2013 at 5:12 Comment(1)
Thanks Jon! Right now I'm just writing unit tests and it's somehow difficult to me to take the decision of not covering some methods, but I guess that's when acceptance tests come to the rescue in cases like this.Zellner
H
3

Albite Jons options are very good options most of the time, sometime it creates less cluttered code when doing the following. For example if your API has a lot small methods that are synchronised using a dispatch queue.

Have a function like this (it could be a method of your class as well).

void dispatch(dispatch_queue_t queue, void (^block)())
{
    if(queue)
    {
        dispatch_async(queue, block);
    }
    else
    {
        block();
    }
}

Then use this function to call the blocks in your API methods

- (void)anAPIMethod
{
    dispatch(dispQueue, ^
    {
        // dispatched code here
    });
}

You would usually initialise the queue in your init method.

@implementation MyAPI
{
    dispatch_queue_t dispQueue;
}

- (instancetype)init
{
    self = [super init];
    if (self)
    {
        dispQueue = dispatch_queue_create("myQueue", DISPATCH_QUEUE_SERIAL);
    }

    return self;
}

Then have a private method like this, to set this queue to nil. It is not part of your interface, the API consumer will never see this.

- (void) disableGCD
{
    dispQueue = nil;
}

In your test target you create a category to expose the GCD disabling method:

@interface TTBLocationBasedTrackStore (Testing)
- (void) disableGCD;
@end

You call this in your test setup and your blocks will be called directly.

The advantage in my eyes is debugging. When a test case involves a runloop so that blocks are actually called, the problem is that there has to be a timeout involved. This timeout is usually quite short because you don't want to have tests that last long if the they run into the timeout. But having a short timeout means your test runs into the timeout when debugging.

Huygens answered 1/9, 2015 at 17:53 Comment(1)
Thanks for your answer! I'm currently opting for other solution: hide the asynchronous code in another class and mock that class during testing. With a spy I capture the completion block and the mock just executes that completion block immediately. There is no asynchronous code in my tests anymore :)Zellner

© 2022 - 2024 — McMap. All rights reserved.