Dec 11 | Discover innovative Dash apps exploring Michelin-starred dining with AI and LLMs.

author photo

Mario Rodríguez Ibáñez

February 9, 2024 - 8 min read

Building Unit Tests for Dash Applications

Testing functions is an essential part of building robust Dash applications. By testing your functions thoroughly, you can ensure that your Dash application works as expected and delivers a high-quality user experience.

In my role on the Professional Services team at Plotly, my teammates and I develop production-grade Dash applications for customers in highly demanding industries. Our customers rely on these applications to solve real, critical challenges, making it fundamental to implement unit testing for reliable Dash apps. There are many online resources about unit testing in Python, and there’s documentation about testing Dash. However, the learning curve to implement unit testing can be steep at first.

With this post, I'll share examples of how to unit test the critical pieces of your codebase. Specifically, I’ll focus on the features that we frequently encounter at Professional Services in any advanced Dash application, such as Redis instances for Snapshot Engine, database connections, and advanced callbacks. Additionally, I'll include some basic examples of unit testing that you can implement as a starting point.

Requirements

You'll need to install the pytest library using pip. Depending on the specific needs of your tests, you may also require additional libraries to mock functions and variables. For example, pytest-mock is a plugin for pytest that simplifies the process of mocking objects in your tests. The mock library provides tools for creating mock objects and functions, which can be useful for testing code that interacts with external services or systems while avoiding the actual interaction. Finally, requests-mock is a library that allows you to mock HTTP requests and responses, making it easier to test code that communicates with web APIs.

You can write a specific requirements file for development since these libraries won’t be used once the app is deployed. For example, you can create a file called requirements-dev.txt which contains the following lines:

pytest
pytest-mock
mock
requests-mock

Then you can install these in your environment using pip with this line pip install -r requirements-dev.txt.

Using fixtures in Pytest

Fixtures are an important feature of Pytest because they allow you to define a set of data or resources that can be used by multiple test functions. You should use fixtures when you are going to repeatedly use the same input for different tests. They can be used to provide test resources that are expensive to create, such as database connections or network resources. By using fixtures, you can ensure that these resources are only created when needed, and are cleaned up properly after the tests have finished. This can help to speed up the test suite and prevent resource leaks. You can read more about fixtures in the official docs.

In this example, you want to test two functions: sum and multiply. Both of these functions take two numbers as inputs and then return the result of the operation. You can create a known set of inputs using fixtures. This will help you ensure that your tests are consistent and predictable. Additionally, you will avoid repeating the same setup code in each test function, making your code more concise and easier to read.

Your functions in utils/data_utils.py:

def sum(a, b):
return a + b
def multiply(a, b):
return a * b

Your tests:

import pytest
import pandas as pd
from utils.data_utils import sum, multiply
@pytest.fixture
def number_a():
return 2
@pytest.fixture
def number_b():
return 3
def test_sum(number_a, number_b):
output = sum(number_a, number_b)
assert output == number_a + number_b
def test_multiply(number_a, number_b):
output = multiply(number_a, number_b)
assert output == number_a * number_b

Mock functions in Pytest

Mocking is important for unit testing because it allows you to isolate the unit under test and test it in isolation from its dependencies. When you mock a dependency, you replace the actual implementation of that dependency with a "fake" implementation that behaves in a specific way for the purposes of your test. By doing this, you can test your code in a controlled environment, without worrying about the behaviour of the dependencies.

To ensure efficient testing, it is crucial to mock functions that retrieve data from external servers, APIs, databases, or those that have a longer runtime. By doing so, the test will not actually call the function, but rather return the value that has been pre-set, resulting in a faster and more consistent test.

Your test:

In utils/db_utils.py you have:

import pandas as pd
from time import sleep
def query_db():
# For simplicity this example waits 5 seconds instead of actually querying a database
sleep(5000)
df = pd.DataFrame()
return df

In utils/data_utils.py you have:

import pandas as pd
from utils import db_utils
def get_data_not_null(column):
"""This function gets all the data from the db and drops the null values"""
df = db_utils.query_db()
df = df[~pd.isnull(df[column])] # some filtering
return df

Your test:

To test the get_data_not_null() function without actually running query_db(), you can utilize the mocker.patch() method to mock the function call. To do so, you will need to specify the path to the function, and the expected output.

