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

本記事は「 [TTDC Advent Calendar 2024](https://qiita.com/advent-calendar/2024/ttdc) 」 17日目の記事です。

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

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

:::note  
- 環境、ライブラリ
  - 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ファイルを読み込み、テスト環境下でアプリケーションを実行することが可能です。
```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](https://docs.streamlit.io/develop/api-reference/app-testing)

### ウィジェットへの入出力
ウィジェットへの入力は`set_value()`を呼び出して行うことができます。  
keyを指定していないウィジェットの場合はindex番号で取得することになりますが、  
後に同種のウィジェットを追加する場合はテストが破綻する可能性があることに注意が必要です。
```python: ウィジェットへ入力
# 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`を呼び出すことで出力を取得し、テストを行うことができます。
```python: ウィジェットの出力テスト
assert at.number_input(key="number").value == 10
```

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

参考：[Session Stateで変数の値を保持する方法](https://qiita.com/kuriyan1204/items/d8342f3a40c6aeb57e5e)

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

```python: 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においては再実行するタイミングを明示的に記述します。
```python
# アプリの再実行
at.run()

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


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

参考：[AppTestで呼び出せるウィジェット一覧](https://docs.streamlit.io/develop/api-reference/app-testing/st.testing.v1.apptest#sttestingv1apptest)

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

参考：[pytest-mock documentation](https://pytest-mock.readthedocs.io/en/latest/index.html#)

```python: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](https://docs.streamlit.io/develop/api-reference/app-testing/st.testing.v1.apptest#apptestexception)
```python
at.exception
```

### 特殊なウィジェット名
一部のウィジェットでは`at.<widget_name>()`のように直接呼び出すことができません。  
例えば以下のようなst.formコンテナ内のst.form_submit_buttonをテストしたい場合、
```python: app.py
with st.form(key="my_form"):
    #####
    # do something
    #####
    st.form_submit_button("Run")
```
単に`at.form_submit_button()`として呼び出すことはできず、次のようなコードになります。
```python: test_app.py
at.button(key="FormSubmitter:my_form-Run").click()
```  
実装中にウィジェットの呼び出しが上手くいかない場合、`at.session_state`からウィジェットのステータスを確認し、ウィジェットの正式なキーを参照しましょう。

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

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

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

## 付録

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

- ディレクトリ構成
    ```
    .
    ├── app
    │   └── app.py
    └── test
        └── test_app.py
    ```

- 実行方法
    ```bash
    $ cd <path/to/test>
    $ pytest test_app.py
    ```

```python: 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")
```

```python: 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
- [Streamlit: App testing](https://docs.streamlit.io/develop/api-reference/app-testing)
- [【Streamlit】Session Stateで変数の値を保持する方法](https://qiita.com/kuriyan1204/items/d8342f3a40c6aeb57e5e)
- [Streamlit: st.testing.v1.apptest](https://docs.streamlit.io/develop/api-reference/app-testing/st.testing.v1.apptest#sttestingv1apptest)
- [pytest-mock documentation](https://pytest-mock.readthedocs.io/en/latest/index.html#)
- [Streamlit: AppTest.exception](https://docs.streamlit.io/develop/api-reference/app-testing/st.testing.v1.apptest#apptestexception)