From b99da2d6b90a971e8564401b211ec08195dd080e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 17 Sep 2023 10:34:32 +0100 Subject: [PATCH] Testing guide (#3329) * testing docs * words * words * testing doc * Apply suggestions from code review Co-authored-by: Gobion <1312216+brokenshield@users.noreply.github.com> --------- Co-authored-by: Gobion <1312216+brokenshield@users.noreply.github.com> --- docs/examples/guide/testing/rgb.py | 42 +++ docs/examples/guide/testing/test_rgb.py | 42 +++ docs/guide/testing.md | 168 ++++++++++ mkdocs-nav.yml | 423 ++++++++++++------------ src/textual/app.py | 4 +- src/textual/pilot.py | 7 +- 6 files changed, 469 insertions(+), 217 deletions(-) create mode 100644 docs/examples/guide/testing/rgb.py create mode 100644 docs/examples/guide/testing/test_rgb.py create mode 100644 docs/guide/testing.md diff --git a/docs/examples/guide/testing/rgb.py b/docs/examples/guide/testing/rgb.py new file mode 100644 index 0000000000..d8b49cd1c3 --- /dev/null +++ b/docs/examples/guide/testing/rgb.py @@ -0,0 +1,42 @@ +from textual import on +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import Button, Footer + + +class RGBApp(App): + CSS = """ + Screen { + align: center middle; + } + Horizontal { + width: auto; + height: auto; + } + """ + + BINDINGS = [ + ("r", "switch_color('red')", "Go Red"), + ("g", "switch_color('green')", "Go Green"), + ("b", "switch_color('blue')", "Go Blue"), + ] + + def compose(self) -> ComposeResult: + with Horizontal(): + yield Button("Red", id="red") + yield Button("Green", id="green") + yield Button("Blue", id="blue") + yield Footer() + + @on(Button.Pressed) + def pressed_button(self, event: Button.Pressed) -> None: + assert event.button.id is not None + self.action_switch_color(event.button.id) + + def action_switch_color(self, color: str) -> None: + self.screen.styles.background = color + + +if __name__ == "__main__": + app = RGBApp() + app.run() diff --git a/docs/examples/guide/testing/test_rgb.py b/docs/examples/guide/testing/test_rgb.py new file mode 100644 index 0000000000..030f62b505 --- /dev/null +++ b/docs/examples/guide/testing/test_rgb.py @@ -0,0 +1,42 @@ +from rgb import RGBApp + +from textual.color import Color + + +async def test_keys(): # (1)! + """Test pressing keys has the desired result.""" + app = RGBApp() + async with app.run_test() as pilot: # (2)! + # Test pressing the R key + await pilot.press("r") # (3)! + assert app.screen.styles.background == Color.parse("red") # (4)! + + # Test pressing the G key + await pilot.press("g") + assert app.screen.styles.background == Color.parse("green") + + # Test pressing the B key + await pilot.press("b") + assert app.screen.styles.background == Color.parse("blue") + + # Test pressing the X key + await pilot.press("x") + # No binding (so no change to the color) + assert app.screen.styles.background == Color.parse("blue") + + +async def test_buttons(): + """Test pressing keys has the desired result.""" + app = RGBApp() + async with app.run_test() as pilot: + # Test clicking the "red" button + await pilot.click("#red") # (5)! + assert app.screen.styles.background == Color.parse("red") + + # Test clicking the "green" button + await pilot.click("#green") + assert app.screen.styles.background == Color.parse("green") + + # Test clicking the "blue" button + await pilot.click("#blue") + assert app.screen.styles.background == Color.parse("blue") diff --git a/docs/guide/testing.md b/docs/guide/testing.md new file mode 100644 index 0000000000..0c756d7c9b --- /dev/null +++ b/docs/guide/testing.md @@ -0,0 +1,168 @@ +# Testing + +Code testing is an important part of software development. +This chapter will cover how to write tests for your Textual apps. + +## What is testing? + +It is common to write tests alongside your app. +A *test* is simply a function that confirms your app is working correctly. + +!!! tip "Learn more about testing" + + We recommend [Python Testing with pytest](https://pythontest.com/pytest-book/) for a comprehensive guide to writing tests. + +## Do you need to write tests? + +The short answer is "no", you don't *need* to write tests. + +In practice however, it is almost always a good idea to write tests. +Writing code that is completely bug free is virtually impossible, even for experienced developers. +If you want to have confidence that your application will run as you intended it to, then you should write tests. +Your test code will help you find bugs early, and alert you if you accidentally break something in the future. + +## Testing frameworks for Textual + +Textual doesn't require any particular test framework. +You can use any test framework you are familiar with, but we will be using [pytest](https://docs.pytest.org/) in this chapter. + + +## Testing apps + +You can often test Textual code in the same way as any other app, and use similar techniques. +But when testing user interface interactions, you may need to use Textual's dedicated test features. + +Let's write a simple Textual app so we can demonstrate how to test it. +The following app shows three buttons labelled "red", "green", and "blue". +Clicking one of those buttons or pressing a corresponding ++r++, ++g++, and ++b++ key will change the background color. + +=== "rgb.py" + + ```python + --8<-- "docs/examples/guide/testing/rgb.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/testing/rgb.py"} + ``` + +Although it is straightforward to test an app like this manually, it is not practical to click every button and hit every key in your app after changing a single line of code. +Tests allow us to automate such testing so we can quickly simulate user interactions and check the result. + +To test our simple app we will use the [`run_test()`][textual.app.App.run_test] method on the `App` class. +This replaces the usual call to [`run()`][textual.app.App.run] and will run the app in *headless* mode, which prevents Textual from updating the terminal but otherwise behaves as normal. + +The `run_test()` method is an *async context manager* which returns a [`Pilot`][textual.pilot.Pilot] object. +You can use this object to interact with the app as if you were operating it with a keyboard and mouse. + +Let's look at the tests for the example above: + +```python title="test_rgb.py" +--8<-- "docs/examples/guide/testing/test_rgb.py" +``` + +1. The `run_test()` method requires that it run in a coroutine, so tests must use the `async` keyword. +2. This runs the app and returns a Pilot instance we can use to interact with it. +3. Simulates pressing the ++r++ key. +4. This checks that pressing the ++r++ key has resulted in the background color changing. +5. Simulates clicking on the widget with an `id` of `red` (the button labelled "Red"). + +There are two tests defined in `test_rgb.py`. +The first to test keys and the second to test button clicks. +Both tests first construct an instance of the app and then call `run_test()` to get a Pilot object. +The `test_keys` function simulates key presses with [`Pilot.press`][textual.pilot.Pilot.press], and `test_buttons` simulates button clicks with [`Pilot.click`][textual.pilot.Pilot.click]. + +After simulating a user interaction, Textual tests will typically check the state has been updated with an `assert` statement. +The `pytest` module will record any failures of these assert statements as a test fail. + +If you run the tests with `pytest test_rgb.py` you should get 2 passes, which will confirm that the user will be able to click buttons or press the keys to change the background color. + +If you later update this app, and accidentally break this functionality, one or more of your tests will fail. +Knowing which test has failed will help you quickly track down where your code was broken. + +## Simulating key presses + +We've seen how the [`press`][textual.pilot.Pilot] method simulates keys. +You can also supply multiple keys to simulate the user typing in to the app. +Here's an example of simulating the user typing the word "hello". + +```python +await pilot.press("h", "e", "l", "l", "o") +``` + +Each string creates a single keypress. +You can also use the name for non-printable keys (such as "enter") and the "ctrl+" modifier. +These are the same identifiers as used for key events, which you can experiment with by running `textual keys`. + +## Simulating clicks + +You can simulate mouse clicks in a similar way with [`Pilot.click`][textual.pilot.Pilot.click]. +If you supply a CSS selector Textual will simulate clicking on the matching widget. + +!!! note + + If there is another widget in front of the widget you want to click, you may end up clicking the topmost widget rather than the widget indicated in the selector. + This is generally what you want, because a real user would experience the same thing. + +### Clicking the screen + +If you don't supply a CSS selector, then the click will be relative to the screen. +For example, the following simulates a click at (0, 0): + +```python +await pilot.click() +``` + +### Click offsets + +If you supply an `offset` value, it will be added to the coordinates of the simulated click. +For example the following line would simulate a click at the coordinates (10, 5). + + +```python +await pilot.click(offset=(10, 5)) +``` + +If you combine this with a selector, then the offset will be relative to the widget. +Here's how you would click the line *above* a button. + +```python +await pilot.click(Button, offset(0, -1)) +``` + +### Modifier keys + +You can simulate clicks in combination with modifier keys, by setting the `shift`, `meta`, or `control` parameters. +Here's how you could simulate ctrl-clicking a widget with an id of "slider": + +```python +await pilot.click("#slider", control=True) +``` + +## Changing the screen size + +The default size of a simulated app is (80, 24). +You may want to test what happens when the app has a different size. +To do this, set the `size` parameter of [`run_test`][textual.app.App.run_test] to a different size. +For example, here is how you would simulate a terminal resized to 100 columns and 50 lines: + +```python +async with app.run_test(size=(100, 50)) as pilot: + ... +``` + +## Pausing the pilot + +Some actions in a Textual app won't change the state immediately. +For instance, messages may take a moment to bubble from the widget that sent them. +If you were to post a message and immediately `assert` you may find that it fails because the message hasn't yet been processed. + +You can generally solve this by calling [`pause()`][textual.pilot.Pilot.pause] which will wait for all pending messages to be processed. +You can also supply a `delay` parameter, which will insert a delay prior to waiting for pending messages. + + +## Textual's test + +Textual itself has a large battery of tests. +If you are interested in how we write tests, see the [tests/](https://github.com/Textualize/textual/tree/main/tests) directory in the Textual repository. diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 2e688c3088..3e7c060583 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -1,212 +1,213 @@ nav: - - Introduction: - - "index.md" - - "getting_started.md" - - "help.md" - - "tutorial.md" - - Guide: - - "guide/index.md" - - "guide/devtools.md" - - "guide/app.md" - - "guide/styles.md" - - "guide/CSS.md" - - "guide/design.md" - - "guide/queries.md" - - "guide/layout.md" - - "guide/events.md" - - "guide/input.md" - - "guide/actions.md" - - "guide/reactivity.md" - - "guide/widgets.md" - - "guide/animation.md" - - "guide/screens.md" - - "guide/workers.md" - - "guide/command_palette.md" - - "widget_gallery.md" - - Reference: - - "reference/index.md" - - CSS Types: - - "css_types/index.md" - - "css_types/border.md" - - "css_types/color.md" - - "css_types/horizontal.md" - - "css_types/integer.md" - - "css_types/name.md" - - "css_types/number.md" - - "css_types/overflow.md" - - "css_types/percentage.md" - - "css_types/scalar.md" - - "css_types/text_align.md" - - "css_types/text_style.md" - - "css_types/vertical.md" - - Events: - - "events/index.md" - - "events/blur.md" - - "events/descendant_blur.md" - - "events/descendant_focus.md" - - "events/enter.md" - - "events/focus.md" - - "events/hide.md" - - "events/key.md" - - "events/leave.md" - - "events/load.md" - - "events/mount.md" - - "events/mouse_capture.md" - - "events/click.md" - - "events/mouse_down.md" - - "events/mouse_move.md" - - "events/mouse_release.md" - - "events/mouse_scroll_down.md" - - "events/mouse_scroll_up.md" - - "events/mouse_up.md" - - "events/paste.md" - - "events/resize.md" - - "events/screen_resume.md" - - "events/screen_suspend.md" - - "events/show.md" - - Styles: - - "styles/align.md" - - "styles/background.md" - - "styles/border.md" - - "styles/border_subtitle_align.md" - - "styles/border_subtitle_background.md" - - "styles/border_subtitle_color.md" - - "styles/border_subtitle_style.md" - - "styles/border_title_align.md" - - "styles/border_title_background.md" - - "styles/border_title_color.md" - - "styles/border_title_style.md" - - "styles/box_sizing.md" - - "styles/color.md" - - "styles/content_align.md" - - "styles/display.md" - - "styles/dock.md" - - "styles/index.md" - - Grid: - - "styles/grid/index.md" - - "styles/grid/column_span.md" - - "styles/grid/grid_columns.md" - - "styles/grid/grid_gutter.md" - - "styles/grid/grid_rows.md" - - "styles/grid/grid_size.md" - - "styles/grid/row_span.md" - - "styles/height.md" - - "styles/layer.md" - - "styles/layers.md" - - "styles/layout.md" - - Links: - - "styles/links/index.md" - - "styles/links/link_background.md" - - "styles/links/link_color.md" - - "styles/links/link_hover_background.md" - - "styles/links/link_hover_color.md" - - "styles/links/link_hover_style.md" - - "styles/links/link_style.md" - - "styles/margin.md" - - "styles/max_height.md" - - "styles/max_width.md" - - "styles/min_height.md" - - "styles/min_width.md" - - "styles/offset.md" - - "styles/opacity.md" - - "styles/outline.md" - - "styles/overflow.md" - - "styles/padding.md" - - Scrollbar colors: - - "styles/scrollbar_colors/index.md" - - "styles/scrollbar_colors/scrollbar_background.md" - - "styles/scrollbar_colors/scrollbar_background_active.md" - - "styles/scrollbar_colors/scrollbar_background_hover.md" - - "styles/scrollbar_colors/scrollbar_color.md" - - "styles/scrollbar_colors/scrollbar_color_active.md" - - "styles/scrollbar_colors/scrollbar_color_hover.md" - - "styles/scrollbar_colors/scrollbar_corner_color.md" - - "styles/scrollbar_gutter.md" - - "styles/scrollbar_size.md" - - "styles/text_align.md" - - "styles/text_opacity.md" - - "styles/text_style.md" - - "styles/tint.md" - - "styles/visibility.md" - - "styles/width.md" - - Widgets: - - "widgets/button.md" - - "widgets/checkbox.md" - - "widgets/collapsible.md" - - "widgets/content_switcher.md" - - "widgets/data_table.md" - - "widgets/digits.md" - - "widgets/directory_tree.md" - - "widgets/footer.md" - - "widgets/header.md" - - "widgets/index.md" - - "widgets/input.md" - - "widgets/label.md" - - "widgets/list_item.md" - - "widgets/list_view.md" - - "widgets/loading_indicator.md" - - "widgets/log.md" - - "widgets/markdown_viewer.md" - - "widgets/markdown.md" - - "widgets/option_list.md" - - "widgets/placeholder.md" - - "widgets/pretty.md" - - "widgets/progress_bar.md" - - "widgets/radiobutton.md" - - "widgets/radioset.md" - - "widgets/rich_log.md" - - "widgets/rule.md" - - "widgets/select.md" - - "widgets/selection_list.md" - - "widgets/sparkline.md" - - "widgets/static.md" - - "widgets/switch.md" - - "widgets/tabbed_content.md" - - "widgets/tabs.md" - - "widgets/tree.md" - - API: - - "api/index.md" - - "api/app.md" - - "api/await_remove.md" - - "api/binding.md" - - "api/color.md" - - "api/command.md" - - "api/containers.md" - - "api/coordinate.md" - - "api/dom_node.md" - - "api/events.md" - - "api/errors.md" - - "api/filter.md" - - "api/fuzzy_matcher.md" - - "api/geometry.md" - - "api/logger.md" - - "api/logging.md" - - "api/map_geometry.md" - - "api/message_pump.md" - - "api/message.md" - - "api/on.md" - - "api/pilot.md" - - "api/query.md" - - "api/reactive.md" - - "api/screen.md" - - "api/scrollbar.md" - - "api/scroll_view.md" - - "api/strip.md" - - "api/suggester.md" - - "api/system_commands_source.md" - - "api/timer.md" - - "api/types.md" - - "api/validation.md" - - "api/walk.md" - - "api/widget.md" - - "api/work.md" - - "api/worker.md" - - "api/worker_manager.md" - - "How To": - - "how-to/index.md" - - "how-to/center-things.md" - - "how-to/design-a-layout.md" - - "FAQ.md" - - "roadmap.md" - - "Blog": - - blog/index.md + - Introduction: + - "index.md" + - "getting_started.md" + - "help.md" + - "tutorial.md" + - Guide: + - "guide/index.md" + - "guide/devtools.md" + - "guide/app.md" + - "guide/styles.md" + - "guide/CSS.md" + - "guide/design.md" + - "guide/queries.md" + - "guide/layout.md" + - "guide/events.md" + - "guide/input.md" + - "guide/actions.md" + - "guide/reactivity.md" + - "guide/widgets.md" + - "guide/animation.md" + - "guide/screens.md" + - "guide/workers.md" + - "guide/command_palette.md" + - "guide/testing.md" + - "widget_gallery.md" + - Reference: + - "reference/index.md" + - CSS Types: + - "css_types/index.md" + - "css_types/border.md" + - "css_types/color.md" + - "css_types/horizontal.md" + - "css_types/integer.md" + - "css_types/name.md" + - "css_types/number.md" + - "css_types/overflow.md" + - "css_types/percentage.md" + - "css_types/scalar.md" + - "css_types/text_align.md" + - "css_types/text_style.md" + - "css_types/vertical.md" + - Events: + - "events/index.md" + - "events/blur.md" + - "events/descendant_blur.md" + - "events/descendant_focus.md" + - "events/enter.md" + - "events/focus.md" + - "events/hide.md" + - "events/key.md" + - "events/leave.md" + - "events/load.md" + - "events/mount.md" + - "events/mouse_capture.md" + - "events/click.md" + - "events/mouse_down.md" + - "events/mouse_move.md" + - "events/mouse_release.md" + - "events/mouse_scroll_down.md" + - "events/mouse_scroll_up.md" + - "events/mouse_up.md" + - "events/paste.md" + - "events/resize.md" + - "events/screen_resume.md" + - "events/screen_suspend.md" + - "events/show.md" + - Styles: + - "styles/align.md" + - "styles/background.md" + - "styles/border.md" + - "styles/border_subtitle_align.md" + - "styles/border_subtitle_background.md" + - "styles/border_subtitle_color.md" + - "styles/border_subtitle_style.md" + - "styles/border_title_align.md" + - "styles/border_title_background.md" + - "styles/border_title_color.md" + - "styles/border_title_style.md" + - "styles/box_sizing.md" + - "styles/color.md" + - "styles/content_align.md" + - "styles/display.md" + - "styles/dock.md" + - "styles/index.md" + - Grid: + - "styles/grid/index.md" + - "styles/grid/column_span.md" + - "styles/grid/grid_columns.md" + - "styles/grid/grid_gutter.md" + - "styles/grid/grid_rows.md" + - "styles/grid/grid_size.md" + - "styles/grid/row_span.md" + - "styles/height.md" + - "styles/layer.md" + - "styles/layers.md" + - "styles/layout.md" + - Links: + - "styles/links/index.md" + - "styles/links/link_background.md" + - "styles/links/link_color.md" + - "styles/links/link_hover_background.md" + - "styles/links/link_hover_color.md" + - "styles/links/link_hover_style.md" + - "styles/links/link_style.md" + - "styles/margin.md" + - "styles/max_height.md" + - "styles/max_width.md" + - "styles/min_height.md" + - "styles/min_width.md" + - "styles/offset.md" + - "styles/opacity.md" + - "styles/outline.md" + - "styles/overflow.md" + - "styles/padding.md" + - Scrollbar colors: + - "styles/scrollbar_colors/index.md" + - "styles/scrollbar_colors/scrollbar_background.md" + - "styles/scrollbar_colors/scrollbar_background_active.md" + - "styles/scrollbar_colors/scrollbar_background_hover.md" + - "styles/scrollbar_colors/scrollbar_color.md" + - "styles/scrollbar_colors/scrollbar_color_active.md" + - "styles/scrollbar_colors/scrollbar_color_hover.md" + - "styles/scrollbar_colors/scrollbar_corner_color.md" + - "styles/scrollbar_gutter.md" + - "styles/scrollbar_size.md" + - "styles/text_align.md" + - "styles/text_opacity.md" + - "styles/text_style.md" + - "styles/tint.md" + - "styles/visibility.md" + - "styles/width.md" + - Widgets: + - "widgets/button.md" + - "widgets/checkbox.md" + - "widgets/collapsible.md" + - "widgets/content_switcher.md" + - "widgets/data_table.md" + - "widgets/digits.md" + - "widgets/directory_tree.md" + - "widgets/footer.md" + - "widgets/header.md" + - "widgets/index.md" + - "widgets/input.md" + - "widgets/label.md" + - "widgets/list_item.md" + - "widgets/list_view.md" + - "widgets/loading_indicator.md" + - "widgets/log.md" + - "widgets/markdown_viewer.md" + - "widgets/markdown.md" + - "widgets/option_list.md" + - "widgets/placeholder.md" + - "widgets/pretty.md" + - "widgets/progress_bar.md" + - "widgets/radiobutton.md" + - "widgets/radioset.md" + - "widgets/rich_log.md" + - "widgets/rule.md" + - "widgets/select.md" + - "widgets/selection_list.md" + - "widgets/sparkline.md" + - "widgets/static.md" + - "widgets/switch.md" + - "widgets/tabbed_content.md" + - "widgets/tabs.md" + - "widgets/tree.md" + - API: + - "api/index.md" + - "api/app.md" + - "api/await_remove.md" + - "api/binding.md" + - "api/color.md" + - "api/command.md" + - "api/containers.md" + - "api/coordinate.md" + - "api/dom_node.md" + - "api/events.md" + - "api/errors.md" + - "api/filter.md" + - "api/fuzzy_matcher.md" + - "api/geometry.md" + - "api/logger.md" + - "api/logging.md" + - "api/map_geometry.md" + - "api/message_pump.md" + - "api/message.md" + - "api/on.md" + - "api/pilot.md" + - "api/query.md" + - "api/reactive.md" + - "api/screen.md" + - "api/scrollbar.md" + - "api/scroll_view.md" + - "api/strip.md" + - "api/suggester.md" + - "api/system_commands_source.md" + - "api/timer.md" + - "api/types.md" + - "api/validation.md" + - "api/walk.md" + - "api/widget.md" + - "api/work.md" + - "api/worker.md" + - "api/worker_manager.md" + - "How To": + - "how-to/index.md" + - "how-to/center-things.md" + - "how-to/design-a-layout.md" + - "FAQ.md" + - "roadmap.md" + - "Blog": + - blog/index.md diff --git a/src/textual/app.py b/src/textual/app.py index f98d98f482..e89a74cc80 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1196,9 +1196,9 @@ async def run_test( notifications: bool = False, message_hook: Callable[[Message], None] | None = None, ) -> AsyncGenerator[Pilot, None]: - """An asynchronous context manager for testing app. + """An asynchronous context manager for testing apps. - Use this to run your app in "headless" (no output) mode and driver the app via a [Pilot][textual.pilot.Pilot] object. + Use this to run your app in "headless" mode (no output) and drive the app via a [Pilot][textual.pilot.Pilot] object. Example: diff --git a/src/textual/pilot.py b/src/textual/pilot.py index a94b41a908..685186d5eb 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -13,13 +13,12 @@ from ._wait import wait_for_idle from .app import App, ReturnType from .events import Click, MouseDown, MouseMove, MouseUp -from .geometry import Offset from .widget import Widget def _get_mouse_message_arguments( target: Widget, - offset: Offset = Offset(), + offset: tuple[int, int] = (0, 0), button: int = 0, shift: bool = False, meta: bool = False, @@ -74,7 +73,7 @@ async def press(self, *keys: str) -> None: async def click( self, selector: type[Widget] | str | None = None, - offset: Offset = Offset(), + offset: tuple[int, int] = (0, 0), shift: bool = False, meta: bool = False, control: bool = False, @@ -112,7 +111,7 @@ async def click( async def hover( self, selector: type[Widget] | str | None | None = None, - offset: Offset = Offset(), + offset: tuple[int, int] = (0, 0), ) -> None: """Simulate hovering with the mouse cursor.