Skip to content

Commit

Permalink
Add App test guide (#874)
Browse files Browse the repository at this point in the history
* Begin App test guide

* Add pages to app testing guide

* Improve headers

* Add login testing example

* Style and clarity edits

* Typo

* Combine pytest and app testing intro pages

* Add link to GitHub Actions and example

* Proofreading edits

* Edits from review
  • Loading branch information
sfc-gh-dmatthews authored Nov 10, 2023
1 parent 9d8446f commit 14bb773
Show file tree
Hide file tree
Showing 7 changed files with 794 additions and 17 deletions.
136 changes: 136 additions & 0 deletions content/library/advanced-features/app-testing/beyond-the-basics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
---
title: Beyond the basics of app testing
slug: /library/advanced-features/app-testing/beyond-the-basics
---

# Beyond the basics of app testing

Now that you're comfortable with executing a basic test for a Streamlit app let's cover the mutable attributes of [`AppTest`](/library/api-reference/app-testing/st.testing.v1.apptest):

- `AppTest.secrets`
- `AppTest.session_state`
- `AppTest.query_params`

You can read and update values using dict-like syntax for all three attributes. For `.secrets` and `.query_params`, you can use key notation but not attribute notation. For example, the `.secrets` attribute for `AppTest` accepts `at.secrets["my_key"]` but **_not_** `at.secrets.my_key`. This differs from how you can use the associated command in the main library. On the other hand, `.session_state` allows both key notation and attribute notation.

For these attributes, the typical pattern is to declare any values before executing the app's first run. Values can be inspected at any time in a test. There are a few extra considerations for secrets and Session State, which we'll cover now.

## Using secrets with app testing

Be careful not to include secrets directly in your tests. Consider this simple project with `pytest` executed in the project's root directory:

```none
myproject/
├── .streamlit/
│ ├── config.toml
│ └── secrets.toml
├── app.py
└── tests/
└── test_app.py
```

```bash
cd myproject
pytest tests/
```

In the above scenario, your simulated app will have access to your `secrets.toml` file. However, since you don't want to commit your secrets to your repository, you may need to write tests where you securely pull your secrets into memory or use dummy secrets.

### Example: declaring secrets in a test

Within a test, declare each secret after initializing your `AppTest` instance but before the first run. (A missing secret may result in an app that doesn't run!) For example, consider the following secrets file and corresponding test initialization to assign the same secrets manually:

Secrets file:

```toml
db_username = "Jane"
db_password = "mypassword"

[my_other_secrets]
things_i_like = ["Streamlit", "Python"]
```

Testing file with equivalent secrets:

```python
# Initialize an AppTest instance.
at = AppTest.from_file("app.py")
# Declare the secrets.
at.secrets["db_username"] = "Jane"
at.secrets["db_password"] = "mypassword"
at.secrets["my_other_secrets.things_i_like"] = ["Streamlit", "Python"]
# Run the app.
at.run()
```

Generally, you want to avoid typing your secrets directly into your test. If you don't need your real secrets for your test, you can declare dummy secrets as in the example above. If your app uses secrets to connect to an external service like a database or API, consider mocking that service in your app tests. If you need to use the real secrets and actually connect, you should use an API to pass them securely and anonymously. If you are automating your tests with GitHub actions, check out their [Security guide](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions).

```python
at.secrets["my_key"] = <value provided through API>
```

## Working with Session State in app testing

The `.session_state` attribute for `AppTest` lets you read and update Session State values using key notation (`at.session_state["my_key"]`) and attribute notation (`at.session_state.my_key`). By manually declaring values in Session State, you can directly jump to a specific state instead of simulating many steps to get there. Additionally, the testing framework does not provide native support for multipage apps. An instance of `AppTest` can only test one page. You must manually declare Session State values to simulate a user carrying data from another page.

### Example: testing a multipage app

Consider a simple multipage app where the first page can modify a value in Session State. To test the second page, set Session State manually and run the simulated app within the test:

Project structure:

```none
myproject/
├── pages/
│ └── second.py
├── first.py
└── tests/
└── test_second.py
```

First app page:

```python
"""first.py"""
import streamlit as st

st.session_state.magic_word = st.session_state.get("magic_word", "Streamlit")

new_word = st.text_input("Magic word:")

if st.button("Set the magic word"):
st.session_state.magic_word = new_word
```

Second app page:

```python
"""second.py"""
import streamlit as st

st.session_state.magic_word = st.session_state.get("magic_word", "Streamlit")

if st.session_state.magic_word == "Balloons":
st.markdown(":balloon:")
```

Testing file:

```python
"""test_second.py"""
from streamlit.testing.v1 import AppTest

def test_balloons():
at = AppTest.from_file("pages/second.py")
at.session_state["magic_word"] = "Balloons"
at.run()
assert at.markdown[0].value == ":balloon:"
```

By setting the value `at.session_state["magic_word"] = "Balloons"` within the test, you can simulate a user navigating to `second.py` after entering and saving "Balloons" on `first.py`.

## Automate your tests with GitHub actions

One of the key benefits of app testing is that tests can be automated. GitHub Actions are commonly used to validate commits and prevent accidental breaks. As an example, take a look at our [`streamlit/llm-examples`](https://github.com/streamlit/llm-examples) repo. Within `.github/workflows`, a script creates a virtual Python environment and [runs `pytest`](https://github.com/streamlit/llm-examples/blob/bbcc2667cec2a347b34ab3420b57d6ecb42a3188/.github/workflows/python-app.yml#L38).

Check out GitHub docs to learn about [GitHub Actions](https://docs.github.com/en/actions) and [Automating Projects using Actions](https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/automating-projects-using-actions).
195 changes: 195 additions & 0 deletions content/library/advanced-features/app-testing/cheat-sheet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
---
title: App testing cheat sheet
slug: /library/advanced-features/app-testing/cheat-sheet
---

# App testing cheat sheet

## Text elements

```python
from streamlit.testing.v1 import AppTest

at = AppTest.from_file("cheatsheet_app.py")

# Headers
assert "My app" in at.title[0].value
assert "New topic" in at.header[0].value
assert "Interesting sub-topic" in at.subheader[0].value
assert len(at.divider) == 2

# Body / code
assert "Hello, world!" in at.markdown[0].value
assert "import streamlit as st" in at.code[0].value
assert "A cool diagram" in at.caption[0].value
assert "Hello again, world!" in at.text[0].value
assert "\int a x^2 \,dx" in at.latex[0].value
```

## Input widgets

```python
from streamlit.testing.v1 import AppTest

at = AppTest.from_file("cheatsheet_app.py")

# button
assert at.button[0].value == False
at.button[0].click().run()
assert at.button[0].value == True

# checkbox
assert at.checkbox[0].value == False
at.checkbox[0].check().run() # uncheck() is also supported
assert at.checkbox[0].value == True

# color_picker
assert at.color_picker[0].value == "#FFFFFF"
at.color_picker[0].pick("#000000").run()

# date_input
assert at.date_input[0].value == datetime.date(2019, 7, 6)
at.date_input[0].set_value(datetime.date(2022, 12, 21)).run()

# form_submit_button - shows up just like a button
assert at.button[0].value == False
at.button[0].click().run()
assert at.button[0].value == True

# multiselect
assert at.multiselect[0].value == ["foo", "bar"]
at.multiselect[0].select("baz").unselect("foo").run()

# number_input
assert at.number_input[0].value == 5
at.number_input[0].increment().run()

# radio
assert at.radio[0].value == "Bar"
assert at.radio[0].index == 3
at.radio[0].set_value("Foo").run()

# selectbox
assert at.selectbox[0].value == "Bar"
assert at.selectbox[0].index == 3
at.selectbox[0].set_value("Foo").run()

# select_slider
assert at.select_slider[0].value == "Feb"
at.select_slider[0].set_value("Mar").run()
at.select_slider[0].set_range("Apr", "Jun").run()

# slider
assert at.slider[0].value == 2
at.slider[0].set_value(3).run()
at.slider[0].set_range(4, 6).run()

# text_area
assert at.text_area[0].value == "Hello, world!"
at.text_area[0].set_value("Hello, yourself!").run()

# text_input
assert at.text_input[0].value == "Hello, world!")
at.text_input[0].set_value("Hello, yourself!").run()

# time_input
assert at.time_input[0].value == datetime.time(8, 45)
at.time_input[0].set_value(datetime.time(12, 30))

# toggle
assert at.toggle[0].value == False
assert at.toggle[0].label == "Debug mode"
at.toggle[0].set_value(True).run()
assert at.toggle[0].value == True
```

## Data elements

```python
from streamlit.testing.v1 import AppTest

at = AppTest.from_file("cheatsheet_app.py")

# dataframe
expected_df = pd.DataFrame([1, 2, 3])
assert at.dataframe[0].value.equals(expected_df)

# metric
assert at.metric[0].value == "9500"
assert at.metric[0].delta == "1000"

# json
assert at.json[0].value == '["hi", {"foo": "bar"}]'

# table
table_df = pd.DataFrame([1, 2, 3])
assert at.table[0].value.equals(table_df)
```

## Layouts and containers

```python
from streamlit.testing.v1 import AppTest

at = AppTest.from_file("cheatsheet_app.py")

# sidebar
at.sidebar.text_input[0].set_value("Jane Doe")

# columns
at.columns[1].markdown[0].value == "Hello, world!"

# tabs
at.tabs[2].markdown[0].value == "Hello, yourself!"
```

## Chat elements

```python
from streamlit.testing.v1 import AppTest

at = AppTest.from_file("cheatsheet_app.py")

# chat_input
at.chat_input[0].set_value("Do you know any jokes?").run()
# Note: chat_input value clears after every re-run (like in a real app)

# chat_message
assert at.chat_message[0].markdown[0].value == "Do you know any jokes?"
assert at.chat_message[0].avatar == "user"
```

## Status elements

```python
from streamlit.testing.v1 import AppTest

at = AppTest.from_file("cheatsheet_app.py")

# exception
assert len(at.exception) == 1
assert "TypeError" in at.exception[0].value

# Other in-line alerts: success, info, warning, error
assert at.success[0].value == "Great job!"
assert at.info[0].value == "Please enter an API key to continue"
assert at.warning[0].value == "Sorry, the passwords didn't match"
assert at.error[0].value == "Something went wrong :("

# toast
assert at.toast[0].value == "That was lit!" and at.toast[0].icon == "🔥"
```

## Limitations

As of Streamlit 1.28, the following Streamlit features are not natively supported by `AppTest`. However, workarounds are possible for many of them by inspecting the underlying proto directly using `AppTest.get()`. We plan to regularly add support for missing elements until all features are supported.

- Chart elements (`st.bar_chart`, `st.line_chart`, etc)
- Media elements (`st.image`, `st.video`, `st.audio`)
- `st.file_uploader`
- `st.data_editor`
- `st.expander`
- `st.status`
- `st.camera_input`
- `st.download_button`
- `st.link_button`
Loading

0 comments on commit 14bb773

Please sign in to comment.