Question:
This question is addressed to members who are familiar with Cucumber
and with projects like Selenium
, Capybara
and others, as they are the ones who will understand the specifics of this question in the first place.
Here is the original post by library author Frank :
Writing iOS acceptance tests using Kiwi – Being Agile
This post discusses the possibility of writing Acceptance tests
using Objective-C itself using only Xcode's Application Testing Target
(see the corresponding section "Setting Up Application Unit Tests" in the Apple documentation ) and a couple of libraries ( PublicAutomation
and Shelley
, which provide communication with UIAutomation
). It turned out that such a possibility exists and the approach described in this article works perfectly.
Here is the code that contains what is described in this article (the link to it lies at the very end of the article page, in the comments).
The following snippet of code in this article contains a method that clicks on a UIView
object specified with a selector.
- (void)tapViewViaSelector:(NSString *)viewSelector{
[UIAutomationBridge tapView:[self viewViaSelector:viewSelector]];
sleepFor(0.1); //ugh
}
Pay attention to the line sleepFor(0.1); //ugh
. It will be discussed in this issue:
If you look at the Github
repository, you will see that the following definition is hidden behind it:
#define sleepFor(interval) (CFRunLoopRunInMode(kCFRunLoopDefaultMode, interval, false))
This line is a naive (not in the sense of the author's naivety, but in the sense of this is the first simple solution that would come to my mind) attempt by the author to wait for the exhaustion of the main Run loop
, spinning in the main thread (those who know this know) , before moving on to the next step.
An example of a possible sequence of UI interactions
, which will clearly demonstrate what is at stake:
Я на экране логина приложения.
Я нажимаю (tap) текстовое поле ввода E-mail адреса (Всплывает клавиатура)
Я ввожу текст, нажимаю Enter (Клавиатура скрывается)
Я нажимаю (tap) текстовое поле ввода Password. (Всплывает клавиатура)
Я ввожу текст, нажимаю Enter (Клавиатура скрывается)
Я нажимаю кнопку "Войти" (происходит запрос к серверу про аутентификацию, в случае успеха происходит насыщенный событиями переход на главный экран приложения)
Я должен увидеть UILabel, содержащий текст "Вы находитесь на главной странице"
The described scenario relies on the helpers described in the article and
IF sleepFor()
removed from all code of all interactions behind each of the described actions (pressing, entering text fields, swipe gestures
and everything else), then each next action will not wait for the end of animations, transitions
and other actions behind the current step and taking time, since they do not block the main thread, but are written to execute ( being scheduled
) in the main thread's run loop
.
A simple example: without waiting for the keyboard to disappear from the previous field, at the moment it disappears, -[UIAutomationBridge tapViewViaSelector:]
will rely on the intermediate coordinate of the field in which you need to enter a value and thus click (tap) will not work on the address. There are countless examples of this (for example, I should eventually see UILabel
named "Some text" on a main screen ).
So, TASK :
Write a helper that has the shortest wait time, the fewest empty runs in the
main run loop
, and therefore the fewest emptyCPU
cycles, to ensure that the moment is guaranteed to wait until themain run loop
is exhausted so that we can move on to the next step of thetest scenario
.
ANNEX 1
Here is my current intermediate code, which works by virtue of being written in a paranoid fashion:
// DON'T like it
static inline void runLoopIfNeeded() {
// https://developer.apple.com/library/mac/#documentation/CoreFOundation/Reference/CFRunLoopRef/Reference/reference.html
while (CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, YES) == kCFRunLoopRunHandledSource);
// DON'T like it
if (CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, YES) == kCFRunLoopRunHandledSource) runLoopIfNeeded();
}
// DON'T like it
static inline BOOL eventually(BOOL(^eventualBlock)(void)) {
NSDate *timeoutDate = [NSDate dateWithTimeIntervalSinceNow:10];
runLoopIfNeeded();
while (eventualBlock() == NO) {
if ([timeoutDate compare:[NSDate date]] == NSOrderedAscending) {
@throw [NSException exceptionWithName:NSGenericException reason:@"Wait timeout has expired" userInfo:nil];
}
runLoopIfNeeded();
}
runLoopIfNeeded();
return YES;
}
Here is the following intermediate solution :
// It is much better, than it was, but still unsure
static inline void runLoopIfNeeded() {
// https://developer.apple.com/library/mac/#documentation/CoreFOundation/Reference/CFRunLoopRef/Reference/reference.html
__block BOOL flag = NO;
// https://stackoverflow.com/questions/7356820/specify-to-call-someting-when-main-thread-is-idle
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
dispatch_async(dispatch_get_main_queue(), ^{
flag = YES;
});
});
while (CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, YES) == kCFRunLoopRunHandledSource);
if (flag == NO) runLoopIfNeeded();
}
APPENDIX 2
At the time of writing this question, I was thinking about the possibility of applications that set timers (or Run loop sources
) so that the function you are looking for can never exhaust the main thread, but let's assume that nothing extravagant happens, and __always there comes a moment when the loop starts spinning idle, waiting for some action to arrive exclusively from the user (that is, CFRunLoopRunInMode
starts returning kCFRunLoopRunHandledSource
. A source was processed
. I have never seen an exception so far – it always comes.)
APPENDIX 3
I will even just be glad to see any sensible comment on the issue under consideration from colleagues who know about these things better than I do.
APPENDIX 4
I would give all my reputation for an exhaustive answer to this question. Most likely, we will be able to agree with the moderators about this;)
APPENDIX 5
This is the simplest example showing the need to exhaust the main run loop. Just please don't think that if your runLoopIfNeeded
option works for this example, then the problem is solved: in a real application, so many things can be assigned to the main run loop that your method will stumble over their number, continuing the main thread significantly sooner than you need. I'm testing my runLoopIfNeeded
on my iOS
, which is where I'll be testing yours.
So, the simplest example:
dispatch_async(^{
// ...
NSLog(@"Completed");
});
runLoopIfNeeded(); // Нужно, чтобы главный поток останавливался на этой строке, продолжая крутить при этом главную петлю (main run loop), дожидаясь пока в консоли появится completed.
NSLog(@"I want to be called exclusively AFTER the moment when animation becomes completed");
Answer:
I'm posting my current solution (if you're interested, it 's here ):
static inline void runLoopIfNeeded() {
// https://developer.apple.com/library/mac/#documentation/CoreFOundation/Reference/CFRunLoopRef/Reference/reference.html
while (CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, YES) == kCFRunLoopRunHandledSource);
}
Late comment: If we compare this solution with intermediate solutions (there were two, see APPENDIX 1 in the question), it will become clear that all three are very similar, but this last one consists of one line at all – this is the result of several clarifications (see below clarification 3) and a little discussion in a parallel open topic on SO (see end of question).
During the investigation, several behaviors became clear that I did not know about, or rather simply never had time to think about them:
The first case It turns out that methods like +[UIView animateWithDuration:...]
do not start their animations in the main thread (this is written here, in the section Starting Animations Using the Block-Based Methods ), but in a child one. It follows that they cannot be "exhausted" even by a properly written runLoopIfNeeded()
method.
Example:
[UIView animateWithDuration:10 animations:^{
self.view.backgroundColor = [UIColor redColor]; // в течение 10 секунд будем краснеть
} completion:^(BOOL finished) {
NSLog(@"Completion called");
}];
runLoopIfNeeded(); // <- (*)
(*) No matter how hard you try to implement it, you won't be able to catch the completion
moment, unless you specifically spin the run loop for 10 or more seconds, which cannot be done in runLoopIfNeeded()
, otherwise it will obviously cease to be a general-purpose helper.
The second case that cannot be "scooped out" with runLoopIfNeeded
is a construct like:
double delayInSeconds = 10.0;
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC));
dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
// <#code to be executed on the main queue after delay#>
});
I have checked many times – they are written somewhere not from where (CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, YES) == kCFRunLoopRunHandledSource)
can return YES. That is, these delayed launches via dispatch_after are completely invisible to runLoopIfNeeded
based on CFRunLoopRunInMode
. We don’t even think about catching this in principle with runLoopIfNeeded
.
The third case is very interesting.
dispatch_async
is exhaustible with (CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, YES)
. What does this mean? Again, a simple example (note I use an array to log occurrences instead of any NSLog
, since every presence of NSLog
in critical code requires 1 run cycle loop distorts the experiment):
NSMutableArray *registry = [NSMutableArray new];
dispatch_async(dispatch_get_main_queue(), ^{
[registry addObject:@"main_queue"];
});
[registry addObject:@"before run loop"];
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, YES); // Первый вариант запускаем так, а второй - комментируем эту строку
[registry addObject:@"after run loop"];
NSLog(@"registry: %@", [registry componentsJoinedByString:@", "]);
Первый вариант: registry: before run loop, main_queue, after run loop
Второй вариант: registry: before run loop, after run loop (то есть к моменту NSLog назначенный блок не выполнился)
A very, very interesting consequence follows from this third case:
Have you ever faced the need to unit test asynchronous network requests using, say, the AFNetworking
library, or even just +[NSURLConnection sendAsynchronousRequest:queue:completionHandler:]
?
There are a lot of threads on SO on this subject. I can't go into the details of how this is usually done right now, so just for those who know the subject, I'll show this example:
[someAsynchronousRequestWithCompletionHandler:^(id JSON){
// some test assertions on JSON
}];
runLoopIfNeeded(); // на весь запрос может потребоваться 2-3 запуска CFRunLoopInMode // (*)
(*) so the presence of this line will be quite enough to "straighten" an asynchronous request, that is, wait for its execution without using any CPU-cycle-consuming methods like
__block BOOL done = NO;
[someAsynchronousRequestWithCompletionHandler:^(id JSON){
// some test assertions on JSON
done = YES
}];
while(done == NO) {}
or for seconds and requiring the correct setting:
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
[someAsynchronousRequestWithCompletionHandler:^(id JSON){
// some test assertions on JSON
dispatch_semaphore_release(sema);
}];
while (dispatch_semaphore_wait(semaphore, DISPATCH_TIME_NOW)) {
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:2]];
}
Now back to the main question:
The described three cases and the same number of undescribed ones convincingly showed me that no matter how good the runLoopIfNeeded()
method is, it is impossible to know for sure and for sure that nothing important is happening on the screen now, the current implementation of runLoopIfNeeded
gives, I guess offhand, 60% reliability. In order for all my helpers similar to those in Capybara
to work, I needed to introduce an additional helper that, using a paranoid strategy, checks a variety of statements for truth:
// Is it possible to make it less paranoid?
static inline BOOL eventually(BOOL(^eventualBlock)(void)) {
NSDate *timeoutDate = [NSDate dateWithTimeIntervalSinceNow:10];
runLoopIfNeeded();
while (eventualBlock() == NO) {
if ([timeoutDate compare:[NSDate date]] == NSOrderedAscending) {
@throw [NSException exceptionWithName:NSGenericException reason:@"Wait timeout has expired" userInfo:nil];
}
runLoopIfNeeded();
}
runLoopIfNeeded();
return YES;
}
So, for example, a helper for entering text into a given text field with the addition of a fair amount of paranoia begins to look like this:
#pragma mark
#pragma mark Text fills
void fillTextFieldWithText(UITextField *textField, NSString *text) {
runLoopIfNeeded();
tapView(textField);
BOOL keyboardAppeared = eventually(^BOOL{
return [UIAutomationBridge checkForKeyboard] && textField.isEditing;
});
if (keyboardAppeared){
[UIAutomationBridge typeIntoKeyboard:text];
[textField endEditing:YES];
eventually(^BOOL{
return textField.isEditing == NO;
});
}
runLoopIfNeeded();
}
Similar helpers take the same form, and as a result, I can run tests of the following type without any problems that something has not yet fully appeared, has not become visible, has not stopped working, etc.:
it(@"should...", ^{
tapButtonWithTitle(@"Зарегистрироваться");
[[theValue(RegistrationScreen.isCurrentScreen) should] beYes];
fillTextFieldWithText(RegistrationScreen.nameField, @"stanislaw");
fillTextFieldWithText(RegistrationScreen.emailField, @"s.pankevich@gmail.com");
fillTextFieldWithText(RegistrationScreen.passwordField, @"11111");
tapButtonWithTitle(@"Зарегистрироваться");
[[theValue(eventually(^{
return hasLabelWithText(@"Проверьте, пожалуйста, почту");
})) should] beYes];
tapButtonWithTitle(@"Готово");
[[theValue(LoginScreen.isCurrentScreen) should] beYes];
});
Those who have had to deal with a bunch of Cucumber
+ Capybara
will surely see the remarkable similarity of this example with how similar things are written in Capybara
or a level lower in Selenium
.
If anyone is interested, I wrapped all this logic in the NativeAutomation project, which I posted on Github.
UPDATED MORE LATER: I just got the first answer in a parallel open thread on SO , which shows that the answerer is thinking in exactly the same direction.