1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TTDCAdvent Calendar 2024

Day 17

【Streamlit】テスト用クラスAppTestを使ってみて分かったこと

Last updated at Posted at 2024-12-16

本記事は「 TTDC Advent Calendar 2024 」 17日目の記事です。

概要

StreamlitはPythonで簡単にWebアプリを作成できるパッケージです。
そうして作成したWebアプリをデプロイする場合、当然ながらテストの実施が必要になります。
本記事ではStreamlitに用意されているテスト用のクラスAppTestを実装し、
できること・できないことを私自身の備忘録も兼ねてまとめました。

最後に本記事の実装例をまとめたサンプルコードを用意しているので、ぜひ参考にしてください。

  • 環境、ライブラリ
    • python 3.10
    • streamlit==1.40.2
    • pandas==2.2.3
    • pytest==8.3.3
    • pytest-mock==3.14.0

TL;DR

  • Streamlit内部の変数値テストを行うようなケースであればAppTestは簡単に実装可能
  • ブラウザ上の出力やUI等をテストする場合はSelenium等のUIテストパッケージの利用が必要

AppTestの実施

呼び出し

下記メソッドによりPythonファイルを読み込み、テスト環境下でアプリケーションを実行することが可能です。

from streamlit.testing.v1 import AppTest

at = AppTest.from_file("<path/to/streamlit_app.py>")

その他、文字列型としてコードを受け取ってテストを行うAppTest.from_stringや、
関数単位でのテストを行うAppTest.from_functionも用意されています。

参考:Streamlit: App testing

ウィジェットへの入出力

ウィジェットへの入力はset_value()を呼び出して行うことができます。
keyを指定していないウィジェットの場合はindex番号で取得することになりますが、
後に同種のウィジェットを追加する場合はテストが破綻する可能性があることに注意が必要です。

ウィジェットへ入力
# keyを指定したウィジェットのvalueを変更する
at.number_input(key="number").set_value(10)
at.sidebar.slider(key="number_range").set_value((3,5))

# keyが未指定のウィジェットはindexで呼び出しを行う 
# 3番目のst.checkboxのvalueを変更する
at.checkbox[2].set_value(True)

# AppTest.getを利用した実装
# 1番目のst.text_inputのvalueを変更する
at.get("text_input")[0].set_value("Hello, World")

各ウィジェットのvalueを呼び出すことで出力を取得し、テストを行うことができます。

ウィジェットの出力テスト
assert at.number_input(key="number").value == 10

session_state変数への入出力

Streamlitはアプリ実行間の変数を保持する機能としてsession_stateが用意されています。

参考:Session Stateで変数の値を保持する方法

session_state変数の代入および読み込みを行うことで処理中の変数をテストすることができます。

session_state変数への入出力テスト
# session_state入力 -> ウィジェットへの反映を確認
at.session_state.number = 20
assert at.number_input(key="number").value == 20

# ウィジェット入力 -> session_stateへの反映を確認
at.sidebar.slider(key="number_range").set_value((4, 6))
assert at.session_state.number_range == (4, 6)

アプリの再実行

at.run()によってアプリが再実行されます。
通常、Streamlitではウィジェットへの入力が行われる度に自動的にアプリが再実行されますが、
AppTestにおいては再実行するタイミングを明示的に記述します。

# アプリの再実行
at.run()

# ウィジェットのメソッドとしても利用可能
at.number_input(key="number").set_value(10).run()

AppTestで未実装のウィジェット

基本的な入力ウィジェット(st.buttonst.slider等)に対しては先ほどのように値の入出力を行うことが出来ます。
一方で発展的なウィジェット(st.file_uploaderst.dataframe等)に関してはAppTestに実装されていません。

参考:AppTestで呼び出せるウィジェット一覧

そのため、それらを利用するアプリのテスト時にはsession_stateを経由して直接ウィジェットのvalueに値を代入する必要があります。
または、pytestのpluginであるmockerを利用し、ウィジェットの戻り値を指定する方法もあります。

参考:pytest-mock documentation

mockerを利用したファイルのアップロード
file = open(path, "rb")
mocker.patch("streamlit.file_uploader", return_value=file)
at.run()

その他、ウィジェットでブラウザ上に出力された結果や、UIの情報を取得するといったこともできません。
また、st.download_buttonのようなウィジェットでダウンロードしたファイルのテストを行うことも不可能です。

