Django Selenium Testing: Browser Automation Guide
Unit tests verify logic. Integration tests verify that components wire together. But only browser tests prove that a user can actually complete a workflow. Django's StaticLiveServerTestCase combined with Selenium gives you a real browser that exercises your JavaScript, CSS, and server code together.
When to Use Browser Tests
Browser tests are slow and fragile compared to unit tests. Use them selectively:
- Critical user flows: signup, login, checkout, form submission
- JavaScript-heavy interactions: dynamic validation, AJAX updates, multi-step wizards
- Workflows where visual state matters: modal dialogs, tab switching, drag-and-drop
Don't write browser tests for things unit tests can cover. The pyramid still applies: many unit tests, fewer integration tests, fewest browser tests.
Setup
Install the required packages:
pip install selenium webdriver-managerwebdriver-manager handles downloading and managing ChromeDriver automatically — no manual driver installation needed.
StaticLiveServerTestCase
Django's LiveServerTestCase starts a real HTTP server on a random port for the duration of the test class. StaticLiveServerTestCase additionally serves static files, which is critical for tests that depend on CSS or JavaScript loaded from STATIC_URL.
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from webdriver_manager.chrome import ChromeDriverManager
class BrowserTestCase(StaticLiveServerTestCase):
"""Base class for all browser tests."""
@classmethod
def setUpClass(cls):
super().setUpClass()
options = Options()
options.add_argument("--headless") # No GUI in CI
options.add_argument("--no-sandbox") # Required in Docker
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--window-size=1920,1080")
cls.driver = webdriver.Chrome(
service=Service(ChromeDriverManager().install()),
options=options,
)
cls.driver.implicitly_wait(10) # Wait up to 10s for elements
@classmethod
def tearDownClass(cls):
cls.driver.quit()
super().tearDownClass()
def setUp(self):
# Clear cookies before each test for clean state
self.driver.delete_all_cookies()
def get(self, path):
"""Navigate to a path relative to the test server."""
self.driver.get(f"{self.live_server_url}{path}")The implicitly_wait(10) call tells Selenium to wait up to 10 seconds when an element is not immediately found. This handles JavaScript that renders elements asynchronously.
Finding Elements
Selenium provides multiple strategies for locating elements. Prefer stable selectors in this order:
By.ID— fastest, most stableBy.NAME— for form inputsBy.CSS_SELECTOR— flexible and readableBy.XPATH— last resort, fragile
from selenium.webdriver.common.by import By
# By ID
submit_button = self.driver.find_element(By.ID, "submit-btn")
# By name (form input)
username_field = self.driver.find_element(By.NAME, "username")
# By CSS selector
product_card = self.driver.find_element(By.CSS_SELECTOR, ".product-card:first-child")
all_products = self.driver.find_elements(By.CSS_SELECTOR, ".product-card")
# By link text
login_link = self.driver.find_element(By.LINK_TEXT, "Log in")find_element raises NoSuchElementException if not found. find_elements returns an empty list.
Explicit Waits
implicitly_wait is a blunt instrument. For specific conditions — element visible, text present, element clickable — use WebDriverWait with expected_conditions.
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
class CheckoutTests(BrowserTestCase):
def wait_for_element(self, by, value, timeout=10):
return WebDriverWait(self.driver, timeout).until(
EC.presence_of_element_located((by, value))
)
def wait_for_element_visible(self, by, value, timeout=10):
return WebDriverWait(self.driver, timeout).until(
EC.visibility_of_element_located((by, value))
)
def wait_for_text_in_element(self, by, value, text, timeout=10):
return WebDriverWait(self.driver, timeout).until(
EC.text_to_be_present_in_element((by, value), text)
)Form Submission Tests
from django.contrib.auth import get_user_model
from selenium.webdriver.common.keys import Keys
User = get_user_model()
class LoginTests(BrowserTestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
username="alice",
email="alice@example.com",
password="correcthorse42!",
)
def test_successful_login(self):
self.get("/accounts/login/")
username_field = self.driver.find_element(By.NAME, "username")
password_field = self.driver.find_element(By.NAME, "password")
username_field.send_keys("alice")
password_field.send_keys("correcthorse42!")
password_field.send_keys(Keys.RETURN)
# After login, should redirect to dashboard
WebDriverWait(self.driver, 10).until(
EC.url_contains("/dashboard/")
)
self.assertIn("/dashboard/", self.driver.current_url)
def test_failed_login_shows_error(self):
self.get("/accounts/login/")
self.driver.find_element(By.NAME, "username").send_keys("alice")
self.driver.find_element(By.NAME, "password").send_keys("wrongpassword")
self.driver.find_element(By.CSS_SELECTOR, "[type='submit']").click()
error = self.wait_for_element(By.CSS_SELECTOR, ".errorlist")
self.assertIn("correct username", error.text.lower())
def test_login_redirects_to_next(self):
self.get("/accounts/login/?next=/orders/")
self.driver.find_element(By.NAME, "username").send_keys("alice")
self.driver.find_element(By.NAME, "password").send_keys("correcthorse42!")
self.driver.find_element(By.CSS_SELECTOR, "[type='submit']").click()
WebDriverWait(self.driver, 10).until(
EC.url_contains("/orders/")
)
self.assertIn("/orders/", self.driver.current_url)Authenticating Without the Login Form
For tests where login isn't the subject, bypass the form to save time. Force a session cookie by creating the session server-side.
from django.contrib.sessions.backends.db import SessionStore
from django.contrib.auth import SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY
class AuthenticatedBrowserTestCase(BrowserTestCase):
def force_login(self, user):
"""Create a session and set the session cookie on the browser."""
session = SessionStore()
session[SESSION_KEY] = str(user.pk)
session[BACKEND_SESSION_KEY] = "django.contrib.auth.backends.ModelBackend"
session[HASH_SESSION_KEY] = user.get_session_auth_hash()
session.save()
# Navigate to the domain first (can't set cookies for other domains)
self.driver.get(self.live_server_url)
self.driver.add_cookie({
"name": "sessionid",
"value": session.session_key,
"secure": False,
"path": "/",
})
class OrderTests(AuthenticatedBrowserTestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user("alice", password="x")
def setUp(self):
super().setUp()
self.force_login(self.user)
def test_order_list_page_loads(self):
self.get("/orders/")
heading = self.wait_for_element(By.TAG_NAME, "h1")
self.assertEqual(heading.text, "Your Orders")JavaScript Interactions
Some UI elements require JavaScript clicks — for example, elements covered by overlays, or React/Vue-rendered components.
def js_click(self, element):
"""Use JavaScript to click when a regular click is intercepted."""
self.driver.execute_script("arguments[0].click();", element)
def scroll_to(self, element):
"""Scroll element into view."""
self.driver.execute_script("arguments[0].scrollIntoView(true);", element)
class DropdownTests(BrowserTestCase):
def test_select_from_dropdown(self):
self.get("/products/filter/")
# Open dropdown
dropdown_toggle = self.driver.find_element(By.CSS_SELECTOR, ".category-filter")
dropdown_toggle.click()
# Wait for options to appear
electronics = WebDriverWait(self.driver, 5).until(
EC.visibility_of_element_located((By.CSS_SELECTOR, "[data-value='electronics']"))
)
electronics.click()
# Verify filtered results
self.wait_for_text_in_element(By.CSS_SELECTOR, ".result-count", "Electronics")Taking Screenshots
Screenshots on test failure are invaluable for debugging CI failures.
import os
from datetime import datetime
class BrowserTestCase(StaticLiveServerTestCase):
def take_screenshot(self, name=None):
"""Save a screenshot. Called automatically on failure."""
os.makedirs("test-screenshots", exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"test-screenshots/{name or 'screenshot'}_{timestamp}.png"
self.driver.save_screenshot(filename)
print(f"Screenshot saved: {filename}")
return filename
def tearDown(self):
if self._outcome.errors:
# A test failure occurred — take a screenshot
test_name = self._testMethodName
self.take_screenshot(test_name)
super().tearDown()Page Object Pattern
For complex pages, the Page Object pattern reduces duplication and improves maintainability.
class LoginPage:
URL = "/accounts/login/"
def __init__(self, driver, live_server_url):
self.driver = driver
self.base_url = live_server_url
def navigate(self):
self.driver.get(f"{self.base_url}{self.URL}")
return self
def enter_username(self, username):
self.driver.find_element(By.NAME, "username").send_keys(username)
return self
def enter_password(self, password):
self.driver.find_element(By.NAME, "password").send_keys(password)
return self
def submit(self):
self.driver.find_element(By.CSS_SELECTOR, "[type='submit']").click()
return self
@property
def error_message(self):
try:
return self.driver.find_element(By.CSS_SELECTOR, ".errorlist").text
except Exception:
return None
class LoginPageTests(BrowserTestCase):
def test_login_flow(self):
User.objects.create_user("alice", password="correcthorse42!")
page = LoginPage(self.driver, self.live_server_url)
page.navigate().enter_username("alice").enter_password("correcthorse42!").submit()
WebDriverWait(self.driver, 10).until(EC.url_contains("/dashboard/"))
self.assertIn("/dashboard/", self.driver.current_url)Running Browser Tests
Exclude browser tests from your default test run and run them separately:
# Run only browser tests
python manage.py test myapp.tests.test_browser
# Run without browser tests (tag-based exclusion requires pytest)
python manage.py test myapp.tests.test_models myapp.tests.test_views
# With pytest-django
pytest myapp/tests/test_browser.py -v --tb=shortIn CI, add --headless Chrome and run browser tests as a separate job after unit tests pass. This keeps your fast feedback loop intact while still catching regressions in real browser flows.
For always-on browser monitoring beyond the CI pipeline — detecting when deployed pages break for real users — HelpMeTest complements your Selenium suite by running scheduled browser checks against your live environment.