Playwright & pytest techniques that bring me joy
I’ve been working with playwright more often to do end to end tests. As a project grows to do more with HTMX and Alpine in the markup, there’s less unit and integration test coverage and a greater need for end to end tests. Here are some techniques I’ve used that bring me joy.
- Open new pages / tabs to be tested
- Using a pytest marker to identify playwright tests
- Using a pytest marker in place of fixtures
- Using
page.pause()
and Playwright’s debugging tool - Using
assert_axe_violations
to prevent accessibility regressions - Using
page.expect_response()
to confirm a background request occurred
Open new pages / tabs to be tested
# Capture the new tab/window in a variable
with page.expect_page() as popup_page_info:
page.get_by_role("button", name="Some trigger").click()
# Rename the actual page to something usable.
popup_page = popup_page_info.value
# We can now run test against the popup page
expect(popup_page.get_by_role("button", name="Action on popup page")).to_be_visible()
Playwright supports opening a window or a tab and then running tests against it. The reference docs about expect_page()
don’t show the full utility of it, but once you understand the above you’re good to go!
I’m sure I’m missing some functionality in regards to the arguments of expect_page()
, but it’s pretty powerful for me without understanding that.
Using a pytest marker to identify playwright tests
I like to have a marker decorator to apply to my playwright tests. This makes it easier to choose when to run playwright tests versus when not to. This involves adding to the pytest configuration area (pytest.ini
, setup.cfg
, pyproject.toml
or similar)
# content of pytest.ini
[pytest]
markers =
playwright: mark test that requires playwright
With this, you can run your tests playwright selectively:
pytest -m playwright
I like to combine this with changing my the tests to always exclude the playwright tests. This does mean CI will need to explicitly run pytest -m playwright
, but that’s a reasonable tradeoff to me.
# content of pytest.ini
[pytest]
addopts = -m 'not playwright'
Using pytest markers in place of fixtures
This may be specific to me, so take this section with a grain of salt. The project I’m working on has two different types of access. If it were a CMS, there’d be the creator and the viewer. They are similar, but definitely different.
I could have certainly used a fixture to adjust the context of the test, but I wanted to call it out more specifically on the test. See the following code example:
@pytest.mark.use_viewer_context
def test_can_toggle_between_sections(page, several, other, fixtures):
pass
def test_can_toggle_between_sections(viewer_page, several, other, fixtures):
pass
To implement this, I needed to make use of pytest’s markers.
@pytest
def page(request, new_context, live_server):
# The live_server is Django specific
context = new_context.new_context(base_url=live_server.url)
page = context.new_page()
if request.node.get_closest_marker("use_viewer_context"):
page.goto("/login/viewer/")
# Login as viewer
else:
page.goto("/login/creator/")
# Login as creator
yield page
# Clean up the context and page
page.close()
context.close()
I also needed to add a marker to my pytest configuration area (pytest.ini
, setup.cfg
, pyproject.toml
or similar)
# content of pytest.ini
[pytest]
markers =
use_course_launch: mark playwright tests that should use a course launch
Using page.pause()
and Playwright’s debugging tool
Playwright provides powerful debugging tools that’s accessible in a few ways. I love this for the inspector tool specifically because it helps me craft the selectors that I can never remember.
To start, you’ll want to drop a page.pause()
in your testing code as a breakpoint.
def test_action(page):
page.pause()
expect(page.get_by_role("button", name="Submit")).to_be_visible()
To run your tests, you can either run the tests in headed mode:
pytest --headed
Or run the tests with the playwright debug mode enabled. I believe this is just headed with no default timeout for selects.
PWDEBUG=1 pytest
Personally, I prefer --headed
since it’s easier to append to a command than prepending something.
Using assert_axe_violations
to prevent accessibility regressions
I like Pamela Fox’s axe-playwright-python package for providing a Python wrapper around axe-core. It provides a nice hook to do assertions, the only thing that’s missing is a pytest assertion fixture to make everything a bit more re-usable. Here’s what I have started with:
import pytest
from axe_playwright_python.sync_playwright import Axe
from playwright.sync_api import Page
@pytest.fixture
def assert_axe_violations(pytestconfig):
"""
pytest assertion fixture for validating that a page has no violations.
"""
axe = Axe()
def _assert(page: Page, context: None | str | list[str] = None):
results = axe.run(page, context=context)
if results.violations_count != 0:
if pytestconfig.getoption("verbose") > 0:
message = results.generate_report()
else:
count = (
"1 violation"
if results.violations_count == 1
else f"{results.violations_count} violations"
)
message = f"There were {count} found.\n\n{results.generate_snapshot()}"
pytest.fail(message)
return _assert
Then using it becomes:
def test_page_accessibility(page):
"""Test the whole page for accessibility concerns."""
assert_axe_violations(page)
def test_section_accessibility(page):
"""Test specific sections for accessibility concerns."""
assert_axe_violations(page, context="section.sections-to-check")
Using page.expect_response()
to confirm a background request occurred
There are times when a user interaction causes a request to the server, but the user interface won’t change. This makes it difficult to determine if that request has occurred. Thankfully, expect_response.
def test_request_occurs(page):
with page.expect_response("**/my-particular-url/"):
page.get_by_role("button", name="Submit").click()
There’s more you can do with this too. You could inspect the response to confirm it was actually a 200 status response.
It can also serve as another tool when trying to wait for an action to occur, though use this with caution. Just because a request occurred, doesn’t mean the frontend of the application updated so the elements in the DOM may not be what you expect. This is no different than the other difficulties with making assertions about the DOM in an interactive web app.
If you have thoughts, comments or questions, please let me know. You can find me on the Fediverse, Django Discord server or via email.