そのため、ブラウザ上の出力やUIに対するテストを行いたい場合はAppTestではなく、Selenium等の一般的なUIテストツールを利用すべきでしょう。

注意点

エラー出力

AppTest実行中に発生したエラーはコマンドライン上には表示されず、アプリ内のフロント側に表示されます。
そのため、エラー確認や異常系テストは以下のメソッドから取得してください。

参考:Streamlit: AppTest.exception

at.exception

特殊なウィジェット名

一部のウィジェットではat.<widget_name>()のように直接呼び出すことができません。
例えば以下のようなst.formコンテナ内のst.form_submit_buttonをテストしたい場合、

app.py
with st.form(key="my_form"):
    #####
    # do something
    #####
    st.form_submit_button("Run")

単にat.form_submit_button()として呼び出すことはできず、次のようなコードになります。

test_app.py
at.button(key="FormSubmitter:my_form-Run").click()

実装中にウィジェットの呼び出しが上手くいかない場合、at.session_stateからウィジェットのステータスを確認し、ウィジェットの正式なキーを参照しましょう。

まとめ

AppTestでできること

  • 基本的なウィジェットに対する入出力が可能
  • session_state変数への入出力を行うことができる

AppTestでできないこと

  • st.file_uploaderst.dataframe等の発展的なウィジェットへの入出力ができない
  • そのため、ブラウザ上に出力される結果やUI等に対するテストは行えない場合が多い

結論

  • Streamlit内部の変数値テストを行うようなケースであればAppTestは簡単に実装可能
  • ブラウザ上の出力やUI等をテストする場合はSelenium等のUIテストパッケージの利用が必要

付録

本記事で説明した内容を含んだサンプルコードです。

  • ディレクトリ構成

    .
    ├── app
    │   └── app.py
    └── test
        └── test_app.py
    
  • 実行方法

    $ cd <path/to/test>
    $ pytest test_app.py
    
app.py
import streamlit as st

st.number_input("input number", key="number")
st.sidebar.slider("slider", value=(0, 10), key="number_range")

st.checkbox("1")
st.checkbox("2")
st.checkbox("3")
st.checkbox("4")

st.text_input("input text")

with st.form("my_form"):
    st.session_state.form_number = st.number_input(
        "form input", value=0, key="form_num"
    )
    st.form_submit_button("Run")


st.session_state.file = st.file_uploader("upload file")
test_app.py
from streamlit.testing.v1 import AppTest
import pandas as pd

at = AppTest.from_file("../app/app.py")
at.run()

# テスト用ファイル(DataFrame)の用意
TEST_DF = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})
TEST_DF_PATH = "test_df.csv"
TEST_DF.to_csv(TEST_DF_PATH, index=False)


def test_stremalit(mocker):
    # ウィジェットのテスト
    at.number_input(key="number").set_value(10).run()
    assert at.number_input(key="number").value == 10

    # サイドバーウィジェットのテスト
    at.sidebar.slider(key="number_range").set_value((3, 5)).run()
    assert at.sidebar.slider(key="number_range").value == (3, 5)

    # key指定の無いウィジェットのテスト
    at.checkbox[2].set_value(True).run()
    assert at.checkbox[2].value

    # AppTest.getを利用したテスト
    at.get("text_input")[0].set_value("Hello, World").run()
    assert at.get("text_input")[0].value == "Hello, World"

    # Session_state変数の代入・テスト
    at.session_state.number = 20
    assert at.number_input(key="number").value == 20
    at.sidebar.slider(key="number_range").set_value((4, 6)).run()
    assert at.session_state.number_range == (4, 6)
    
    # formコンテナ内でのウィジェットのテスト
    at.number_input(key="form_num").set_value(10)
    at.button(key="FormSubmitter:my_form-Run").click().run()
    assert at.session_state.form_number == 10

    # mockerを用いたファイルアップロードおよび読み込みテスト
    file = open(TEST_DF_PATH, "rb")
    mocker.patch("streamlit.file_uploader", return_value=file)
    at.run()
    loaded_df = pd.read_csv(at.session_state.file)
    pd.testing.assert_frame_equal(TEST_DF, loaded_df)

おわりに

今回の記事は以上になります。
最後まで読んでいただき、ありがとうございました!

本記事の内容に誤りなどあれば、コメントにてご教授お願いいたします。

Reference

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?