Spaces:
Sleeping
Sleeping
title: App testing example | |
slug: /develop/concepts/app-testing/examples | |
# App testing example | |
## Testing a login page | |
Let's consider a login page. In this example, `secrets.toml` is not present. We'll manually declare dummy secrets directly in the tests. To avoid [timing attacks](https://en.wikipedia.org/wiki/Timing_attack), the login script uses `hmac` to compare a user's password to the secret value as a security best practice. | |
### Project summary | |
#### Login page behavior | |
Before diving into the app's code, let's think about what this page is supposed to do. Whether you use test-driven development or you write unit tests after your code, it's a good idea to think about the functionality that needs to be tested. The login page should behave as follows: | |
- Before a user interacts with the app: | |
- Their status is "unverified." | |
- A password prompt is displayed. | |
- If a user types an incorrect password: | |
- Their status is "incorrect." | |
- An error message is displayed. | |
- The password attempt is cleared from the input. | |
- If a user types a correct password: | |
- Their status is "verified." | |
- A confirmation message is displayed. | |
- A logout button is displayed (without a login prompt). | |
- If a logged-in user clicks the **Log out** button: | |
- Their status is "unverified." | |
- A password prompt is displayed. | |
#### Login page project structure | |
```none | |
myproject/ | |
βββ app.py | |
βββ tests/ | |
βββ test_app.py | |
``` | |
#### Login page Python file | |
The user's status mentioned in the page's specifications are encoded in `st.session_state.status`. This value is initialized at the beginning of the script as "unverified" and is updated through a callback when the password prompt receives a new entry. | |
```python | |
"""app.py""" | |
import streamlit as st | |
import hmac | |
st.session_state.status = st.session_state.get("status", "unverified") | |
st.title("My login page") | |
def check_password(): | |
if hmac.compare_digest(st.session_state.password, st.secrets.password): | |
st.session_state.status = "verified" | |
else: | |
st.session_state.status = "incorrect" | |
st.session_state.password = "" | |
def login_prompt(): | |
st.text_input("Enter password:", key="password", on_change=check_password) | |
if st.session_state.status == "incorrect": | |
st.warning("Incorrect password. Please try again.") | |
def logout(): | |
st.session_state.status = "unverified" | |
def welcome(): | |
st.success("Login successful.") | |
st.button("Log out", on_click=logout) | |
if st.session_state.status != "verified": | |
login_prompt() | |
st.stop() | |
welcome() | |
``` | |
#### Login page test file | |
These tests closely follow the app's specifications above. In each test, a dummy secret is set before running the app and proceeding with further simulations and checks. | |
```python | |
from streamlit.testing.v1 import AppTest | |
def test_no_interaction(): | |
at = AppTest.from_file("app.py") | |
at.secrets["password"] = "streamlit" | |
at.run() | |
assert at.session_state["status"] == "unverified" | |
assert len(at.text_input) == 1 | |
assert len(at.warning) == 0 | |
assert len(at.success) == 0 | |
assert len(at.button) == 0 | |
assert at.text_input[0].value == "" | |
def test_incorrect_password(): | |
at = AppTest.from_file("app.py") | |
at.secrets["password"] = "streamlit" | |
at.run() | |
at.text_input[0].input("balloon").run() | |
assert at.session_state["status"] == "incorrect" | |
assert len(at.text_input) == 1 | |
assert len(at.warning) == 1 | |
assert len(at.success) == 0 | |
assert len(at.button) == 0 | |
assert at.text_input[0].value == "" | |
assert "Incorrect password" in at.warning[0].value | |
def test_correct_password(): | |
at = AppTest.from_file("app.py") | |
at.secrets["password"] = "streamlit" | |
at.run() | |
at.text_input[0].input("streamlit").run() | |
assert at.session_state["status"] == "verified" | |
assert len(at.text_input) == 0 | |
assert len(at.warning) == 0 | |
assert len(at.success) == 1 | |
assert len(at.button) == 1 | |
assert "Login successful" in at.success[0].value | |
assert at.button[0].label == "Log out" | |
def test_log_out(): | |
at = AppTest.from_file("app.py") | |
at.secrets["password"] = "streamlit" | |
at.session_state["status"] = "verified" | |
at.run() | |
at.button[0].click().run() | |
assert at.session_state["status"] == "unverified" | |
assert len(at.text_input) == 1 | |
assert len(at.warning) == 0 | |
assert len(at.success) == 0 | |
assert len(at.button) == 0 | |
assert at.text_input[0].value == "" | |
``` | |
See how Session State was modified in the last test? Instead of fully simulating a user logging in, the test jumps straight to a logged-in state by setting `at.session_state["status"] = "verified"`. After running the app, the test proceeds to simulate the user logging out. | |
### Automating your tests | |
If `myproject/` was pushed to GitHub as a repository, you could add GitHub Actions test automation with [Streamlit App Action](https://github.com/marketplace/actions/streamlit-app-action). This is as simple as adding a workflow file at `myproject/.github/workflows/`: | |
```yaml | |
# .github/workflows/streamlit-app.yml | |
name: Streamlit app | |
on: | |
push: | |
branches: ["main"] | |
pull_request: | |
branches: ["main"] | |
permissions: | |
contents: read | |
jobs: | |
streamlit: | |
runs-on: ubuntu-latest | |
steps: | |
- uses: actions/checkout@v4 | |
- uses: actions/setup-python@v5 | |
with: | |
python-version: "3.11" | |
- uses: streamlit/[email protected] | |
with: | |
app-path: app.py | |
``` | |