Textual 应用测试指南:使用 Pilot 驱动交互测试与快照测试
【免费下载链接】textualThe lean application framework for Python. Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal and a web browser.项目地址: https://gitcode.com/gh_mirrors/te/textual
测试是软件开发中不可或缺的一环,本指南围绕 Textual 终端应用的自动化测试展开,覆盖从测试框架选型、借助App.run_test()与Pilot模拟按键和鼠标交互,到使用pytest-textual-snapshot插件进行视觉回归快照测试的完整链路。读完本文,你将能够为任意 Textual 应用编写可自动运行的单元测试与快照测试,在终端与浏览器中运行的界面都能得到持续验证。
为什么需要为 Textual 应用写测试
答案很直接:没有人"必须"写测试,代码一样可以运行。但在实践中,写测试几乎是每个严肃项目的标配。写出完全没有 bug 的代码几乎不可能,即使是经验丰富的开发者也不例外。测试的意义在于:
- 尽早发现 bug:在改动代码后迅速暴露回归问题;
- 建立信心:让开发者确信应用的行为符合预期;
- 定位故障:当测试失败时,能快速锁定被破坏的功能点。
一个典型的例子是:应用有多个按钮和快捷键,手工测试时逐个点击、逐个按键尚可接受,但在每次修改一行代码后都要重复全套手工验证就不现实了。自动化测试把这类重复劳动交给机器完成。
测试框架选型:pytest + pytest-asyncio
Textual 是基于 Pythonasyncio的异步框架,因此测试框架必须支持 asyncio 测试。Textual 本身不限定特定框架,但官方文档以 pytest 配合 pytest-asyncio 插件为例进行讲解。
默认情况下,pytest-asyncio 要求每个异步测试函数都要用@pytest.mark.asyncio装饰。如果不想在每个测试上都加这个标记,可以在 pytest 配置中设置:
# pyproject.toml 或 pytest.ini [tool.pytest.ini_options] asyncio_mode = "auto"也可以在运行 pytest 时传入--asyncio-mode=auto选项。开启 auto 模式后,所有async def test_*函数都会被自动识别为异步测试,无需逐个装饰。
从一个示例应用开始:RGBApp
为了演示 Textual 的测试能力,官方文档提供了一个简单的 RGB 应用。该应用展示三个按钮,分别标注 "red"、"green"、"blue",点击按钮或按下r、g、b键会改变应用背景色。完整源码位于 rgb.py:
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()应用通过BINDINGS把r/g/b三个键映射到switch_color动作,同时通过@on(Button.Pressed)装饰器把按钮点击转发到同一个动作,最终都调用action_switch_color修改self.screen.styles.background。
run_test:headless 模式运行应用
要自动化测试上述应用,核心 API 是App.run_test()方法(实现见 src/textual/app.py)。它替代常规的run()调用,以headless(无头)模式运行应用——不更新终端输出,但其余行为与正常一致。
run_test()是一个异步上下文管理器,返回一个Pilot对象(实现见 src/textual/pilot.py),通过它可以像使用键盘鼠标操作应用一样驱动程序。
从源码看,run_test()的完整签名还支持若干可选参数:
async def run_test( self, *, headless: bool = True, size: tuple[int, int] | None = (80, 24), tooltips: bool = False, notifications: bool = False, message_hook: Callable[[Message], None] | None = None, ) -> AsyncGenerator[Pilot[ReturnType], None]:headless:是否无头运行(默认True,不产生任何终端输出);size:强制终端尺寸为(宽, 高),传None则自动探测;tooltips:测试时是否启用 tooltip;notifications:测试时是否启用通知;message_hook:每次消息到达任意 message pump 时被调用的回调,可用于观测消息流。
其内部流程是:创建一个后台任务运行应用的消息循环,等待app_ready_event就绪后,创建Pilot并yield给测试代码;退出上下文时干净地关闭应用,并把应用内部捕获的异常重新抛出,以便测试框架感知。
编写第一个测试:按键与点击
官方文档提供了配套测试 test_rgb.py:
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")代码中的关键点:
run_test()必须在协程中调用,因此测试函数必须使用async def;app.run_test()运行应用并返回Pilot实例用于交互;await pilot.press("r")模拟按下r键;assert断言背景色确实变为红色;await pilot.click("#red")模拟点击id为red的按钮(即 "Red" 按钮)。
测试完成后运行pytest test_rgb.py,应当得到 2 个通过的测试(test_keys与test_buttons)。值得注意的是test_keys中还测试了按x键的情况——由于没有对应绑定,背景色保持蓝色不变,这验证了"未绑定的按键不应产生效果"这一行为。
模拟交互后,测试通常用assert检查状态是否已更新,pytest 会把失败的断言记录为测试失败。如果以后改动应用意外破坏了功能,相应测试会失败,帮助快速定位问题位置。
模拟按键:Pilot.press
Pilot.press支持一次传入多个按键字符串,每个字符串产生一次按键事件,可以用来模拟用户连续输入。例如模拟输入单词 "hello":
await pilot.press("h", "e", "l", "l", "o")按键标识符与按键事件使用的名称一致:非打印键使用其名称(如"enter"),支持"ctrl+"等修饰键前缀。这些标识符可以通过运行textual keys命令交互式实验。
从源码看(src/textual/pilot.py),press内部调用app._press_keys(keys),随后等待屏幕处理完所有待处理事件(_wait_for_screen),确保按键的效果已经生效后再返回,因此断言前无需额外等待。
模拟点击:Pilot.click
Pilot.click的完整签名(src/textual/pilot.py):
async def click( self, widget: Widget | type[Widget] | str | None = None, offset: tuple[int, int] = (0, 0), shift: bool = False, meta: bool = False, control: bool = False, times: int = 1, button: int = 1, ) -> bool:传入 CSS 选择器时,Textual 会模拟点击匹配到的 widget。需要注意:如果目标 widget 前面还有别的 widget 遮挡,实际点击到的可能是最上层的 widget 而非选择器指定的那个——这通常正是我们想要的,因为真实用户也会经历同样的行为。
点击屏幕
不传选择器时,点击坐标相对于屏幕。例如下面这行模拟在 (0, 0) 处点击:
await pilot.click()点击偏移
offset参数会加到模拟点击的坐标上。例如下面的代码模拟在坐标 (10, 5) 处点击:
await pilot.click(offset=(10, 5))若同时传入选择器,偏移量相对于该 widget。下面这行会点击按钮上方一行(偏移(0, -1)):
await pilot.click(Button, offset=(0, -1))双击与三击
通过times参数模拟双击和三击:
await pilot.click(Button, times=2) # Double click await pilot.click(Button, times=3) # Triple click源码中还提供了便捷别名double_click()与triple_click(),内部即调用click(..., times=2/3)。
修饰键
通过shift、meta、control参数模拟带修饰键的点击。例如模拟 ctrl 点击id为 "slider" 的 widget:
await pilot.click("#slider", control=True)从实现看,click内部通过_post_mouse_events依次派发MouseDown、MouseUp、Click事件(src/textual/pilot.py),并在派发前检查目标偏移是否落在屏幕可见区域内,越界会抛出OutOfBounds。返回值表示点击是否落在了指定 widget 上。
更改屏幕尺寸
模拟应用的默认尺寸是(80, 24)。如果应用在不同终端尺寸下表现不同,可以通过run_test的size参数指定。例如模拟终端被调整到 100 列 × 50 行:
async with app.run_test(size=(100, 50)) as pilot: ...测试过程中还可以用Pilot.resize_terminal(width, height)(src/textual/pilot.py)动态改变尺寸:它会更新 headless 驱动的大小并向应用投递Resize消息,随后pause()等待布局与重绘完成。
暂停与等待:Pilot.pause
Textual 应用中的某些操作不会立即改变状态。例如消息从发出它的 widget 冒泡到应用需要时间——如果投递消息后立刻assert,可能因消息尚未处理而失败。
通用的解决办法是调用pause()(src/textual/pilot.py),它等待所有待处理消息被处理完毕。也可以传入delay参数,先插入一段延时再等待待处理消息:
await pilot.pause() # 等待所有待处理消息处理完毕 await pilot.pause(delay=0.5) # 先等 0.5 秒,再等待消息处理完毕从实现看,pause依赖_wait_for_screen()(src/textual/pilot.py):它对应用及屏幕所有后代节点逐一注册call_later回调并计数,等待计数归零(意味着消息队列排空),带超时保护,超时会抛出WaitForScreenTimeout。delay=None时还会进一步wait_for_idle(0)等待 CPU 空闲,确保动画帧、定时器等异步工作也已收敛。
Pilot 还提供了wait_for_animation()与wait_for_scheduled_animations(),分别等待当前动画、当前及已排程动画全部完成,适合测试动画结束后的最终状态。
Textual 自身的测试体系
Textual 仓库自身带有大规模测试集,位于 tests/ 目录。如果你对官方如何组织测试感兴趣,可以直接翻阅其中的测试文件,例如:
- tests/test_app.py、tests/test_pilot.py 相关交互测试 覆盖
run_test与 Pilot 的用法; - tests/snapshot_tests/ 是 Textual 内部快照测试的主战场,包含 test_snapshots.py、存放快照应用的 snapshot_apps/ 以及存放比对基线 SVG 的snapshots/;
- tests/test_actions.py、tests/test_message_handling.py 等则验证消息与动作系统的行为。
这些测试本身就是学习如何测试 Textual 应用的绝佳范例。
快照测试:捕获视觉回归
快照测试(Snapshot Testing)的过程是:记录一次测试运行的输出,再与之前运行的输出进行比较。Textual 内部用快照测试保证内置 widget 在每次发布时外观与功能正确,并把构建的 pytest 插件开源为pytest-textual-snapshot供公众使用。
它的工作原理是:从你的应用生成一张 SVG 格式的"截图"(就像本文档中的那些示例图)。如果某次测试运行中截图发生变化,你就可以在视觉上对比新输出与旧输出的差异——这能捕获其他方式很难发现的视觉变化。
安装插件
使用你喜欢的包管理器(pip、poetry等)安装:
pip install pytest-textual-snapshot创建快照测试
安装后即可使用snap_compare这个 pytest fixture。下面以为 calculator.py(仓库自带的计算器示例应用)编写快照测试为例。
首先创建测试并指定应用 Python 文件的路径,该路径相对于测试文件的位置:
def test_calculator(snap_compare): assert snap_compare("path/to/calculator.py")正常运行 pytest:
pytest首次运行时,计算器的 SVG 截图会被生成,但测试会失败——快照测试在首次运行时必然失败,因为没有历史版本可供比对。
在浏览器中打开快照报告,会看到类似下图的内容(通常可以直接从终端点击链接打开;部分终端模拟器可能需要按住ctrl或command键才能点击链接):
报告会提示 "No history for this test"(该测试尚无历史记录)。此时需要人工确认初始快照是否正确,确认计算器渲染符合预期后,保存这份快照:
pytest --snapshot-update警告:只有在快照报告左侧的输出令你满意时,才应运行
pytest --snapshot-update。运行该命令相当于声明:"报告中的所有截图我都确认无误,它们将成为后续所有运行的比对基准(ground truth)"。因此--snapshot-update必须是在运行pytest并确认输出正常之后才执行。
快照保存后,再次运行不带参数的pytest,测试就会通过——因为本次运行生成的截图与保存的基准一致。
捕获一个真实的 bug
快照测试的真正威力在于捕获容易遗漏的视觉回归。
设想一位新开发者试图修改计算器,却意外破坏了样式,导致右侧按钮的橙色全部消失。当他运行pytest时,报告立刻揭示了问题:
右侧是"历史"快照(之前保存的基准),左侧是应用当前的渲染效果——显然不是预期结果。
点击报告右上角的 "Show difference" 开关,将两个版本叠加对比:
差异叠加视图还揭示了另一个快速目视检查很容易漏掉的问题:新开发者还删掉了数字 4!
提示:快照测试在所有受支持操作系统上的 CI 中都能正常工作,快照报告本身只是一个 HTML 文件,可以导出为构建产物(build artifact)。
快照前按键:press 参数
可以在截图前模拟按键,使用press参数:
def test_calculator_pressing_numbers(snap_compare): assert snap_compare("path/to/calculator.py", press=["1", "2", "3"])这与 Pilot 的press类似,按键发生在截图之前。快照文档中的计算器示例就是通过press="3,.,1,4,5,9,2,wait:400"这类带wait:毫秒的按键序列,在截图前先进行一系列交互。
更改终端尺寸:terminal_size 参数
要按不同终端尺寸截图,传入(width, height)元组作为terminal_size参数:
def test_calculator(snap_compare): assert snap_compare("path/to/calculator.py", terminal_size=(50, 100))运行自定义代码:run_before 参数
还可以在截图前执行任意代码,使用run_before参数。下面的示例在截图前把鼠标光标悬停到id为number-5的 widget 上:
def test_calculator_hover_number(snap_compare): async def run_before(pilot) -> None: await pilot.hover("#number-5") assert snap_compare("path/to/calculator.py", run_before=run_before)run_before接收一个以pilot为参数的异步函数,与上文介绍的 Pilot API 完全兼容,例如其中的hover方法对应 src/textual/pilot.py 的实现,它会在移动鼠标前先pause()让鼠标"落定"。
小结
- 测试 Textual 应用不需要特殊框架,只需一个支持 asyncio 的测试框架;
pytest+pytest-asyncio(配合asyncio_mode = auto)是最直接的选择; App.run_test()以 headless 模式启动应用并返回Pilot,通过press、click、hover、pause、resize_terminal等 API 完整模拟用户交互,再以assert校验状态;- 涉及异步时序时用
pause()等待待处理消息排空,涉及动画时用wait_for_animation(); - 需要防止视觉回归时,使用
pytest-textual-snapshot插件,通过snap_comparefixture 生成 SVG 快照,用--snapshot-update固化基准,配合press、terminal_size、run_before参数覆盖复杂交互场景。
把这些工具组合起来,就能为 Textual 应用建立一套从交互逻辑到视觉呈现的完整自动化测试防线。
【免费下载链接】textualThe lean application framework for Python. Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal and a web browser.项目地址: https://gitcode.com/gh_mirrors/te/textual
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考