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_view and the model that it asks for. Without the app, a callback that asks for main_view raises ValueError.

  • 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 text then text_edited. For a button, emit clicked. For a menu or toolbar action, emit triggered.

  • Check the fields. For example status_bar.message, the text of a label, the rows of a table, or is_enabled.

  • Keep the messages. The status bar clears a message after its timeout, in a timer of its own. With a timeout of 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_view into 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 its finished or failed callbacks 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 the yield, so a failed test closes it too.

  • Save and open a study. save_study and open_study from composeui.core.study save 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) and main_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:

The tests of the example apps, such as tests/test_pipesweep.py, test the views as this page does.