Как выполнить модульное тестирование асинхронных API?
Я установил Google Toolbox для Mac в Xcode и следовал инструкциям по настройке модульного тестирования, найденным здесь.
Все это прекрасно работает, и я могу тестировать свои синхронные методы на всех моих объектах абсолютно нормально. Тем не менее, большинство сложных API, которые я на самом деле хочу протестировать, возвращают результаты асинхронно, вызывая метод для делегата - например, вызов системы загрузки и обновления файлов сразу же возвращается, а затем запускает -fileDownloadDidComplete: метод, когда файл заканчивает загрузку.,
Как бы я проверить это как модульный тест?
Похоже, что я бы хотел, чтобы функция testDownload или, по крайней мере, тестовый фреймворк "ждал" для запуска fileDownloadDidComplete: метод.
РЕДАКТИРОВАТЬ: Теперь я переключился на использование встроенной системы XCode XCode и обнаружил, что TVRSMonitor на Github предоставляет очень простой способ использования семафоров для ожидания завершения асинхронных операций.
Например:
- (void)testLogin {
TRVSMonitor *monitor = [TRVSMonitor monitor];
__block NSString *theToken;
[[Server instance] loginWithUsername:@"foo" password:@"bar"
success:^(NSString *token) {
theToken = token;
[monitor signal];
}
failure:^(NSError *error) {
[monitor signal];
}];
[monitor wait];
XCTAssert(theToken, @"Getting token");
}
13 ответов
Я столкнулся с тем же вопросом и нашел другое решение, которое работает для меня.
Я использую подход "старой школы" для преобразования асинхронных операций в поток синхронизации с помощью семафора следующим образом:
// create the object that will perform an async operation
MyConnection *conn = [MyConnection new];
STAssertNotNil (conn, @"MyConnection init failed");
// create the semaphore and lock it once before we start
// the async operation
NSConditionLock *tl = [NSConditionLock new];
self.theLock = tl;
[tl release];
// start the async operation
self.testState = 0;
[conn doItAsyncWithDelegate:self];
// now lock the semaphore - which will block this thread until
// [self.theLock unlockWithCondition:1] gets invoked
[self.theLock lockWhenCondition:1];
// make sure the async callback did in fact happen by
// checking whether it modified a variable
STAssertTrue (self.testState != 0, @"delegate did not get called");
// we're done
[self.theLock release]; self.theLock = nil;
[conn release];
Обязательно вызывайте
[self.theLock unlockWithCondition:1];
В делегат (ы) тогда.
Я ценю, что на этот вопрос задавали и отвечали почти год назад, но я не могу не согласиться с данными ответами. Тестирование асинхронных операций, особенно сетевых операций, является очень распространенным требованием и важно, чтобы получить правильные результаты. В данном примере, если вы зависите от реальных сетевых ответов, вы теряете некоторые важные значения ваших тестов. В частности, ваши тесты становятся зависимыми от доступности и функциональной корректности сервера, с которым вы общаетесь; эта зависимость делает ваши тесты
- более хрупкий (что произойдет, если сервер выйдет из строя?)
- менее полный (как вы последовательно проверяете реакцию на ошибку или сетевую ошибку?)
- значительно медленнее представить себе тестирование этого:
Модульные тесты должны выполняться за доли секунды. Если вам приходится ждать многосекундного сетевого ответа каждый раз, когда вы запускаете свои тесты, то вы вряд ли будете часто их запускать.
Модульное тестирование в основном сводится к инкапсуляции зависимостей; с точки зрения тестируемого кода происходят две вещи:
- Ваш метод инициирует сетевой запрос, возможно, путем создания NSURLConnection.
- Указанный вами делегат получает ответ через определенные вызовы методов.
Ваш делегат не заботится или не должен заботиться о том, откуда поступил ответ, от фактического ответа с удаленного сервера или от вашего тестового кода. Вы можете воспользоваться этим для проверки асинхронных операций, просто генерируя ответы самостоятельно. Ваши тесты будут выполняться намного быстрее, и вы сможете надежно протестировать ответы об успехах или сбоях.
Это не значит, что вы не должны запускать тесты на реальном веб-сервисе, с которым вы работаете, но это интеграционные тесты, которые принадлежат их собственному тестовому набору. Сбои в этом наборе могут означать, что веб-служба изменилась или просто не работает. Поскольку они более хрупкие, их автоматизация имеет меньшую ценность, чем автоматизация ваших модульных тестов.
Относительно того, как именно выполнить тестирование асинхронных ответов на сетевой запрос, у вас есть несколько вариантов. Вы можете просто протестировать делегат изолированно, вызывая методы напрямую (например, [someDelegate connection:connection didReceiveResponse:someResponse]). Это будет работать несколько, но немного неправильно. Делегат, предоставляемый вашим объектом, может быть только одним из нескольких объектов в цепочке делегатов для конкретного объекта NSURLConnection; если вы вызываете методы вашего делегата напрямую, вам может не хватать какой-то ключевой функциональности, предоставляемой другим делегатом в дальнейшем. В качестве лучшей альтернативы вы можете создать заглушку созданного вами объекта NSURLConnection и отправить ему ответные сообщения всей цепочке делегатов. Существуют библиотеки, которые откроют NSURLConnection (среди других классов) и сделают это для вас. Например: https://github.com/pivotal/PivotalCoreKit/blob/master/SpecHelperLib/Extensions/NSURLConnection%2BSpec.m
Ст3фан, ты гений. Большое спасибо!
Вот как я это сделал, используя ваше предложение.
Downloader определяет протокол с методом DownloadDidComplete, который запускается при завершении. Существует переменная-член BOOL 'downloadComplete', которая используется для завершения цикла выполнения.
-(void) testDownloader {
downloadComplete = NO;
Downloader* downloader = [[Downloader alloc] init] delegate:self];
// ... irrelevant downloader setup code removed ...
NSRunLoop *theRL = [NSRunLoop currentRunLoop];
// Begin a run loop terminated when the downloadComplete it set to true
while (!downloadComplete && [theRL runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]);
}
-(void) DownloaderDidComplete:(Downloader*) downloader withErrors:(int) errors {
downloadComplete = YES;
STAssertNotEquals(errors, 0, @"There were errors downloading!");
}
Конечно, цикл выполнения потенциально может работать вечно... Я улучшу это позже!
Я написал небольшой помощник, который облегчает тестирование асинхронного API. Первый помощник:
static inline void hxRunInMainLoop(void(^block)(BOOL *done)) {
__block BOOL done = NO;
block(&done);
while (!done) {
[[NSRunLoop mainRunLoop] runUntilDate:
[NSDate dateWithTimeIntervalSinceNow:.1]];
}
}
Вы можете использовать это так:
hxRunInMainLoop(^(BOOL *done) {
[MyAsyncThingWithBlock block:^() {
/* Your test conditions */
*done = YES;
}];
});
Это будет продолжаться только в том случае, если done
становится TRUE
, поэтому убедитесь, что установить его после завершения. Конечно, вы можете добавить время ожидания для помощника, если хотите,
Это сложно. Я думаю, что вам нужно будет установить runloop в вашем тесте, а также возможность указать этот runloop для вашего асинхронного кода. В противном случае обратные вызовы не произойдут, поскольку они выполняются в цикле выполнения.
Я думаю, вы могли бы просто запустить runloop на короткое время в цикле. И пусть обратный вызов устанавливает некоторую общую переменную состояния. Или, может быть, даже просто попросить обратный вызов, чтобы завершить цикл запуска. Таким образом, вы знаете, что тест закончен. Вы должны быть в состоянии проверить время ожидания, остановив цикл через определенное время. Если это произойдет, то произошел тайм-аут.
Я никогда не делал этого, но мне скоро придется, я думаю. Пожалуйста, поделитесь своими результатами:-)
Если вы используете библиотеку, такую как AFNetworking или ASIHTTPRequest, и ваши запросы обрабатываются с помощью NSOperation (или подкласса с этими библиотеками), то их легко протестировать на сервере test/dev с помощью NSOperationQueue:
В тесте:
// create request operation
NSOperationQueue* queue = [[NSOperationQueue alloc] init];
[queue addOperation:request];
[queue waitUntilAllOperationsAreFinished];
// verify response
Это, по сути, запускает цикл запуска до завершения операции, позволяя выполнять все обратные вызовы в фоновых потоках, как обычно.
Чтобы уточнить решение @St3fan, вы можете попробовать это после инициирования запроса:
- (BOOL)waitForCompletion:(NSTimeInterval)timeoutSecs
{
NSDate *timeoutDate = [NSDate dateWithTimeIntervalSinceNow:timeoutSecs];
do
{
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:timeoutDate];
if ([timeoutDate timeIntervalSinceNow] < 0.0)
{
break;
}
}
while (!done);
return done;
}
По-другому:
//block the thread in 0.1 second increment, until one of callbacks is received.
NSRunLoop *theRL = [NSRunLoop currentRunLoop];
//setup timeout
float waitIncrement = 0.1f;
int timeoutCounter = (int)(30 / waitIncrement); //30 sec timeout
BOOL controlConditionReached = NO;
// Begin a run loop terminated when the downloadComplete it set to true
while (controlConditionReached == NO)
{
[theRL runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:waitIncrement]];
//control condition is set in one of your async operation delegate methods or blocks
controlConditionReached = self.downloadComplete || self.downloadFailed ;
//if there's no response - timeout after some time
if(--timeoutCounter <= 0)
{
break;
}
}
Мне очень удобно использовать https://github.com/premosystems/XCAsyncTestCase
Он добавляет три очень удобных метода в XCTestCase
@interface XCTestCase (AsyncTesting)
- (void)waitForStatus:(XCTAsyncTestCaseStatus)status timeout:(NSTimeInterval)timeout;
- (void)waitForTimeout:(NSTimeInterval)timeout;
- (void)notify:(XCTAsyncTestCaseStatus)status;
@end
которые позволяют очень чистые тесты. Пример из самого проекта:
- (void)testAsyncWithDelegate
{
NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.google.com"]];
[NSURLConnection connectionWithRequest:request delegate:self];
[self waitForStatus:XCTAsyncTestCaseStatusSucceeded timeout:10.0];
}
- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
NSLog(@"Request Finished!");
[self notify:XCTAsyncTestCaseStatusSucceeded];
}
- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error
{
NSLog(@"Request failed with error: %@", error);
[self notify:XCTAsyncTestCaseStatusFailed];
}
Я реализовал решение, предложенное Томасом Темпельманном, и в целом оно отлично работает для меня.
Тем не менее, есть гоча. Предположим, что тестируемый модуль содержит следующий код:
dispatch_async(dispatch_get_main_queue(), ^{
[self performSelector:selector withObject:nil afterDelay:1.0];
});
Селектор никогда не может быть вызван, так как мы указали основному потоку блокироваться до завершения теста:
[testBase.lock lockWhenCondition:1];
В целом, мы могли бы полностью избавиться от NSConditionLock и просто использовать класс GHAsyncTestCase.
Вот как я использую это в моем коде:
@interface NumericTestTests : GHAsyncTestCase { }
@end
@implementation NumericTestTests {
BOOL passed;
}
- (void)setUp
{
passed = NO;
}
- (void)testMe {
[self prepare];
MyTest *test = [MyTest new];
[test run: ^(NSError *error, double value) {
passed = YES;
[self notify:kGHUnitWaitStatusSuccess];
}];
[test runTest:fakeTest];
[self waitForStatus:kGHUnitWaitStatusSuccess timeout:5.0];
GHAssertTrue(passed, @"Completion handler not called");
}
Гораздо чище и не блокирует основной поток.
Похоже, Xcode 6 решит проблему. https://developer.apple.com/library/prerelease/ios/documentation/DeveloperTools/Conceptual/testing_with_xcode/testing_3_writing_test_classes/testing_3_writing_test_classes.html
Я только что написал запись в блоге об этом (фактически я начал блог, потому что я думал, что это была интересная тема). В итоге я использовал метод swizzling, чтобы можно было вызывать обработчик завершения, используя любые аргументы, которые я хочу, без ожидания, что казалось хорошим для модульного тестирования. Что-то вроде этого:
- (void)swizzledGeocodeAddressString:(NSString *)addressString completionHandler:(CLGeocodeCompletionHandler)completionHandler
{
completionHandler(nil, nil); //You can test various arguments for the handler here.
}
- (void)testGeocodeFlagsComplete
{
//Swizzle the geocodeAddressString with our own method.
Method originalMethod = class_getInstanceMethod([CLGeocoder class], @selector(geocodeAddressString:completionHandler:));
Method swizzleMethod = class_getInstanceMethod([self class], @selector(swizzledGeocodeAddressString:completionHandler:));
method_exchangeImplementations(originalMethod, swizzleMethod);
MyGeocoder * myGeocoder = [[MyGeocoder alloc] init];
[myGeocoder geocodeAddress]; //the completion handler is called synchronously in here.
//Deswizzle the methods!
method_exchangeImplementations(swizzleMethod, originalMethod);
STAssertTrue(myGeocoder.geocoded, @"Should flag as geocoded when complete.");//You can test the completion handler code here.
}
запись в блоге для всех, кто заботится.
Я нашел эту статью на этом, который является muc http://dadabeatnik.wordpress.com/2013/09/12/xcode-and-asynchronous-unit-testing/
Мой ответ заключается в том, что модульное тестирование концептуально не подходит для тестирования асинхронных операций. Асинхронная операция, такая как запрос к серверу и обработка ответа, происходит не в одном блоке, а в двух.
Чтобы связать ответ с запросом, вы должны либо каким-либо образом заблокировать выполнение между двумя модулями, либо поддерживать глобальные данные. Если вы блокируете выполнение, то ваша программа не выполняется нормально, и если вы поддерживаете глобальные данные, вы добавили постороннюю функциональность, которая сама может содержать ошибки. Любое решение нарушает саму идею модульного тестирования и требует от вас вставки специального кода тестирования в ваше приложение; а затем, после модульного тестирования, вам все равно придется отключить код тестирования и выполнить старомодное "ручное" тестирование. Время и усилия, потраченные на модульное тестирование, тогда, по крайней мере, частично теряются.