docs/enterprise/testing.md
New in reflex-enterprise v0.9.2.
reflex-enterprise ships a pytest plugin that starts an existing Reflex app with
reflex run and passes the live URL to tests. Tests drive the running app —
frontend and backend — with browser automation such as
pytest-playwright.
The plugin manages the app process and returns its URL. Browser automation is chosen and controlled by the test suite.
uv add "reflex-enterprise[testing]>=0.9.2"
uv add pytest-playwright # browser driver of your choice
uv run playwright install chromium
The [testing] extra installs pytest, but does not include a browser driver.
pytest-playwright is used in the examples below, but any browser automation
framework that can drive a live URL is compatible.
The fixture is registered through a pytest11 entry point, so no
conftest.py configuration is required.
Place a test anywhere at or below the directory containing rxconfig.py:
my_app/
rxconfig.py
my_app/
my_app.py
tests/
test_homepage.py
from playwright.sync_api import Page, expect
from reflex_enterprise.testing import ReflexApp
def test_homepage(reflex_app: ReflexApp, page: Page):
page.goto(reflex_app.url)
expect(page.get_by_role("heading", name="Welcome")).to_be_visible()
Run it:
uv run pytest
The first test that requests reflex_app starts the app. A cold start
compiles the frontend; subsequent runs reuse a cached working directory and
start quickly. The running app is shared by every test in the session and shut
down when the session ends.
# Shared app process, isolated per-test state.
The app process stays alive across test cases, but the state does not carry
over between them. Each browser context connects with its own `client_token`,
and Reflex keys backend state by that token, so every test that opens a fresh
`page` (the function-scoped `pytest-playwright` fixture) talks to a clean,
independent state instance.
This means tests never see each other's state, but it also means nothing a
test does in the UI — logging in, navigating, opening a dialog — persists into
the next test. Reproduce any required starting condition per test (see
[Reusing setup steps](#reusing-setup-steps)). Reusing one `page`/context
across tests would share a `client_token` and leak state between them; keep to
one context per test.
rxconfig.py. That directory is the app root.REFLEX_WEB_WORKDIR /
REFLEX_STATES_WORKDIR, telemetry disabled, in a working directory outside
the checkout, so compiled frontend and state artifacts never land in the
working tree. reflex run itself may still write a reflex.lock next to
rxconfig.py, you may .gitignore it for pure test apps.max_apps alive, and
evicting the least recently used to make room for a different app. The
reflex_app fixture is function-scoped but borrows from the manager, so apps
outlive individual tests.reflex run picks its own ports (auto-incrementing from
3000/8000) and the plugin reads the real URL back from its output.reflex run output in the
message, and the test is reported as an error. The same contract governs
reuse: a cached app whose frontend or backend is no longer reachable is torn
down and restarted before being handed to a test.reflex_app fixtureThe reflex_app fixture returns a ReflexApp object with a small, stable surface:
| Property | Description |
|---|---|
reflex_app.url | Live frontend URL (no trailing slash) — pass to page.goto(...). |
reflex_app.backend_url | Live backend URL, if available. |
reflex_app.app_root | The resolved app root (directory containing rxconfig.py). |
reflex_app.logs() | Captured reflex run output (stdout and stderr merged, most recent lines). |
Because every test starts from a clean state (see the warning above), any starting condition a test depends on — a logged-in session, a populated form, an open dialog — has to be re-established for that test. Factor those steps into a function-scoped fixture so each test body can assume the app is already in the state it cares about.
For example, a fixture that clicks through a menu to open a settings modal, so the test starts with the modal already open:
import pytest
from playwright.sync_api import Page, expect
from reflex_enterprise.testing import ReflexApp
@pytest.fixture
def settings_modal(reflex_app: ReflexApp, page: Page) -> Page:
"""Open the settings modal and hand the test a page where it is visible."""
page.goto(reflex_app.url)
page.get_by_role("button", name="Menu").click()
page.get_by_role("menuitem", name="Settings").click()
expect(page.get_by_role("dialog", name="Settings")).to_be_visible()
return page
def test_change_theme(settings_modal: Page):
# The modal is already open; go straight to the assertion.
settings_modal.get_by_label("Dark mode").check()
expect(settings_modal.get_by_label("Dark mode")).to_be_checked()
The fixture depends on reflex_app and page, both function-scoped, so it
runs once per test against that test's own client_token — the setup is
repeated for every test but the state never leaks between them. Fixtures like
this compose: a logged_in fixture can navigate from the login page, and
settings_modal can depend on it to open the modal as an authenticated user.
A frontend assertion can fail because of a server-side problem — a startup warning, a compile error, a backend traceback during an event. The plugin surfaces the server output in three places:
When a test fails, the captured reflex run output of every app the
test borrowed is attached to the report as a section, printed alongside the
failure like captured stdout:
------- captured `reflex run` output (my_app) -------
...
App running at: http://localhost:3000
ERROR: Traceback (most recent call last): ...
When the app fails to start, the ReflexAppStartError raised during
fixture setup embeds the full captured output, so the cause (bad rxconfig,
missing dependency, port conflict) is part of the error.
Programmatic access — reflex_app.logs() returns the same captured
output for custom assertions or logging.
Every option can be set via a command-line flag, a pytest ini option, or an environment variable. Precedence is CLI > env > ini > default.
| Setting | CLI | ini | env var | Default |
|---|---|---|---|---|
| Max running apps | --reflex-max-apps | reflex_max_apps | REFLEX_TEST_MAX_APPS | 1 |
| Run mode(s) | --reflex-run-mode | reflex_run_mode | REFLEX_TEST_RUN_MODE | dev |
| Workdir strategy | --reflex-workdir-strategy | reflex_workdir_strategy | REFLEX_TEST_WORKDIR_STRATEGY | persistent |
| Workdir root | --reflex-workdir-root | reflex_workdir_root | REFLEX_TEST_WORKDIR_ROOT | <tmp>/reflex-enterprise-testing |
| Start timeout (s) | --reflex-start-timeout | reflex_start_timeout | REFLEX_TEST_START_TIMEOUT | 300 |
Extra reflex run args | --reflex-run-arg (repeatable) | reflex_run_args | REFLEX_TEST_RUN_ARGS | (none) |
| Extra subprocess env | --reflex-run-env (repeatable) | reflex_run_env | REFLEX_TEST_RUN_ENV | (none) |
Share/isolate REFLEX_DIR | --reflex-share-reflex-dir / --reflex-isolate-reflex-dir | reflex_share_reflex_dir | REFLEX_TEST_SHARE_REFLEX_DIR | shared |
run_mode — the reflex run env mode: dev (default), prod, or a
comma-separated list (--reflex-run-mode=dev,prod). With more than one
mode, every test that uses reflex_app is parameterized to run once per
mode, and tests are grouped so all of one mode run before the next mode
starts. Dev and prod instances of the same app are cached separately (each
counts toward max_apps) with separate persistent workdirs, so their build
outputs never mix. Tests can read the current mode via the session-scoped
reflex_run_mode fixture ("dev" or "prod"). In prod mode reflex
serves the frontend and backend on a single address, so
reflex_app.backend_url falls back to reflex_app.url.workdir_strategy — persistent reuses a per-app, per-run-mode cache
directory across sessions (warm starts, roughly 6x faster than cold); tmp
uses a fresh temporary directory each start (clean but always cold).reflex_run_args — extra CLI arguments appended to reflex run.
--frontend-only / --backend-only are rejected immediately, since a
partial run can never satisfy the readiness check. The plugin appends its
own --env <mode> after these args, so an --env here is overridden —
set the mode with --reflex-run-mode instead.REFLEX_DIR — by default the global bun/node/reflex
dependencies are shared with the host to avoid a slow re-download. Pass
--reflex-isolate-reflex-dir for a fully hermetic (slower) run, or
--reflex-share-reflex-dir to force sharing over an ini/env setting (CLI
wins either way).Example pyproject.toml:
[tool.pytest.ini_options]
reflex_max_apps = "2"
reflex_workdir_strategy = "persistent"
reflex_start_timeout = "180"
For anything the static options can't express, the session-scoped
reflex_app_manager fixture returns the AppManager, whose settings
attribute (a ReflexAppTestSettings) is the supported integration point —
mutate it from your own fixture. Settings are consumed when an app starts, so
apply changes before the first reflex_app use, e.g. in an autouse session
fixture:
import pytest
from reflex_enterprise.testing import AppManager
@pytest.fixture(scope="session", autouse=True)
def _reflex_settings(reflex_app_manager: AppManager) -> None:
reflex_app_manager.settings.start_timeout = 120.0
reflex_app_manager.settings.extra_env = {"MY_FEATURE_FLAG": "1"}
ReflexAppTestSettings is a dataclass holding the resolved value of every
option from the table above (CLI/env/ini already applied), plus the same
defaults when unset:
@dataclass
class ReflexAppTestSettings:
max_apps: int = 1
run_modes: tuple[str, ...] = ("dev",)
workdir_strategy: Literal["persistent", "tmp"] = "persistent"
workdir_root: Path = ...
start_timeout: float = 300.0 # seconds
reflex_run_args: tuple[str, ...] = ()
extra_env: Mapping[str, str] = field(default_factory=dict)
share_reflex_dir: bool = True
AppManager, ReflexApp, and ReflexAppTestSettings are all importable from
reflex_enterprise.testing for proper typing of fixtures like the one above.
Because the app code executes in the reflex run subprocess started by the
fixture, pytest itself may not import or execute any app code directly. To
record coverage requires enabling coverage.py's subprocess patch to measure
the child process data and combine it into the final report.
Configure it in pyproject.toml — for an app named my_app:
[tool.coverage.run]
# Measure the app code explicitly since it may not be directly imported in the
# main pytest process.
source = ["my_app"]
# a subprocess will write a separate .coverage.* file to be combined by pytest-cov.
parallel = true
patch = [
"subprocess", # inject coverage into `reflex run` and workers
"_exit", # flush coverage data on os._exit()
]
# The test harness stops the app with SIGTERM.
sigterm = true
# Ignore warning if the test cases never imports `my_app` directly.
disable_warnings = ["no-data-collected"]
Then run with pytest-cov:
uv add "pytest-cov>=7.1"
uv run pytest --cov
Each app subprocess writes its own .coverage.* data file; pytest-cov
combines them when the session ends and reports coverage for the app package.
max_apps * num_workers.pytest runs — in persistent mode each app's workdir
carries a pid lockfile. If another pytest process is already using an
app's workdir, a second run transparently falls back to its own throwaway
workdir so the two never recompile or clobber the same .web. Stale locks
(from a crashed run) are reclaimed automatically.frontend_path — reflex_app.url honors a configured frontend_path
(it is read from reflex's own "App running at:" line).reflex run's port auto-increment to work correctly when more
than one app runs.