In Part 1 I argued that Django's test client can return a perfect 200 while the rendered page is quietly broken — because it never runs the JavaScript. Playwright is how you close that gap: it drives a real browser against your real app.
This post is the hands-on setup. By the end you'll have Playwright running against a live Django instance, logging in once instead of on every test, using selectors that don't rot, and running green in CI. Every snippet here is real, runnable code — I drive VanillaPM (a large Django app, 2,200+ tests) with Playwright daily, and this is the setup I'd hand any Django team.
We'll go in order: install → the live-server problem → your first real test → logging in once → durable selectors → debugging → CI → the gotchas that cost me hours.
1. Install
Two layers, and people forget the second one. First the Python package, then the actual browser binaries:
pip install pytest-playwright # the Python API + pytest fixtures
playwright install chromium # downloads the Chromium binary (~150MB)
pip install gives you the library; playwright install downloads the browser Playwright drives. On a fresh CI runner you also need the system libraries — more on that in §7.
Put both in your dev requirements so the team and CI stay in sync:
# requirements-dev.txt
pytest-playwright==0.5.2
pytest-django==4.9.0
2. The one thing that trips everyone up: a live server
Your normal Django tests call views in-process through the test client. Playwright can't use that — it's a real browser, it needs a real URL to navigate to. So you need Django to actually serve your app during the test.
Django ships exactly this: StaticLiveServerTestCase. It starts a real HTTP server on a background thread and serves your static files (plain LiveServerTestCase doesn't serve static files — use the Static one or your CSS/JS 404s and half your selectors break).
Here's the subtle part that explains a whole class of "why can't the browser see my data" bugs: LiveServerTestCase is built on TransactionTestCase, not TestCase. A normal TestCase wraps each test in a transaction and rolls it back — fast, but the data is never committed. The live server runs on a different thread with its own DB connection, so it would never see uncommitted rows. TransactionTestCase commits (and truncates tables between tests instead), which is slower but is the only way the browser-facing server can see the objects you create in the test. Keep that in mind — it's the root of the #1 gotcha in §8.
3. Your first real test
Two ways to wire this up. I'll show both because teams split on pytest vs Django's unittest.
Django's native way (unittest + raw Playwright)
# tests/test_e2e.py
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from playwright.sync_api import sync_playwright, expect
class HomepageTests(StaticLiveServerTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.playwright = sync_playwright().start()
cls.browser = cls.playwright.chromium.launch()
@classmethod
def tearDownClass(cls):
cls.browser.close()
cls.playwright.stop()
super().tearDownClass()
def test_homepage_shows_the_hero(self):
page = self.browser.new_page()
page.goto(f"{self.live_server_url}/")
expect(page.get_by_role("heading", name="Welcome")).to_be_visible()
page.close()
self.live_server_url is handed to you by LiveServerTestCase — it's the real http://localhost:<random-port> the server is on. Run it with python manage.py test.
The pytest way (pytest-playwright + pytest-django)
This is what I'd pick for a real suite — you get the page fixture, --headed, --browser firefox, and automatic screenshots-on-failure for free:
# tests/test_e2e.py
import pytest
from playwright.sync_api import Page, expect
@pytest.mark.django_db(transaction=True) # note: transaction=True — see below
def test_homepage_shows_the_hero(live_server, page: Page):
page.goto(live_server.url)
expect(page.get_by_role("heading", name="Welcome")).to_be_visible()
live_server (from pytest-django) is the fixture equivalent of StaticLiveServerTestCase; page (from pytest-playwright) is a fresh browser page per test.
The transaction=True is not optional here. Plain @pytest.mark.django_db wraps the test in a rolled-back transaction — and, exactly as in §2, the live server's thread won't see that uncommitted data. The moment a test creates something the browser needs to see (a user, a project, a row), pair live_server with transaction=True (or the transactional_db fixture). Forget it and you'll chase a phantom "the page says 0 results but I just created three."
Notice there are no sleep() calls and no manual waits. expect(locator).to_be_visible() is a web-first assertion: it polls and retries for a few seconds until the condition holds (or fails the test). This auto-waiting is most of what makes Playwright stable where Selenium was flaky — lean on it, never on time.sleep.
4. Log in once, not on every test
Logging in through the UI in every test is the single biggest waste of time in an E2E suite — a 400ms form round-trip × 80 tests is real minutes, every run. Two ways to fix it.
Option A — log in through the real UI
A small fixture that drives the actual login form. Use it in one test to prove login itself works:
# conftest.py
import pytest
@pytest.fixture
def ui_login(live_server, page):
def _login(email, password):
page.goto(f"{live_server.url}/accounts/login/")
page.get_by_label("Email").fill(email)
page.get_by_label("Password").fill(password)
page.get_by_role("button", name="Log in").click()
page.wait_for_url(lambda u: "/login" not in u) # wait for the redirect
return _login
(If you want to reuse an authenticated session across runs, save context.storage_state(path="auth.json") after logging in and pass storage_state="auth.json" to a new context. One caveat worth knowing: live_server's port changes between runs, so cookies pinned to a URL won't carry over unless you fix the port — which is exactly why, for in-suite reuse, I reach for Option B instead.)
Option B — skip the UI entirely (fastest)
Most tests don't need to re-test login — they need to be logged in. Django's test Client.force_login() mints a real session; grab its cookie and inject it straight into the browser context:
# conftest.py
import pytest
from django.conf import settings
from django.test import Client
@pytest.fixture
def login(live_server, context):
"""Return a helper that logs a given user into the browser context — no UI."""
def _login(user):
client = Client()
client.force_login(user) # creates the session server-side
cookie = client.cookies[settings.SESSION_COOKIE_NAME]
context.add_cookies([{
"name": settings.SESSION_COOKIE_NAME,
"value": cookie.value,
"url": live_server.url,
}])
return _login
@pytest.mark.django_db(transaction=True)
def test_dashboard_lists_my_projects(live_server, page, login, django_user_model):
user = django_user_model.objects.create_user(email="a@b.com", password="x")
# ... create a couple of projects owned by `user` ...
login(user)
page.goto(f"{live_server.url}/dashboard/")
expect(page.get_by_role("link", name="Coral Ridge Resort")).to_be_visible()
force_login is the same mechanism Django's own auth uses, so the session is genuinely valid — the browser sends the sessionid, the live server reads it (committed, because transaction=True), and you're authenticated without rendering a single login form. Rule of thumb: one test that logs in through the UI (Option A) to prove login works; every other test uses Option B.
5. Selectors that survive a refactor
Brittle selectors are why people abandon E2E suites — a designer renames a class and 40 tests go red. The fix is to select the way a user finds things, not the way the DOM is nested. Playwright's locators are built for this, in rough order of preference:
page.get_by_role("button", name="Submit") # best — role + accessible name
page.get_by_label("Email") # form fields by their <label>
page.get_by_text("Ready for approval") # visible copy
page.get_by_placeholder("Search projects…")
page.get_by_test_id("project-row") # the escape hatch: data-testid
Reserve raw CSS/XPath (page.locator(".pce-sec-head > div:nth-child(2)")) for when nothing else works — it's the first thing to break. For the genuinely hard cases, add a stable data-testid to the template and select on that; it's an explicit contract with your tests that a restyle won't void.
A concrete before/after from my own code:
# ❌ brittle — tied to layout and order
page.locator("div.toolbar > button:nth-of-type(3)").click()
# ✅ durable — tied to meaning
page.get_by_role("button", name="Save").click()
And always assert with the web-first expect, which retries, rather than reading state once:
# ❌ reads once, races the UI
assert page.locator("#status").inner_text() == "Approved"
# ✅ retries until true (or fails)
expect(page.get_by_test_id("status")).to_have_text("Approved")
6. Debugging: the tools that save the afternoon
When a test fails and you can't see why, you have better options than print-debugging:
pytest --headed --slowmo 400 # watch it run, slowed down
PWDEBUG=1 pytest -k test_dashboard # open Playwright Inspector — step through, pick selectors
playwright codegen http://localhost:8000 # RECORD a session → it writes the selector code for you
playwright codegen is the quiet superpower for Django devs: open your running app, click through the flow by hand, and Playwright prints working locator code you paste into a test. And for the failures that only happen in CI, turn on tracing and replay them frame by frame:
pytest --tracing retain-on-failure # pytest-playwright writes a trace.zip on failures
playwright show-trace test-results/.../trace.zip # a full timeline: DOM snapshots, network, console
The trace viewer is the difference between "it's flaky on CI, no idea why" and watching the exact moment it went wrong, with the DOM state at every step.
7. Running it in CI (GitHub Actions)
Three CI-specific things matter: install the system libraries the browser needs (--with-deps), give Django a real database (a Postgres service), and keep these slow tests on their own job so they don't gate every unit-test run.
# .github/workflows/e2e.yml
name: e2e
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements-dev.txt
- run: playwright install --with-deps chromium # --with-deps = the system libs
- run: pytest -m e2e --tracing retain-on-failure
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-traces
path: test-results/
--with-deps is the line people miss — on a bare Ubuntu runner Chromium won't launch without a set of shared libraries, and this installs them. Uploading the traces on failure means a red CI run hands you a replayable recording, not just a stack trace. (Mark your browser tests with @pytest.mark.e2e and register the marker in pytest.ini so pytest -m e2e picks exactly them.)
8. The gotchas that cost me hours
-
"The browser sees 0 rows but I created 3." The transactional-DB issue from §2/§3. Use
@pytest.mark.django_db(transaction=True)(ortransactional_db) withlive_server. This is #1 by a distance. -
Static files 404 / unstyled page / missing selectors. You used
LiveServerTestCaseinstead ofStaticLiveServerTestCase, or you're testing againstrunserverwithDEBUG=Falseand nocollectstatic. Serve the statics. -
sync_playwright+ async errors ("It looks like you are using Playwright Sync API inside the asyncio loop"). Something in your stack is running an event loop. Keep the sync API out of async contexts, or switch to the async API throughout. -
Flakiness from manual waits. Every
time.sleep(2)is a future flake. Delete them and trustexpect(...)auto-waiting andpage.wait_for_url/wait_for_load_state. - Slow suite. E2E is slow by nature — keep it small (see below), reuse auth (§4), run it as its own CI job, and run the fast unit tests on every push.
What to test with this (and what not to)
Resist testing everything through the browser — a huge E2E suite is slow and flaky and the team learns to ignore it. Keep the base of your pyramid wide with fast unit tests (VanillaPM has 2,200+ of those), and spend Playwright on a small set of critical journeys: can a user log in, can they complete the one flow that must never break, does the JavaScript-heavy screen actually render. Reach for the browser only when the thing you need to verify only exists in the browser.
That's the whole setup — live server, auth reuse, durable selectors, debugging, CI, and the traps. It's the foundation the next two posts build on: Part 3 uses this same machinery to automate a couple hundred product screenshots, and Part 4 turns Playwright into a debugging instrument to hunt a CSS bug that had no visible element.
I'm building *VanillaPM** — a free, full-lifecycle PM platform — in Django, in the open. The rest of this Playwright for Django series is here if build-in-public engineering is your thing.*
Top comments (0)