import pytest
import mock
from utils import data_utils
def test_get_data_not_null(mocker):
columns = ["column 1", "column 2"]
db_df = pd.DataFrame([['ant', 'bee', 'cat'], ['dog', None, 'fly']], columns=columns)
mocker.patch("utils.data_utils.db_utils.query_db", return_value=db_df)
output_df = data_utils.get_data_not_null(columns[1])
assert type(output_df) == type(pd.Dataframe())
assert output_df[pd.isnull(output_df[columns[1])].shape[0] == 0

Mock HTTP requests and responses:

Mocking also allows you to test error conditions that might be difficult to reproduce in a real system. For example, you can mock a database connection that always throws an error when you try to query it. This allows you to test how your code handles that error condition, without actually having to set up a faulty database.

Your functions in utils/db_utils.py:

import pandas as pd
import requests
def get_data(link_address, headers, payload):
response = requests.request(
"POST",
link_address,
headers=headers,
data=payload,
)
data = check_response_status(response, link_address, payload)
return data
def check_response_status(response, link_address, payload):
if response.status_code == 200: # Status successful!
data = pd.DataFrame(response.json()["elements"])
elif response.status_code == 204: # Status No content
data = pd.DataFrame()
else:
raise requests.exceptions.RequestException(
"Database returned an unexpected status code.\n"
+ f"Response status: {response.status_code}\n Response content: {response.text}\n"
+ f"Request url: {link_address}\n Request body {payload}"
)
return data

Your test:

To test check_response_status() function without actually sending a request to the server, you can use:

import pytest
import requests_mock
import requests
import pandas as pd
from utils.db_utils import check_response_status
@requests_mock.Mocker(kw="mock")
def test_check_response_status(**kwargs):
payload = {}
elements = [{"a": "1"}, {"b": "2"}, {"c": "3"}]
mock_link_address = "https://mock-server.com" # Can be anything you want
# Case where the request is successful
kwargs["mock"].get(
mock_link_address, status_code=200, json={"elements": elements}
)
response = requests.get(mock_link_address)
data = check_response_status(response, mock_link_address, payload)
assert type(data) == type(pd.DataFrame())
assert data.equals(pd.DataFrame(elements))

You should also test the exception raised when the response status code is anything other than 200 or 204. To test exceptions raised by your code you can use pytest.raises with the exception that you expect as the input. In this example, the function throws a requests.exceptions.RequestException containing the information of the request, so you can test the exception message as follows:

@requests_mock.Mocker(kw="mock")
def test_check_response_status_error(**kwargs):
payload = {}
elements = []
mock_link_address = "https://mock-server.com" # Can be anything you want
# Case where the request is successful
kwargs["mock"].get(
mock_link_address, status_code=404, json={"elements": elements}
)
response = requests.get(mock_link_address)
with pytest.raises(requests.exceptions.RequestException) as e_info:
check_response_status(response, mock_link_address, payload)
assert e_info is f"Database returned an unexpected status code.\nResponse status: {response.status_code}\n Response content: {response.text}\nRequest url: {mock_link_address}\n Request body {payload}"

Mocking global/module defined objects:

Dash applications often involve the use of globally or module-level defined objects, such as API clients or Redis instances, which are typically defined in one module (e.g., app.py) and utilized in another (e.g., utils.py).

In this example, you have defined your Redis instance in app.py and you need to test a function in utils.py responsible for retrieving a list of emails stored in the Redis database. However, to ensure controlled testing environments and optimize the efficiency and speed of your tests, it's crucial to isolate your tests from any actual database interactions. To overcome this challenge, you can use mocker.patch(path, new=YourMockObject()). This approach enables you to mock the Redis instance by patching the imported object with a custom-built class, effectively replacing the actual Redis database with a simulated version.

By leveraging mock objects, you gain the ability to thoroughly test the logic of your function without relying on the presence or state of the real Redis instance. This method not only ensures reliable and reproducible test results but also reduces external dependencies and enhances test efficiency.

The versatility of this approach extends beyond Redis instances and can be applied to other globally defined or module-level objects, such as API clients. By embracing mocking techniques, you can create controlled test environments, isolate dependencies, and achieve more comprehensive and efficient unit testing for your Dash applications.

Your functions in utils.py:

from app import redis_instance
import pickle
def get_emails():
return pickle.loads(redis_instance.hget("usernames", "emails"))

Your test:

import pytest
import pickle
import utils
emails = ["user1@plot.ly", "user2@plot.ly", "user3@plot.ly"]
# Example of redis instance with mocked functions.
# Depending on the scope of your test, you may need to fully implement the logic
# to store and retrieve keys. Alternatively, you can simply hardcode some
# outputs as desired.
class RedisInstanceMock:
def __init__(self):
self.data = {"usernames": {"emails": emails}}
def hget(self, key, field):
data = self.data
try:
output = data[key][field]
except KeyError as e:
print(f"KeyError: {e}")
output = None
return pickle.dumps(output)
def hset(self, key, field, data):
new_data = self.data
new_data[key][field] = data
self.data = new_data
def test_function_that_uses_redis_instance(mocker):
mocker.patch("utils.utils.redis_instance", new=RedisInstanceMock())
output = utils.get_emails()
assert output == emails

Mocking environment variables:

In some cases, the code may behave differently depending on the value of an environment variable. By mocking environment variables, you can ensure that your unit tests are consistent and repeatable, regardless of the environment in which they are run. Additionally, it allows you to test different scenarios and edge cases without modifying the actual environment variables, making it easier to isolate and debug issues.

To mock environment variables, you can use monkeypatch.

Your functions in utils.py:

import os
def check_mode():
mode = os.getenv("MODE")
if mode == "dev":
return "Dev mode activated"
elif mode == "debug":
return "Debug mode activated"
else:
return "Mode not recognised"

Your test:

import utils
def test_update_odv_data_dev(monkeypatch):
monkeypatch.setenv("MODE", "dev")
output = utils.check_mode()
assert output == "Dev mode activated"

Testing callbacks:

The official documentation on testing Dash includes basic examples on how to test callbacks. However, for more complex callbacks, testing can become tedious and prone to code duplication. The following test cases provide examples for more complex callbacks while also addressing the issue of code duplication. You can use these as a baseline for your tests.

Testing callbacks with different actions depending on ctx.triggered_id:

In this example, you have a callback that depending on the button clicked performs different operations: sum, multiply, or clear the displayed value. The callback uses ctx.triggered_id to know which button has been clicked so you can’t call the function directly without providing some context. First, you will need to import some functions and classes to mock the callback context:

from contextvars import copy_context
from dash._callback_context import context_value
from dash._utils import AttributeDict

In the test you will have to create a function that sets the context and calls the callback function, in this example it’ll be named run_callback. If you want to test different scenarios for this test depending on the input and/or the context, you can pass these parameters as inputs for this function to avoid code duplication. You can then call run_callback using ctx.run(run_callback), first defining ctx as ctx = copy_context(). If run_callback has input arguments you can pass them after the function as ctx.run(run_callback, trigger, n_clicks)

def test_out_callback_example():
def run_callback(trigger, n_clicks):
context_value.set(AttributeDict(**{"triggered_inputs": [trigger]}))
return callbacks.our_callback_example(n_clicks)
ctx = copy_context()
trigger = {"prop_id": "btn-1.n_clicks"}
n_clicks = 1
output = ctx.run(run_callback, trigger, n_clicks)
assert output == 1
trigger = {"prop_id": "btn-2.n_clicks"}
n_clicks = 3
output = ctx.run(run_callback, trigger, n_clicks)
assert output = 3

Your callback in callbacks/callbacks.py:

import dash
from dash import Input, Output, State, callback, ctx
from dash.exceptions import PreventUpdate
@callback(
Output("container", "children"),
Input("sum-btn", "n_clicks"),
Input("multiply-btn", "n_clicks"),
Input("reset-btn", "n_clicks"),
Input("other-btn", "n_clicks"),
State("number-1", "data"),
State("number-2", "data"),
)
def display(sum_btn, multiply_btn, reset_btn, other_btn, number_1, number_2):
button_clicked = ctx.triggered_id
if not button_clicked:
raise PreventUpdate
elif button_clicked == "sum-btn":
return number_1 + number_2
elif button_clicked == "multiply-btn":
return number_1 * number_2
elif button_clicked == "reset-btn":
return ""
return dash.no_update

Your test:

In this example, it is important to thoroughly test all possible cases for the callback, which depend on the input that triggers it. By using the decorator pytest.mark.parametrize, you can easily define and test multiple cases for your callback, ensuring comprehensive coverage and reliable functionality. The first argument of the parametrize decorator is a string containing the names of the input variables for your test. The second argument is a list, where each item is a tuple representing the input values for each case. For example, the inputs for the first case are:

  • trigger = {"prop_id": "sum-btn.n_clicks"}
  • sum_btn = 1
  • multiply_btn = 0
  • reset_btn = 0
  • other_btn = 0
  • expected_output = 2+3
import pytest
from contextvars import copy_context
from dash._callback_context import context_value
from dash._utils import AttributeDict
import dash
from callbacks import callbacks
@pytest.fixture
def number_a():
return 2
@pytest.fixture
def number_b():
return 3
@pytest.mark.parametrize(
"trigger,sum_btn,multiply_btn,reset_btn,other_btn,expected_output",
[
({"prop_id": "sum-btn.n_clicks"}, 1, 0, 0, 0, 2+3),
({"prop_id": "multiply-btn.n_clicks"}, 0, 1, 0, 0, 2*3),
({"prop_id": "reset-btn.n_clicks"}, 0, 0, 1, 0, ""),
({"prop_id": "other-btn.n_clicks"}, 0, 0, 0, 1, dash.no_update)
]
)
def test_display_callback(
trigger,
sum_btn,
multiply_btn,
reset_btn,
other_btn,
expected_output,
number_a,
number_b,
):
def run_callback(
trigger,
sum_btn,
multiply_btn,
reset_btn,
other_btn,
number_1,
number_2,
):
context_value.set(AttributeDict(**{"triggered_inputs": [trigger]}))
return callbacks.display(
sum_btn, multiply_btn, reset_btn, other_btn, number_1, number_2
)
ctx = copy_context()
output = ctx.run(
run_callback,
trigger,
sum_btn,
multiply_btn,
reset_btn,
other_btn,
number_a,
number_b,
)
assert output == expected_output

Testing callbacks with different actions depending on ctx.inputs_list and ctx.outputs_list:

In this example, you want to test a callback that listens to changes in the values of four different buttons (sum-btn, multiply-btn, reset-btn, and other-btn). When any of the four buttons are clicked, the function updates the content of a container with the id inputs-list-container with the ids of all the inputs and outputs.

Your callback in callbacks/callbacks.py:

@callback(
Output("inputs-list-container", "children"),
Input("sum-btn", "n_clicks"),
Input("multiply-btn", "n_clicks"),
Input("reset-btn", "n_clicks"),
Input("other-btn", "n_clicks"),
State("number-1", "data"),
State("number-2", "data"),
)
def display_inputs_and_outpus_ids(
sum_btn, multiply_btn, reset_btn, other_btn, number_1, number_2
):
return f"Inputs: {str(ctx.inputs_list)}.\nOutputs:{str(ctx.outputs_list)}"

Your test:

As the callback above is getting information from dash.ctx you have to provide a context in the test so it doesn’t fail. The input values aren’t used in the callback so they are completely arbitrary, in this example all equal to zero.

def test_display_inputs_and_outpus_ids():
def run_callback():
context_value.set(
AttributeDict(
**{
"inputs_list": [
{"id": "sum-btn", "property": "n_clicks"},
{"id": "multiply-btn", "property": "n_clicks"},
{"id": "reset-btn", "property": "n_clicks"},
{"id": "other-btn", "property": "n_clicks"},
],
"outputs_list": [
{
"id": "inputs-list-container",
"property": "children",
},
],
}
)
)
return callbacks.display_inputs_and_outpus_ids(0, 0, 0, 0, 0, 0)
ctx = copy_context()
output = ctx.run(run_callback)
assert "sum-btn" in output
assert "multiply-btn" in output
assert "reset-btn" in output
assert "other-btn" in output
assert "inputs-list-container" in output

Conclusion

Unit testing is crucial to ensure your Dash applications operate flawlessly and provide a seamless user experience. This practice is particularly vital in the work of Plotly’s Professional Services team where we develop advanced Dash applications for clients facing real-world challenges. Through this blog, we've aimed to demystify unit testing, offering practical examples and strategies to help you get started. If you’re looking to elevate your Dash apps or want to learn how we can help you harness the full potential of Dash in your projects, don’t hesitate to reach out!

Products & Services

COMPANY

  • WE ARE HIRING

© 2024
Plotly. All rights reserved.
Cookie Preferences