Course Table of Contents

Previously we covered Flask testing. In this chapter we’ll cover the more advanced Dash app testing with Selenium WebDriver.

Before we get started, let’s add some Dash-required Selenium WebDriver options to the conftest.py file. Pytest will use everything in this module automatically. These are Dash-recommended options for Selenium WebDriver, to speed up the performance of the browser, among other Dash requirements.

from selenium.webdriver.chrome.options import Options


def pytest_setup_options():
    """pytest extra command line arguments for running
    in a Debian Docker container"""

    options = Options()
    options.add_argument("--disable-gpu")
    options.add_argument("--no-sandbox")
    options.add_argument("--headless")

    return options

Front-end testing with Selenium WebDriver requires some installation. Detailed instructions for various operating systems can be found here.

Since I code inside a Docker container in VS Code, here’s the Debian Linux setup I use to ensure I have it installed.

# Base image
# Python 3.9 doesn't install scikit-learn correctly
FROM python:3.8

# Install Google Chrome for Selenium WebDriver integration testing
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list'
RUN apt-get -y update && \
    apt-get install -y google-chrome-stable git && \
    apt-get autoremove -y && \
    apt-get clean -y && \
    rm -rf /var/lib/apt/lists/*

# Install ChromeDriver
RUN apt-get install -yqq unzip
RUN wget -O /tmp/chromedriver.zip http://chromedriver.storage.googleapis.com/`curl -sS chromedriver.storage.googleapis.com/LATEST_RELEASE`/chromedriver_linux64.zip
RUN unzip /tmp/chromedriver.zip chromedriver -d /usr/local/bin/

# Set display port to avoid crash in Selenium WebDriver integration testing
ENV DISPLAY=:99

# Install Poetry for package management
RUN pip3 install --upgrade pip && \
    pip3 install poetry

# Install packages with Poetry,
# including development packages like Black and PyLint
COPY pyproject.toml /
RUN poetry config virtualenvs.create false && \
  poetry install --no-interaction --no-ansi

Now that Selenium WebDriver is installed and configured, let’s dive right into testing our Dash app–a topic that’s not covered much on the web right now. There’s a little bit of documentation here, but we’re going to cover some more advanced topics like logging in (a requirement for viewing our Dash app).

Pytest can be a bit confusing–it almost abstracts away too much sometimes, and it can be hard to see what’s going on behind the scenes. But notice our test_dash_app function uses four different pytest fixtures–two of which we did not create ourselves (dash_br and dash_thread_server).

dash_br is a pytest fixture, included in the Dash package, with methods for selecting Dash elements in the DOM. It’s short for “Dash browser”, and inside dash_br is a driver property, which is the actual Selenium WebDriver instance. dash_br comes with a few convenient methods for working with Dash, so we use it instead of loading our own Selenium WebDriver instance.

dash_thread_server is also a Dash pytest fixture, for running the Dash app in a lightweight threaded server. In fact, the first thing we do after extracting the flask_app, dashapp from the flask_and_dash_tuple is to start the server on port 5000.

import os
import pathlib
import sys
import time

import dash
import pytest

from app import create_app
from app.dashapp.utils import ml_features_map, ml_models_map
from tests.test_flask import flask_and_dash_tuple, init_database


def test_dash_app(dash_br, dash_thread_server, flask_and_dash_tuple, init_database):
    """
    Test our Dash app in headless Chrome using Selenium WebDriver.

    "dash_br" is a pytest fixture, included in the Dash package, with
    methods for selecting Dash elements in the DOM.

    "dash_thread_server" is also a Dash pytest fixture, for running the
    Dash app in a lightweight threaded server
    """

    flask_app, dashapp = flask_and_dash_tuple

    # Start the lightweight threaded server with our Dash app,
    # and pass optional Dash arguments
    dash_thread_server.start(dashapp, host="0.0.0.0", port=5000)

Next we set the default Dash URL (_url) to include not only the host and port, but also the “/dash/” url_base_pathname.

    # Update the server_url to include our "/dash/" prefix for our app.
    extended_url = dash_thread_server.url + dashapp.config.url_base_pathname
    # Set the main _url property
    dash_br._url = extended_url

The next step is to login, and it’s pretty cool if you’ve never seen anything like it before.

We first ask the underling WebDriver to GET the login page. It’s doing this in a --headless Google Chrome browser that you can’t see.

Once it’s got the login page loaded, we use the dash_br.find_element() method to search the HTML page source code for an element whose ID is “email”. The # hash signifies we’re searching for an ID, as opposed to other CSS elements. Then we do the same to find the “password” ID element. What we’ve found are the HTML form input fields where we can enter our email and password to login.

Once we’re found the email and password elements, we use the send_keys() method to type in our email and password. Then we find the “submit” button using its ID (#submit), and use the button’s click() method to login! Pretty neat–now we’re logged in and can access our Dash app.


    # Now that the threaded server is started, use the Selenium WebDriver "browser"
    # to first log in and then get redirected to the /dash/ page
    dash_br.driver.get("http://localhost:5000/login/")

    # Find the email and password form inputs (id="email" and id="password")
    email = dash_br.find_element("#email")
    password = dash_br.find_element("#password")

    # "Type" the demo user login credentials into the form input fields
    email.send_keys("demo@test.com")
    password.send_keys("password")

    # Find the "submit" button and click it to login
    # and be redirected to the "/dash/" page
    dash_br.find_element("#submit").click()
    

For a slower introduction to Selenium WebDriver, read the official “Getting Started” with Python page here.

Once the app logs us in, it redirects us to the “/dash/” page, so we should check if it’s loaded correctly. As in the Flask login test, we search for the dash_br.dash_entry_locator element that all Dash pages have (i.e. id=”#react-entry-point”).

Notice we “wait” for the element for up to timeout=3 seconds, to give the Dash app some time to load. It only waits as long as it needs to.

    # Wait for the Dash layout element with id="#react-entry-point"
    # so we know the Dash single page application has fully rendered/loaded
    dash_br.wait_for_element_by_css_selector(
        dash_br.dash_entry_locator, timeout=3
    )

Next we wait for a custom element–our H4 header that contains “Pick an Industry”. This demonstrates the flexibility of the selectors available. We can search for any HTML element on the page.

    # Ensure our initial layout is loaded
    assert (
        dash_br.wait_for_text_to_equal("h4", "Pick an Industry", timeout=2) is True
    ), "Check if 'Pick an Industry' is rendered"

Next we check to see if our radio items menu contains the machine learning models we expect.


    # Our first DOM test. Assert our radio items menu contains the following text
    text_we_expect = (
        "Logistic Regression\nRidge Classifier\nK-Nearest Neighbors\n"
        + "AdaBoost Decision Tree\nRandom Forest\nSupport Vector Machine\nNeural Network"
    )
    element = dash_br.find_element("#ml_models_radio")
    assert (
        element.text == text_we_expect
    ), "ml_models_radio contains our ML model options"

Now we actually click on or select the “Logistic Regression” radio option, since that’s a fast linear model to run.


    # Click on the "Logistic Regression" radio item using the Selenium WebDriver
    dash_br.driver.find_element_by_xpath(
        "//label[contains(.,'Logistic Regression')]"
    ).click()

Let’s now add a stock ticker (mrna for Moderna Inc - the company that made one of the first COVID-19 vaccines) to the download input field, and click the download button to download its historical price data.

    # Add a stock to the download input, and click the download button
    stock_uploaded_msg = dash_br.find_element("#stock_uploaded_msg")
    assert stock_uploaded_msg.text == "", "no message yet under the download button"
    
    new_stock_input = dash_br.find_element("#add_stock_input")
    # Ensure there's no text in the input field
    new_stock_input.clear()
    ticker = "mrna"
    new_stock_input.send_keys(ticker)

    download_button = dash_br.find_element("#add_stock_input_button")
    download_button.click()
    # This call requires a few seconds to download the historical stock price data
    dash_br.wait_for_text_to_equal(
        "#stock_uploaded_msg", f"{ticker.upper()} downloaded!", timeout=5
    )

Now that we’ve downloaded some historical price data and selected a machine learning model, we can train the machine learning model by clicking the button! to test if it worked, we wait for a message that includes the text “logistic regression model trained in”.

    # Click the "Train Model" button to start the training/analysis
    dash_br.find_element("#train_ml_btn").click()

    # Check that the machine learning model has completed
    dash_br.wait_for_contains_text(
        "#model_trained_msg",
        f"{ticker.upper()} logistic regression model trained in ",
        timeout=5,
    )

We also check the “Testing Period Ending Value” to see if it’s calculated our stock trading profits.

    # Check that the machine learning model has completed, and has calculated
    # a testing period ending value
    text_we_expect = "Testing Period Ending Value:"
    assert (
        text_we_expect in dash_br.find_element("#profits_chart_msg_div").text
    ), f"Check that {text_we_expect} is visible now"

Finally, we take a screenshot of what the page looks like and save it as “screenshot.png”, and assert there are no error messages in the JavaScript console. Have a look at the screenshot image after you’ve run the tests, as a sanity check.

    # Save a screenshot of what we see
    filename = "screenshot.png"
    # Remove any existing file
    if os.path.exists(filename):
        os.remove(filename)
    assert dash_br.driver.save_screenshot(filename) is True, "Save a screenshot"

    # Optional: Assert there are no errors in the browser console
    # Note: get_logs always returns None with webdrivers other than Chrome
    assert dash_br.get_logs() == [], "no errors in the browser console"

To run this test, plus our Flask tests, just run pytest in your shell. Here are some optional arguments I like to add as well:

/workspace/tests/  # tests' location
-v  # verbose Pytest logging
--lf  # run the last-failed test first
-x  # stop after first failed test
--headless  # this makes Dash integration testing faster

That’s it for testing our Dash app. Next up, we save automatic TimescaleDB database backups to an AWS S3 bucket.

Course Table of Contents