Testing Django login and registration forms with Pytest and Selenium

Testing Django login and registration forms with Pytest and Selenium

Automated in-browser testing of a Django login and user registration form plus password reset functionality using Pytest and Selenium WebDriver

One of my goals while working on my current Django project is to get to grips with unit and functional testing. A commonly-used framework for testing Python code is Pytest, which allows us to write tests using fixtures to initialise test data and to collect unit tests into classes with common setup and teardown functions.

After setting up the Django project and building a landing page, the first aspect of the website I worked on was the user registration and login functionality. This functionality is based on the django.contrib.auth module, albeit with a custom user model and hence custom login and registration forms (in short, I wanted users to be identified by their e-mail address rather than by a username). I also wanted the login and registration form on the same page, which is not the case when default views from this module are used. Login-registration-form.png

I could simply have used Pytest along with Django's built-in functions and written unit tests to attempt to sign up and log in programmatically. This is not how users actually interact with the site though, and we've all experienced the frustration of a UI which doesn't behave as it's supposed to. I therefore turned to Selenium WebDriver to simulate the process of a user navigating to the login/registration page and entering their details.

The first step in the process is to install the Selenium package (pip install selenium) and one or more drivers for the browsers you want to test. Since I'm working on a very simple website with basic functionality which is common to all browsers I opted to use just one driver in my tests, namely geckodriver for Firefox. Drivers are available for Chrome, Edge, Firefox, Safari and IE (Opera users are recommended to use chromedriver). I added the required applications to INSTALLED_APPS in the settings file, then created a conftest.py file at the top level of my Django project and added a Pytest fixture to set up the Selenium driver:

import pytest
from selenium import webdriver
from selenium.webdriver.firefox.options import Options

@pytest.fixture(scope="class")
def setup(request):
    options = Options()
    options.headless = True
    driver = webdriver.Firefox(options=options)
    request.cls.driver = driver
    yield driver
    driver.close()

This setup function instantiates the driver for Firefox with the 'headless' option (so as not to open the browser on the screen during the tests, which takes longer for no functional gain and will not work on a server with no display). It declares the variable self.driver to be available to the class to which this fixture is attached, then yields, providing the driver so the test functions can use it, before closing the browser once all tests in the class have been run.

I then created a tests directory with a file test_site_ui.py containing four classes. Two classes tested the registration form: one was for successful user registration, while the other was for testing the error cases (invalid password choices, or trying to register an existing user). The other two classes tested the login form, again with one class for the 'happy path' and another for the error cases. In each case, a setUp function defines the page to which the driver navigates at the start of the test, and finds the elements on that page necessary for testing. As an example, the class for testing login success is as follows:

@pytest.mark.usefixtures('setup')
class TestUserLoginFormSuccess(LiveServerTestCase):

    def setUp(self):
        self.driver.get(self.live_server_url+'/memberships/login')
        self.user_email = self.driver.find_element(By.ID, 'id_username')
        self.user_pwd = self.driver.find_element(By.ID, 'id_password')
        self.login = self.driver.find_element(By.XPATH, ".//input[@value='Log in' and @type='submit']")

    def test_user_login_success(self):
        SiteUser.objects.create_user(email="juan.gomez@realtalk.com", password="PwdForTest1")
        self.user_email.send_keys("juan.gomez@realtalk.com")
        self.user_pwd.send_keys("PwdForTest1")
        self.login.send_keys(Keys.ENTER)

        try:
            WebDriverWait(self.driver, 2)\
                .until(ec.url_matches(self.live_server_url+'/memberships/my-memberships'))
        except TimeoutException:
            print("User login failed!")
        finally:
            self.assertURLEqual(self.driver.current_url, self.live_server_url+'/memberships/my-memberships')

Note that the classes extend LiveServerTestCase, which is a Django class allowing other clients than the Django dummy client (i.e. in this case the Selenium client) to execute functional tests.

One of the key aspects of functional testing with Selenium WebDriver is the use of WebDriverWait with a timeout and expected conditions. In the example above, we wait until the URL changes to the live server url plus /memberships/my-memberships (the page we expect to reach upon successful login), with a timeout of 2 seconds. Either the driver gets to that URL within 2 seconds (ample time if the login succeeds), or it times out and a message is printed to the console. In either case, we assert that the URL at the end of the test is equal to the expected URL (this protects against potential redirection, and will obviously cause the test to fail if the login failed).

Once the registration and login were working successfully and the tests were passing, I added password reset functionality. During development, the password reset e-mail can be sent to the console from a dummy e-mail server specified in settings.py as EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'. Tests can then access e-mail sent from this backend through django.core.mail.outbox. Using a regex to find the password reset link in the body of the e-mail, the token for the password reset can be retrieved as follows:

self.assertURLEqual(self.driver.current_url, self.live_server_url+'/password-reset/done')
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "Password reset requested")
email_content = mail.outbox[0].body
user = SiteUser.objects.get(email="juan.gomez@realtalk.com")
uid = urlsafe_base64_encode(force_bytes(user.pk))
uid_token_regex = r"password-reset\/"+re.escape(uid)+r"\/([A-Za-z0-9:\-]+)"
match = re.search(uid_token_regex, email_content)
assert match, "UID and token not found in email"

The Selenium driver can then navigate to the appropriate link and enter a new password. Upon success this loads a password-reset/complete page, and we can test that this page was reached and that the password was successfully updated:

self.assertURLEqual(self.driver.current_url,
                    self.live_server_url+'/password-reset/complete')
user = SiteUser.objects.get(email="juan.gomez@realtalk.com")
assert user.check_password("PwdForTest2")