Python's for...else: Surprisingly useful for implementing retry logic in test scripts
05 Dec 2019.
The optional else
clause after a for
loop is an obscure Python feature that
is surprisingly useful when writing automated GUI tests.
In our test scripts we often need to look for something by navigating through a menu. For example, let’s say we’re looking for an app on the Apple TV’s “running apps” view, so that we can quit it:
We have a Page Object called AppSwitcher that knows how to read the app name from this view (circled in red above). The following Python code presses the LEFT button on the remote control until it finds the desired app (up to 20 times) and then it kills the app by double-clicking the UP button:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def kill_app(app_name):
double_click("KEY_TV") # Open the "running apps" view
page = wait_until(appletv.AppSwitcher)
assert page, 'Failed to open the "running apps" view'
for _ in range(20):
if page.title == app_name:
# Kill the app by double-clicking KEY_UP:
double_click("KEY_UP")
return
stbt.press_and_wait("KEY_LEFT")
page = page.refresh()
assert False, "Didn't find app '%s'" % (app_name,)
Once it finds & kills the desired app, this code returns (on line 10) so it
doesn’t keep on pressing LEFT looking for other apps. The assert False
statement on line 14 only runs if you didn’t find the target app and didn’t
return early from the loop.
But what if you want to exit the loop without returning from the function?
Sometimes it isn’t convenient to put each for
loop into its own function.
Instead of return
we can use break
, and we can put our error-handling in an
else
clause.
Here’s the official Python documentation on the for
statement:
When the items are exhausted, the suite in the
else
clause, if present, is executed, and the loop terminates.A
break
statement executed in the first suite terminates the loop without executing theelse
clause’s suite.
Our previous example would look like this:
1
2
3
4
5
6
7
8
9
10
11
for _ in range(20):
if page.title == app_name:
# Kill the app by double-clicking KEY_UP:
double_click("KEY_UP")
break
stbt.press_and_wait("KEY_LEFT")
page = page.refresh()
else:
assert False, "Didn't find app '%s'" % (app_name,)
# Here I can do other stuff before returning
Note that assert False
on line 9 only runs if we didn’t break from the loop.
Line 11 always gets run — it doesn’t matter if the loop ran all 20 times, or
if it exited early due to the break
statement.
An example of retry logic: Handling an unknown number of dialogs
When testing playback on a video-streaming app (specifically, BBC iPlayer) we needed to handle a series of dialogs before the content starts playing — anywhere between 0 and 4 dialogs:
Our test script to handle this is shown below. If we see the “Resume” dialog above, we’ll select “Start from the beginning”. We loop (retry) several times so that we can handle any other dialogs that might appear, and we break from the loop when we detect that content playback has started.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def press_ok_to_start_content():
stbt.press("KEY_OK")
# Handle any dialogs that may appear:
for _ in range(5):
page = wait_until(lambda: Content() or Dialog())
assert page, "Failed to detect content start or dialog"
print("Page is a %s" % (type(page),))
if isinstance(page, Dialog):
if page.selection in [
"I am aged 16 or older",
"I understand, continue",
"Start from the beginning",
"Watch from Start",
"Watch Live",
"I have a TV Licence. Watch now.",
"Continue without setting up lock"]:
stbt.press_and_wait("KEY_OK")
elif page.selection in [
"Resume",
"Cancel",
"I don't have a TV Licence.",
"Set up Parental Guidance lock"]:
stbt.press_and_wait("KEY_DOWN")
else:
assert False, "Unknown dialog %r" % (page,)
else: # Content
break
else:
assert False, "Didn't get to content after dismissing 5 dialogs"
# Check playback:
assert stbt.wait_for_motion(mask="content-spinner-mask.png")
(Note: Content and Dialog are Page Objects that know how to recognize the content playback screen and the dialog screens, respectively. Dialog has a property called selection that reads the text of the selected button — for example “Resume” in the screenshot above).
Why not use a while loop?
We could have written the above loop like this:
1
2
3
4
5
6
7
8
9
10
11
12
page = wait_until(lambda: Content() or Dialog())
assert page, "Failed to detect content start or dialog"
# Handle any dialogs that may appear:
while isinstance(page, Dialog):
# ... Dialog-handling code here (not shown), and then:
page = wait_until(lambda: Content() or Dialog())
assert page, "Failed to detect content start or dialog"
# Check playback:
assert stbt.wait_for_motion(mask="content-spinner-mask.png")
But Python’s while
loops have 2 problems:
-
We have to repeat the code that updates
page
twice: In lines 1-2 and lines 8-9. It’s more boilerplate. -
Bugs in the test script or in the system-under-test can lead to an infinite loop. We would have to add explicit timeout checks inside the loop — more boilerplate.
The for…else pattern allows us to add assertions to our retry logic, to handle the error case with a minimal amount of extra code.