Django Selenium Testing: Browser Automation Guide

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-manager

webdriver-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:

  1. By.ID — fastest, most stable
  2. By.NAME — for form inputs
  3. By.CSS_SELECTOR — flexible and readable
  4. By.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=short

In 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.

Read more

Start now free