🙆‍♀️

Python Dashで単体テストを行う

2023/04/12に公開

Dashの単体テスト

プログラムを作成した後、そのプログラムが正しく動作しているかを検出するためにテストを行います。テストは人間が操作する方法や自動テストなど、またメソッド単位からブラウザ経由でのテストなど様々な手法・粒度があります。特に関数単位などの粒度が小さいテストのことを単体(ユニット)テストといいます。逆に実際にユーザが使うようにブラウザ経由で操作するテストのことをUIテストとかEnd-to-End (E2E)テストと呼んだりします。Pythonでのユニットテストはpytestやunittestが用いられることが多いと思います。

Dashで作成したWebアプリケーションに対してコンポーネントやcallbackのテストを行う場合、
pytestなどをそのまま用いることも可能ですが、専用のライブラリとしてdash[testing]というものが公開されています。このdash[testing]を用いると、callbackのテストの際にブラウザ上の操作を注入したり、Seleniumなどのツールを使わなくてもE2Eテストが可能になるようです。ただ、このdash[testing]に関する日本語記事はほとんどないようで、実際に使おうと思うとハードルもありそうです。

この記事ではまずdash[testing]を用いた単体テストについて解説します。とはいえ基本的にDash Testingの記載の内容のとおりです。

準備

dashのテストライブラリは以下でインストールできます。

pip install dash[testing]

筆者のようにpipenvを用いている場合は、

pipenv install dash[testing]

となります。

テスト対象のアプリケーション

ボタンを押すとそれぞれのボタンを押した回数と
最後に押したボタンを表示してくれるアプリケーションを扱います。

コード (app.py) は下記のとおりです。

import dash
from dash import callback, html, dcc, Input, Output, ctx

app = dash.Dash(__name__)

app.layout = html.Div([
    html.Button('Button 1', id='btn-1'),
    html.Button('Button 2', id='btn-2'),
    html.Button('Button 3', id='btn-3'),
    html.Div(id='container'),
    html.Div(id='container-no-ctx')
])

@callback(
    Output('container-no-ctx', 'children'),
    Input('btn-1', 'n_clicks'),
    Input('btn-2', 'n_clicks'))
def update(btn1, btn2):
    return f'button 1: {btn1} & button 2: {btn2}'


@callback(Output('container','children'),
              Input('btn-1', 'n_clicks'),
              Input('btn-2', 'n_clicks'),
              Input('btn-3', 'n_clicks'))
def display(btn1, btn2, btn3):
    button_clicked = ctx.triggered_id
    return f'You last clicked button with ID {button_clicked}'

if __name__ == '__main__':
    app.run_server(debug=True)

テストの実行環境を作成する

dashのテストライブラリはpytestをベースにしています。pytestではtestsディレクトリ以下にあるtest_で始まるスクリプトとtest_で始まるメソッドに対してテストを実施します。今回のアプリの場合は下記のようなディレクトリ構成にします。

.
├── Pipfile
├── Pipfile.lock
├── src
│   └── app.py
└── tests
    └── test_app.py

テストコードを作成する

テストとして、

  1. ボタンを押した回数が正しく取得できるか (updateメソッド)
  2. 最後に押されたボタンのIDが正しく取得できるか (displayメソッド)
    という2つを試してみます。

updateメソッドのテストは単純に入力を受け取り、文字列を返すだけなので、通常のpytestによるテストと同じように記述できます。例えば、btn-1のクリック回数が1, btn-2のクリック回数が0の時の表示を確かめるコードは下記のようになります。

from src.app import update
def test_update_callback():
    output = update(1, 0)
    assert output == 'button 1: 1 & button 2: 0'

displayメソッドのテストはupdateメソッドのそれより複雑です。displayメソッドはbtn-1~3のクリック回数を引数に取っていますが、返り値は最後に押したボタンです。そのため、updateメソッドと同様にdisplay(1, 1, 0)としたとしても、btn-1かbtn-2のどちらを押したかは区別できません。

これを区別するにはテスト中にbtn-1~3のどれがトリガーとなってdisplayが実行されるかを指定する必要があります。これは、コンテキスト変数を用いて下記のように記述することができます。
下のコードではbtn-1のクリック数の変化がトリガーとなってdisplayメソッドが呼び出された時のテストになります。

from contextvars import copy_context
from dash._callback_context import context_value
from dash._utils import AttributeDict
from src.app import display

def test_display_callback():
    def run_callback():
        context_value.set(AttributeDict(**{"triggered_inputs": [{"prop_id": "btn-1-ctx-example.n_clicks"}]}))
        return display(1, 0, 0)

    ctx = copy_context()
    output = ctx.run(run_callback)
    assert output == f'You last clicked button with ID btn-1-ctx-example'

テストコードの全体 (test_app.py) は下記のとおりです。

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

from src.app import display, update

def test_update_callback():
    output = update(1, 0)
    assert output == 'button 1: 1 & button 2: 0'

def test_display_callback():
    def run_callback():
        context_value.set(AttributeDict(**{"triggered_inputs": [{"prop_id": "btn-1-ctx-example.n_clicks"}]}))
        return display(1, 0, 0)

    ctx = copy_context()
    output = ctx.run(run_callback)
    assert output == f'You last clicked button with ID btn-1-ctx-example'

終わりに

dashのテストライブラリはcallbackのトリガーなどdash固有のものはありますが、pytestとほぼ同じように記述することができます。

Discussion