Testing your app¶
The views hold the whole state of the UI, so most tests need no backend. A test builds the app, acts as the user does on the views, and checks their fields. The tests run with pytest, and without a window, a terminal, or a browser.
Test the views¶
Build the app with Application from composeui.core.application, the application of
composeui.core. Then do what the user does, and check the fields:
from attrs import Factory
from composeui.core.application import Application
from composeui.core.view import InputView, MainView, define_view
@define_view
class GreetingInputView(InputView):
def __attrs_post_init__(self) -> None:
super().__attrs_post_init__()
self.text_edited.connect(self._on_name_changed)
def _on_name_changed(self, text: str, *, main_view: MainView) -> None:
main_view.status_bar.show_message(f"Hello, {text}!" if text else "")
@define_view
class HelloMainView(MainView):
app_title = "Hello ComposeUI"
extension = ".hello"
greeting: GreetingInputView = Factory(GreetingInputView)
def test_the_status_bar_greets_the_name_typed() -> None:
app = Application(build_main_view=HelloMainView)
app.main_view.status_bar.timeout = 0 # the message stays until the next one
app.main_view.greeting.text = "Ada" # as the user types, a renderer sets the text,
app.main_view.greeting.text_edited.emit("Ada") # then emits the signal
assert app.main_view.status_bar.message == "Hello, Ada!"
Build the app, not only the main view. The app gives each callback the
main_viewand themodelthat it asks for. Without the app, a callback that asks formain_viewraisesValueError.Act as the user. When the user edits a field, the renderer sets the view’s field, then emits its signal. So set the field, then emit the signal, for example
textthentext_edited. For a button, emitclicked. For a menu or toolbar action, emittriggered.Check the fields. For example
status_bar.message, thetextof a label, therowsof a table, oris_enabled.Keep the messages. The status bar clears a message after its
timeout, in a timer of its own. With atimeoutof 0, the message stays, so a slow test still sees it.
Test a component¶
Test a component in a main view that holds only it. Then test the app that connects the components:
from attrs import Factory
from composeui.core.application import Application
from composeui.core.view import MainView, define_view
@define_view
class SearchBarHost(MainView):
search_bar: SearchBar = Factory(SearchBar) # the component of "Structure an app"
def test_the_search_bar_says_what_to_search() -> None:
app = Application(build_main_view=SearchBarHost)
search_bar = app.main_view.search_bar
searches = []
search_bar.searched.connect(lambda query: searches.append(query))
search_bar.query.text = "steel"
search_bar.query.text_edited.emit("steel")
search_bar.search.clicked.emit()
assert search_bar.search.is_enabled
assert searches == ["steel"]
def test_two_apps_share_no_state() -> None:
first = Application(build_main_view=SearchMainView) # the app of "Structure an app"
second = Application(build_main_view=SearchMainView)
first.main_view.search_bar.query.text = "steel"
first.main_view.search_bar.search.clicked.emit()
assert first.main_view.last_search.text == "Last search: steel"
assert second.main_view.last_search.text == "No search yet"
The host is a main view, so that the app injects
main_viewinto the component’s callbacks, as it does in the whole app.Two apps share no state. On the web, each session builds its own main view. A test with two apps finds state that a module or class variable shares between them.
Test a form¶
A form runs its checks without an app. check() runs them all, as a Save button does
before it acts. is_valid then tells whether they pass, and each row’s log_message holds
its error:
from typing import Optional
from composeui.core.view import FormView, LabelRowInputView, child, define_view
@define_view
class NameRow(LabelRowInputView):
def __attrs_post_init__(self) -> None:
super().__attrs_post_init__()
self.set_validator(self._check_name, self.text_edited)
def _check_name(self) -> Optional[str]:
return "A name is required" if self.text == "" else None
@define_view
class PipeForm(FormView):
name: NameRow = child(NameRow, label="Name")
def test_a_pipe_needs_a_name() -> None:
form = PipeForm()
form.check()
assert not form.is_valid
assert form.name.log_message == "A name is required"
def test_a_named_pipe_is_valid() -> None:
form = PipeForm()
form.name.text = "Main pipe"
form.check()
assert form.is_valid
See Check a form.
Test the jobs¶
No event loop runs in a test, so the results of the jobs wait.
main_view.jobs.wait() waits for the jobs, then emits their signals on the test’s thread:
from attrs import Factory
from composeui.core.application import Application
from composeui.core.jobs import Job
from composeui.core.view import ButtonView, LabelView, MainView, child, define_view
def count_rows(job: Job[int], count: int) -> int: # in its thread
return count
@define_view
class CountMainView(MainView):
count: ButtonView = child(ButtonView, text="Count")
result: LabelView = Factory(LabelView)
def __attrs_post_init__(self) -> None:
super().__attrs_post_init__()
self.count.clicked.connect(self._count)
def _count(self) -> None:
job = self.jobs.start(count_rows, 3)
job.finished.connect(self._show)
def _show(self, rows: int) -> None:
self.result.text = f"{rows} rows"
def test_count_shows_the_rows_once_its_job_is_over() -> None:
app = Application(build_main_view=CountMainView)
app.main_view.count.clicked.emit()
app.main_view.jobs.wait() # the job's signals then emit, on the test's thread
assert app.main_view.result.text == "3 rows"
Make the work fast. Replace a slow computation or a long wait with
monkeypatch.setattr, for example a constant that sets the duration of a step.A task too.
jobs.wait()also gives a loading task its outcome, so itsfinishedorfailedcallbacks run.
Test with a model¶
Build the app with ModelApplication, with the same arguments as run. A fixture builds
the app for each test, and closes the model after it:
from collections.abc import Iterator
from pathlib import Path
import attrs
import pytest
from attrs import Factory
from composeui.core.application import ModelApplication
from composeui.core.sqlitemodel import SqliteModel, SqliteTable, table
from composeui.core.study import open_study, save_study
from composeui.core.view import ButtonView, MainView, TableView, child, define_view
@attrs.define
class Run:
id: int
radius: float
class Runs(SqliteTable):
create = "CREATE TABLE run(id INTEGER PRIMARY KEY, radius REAL NOT NULL);"
def add(self, radius: float) -> None:
with self.connection() as db:
db.execute("INSERT INTO run(radius) VALUES (?)", (radius,))
def all(self) -> list[Run]:
return self.fetch(Run, "SELECT * FROM run ORDER BY id")
class PipeModel(SqliteModel):
runs = table(Runs)
@define_view
class PipeMainView(MainView):
extension = ".pipe"
add: ButtonView = child(ButtonView, text="Add")
runs: TableView = child(TableView, headers=["Radius"])
def __attrs_post_init__(self) -> None:
super().__attrs_post_init__()
self.add.clicked.connect(self._add)
def _add(self, *, model: PipeModel) -> None:
model.runs.add(0.5)
def connect_model(self, model: PipeModel) -> None:
model.runs.changed.connect(self._show_runs, now=True)
def _show_runs(self, *, model: PipeModel) -> None:
self.runs.rows = [[f"{run.radius:g}"] for run in model.runs.all()]
App = ModelApplication[PipeMainView, PipeModel]
def _app() -> App:
return App(
build_main_view=PipeMainView,
build_model=PipeModel,
setup_model=PipeMainView.connect_model,
)
@pytest.fixture
def app() -> Iterator[App]:
app = _app()
yield app
app.model.close() # deletes the model's scratch copy of the database
def test_add_shows_the_new_run(app: App) -> None:
app.main_view.add.clicked.emit()
assert app.main_view.runs.rows == [["0.5"]]
def test_a_study_keeps_the_runs(app: App, tmp_path: Path) -> None:
app.main_view.add.clicked.emit()
save_study(app.main_view, app.model, tmp_path / "runs.pipe")
other = _app()
try:
open_study(other.main_view, other.model, tmp_path / "runs.pipe")
assert other.main_view.runs.rows == [["0.5"]]
finally:
other.model.close()
Close the model.
model.close()deletes the scratch copy of its database. Close it in the fixture, after theyield, so a failed test closes it too.Save and open a study.
save_studyandopen_studyfromcomposeui.core.studysave and open the views and the model’s data together, as the File menu does. For an app without a model,main_view.save_state(path)andmain_view.load_state(path)do the same.One model per test. Each test builds its own app. So the tests also run in parallel, for example with pytest-xdist.
Test on a backend¶
Test on a backend only what the backend does: the widget that it shows, a key that the user presses, a page that the server sends. The tests of ComposeUI do this, and show how:
Qt: pytest-qt’s
qtbot, intests/test_qtrenderer.py. SetQT_QPA_PLATFORM=offscreento run them without a display.Textual:
App.run_test()and its pilot, intests/test_textual_renderer.py.The web: Starlette’s
TestClient, intests/test_webrenderer.py.
The tests of the example apps, such as
tests/test_pipesweep.py, test the views as this page does.