05 Dec 2019.

Tags: Test Development

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:

Apple TV's “running apps” view, with app name circled in red

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 the else 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:

One of iPlayer's many 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:

  1. We have to repeat the code that updates page twice: In lines 1-2 and lines 8-9. It’s more boilerplate.

  2. 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.