Skip to content

Playwright Testing

Playwright tests drive a real browser against a running Nautobot instance. They run under pytest, in their own CI job, and never touch the unittest-based suites. nautobot-server test and the workflow described in Testing are unchanged.

Interim structure

The Playwright tests in this release are one-to-one ports of existing Selenium tests, keeping the Selenium file and class names so the migration is traceable. Do not treat their structure, naming, or layout as the standard. The long-term pattern (including generic, per-model test classes) will be defined in a separate proposal before the next release. In the meantime, browser tests for new features should be written as Playwright one-offs following the existing tests' structure, never as new Selenium tests, with the expectation that the long-term pattern may restructure them.

This page covers how to run the suite.

Layout

nautobot/<app>/tests/integration/     Playwright tests for that app (pytest-only)
nautobot/<app>/tests/selenium/        the existing Selenium tests (unchanged)
nautobot/playwright/                  shared infrastructure the Playwright tests import

Do not move nautobot/playwright/ under nautobot.core

Importing anything under nautobot.core executes nautobot/core/__init__.py, which initializes the Celery app from Django settings. That would make every Playwright run require a local nautobot_config.py, even when the instance under test is remote. The suite is black-box, requiring only a URL and a token, not a database connection.

Running the suite

The suite targets any running Nautobot instance it can reach over HTTP, configured by environment variables (NAUTOBOT_PLAYWRIGHT_URL, NAUTOBOT_PLAYWRIGHT_USERNAME, NAUTOBOT_PLAYWRIGHT_PASSWORD, NAUTOBOT_PLAYWRIGHT_API_TOKEN). The defaults match a local development instance at http://localhost:8080 with the admin/admin superuser and the development API token. The machine running pytest still needs this repository installed, because pytest imports the nautobot.playwright package at collection time.

poetry install --with playwright
poetry run playwright install chromium

invoke playwright                       # run all playwright tests
invoke playwright --app dcim            # one app's tests
invoke playwright --headed              # watch the browser while tests are running
invoke playwright --pattern filter      # subset by test name
invoke playwright --url https://...     # a remote instance
invoke playwright --marker behavioral        # output-correctness tests only
invoke playwright --marker "not behavioral"  # fast structural pass

Every browser context sends the X-Disable-Debug-Toolbar request header, which the development configuration reads to keep django-debug-toolbar off the page. Without it the toolbar's expanded panel covers the filter button on list views and clicks time out. A page opened in your own browser still shows the toolbar.

invoke playwright runs pytest with PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 and enables only the intended plugins explicitly (-p playwright -p base_url). Nothing runs in the test process unless it was named. Traces and screenshots are captured for failed tests only, under test-results/.

Selecting tests

Tests that verify application output (row counts, values, side effects) carry @pytest.mark.behavioral. Unmarked tests check page structure only. The mark is simply a filter. Without --marker, every test runs, marked or not, and an unmarked test counts as "not behavioral" when filtering. Marks are registered in pyproject.toml, and a misspelled mark on a test fails collection with --strict-markers in the repo's pytest config. App scoping is by directory (--app dcim), not by mark.

Writing a script instead of a test

A one-off script that checks a selector or watches a page does not need pytest. The page objects take a Playwright Page and a base URL, and log_in in nautobot.playwright.helpers performs the same UI login the session fixture uses. It fills the login form and waits for the logout link, which only renders once a session exists. On a failed login it raises LoginError (a RuntimeError subclass, also in nautobot.playwright.helpers) after 15 seconds, chained from the Playwright TimeoutError so the call log still shows the selector it waited for.

log_in navigates to the relative path /login/, so the browser context must be created with base_url. A page from browser.new_page() has no base URL and the first goto fails.

import os

from playwright.sync_api import sync_playwright

from nautobot.dcim.tests.integration.pages.locations_page import LocationsPage
from nautobot.playwright.helpers import log_in

base_url = os.getenv("NAUTOBOT_PLAYWRIGHT_URL", "http://localhost:8080").rstrip("/")
username = os.getenv("NAUTOBOT_PLAYWRIGHT_USERNAME", "admin")
password = os.getenv("NAUTOBOT_PLAYWRIGHT_PASSWORD", "admin")

with sync_playwright() as playwright:
    browser = playwright.chromium.launch()
    context = browser.new_context(base_url=base_url)
    page = context.new_page()

    log_in(page, username, password)

    locations = LocationsPage(page, base_url)
    locations.navigate()
    print(f"{locations.get_data_row_count()} rows on the first page")

    browser.close()

Run it with poetry run python <script> from this repository so nautobot.playwright imports. The pytest flags --headed and --slowmo do not apply outside pytest. Use playwright.chromium.launch(headless=False, slow_mo=500) instead.

CI

The CI job (playwright-test) starts an isolated instance, seeds it with nautobot-server generate_test_data (TEST_FACTORY_SEED), and runs the same invoke playwright command. Tests create the specific records they assert on over the REST API and delete them on teardown. The seed is run to provide a realistically populated instance around those records, so narrowing and exclusion assertions are meaningful rather than trivially true. On failure the job uploads the Playwright traces and screenshots as a build artifact and prints the server log.

Adding a test package

If a new nautobot/<app>/tests/integration/ package is created, its __init__.py MUST re-export the discovery guard (from nautobot.playwright import load_tests) copied from any existing app's __init__.py. Without it, nautobot-server test discovery imports pytest-only modules and fails in environments without the playwright dependency group.

If a behavioral assertion reveals the application doing something unexpected, do not just change the assertion to match. Note it in the test and raise it for triage.

When writing tests, poetry run pytest --fixtures nautobot/<app>/tests/integration lists the available fixtures with their docstrings (the path scopes the listing to fixtures that suite can use).