Python for test automation: pytest and playwright
Installation
To get started, we install pytest-playwright using pip:
pip install pytest-playwright
This command downloads and installs the pytest-playwright package along with its dependencies.
Next we must run
playwright install
This command installs browsers required by Playwright.
Locators
In web UI testing, locators are elements or identifiers used to locate and interact with specific elements on a web page. These locators are typically attributes or properties of HTML elements, such as IDs, class names, CSS selectors, XPath expressions, or link text. By using locators, we can precisely pinpoint the elements we need to interact with during automated tests, such as when clicking buttons, filling out forms, or verifying text. Understanding locators is crucial for ensuring accurate and reliable web UI tests, as they allow us to identify elements regardless of their position on the page or changes in the layout.
The difficulty of choosing selectors depends on the application; if the application has been made from the ground up with testing in mind, the HTML elements needed for testing the application most likely will have some easy identifier options to use, such as 'id', 'name' or 'data-testid' attributes. Many times, however, the application has been in development for some time, and testing comes as an afterthought. Often time some elements still have easy identifiers, but that is not the case for all elements we want to interact with. This is when we need to look for elements by some text they contain, or other elements near them.
CSS selectors
CSS selectors are patterns used to select and target HTML elements for styling in web development. They are part of Cascading Style Sheets (CSS) and enable developers to apply styles to specific elements or groups of elements on a web page. CSS selectors can target elements based on various criteria such as element type (e.g., div, >), class (e.g., .classname), ID (e.g., #idname), attribute values (e.g., [attribute="value"]
), hierarchy (e.g., parent-child relationships), and more. For a comprehensive list of CSS selectors refer to w3schools.
#
can be used to refer to the id of an HTML element. For example if we have an input element with and id of 'email': <input id="email" />
. Here we can use CSS selectors to refer to the element in multiple ways, all of which are easily readable:
- input#email
- input[id=email]
Then .
refers to the class of an HTML element. For example we can have a div element such as <div class="box"></div>
, and similar to before, there are multiple ways to refer to it:
- .box
- div.box
- div[class=box]
Note that if the div has multiple classes, simple . selector cannot be used, we must use the last option: div[class="box box-dark"]
XPath selectors
XPath selectors are expressions used to navigate through and select elements in HTML documents. XPath stands for XML Path Language, and it provides a way to traverse the hierarchical structure of HTML documents to locate specific elements based on their attributes, text content, position in the document, or relationship to other elements. XPath selectors are particularly useful in web scraping and web UI testing, where they can be used to locate elements on a web page based on their unique attributes or properties. XPath expressions can be relatively simple, such as selecting elements by tag name or class attribute, or they can be more complex, incorporating various axes, predicates, and functions to refine the selection criteria. For help with XPath syntax, and about axes and predicates, refer to w3schools.
The selectors we defined in the previous section can be converted to XPath:
- //input[@id="email"]
- //div[@class="box"]
The double slash //
is a way of telling XPath to look at what ever depth in the HTML document. For simple selections such as by id or class, we should not use XPath, but just use CSS selectors instead. XPath is useful for example when we need to find an element based on text it contains, either exact text or a partial match:
- //p[text()='Paragraph text']
- //p[contains(text(), 'Paragraph partial text')]
A single slash /
will only match elements that are the immediate children of the root element. For example //div[@class='box']/div
will find all <div>
elements that are the immediate children of a div with the class 'box'.
Interacting with the browser
Navigation
Navigating to specific pages using Playwright happens using the goto
method of a Page
object:
from playwright import sync_api
def test_navigation(page: sync_api.Page):
page.goto("https://playwright.dev")
The goto
method takes in a URL to navigate to as the first argument. There are also other, optional, arguments, but in most cases they are unnecessary. The function call automatically waits until the web page fires a load event.
Buttons and links
Buttons and links in HTML only have a single function: to receive a click event and perform some action upon receiving the event. The Page
object we receive as a fixture in pytest has a method called click
that receives a HTML element selector as the first argument:
page.click("button#someButtonId")
This method implicitly waits until the button is actionable (it is visible and enabled) before sending a click event to the element. The same method of clicking can of course be used to click any other type of element, as needed. The selector is what defines what element will be clicked.
Inputs and textareas
The function of inputs and textareas are to receive user input as for example text, numbers or files. For this, the Page
object has a method called fill
, which similar to click
receives a selector as the first argument, and additionally a second argument value which is the value (text) to be placed in the <input>
or <textarea>
element:
page.fill("input#email", "test@example.com")
This method also implicitly waits until the input or textarea is actionable.
Checkboxes
Checkboxes basically hold a boolean value, which is they are either ON (checked) or OFF (unchecked). The Page
object has three methods to interact with checkboxes:
page.check("input#checkboxId")
page.uncheck("input#checkboxId")
page.set_checked("input#checkboxId", True)
page.set_checked("input#checkboxId", False)
Basically check
sets the checkbox as checked, and ignores the call if the checkbox is already checked. uncheck
sets the checkbox unchecked, and ignores the call if the checkbox is already not checked. set_checked
is used to check or uncheck the checkbox based on the second argument; if the second argument is True, the checkbox will be set as checked; if the second argument is False, the checkbox will be set as unchecked.
Select
Select elements are typically dropdowns for users to select one or more options in a list. The Page
object has a method called select_option
that enables us to select options based on value, label or index.
For the next examples we will refer to a <select>
element like this:
<select id="mySelect" multiple>
<option value="first">First</option>
<option value="second">Second</option>
<option value="third">Third</option>
</select>
Here, we select a single option based on each possible argument, value, label and index:
page.select_option("select#mySelect", value="first")
page.select_option("select#mySelect", label="Second")
page.select_option("select#mySelect", index=3)
To select multiple elements, we simply use a list, or tuple, of strings as the argument to value or label, and a list, or tuple, of integers as the argument for index:
page.select_option("select#mySelect", value=("first", "second", "third"))
page.select_option("select#mySelect", label=("First", "Second", "Third"))
page.select_option("select#mySelect", index=(1, 2, 3))
Here each call selects all available options in our example <select>
element.
Waiting explicitly
It can be the case that Playwright is a little too eager to interact with some element immediately when it becomes visible and enabled. Therefore we sometimes have to explicitly wait for the visibility of another element, this could e.g. be a loading indicator. We can explicitly wait for an element to become hidden by using the locator
method of the Page
object, and the wait_for
method of the Locator
object:
page.locator("div#loadingIndicator").wait_for(state="hidden")
By default this will wait for the loading indicator <div>
element to disappear for 30 seconds. The timeout argument of wait_for
can be used to set a custom timeout. Note that Playwright expectes all timeout arguments to be given in milliseconds!
Example test suite
Let's put this all together by creating an example test suite for "the-internet login page". We will create two tests, one for a successful login and another for a failed login attempt. In the successful case, we verify that the title in the secret area is visible after login, and in the failed login case we verify that an error message is displayed. We define a class Selector to organize our selectors, and to have autocomplete for our selectors in our IDE. This way the same selectors are easy to use in multiple places, and when a selector needs to be updated due to changes in the UI, we can simply update the selector in a single location as well.
We also define a fixture to teardown our successful login by logging out after the test case has run. To be able to run this test for yourself, you need to place SuperSecretPassword! as a environment variable with the name TOMSMITH_PASSWORD. For a single use this can simply be done by calling pytest like this TOMSMITH_PASSWORD=SuperSecretPassword! pytest
in the folder where you place the test file.
To keep the example simple, and runnable from a single file, I've placed all necessary code in, well, a single file. To clean up, constants could be moved to another file, such as the valid username and page URL, also fixtures and the Selector class could be defined in other files.
import os
import pytest
from playwright import sync_api
TOMSMITH_USERNAME = "tomsmith"
URL = "https://the-internet.herokuapp.com/login"
class Selector:
data_alert_div = "div[data-alert]"
h2 = "div.example>h2"
class LoginForm:
password_input = "input#password"
submit_button = "form#login>button[type=Submit]"
username_input = "input#username"
class SecureArea:
logout_link = "a[href='/logout']"
@pytest.fixture
def logout(page: sync_api.Page):
yield
if page.is_visible(Selector.SecureArea.logout_link):
page.click(Selector.SecureArea.logout_link)
page.locator(Selector.h2).wait_for(state="visible")
def test_login_should_navigate_to_secure_area_when_provided_with_valid_credentials(
page: sync_api.Page, logout
):
# Arrange
page.goto(URL)
# Act
page.fill(Selector.LoginForm.username_input, TOMSMITH_USERNAME)
page.fill(Selector.LoginForm.password_input, os.getenv("TOMSMITH_PASSWORD"))
page.click(Selector.LoginForm.submit_button)
# Assert
sync_api.expect(page.locator(Selector.h2)).to_have_text("Secure Area")
def test_login_should_display_error_message_when_provided_with_invalid_credentials(
page: sync_api.Page,
):
# Arrange
page.goto(URL)
# Act
page.fill(Selector.LoginForm.username_input, "invaliduser")
page.fill(Selector.LoginForm.password_input, "invalidpassword")
page.click(Selector.LoginForm.submit_button)
# Assert
sync_api.expect(page.locator(Selector.data_alert_div)).to_contain_text(
"Your username is invalid!"
)