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>
This commit is contained in:
Will McGugan 2023-09-17 10:34:32 +01:00 committed by GitHub
parent 31eaf3ffb8
commit b99da2d6b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 469 additions and 217 deletions

View file

@ -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()

View file

@ -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")

168
docs/guide/testing.md Normal file
View file

@ -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.

View file

@ -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

View file

@ -1196,9 +1196,9 @@ class App(Generic[ReturnType], DOMNode):
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:

View file

@ -13,13 +13,12 @@ import rich.repr
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 @@ class Pilot(Generic[ReturnType]):
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 @@ class Pilot(Generic[ReturnType]):
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.