Testing Django with Selenium, pytest, and Docker
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
: