Posts>Testing Django with Selenium, pytest, and Docker

Testing Django with Selenium, pytest, and Docker

Marc Gibbons
Published: November 6, 2023

In 2017, I wrote a post detailing how to run Selenium tests from Django within a dockerized environment. I was recently configuring an end-to-end test suite for a project, and realized the post needed an update... And since I now use pytest almost exclusively, I've adapted the example to use it.

tl;dr: Here is the repo: marcgibbons/django-selenium-docker and here is the screen recording demo.

Goals

  • Use pytest & pytest-django
  • Install/run browser in a standalone Docker container to which the application/test container will connect. This avoids bloating the application container and mixing concerns.
  • Debug tests & manually interact with the browser

Docker & compose

Official browser images by Selenium now conveniently include noVNC, which allows us to interact with the browser running inside containers. As the name suggests, this can now be accomplished without VNC. Instead, you can connect directly from your web browser on the host.

Dockerfile

Here's a basic Dockerfile used to run the Django project:

# Dockerfile
FROM python:3.12
ENV PYTHONUNBUFFERED 1

RUN adduser --disabled-password --gecos '' django
ENV PATH=${PATH}:/home/django/.local/bin
USER django
WORKDIR /home/django/project

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

CMD ["./manage.py", "runserver", "[::]:8000"]

docker-compose.yml

My Docker compose config looks like this:

# docker-compose.yml

version: "3"
services:
  django:
    build: .
    volumes:
      - ".:/home/django/project"
    links:
      - selenium
    ports:
      - 8000:8000
  selenium:
    environment:
      - VNC_NO_PASSWORD=1 # Don't ask for a password
    image: selenium/standalone-chrome
    ports:
      - 7900:7900

Note that Selenium runs in its own container, and exposes port 7900 to the host (noVNC).

pytest fixtures

Next, I've defined some pytest fixtures to help set up the Selenium driver, point it to the Django live test server, and tear it down after each test finishes.

base_url

The first fixture will expose the base_url of the test server.

# conftest.py
import typing as t

import pytest
from pytest_django.live_server_helper import LiveServer


@pytest.fixture
def base_url(live_server: LiveServer) -> str:
    return live_server.url

A critical step to successfully link both containers at runtime is to specify the --liveserver <host(:port)>. In the example project, I've configured this in pyproject.toml as --liveserver django, where django is the name of service container. Alternatively, this value can be set using the DJANGO_LIVE_TEST_SERVER_ADDRESS environment variable.

Note: these configuration settings are specific to the pytest-django plugin.

# pyproject.toml

[tool.pytest.ini_options]
addopts = [
    "--ds=project.settings",
    "--liveserver",
    "django"  # The name of the container running Django.
]

driver

Next, the driver fixture will be used to make requests. In this example project, we're using a single browser (Chrome).

# conftest.py
...

from selenium.webdriver import ChromeOptions, Remote

# The Docker container running Selenium
SELENIUM_CMD_EXECUTOR = "http://selenium:4444/wd/hub"

...

@pytest.fixture
def driver() -> t.Iterator[Remote]:
    options = ChromeOptions()
    driver = Remote(command_executor=SELENIUM_CMD_EXECUTOR, options=options)
    driver.implicitly_wait(5)
    yield driver
    driver.quit()

query_selector

Finally, I've written a convenience fixture which lets us query the DOM using CSS selectors (with less boilerplate):

from functools import partial

...
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webelement import WebElement
...

@pytest.fixture
def query_selector(driver: Remote) -> partial[WebElement]:
    """Select DOM element by CSS selector."""
    return partial(driver.find_element, By.CSS_SELECTOR)

Testing the UI

Here's an example test which has a superuser log into the Django admin and create a new Group object.

from functools import partial

from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from selenium.webdriver import Remote
from selenium.webdriver.remote.webelement import WebElement

User = get_user_model()


def test_create_new_group(
    base_url: str,
    driver: Remote,
    query_selector: partial[WebElement],
) -> None:
    # Create a superuser
    user = User.objects.create(
        username="george",
        is_staff=True,
        is_superuser=True,
    )
    user.set_password("costanza123")
    user.save()

    # Go to admin login
    driver.get(base_url + "/admin/")

    # Log in user
    query_selector("input[name=username]").send_keys("george")
    query_selector("input[name=password]").send_keys("costanza123")
    query_selector("input[type=submit]").click()

    # Create a new group
    query_selector(".model-group .addlink").click()
    query_selector("input[name=name]").send_keys("Vandelay Industries\n")

    # Assert that the group was created successfully.
    success_element = query_selector(".messagelist .success")
    expected = "The group “Vandelay Industries” was added successfully."
    assert success_element.text == expected

    # Assert that the group was added to the database.
    assert Group.objects.filter(name="Vandelay Industries").exists() is True

Run tests

To run the tests in a one-off container, be sure to use the --use-aliases flag, like so:

docker compose run --rm --use-aliases django pytest

Demo

Here's what the test looks like when using noVNC by opening the browser at http://localhost:7900: