How I Tested ReactJS-based Webapp with Selenium
Tags: selenium, django, react, xpath
Published: 2020-04-10 19:21:28

---
title: How I Tested ReactJS-based Webapp with Selenium
published: true
date: 2020-04-10 18:32:00 UTC
tags: Selenium,Django,ReactJS,XPath
canonical_url: http://djangotricks.blogspot.com/2020/04/how-i-tested-reactjs-based-webapp-with-selenium.html
---

![How I Tested ReactJS-based Webapp with Selenium](https://3.bp.blogspot.com/-k3gynx3y_ZM/XpC2-t1ek0I/AAAAAAAACCU/1tZuk-wFpV8NZEEL9GtR3Lp8FyAUluwkQCLcBGAsYHQ/s1600/how-i-tested-reactjs-based-webapp-with-selenium.png)

For quite some time, I have been building a SaaS product - [strategic prioritizer 1st things 1st](https://www.1st-things-1st.com). It's using Django in the backend and ReactJS in the frontend and communicating between those ends by REST API. Every week I try to make progress with this project, be it a more prominent feature, some content changes, or small styling tweaks. In the past week, I implemented frontend testing with [Selenium](https://selenium-python.readthedocs.io/), and I want to share my journey with you.

## What can you do with 1st things 1st

1st things 1st allows you to evaluate a list of items by multiple criteria and calculates priorities for you to follow and take action. The service has 4 main steps:

1. Defining criteria.
2. Listing out things.
3. Evaluating things by each criterion.
4. Exploring the priorities.

Selenium is a testing tool that mimics user interaction in the browser: you can fill in fields, trigger events, or read out information from the HTML tags. To test the frontend of 1st things 1st with Selenium, I had to

1. enter the user credentials and login,
2. create a project from a blank project template,
3. add some criteria,
4. add some things to do,
5. evaluate each thing by each criterion, and
6. see if the generated list of priorities was correct.

Let's see how I did it.

## Preparation

In 2020, Chrome is the most popular browser, and it's my default browser, so I decided to develop tests using it.

I had to install Selenium with pip into my virtual environment:

```
(venv)$ pip install selenium
```

Also, I needed a [binary chromedriver](https://chromedriver.chromium.org/downloads), which makes Selenium talk to your Chrome browser. I downloaded it and placed it under `myproject/drivers/chromedriver`.

In the Django project configuration, I needed a couple of settings. I usually have separate settings-file for each of the environments, such as:

- `myproject.settings.local` for the local development,
- `myproject.settings.staging` for the staging server,
- `myproject.settings.test` for testing, and
- `myproject.settings.production` for production.

All of them import defaults from a common base, and I have to set only the differences for each environment. 

In the `myproject.settings.test` I added these settings:

```python
WEBSITE_URL = 'http://my.1st-things-1st.127.0.0.1.xip.io:8080'  # no trailing slash

TESTS_SHOW_BROWSER = True
```

Here for the `WEBSITE_URL`, I was using the [xip.io](http://xip.io) service. It allows you to create domains dynamically pointing to the localhost or any other IP. The Selenium tests will use this URL.

The `TEST_SHOW_BROWSER` was my custom setting, telling whether to show a browser while testing the frontend or just to run the tests in the background.

## The test case

In one of my apps, `myproject.apps.evaluations`, I created a `tests` package, and there I placed a test case `test_evaluations_frontend.py` with the following content:

```python
import os
from time import sleep
from datetime import timedelta

from django.conf import settings
from django.test import LiveServerTestCase
from django.test import override_settings
from django.contrib.auth import get_user_model
from django.utils import timezone

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.support.ui import WebDriverWait


User = get_user_model()

SHOW_BROWSER = getattr(settings, "TESTS_SHOW_BROWSER", False)


@override_settings(DEBUG=True)
class EvaluationTest(LiveServerTestCase):
    host = settings.WEBSITE_URL.rsplit(":", 1)[0].replace(
        "http://", ""
    )  # domain before port
    port = int(settings.WEBSITE_URL.rsplit(":", 1)[1])  # port
    USER1_USERNAME = "user1"
    USER1_FIRST_NAME = "user1"
    USER1_LAST_NAME = "user1"
    USER1_EMAIL = "user1@example.com"
    USER1_PASSWORD = "change-me"

    @classmethod
    def setUpClass(cls):
        # …

    @classmethod
    def tearDownClass(cls):
        # …

    def wait_until_element_found(self, xpath):
        # …

    def wait_a_little(self, seconds=2):
        # …

    def test_evaluations(self):
        # …
```

It's a live-server test case, which runs a Django development server under the specified IP and port and then runs the Chrome browser via Selenium and navigates through the DOM and fills in forms.

By default, the `LiveServerTestCase` runs in non-debug mode, but I want to have the debug mode on so that I could see any causes of server errors. With the `@override_settings` decorator, I could change the `DEBUG` setting to `True`.

The `host` and `port` attributes define on which host and port the test server will be running (instead of a 127.0.0.1 and a random port). I extracted those values from the `WEBSITE_URL` setting.

The test case also had some attributes for the user who will be navigating through the web app.

Let's dig deeper into the code for each method.

## Test-case setup and teardown

Django test cases can have class-level setup and teardown, which run before and after all methods whose names start with `test_`:

```python
    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.user1 = User.objects.create_user(
            cls.USER1_USERNAME, cls.USER1_EMAIL, cls.USER1_PASSWORD
        )
        # … add subscription for this new user …

        driver_path = os.path.join(settings.BASE_DIR, "drivers", "chromedriver")
        chrome_options = Options()
        if not SHOW_BROWSER:
            chrome_options.add_argument("--headless")
        chrome_options.add_argument("--window-size=1200,800")

        cls.browser = webdriver.Chrome(
            executable_path=driver_path, options=chrome_options
        )
        cls.browser.delete_all_cookies()

    @classmethod
    def tearDownClass(cls):
        super().tearDownClass()
        cls.browser.quit()
        # … delete subscription for the user …
        cls.user1.delete()
```

In the setup, I created a new user, added a subscription to them, and prepared the Chrome browser to use.

If the `TEST_SHOW_BROWSER` setting was `False`, Chrome was running headless, that is, in the background without displaying a browser window.

When the tests were over, the browser closed, and the subscription, as well as the user, were deleted. 

## Utility methods

I created two utility methods for my Selenium test: `wait_until_element_found()` and `wait_a_little()`:

```python
    def wait_until_element_found(self, xpath):
        WebDriverWait(self.browser, timeout=10).until(
            lambda x: self.browser.find_element_by_xpath(xpath)
        )

    def wait_a_little(self, seconds=2):
        if SHOW_BROWSER:
            sleep(seconds)
```

I used the `wait_until_element_found(xpath)` method to keep the test running while pages switched.

I used the `wait_a_little(seconds)` method to stop the execution for 2 or more seconds so that I could follow what's on the screen, make some screenshots, or even inspect the DOM in the Web Developer Inspector.

## XPath

Selenium allows to select DOM elements [by ID, name, CSS class, tag name, and other ways](https://selenium-python.readthedocs.io/locating-elements.html), but the most flexible approach, in my opinion, is selecting elements by [XPath (XML Path Language)](https://en.wikipedia.org/wiki/XPath).

Contrary to jQuery, ReactJS doesn't use IDs or CSS classes in the markup to update the contents of specific widgets. So the straightforward Selenium's methods for finding elements by IDs or classes won't always work.

XPath is a very flexible and powerful tool. For example, you can:

- Select elements by ID: `"//input[@id='id_title']"`
- Select elements by any other attribute: `"//div[@aria-label='Blank']"`
- Select elements by innerText: `"//button[.='Save']"`
- Select elements by CSS class and innerText: `"//button[contains(@class,'btn-primary')][.='Save']"`
- Select the first element by innerText: `"(//button[.='yes'])[1]"`

You can try out XPath syntax and capabilities in Web Developer Console in Chrome and Firefox, using the `$x()` function, for example:

```
»  $x("//h1[.='Projects']")
←  Array [ h1.display-4.mb-4 ]
```

## Login and adding a project

I started with opening a login page, dismissing cookie consent notification, filling in user credentials into the login form, creating a new project from a blank template, setting title and description, etc.

```python
    def test_evaluations(self):
        self.browser.get(f"{self.live_server_url}/")
        self.wait_until_element_found("//h1[.='Log in or Sign up']")
        # Accept Cookie Consent
        self.wait_until_element_found("//a[.='Got it!']")
        self.browser.find_element_by_xpath("//a[.='Got it!']").click()
        # Log in
        self.browser.find_element_by_id("id_email").send_keys(self.USER1_EMAIL)
        self.browser.find_element_by_id("id_password").send_keys(self.USER1_PASSWORD)
        self.browser.find_element_by_xpath('//button[text()="Log in"]').send_keys(
            "\n"
        )  # submit the form

        self.wait_until_element_found("//h1[.='Projects']")

        # Click on "Add new project"
        self.wait_until_element_found("//a[.='Add new project']")

        self.wait_a_little()
        self.browser.find_element_by_xpath("//a[.='Add new project']").send_keys("\n")

        self.wait_until_element_found("//div[@aria-label='Blank']")

        # Create a project from the project template "Blank"
        self.wait_a_little()
        self.browser.find_element_by_xpath("//div[@aria-label='Blank']").send_keys("\n")

        # Enter project title and description
        self.wait_until_element_found("//input[@id='id_title']")
        self.browser.find_element_by_xpath("//input[@id='id_title']").send_keys(
            "Urgent and Important Activities"
        )
        self.browser.find_element_by_xpath(
            "//textarea[@id='id_description']"
        ).send_keys("I want to find which things to do and which to skip.")
        self.browser.find_element_by_xpath("//button[.='Next']").send_keys("\n")

        # Keep the default verbose names for the criteria and initiatives
        self.wait_until_element_found("//input[@id='id_initiative_verbose_name_plural']")
        self.wait_a_little()
        self.browser.find_element_by_xpath("//button[.='Next']").send_keys("\n")
```

If `TESTS_SHOW_BROWSER` was set to `True`, we would see all this workflow in an opened browser window.

I was creating the test by carefully inspecting the markup in Web Developer Inspector and creating appropriate DOM navigation with XPath. For most of the navigation, I was using `send_keys()` method, which triggers keyboard events. During the testing, I also noticed that my cookie consent only worked with a mouse click, and I couldn't approve it by the keyboard. That's some room for improving accessibility.

I ran the test with the following command each time I added some more lines:

```
(venv)$ python manage.py test myproject.apps.evaluations --settings=myproject.settings.test
```

![Creating a project](https://4.bp.blogspot.com/-xb0q4UlsJCs/XpC24d9V_UI/AAAAAAAACCA/1nboSL0Szkkb3k9cQpDbp0g_Wd40_HsBwCLcBGAsYHQ/s1600/00-creating-a-project.png)

The test case failed if any command in the test failed. I didn't even need asserts.

## Adding criteria

Now it was time to add some criteria:

```python
        self.wait_until_element_found("//h2[.='Criteria']")

        # Add new criterion "Urgent" with the evaluation type Yes/No/Maybe
        self.wait_until_element_found("//a[.='Add new criterion']")
        self.browser.find_element_by_xpath("//a[.='Add new criterion']").send_keys("\n")
        self.wait_until_element_found("//input[@id='id_title']")
        self.browser.find_element_by_xpath("//input[@id='id_title']").send_keys(
            "Urgent"
        )
        self.browser.find_element_by_xpath("//input[@id='widget_y']").send_keys(" ")
        self.browser.find_element_by_xpath("//button[.='Save']").send_keys("\n")

        # Add new criterion "Important" with the evaluation type Yes/No/Maybe
        self.wait_until_element_found("//a[.='Add new criterion']")
        self.browser.find_element_by_xpath("//a[.='Add new criterion']").send_keys("\n")
        self.wait_until_element_found("//input[@id='id_title']")
        self.browser.find_element_by_xpath("//input[@id='id_title']").send_keys(
            "Important"
        )
        self.browser.find_element_by_xpath("//input[@id='widget_y']").send_keys(" ")
        self.browser.find_element_by_xpath("//button[.='Save']").send_keys("\n")

        # Click on the button "Done"
        self.wait_until_element_found("//a[.='Done']")
        self.browser.find_element_by_xpath("//a[.='Done']").send_keys("\n")
```

I added two criteria, "Urgent" and "Important", with evaluation type "Yes/No/Maybe".

![Defining criteria](https://2.bp.blogspot.com/-WeWRz4PHeW0/XpC24807KTI/AAAAAAAACCE/IPY6f0BQGzE7vtb2wvf_ibwoYaEIXuuaACLcBGAsYHQ/s1600/01-define-criteria.png)

## Adding things

Then I created some activities to evaluate:

```python
        self.wait_until_element_found("//h2[.='Things']")

        # Add new thing "Write a blog post"
        self.wait_until_element_found("//a[.='Add new thing']")
        self.browser.find_element_by_xpath("//a[.='Add new thing']").send_keys("\n")
        self.wait_until_element_found("//input[@id='id_title']")
        self.browser.find_element_by_xpath("//input[@id='id_title']").send_keys(
            "Write a blog post"
        )
        self.browser.find_element_by_xpath("//textarea[@id='id_description']").send_keys(
            "I have an idea of a blog post that I want to write."
        )
        self.browser.find_element_by_xpath("//button[.='Save']").send_keys("\n")

        # Add new thing "Fix a bug"
        self.wait_until_element_found("//a[.='Add new thing']")
        self.browser.find_element_by_xpath("//a[.='Add new thing']").send_keys("\n")
        self.wait_until_element_found("//input[@id='id_title']")
        self.browser.find_element_by_xpath("//input[@id='id_title']").send_keys(
            "Fix a bug"
        )
        self.browser.find_element_by_xpath("//textarea[@id='id_description']").send_keys(
            "There is a critical bug that bothers our clients."
        )
        self.browser.find_element_by_xpath("//button[.='Save']").send_keys("\n")

        # Add new thing "Binge-watch a series"
        self.wait_until_element_found("//a[.='Add new thing']")
        self.browser.find_element_by_xpath("//a[.='Add new thing']").send_keys("\n")
        self.wait_until_element_found("//input[@id='id_title']")
        self.browser.find_element_by_xpath("//input[@id='id_title']").send_keys(
            "Binge-watch a series"
        )
        self.browser.find_element_by_xpath("//textarea[@id='id_description']").send_keys(
            "There is an exciting series that I would like to watch."
        )
        self.browser.find_element_by_xpath("//button[.='Save']").send_keys("\n")

        # Click on the button "Done"
        self.wait_until_element_found("//a[.='Done']")
        self.browser.find_element_by_xpath("//a[.='Done']").send_keys("\n")

```

These were three activities: "Write a blog post", "Fix a bug", and "Binge-watch a series" with their descriptions:

![Listing out things](https://4.bp.blogspot.com/-fmGf7-rchBA/XpC245D_9bI/AAAAAAAACCI/NfQtrfmEbUMDrTVGTNJ0WG6XsXOgny9FQCLcBGAsYHQ/s1600/02-list-out-things.png)

## Evaluating things

In this step, there was a list of widgets to evaluate each thing by each criterion with answers "No", "Maybe", or "Yes". The buttons for those answers had no specific id or CSS class, but I could target them by the text on the button using XPath like `"//button[.='maybe']"`:

```python
        self.wait_until_element_found("//h2[.='Evaluations']")
        self.wait_until_element_found("//button[.='maybe']")

        # Evaluate all things by Urgency
        self.browser.find_element_by_xpath("(//button[.='no'])[1]").send_keys("\n")
        self.wait_until_element_found("//footer[.='Evaluation saved.']")
        self.browser.find_element_by_xpath("(//button[.='yes'])[2]").send_keys("\n")
        self.wait_until_element_found("//footer[.='Evaluation saved.']")
        self.browser.find_element_by_xpath("(//button[.='no'])[3]").send_keys("\n")
        self.wait_until_element_found("//footer[.='Evaluation saved.']")

        # Evaluate all things by Importance
        self.browser.find_element_by_xpath("(//button[.='yes'])[4]").send_keys("\n")
        self.wait_until_element_found("//footer[.='Evaluation saved.']")
        self.browser.find_element_by_xpath("(//button[.='yes'])[5]").send_keys("\n")
        self.wait_until_element_found("//footer[.='Evaluation saved.']")
        self.browser.find_element_by_xpath("(//button[.='maybe'])[6]").send_keys("\n")
        self.wait_until_element_found("//footer[.='Evaluation saved.']")

        # Click on the button "Done"
        self.browser.find_element_by_xpath("//a[.='Done']").send_keys("\n")

```

![Evaluating things](https://4.bp.blogspot.com/-xD-6Co_n_EQ/XpC28SOF8JI/AAAAAAAACCM/mU1aAAW16g4dobe4pCTHa_SIs5O1JbdZACLcBGAsYHQ/s1600/03-evaluate-things.png)


These were my evaluations:

- "Write a blog post" was not urgent, but important.
- "Fix a bug" was urgent and important.
- "Binge-watch a series" was not urgent and maybe important (because one has to have rest and feed imagination too).

## Checking priorities

So in the last step, I got the calculated priorities:

```python
        self.wait_until_element_found("//h2[.='Priorities']")

        self.wait_until_element_found("//h5[.='1. Fix a bug (100%)']")
        self.wait_until_element_found("//h5[.='2. Write a blog post (50%)']")
        self.wait_until_element_found("//h5[.='3. Binge-watch a series (25%)']")
        self.wait_a_little()
```

![Exploring priorities](https://1.bp.blogspot.com/-lwrMVGlrX2M/XpC2-FFcOdI/AAAAAAAACCQ/kJ-K5M4FbnAnirAkn2uh5uCUzbVPtgkEgCLcBGAsYHQ/s1600/04-explore-priorities.png)

The results looked correct:

- "Fix a bug" was of the 100% priority.
- "Write a blog post" was of the 50% priority.
- "Binge-watch a series was of the 25% priority.

## Final words

- Selenium needs a binary browser driver that lets you manipulate DOM in the browser from Python. 
- You can set a specific host and port for a `LiveServerTestCase`.
- The Chrome browser can be displayed or executed in the background, depending on your settings.
- XPath is a flexible and powerful tool to address DOM elements by any attributes or even inner text.
- Selenium can trigger keyboard or mouse events that are handled by JavaScript functions.

I hope that my journey was useful to you too.

Happy coding!

---
Thanks a lot to [Adam Johnson](https://adamj.eu/) for the review.  
Cover photo by [Science in HD](https://unsplash.com/photos/E1eCQdiO_E4).