news 2026/9/19 20:18:37

Textual 应用测试指南:使用 Pilot 驱动交互测试与快照测试

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Textual 应用测试指南:使用 Pilot 驱动交互测试与快照测试

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",点击按钮或按下rgb键会改变应用背景色。完整源码位于 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()

应用通过BINDINGSr/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就绪后,创建Pilotyield给测试代码;退出上下文时干净地关闭应用,并把应用内部捕获的异常重新抛出,以便测试框架感知。

编写第一个测试:按键与点击

官方文档提供了配套测试 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")

代码中的关键点:

  1. run_test()必须在协程中调用,因此测试函数必须使用async def
  2. app.run_test()运行应用并返回Pilot实例用于交互;
  3. await pilot.press("r")模拟按下r键;
  4. assert断言背景色确实变为红色;
  5. await pilot.click("#red")模拟点击idred的按钮(即 "Red" 按钮)。

测试完成后运行pytest test_rgb.py,应当得到 2 个通过的测试(test_keystest_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)

修饰键

通过shiftmetacontrol参数模拟带修饰键的点击。例如模拟 ctrl 点击id为 "slider" 的 widget:

await pilot.click("#slider", control=True)

从实现看,click内部通过_post_mouse_events依次派发MouseDownMouseUpClick事件(src/textual/pilot.py),并在派发前检查目标偏移是否落在屏幕可见区域内,越界会抛出OutOfBounds。返回值表示点击是否落在了指定 widget 上。

更改屏幕尺寸

模拟应用的默认尺寸是(80, 24)。如果应用在不同终端尺寸下表现不同,可以通过run_testsize参数指定。例如模拟终端被调整到 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回调并计数,等待计数归零(意味着消息队列排空),带超时保护,超时会抛出WaitForScreenTimeoutdelay=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 格式的"截图"(就像本文档中的那些示例图)。如果某次测试运行中截图发生变化,你就可以在视觉上对比新输出与旧输出的差异——这能捕获其他方式很难发现的视觉变化。

安装插件

使用你喜欢的包管理器(pippoetry等)安装:

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 截图会被生成,但测试会失败——快照测试在首次运行时必然失败,因为没有历史版本可供比对。

在浏览器中打开快照报告,会看到类似下图的内容(通常可以直接从终端点击链接打开;部分终端模拟器可能需要按住ctrlcommand键才能点击链接):

报告会提示 "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参数。下面的示例在截图前把鼠标光标悬停到idnumber-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,通过pressclickhoverpauseresize_terminal等 API 完整模拟用户交互,再以assert校验状态;
  • 涉及异步时序时用pause()等待待处理消息排空,涉及动画时用wait_for_animation()
  • 需要防止视觉回归时,使用pytest-textual-snapshot插件,通过snap_comparefixture 生成 SVG 快照,用--snapshot-update固化基准,配合pressterminal_sizerun_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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 20:15:40

水库除险加固设计说明编写指南:参数校验与Python自动化实践

简介:这份资源是一份水库除险加固设计说明文档,面向水利工程专业学生、设计人员及备考注册工程师的从业者,以PS水库为实例,系统梳理小(2)型五等工程在安全复核与加固设计中的关键问题与解决思路。资源包内含…

作者头像 李华
网站建设 2026/9/19 20:15:13

Hadoop 3.x伪分布式集群搭建:从环境配置到排错的全流程讲解

我见过太多人栽在Hadoop安装这道坎上。网上的教程一抓一大把,但大多只告诉你“敲什么命令”,不告诉你“为什么这么敲”。于是很多人照着一篇文章配完环境,start-dfs.sh一执行,报错一个接一个——NameNode起不来、DataNode闪退、端…

作者头像 李华
网站建设 2026/9/19 20:13:32

Codex本地AI工具配置指南:模型路由、协议适配与DeepSeek接入

1. 项目概述:Codex 是什么,它解决的是哪类真实问题?Codex 这个词,在当前技术社区里其实存在明显的语义漂移——它不再特指某一家公司的单一产品,而更像一个被泛化使用的功能型代称。从你提供的热搜词和网络热词来看&am…

作者头像 李华
网站建设 2026/9/19 20:13:03

Web前端脱敏实战:从工具函数到框架集成的防泄露指南

先说一个我去年遇到的真实事故。我们公司后台的客户列表页,一个客服同事要把一张订单截图发给客户核对信息,结果手滑把截图发到了客户群里。那张截图里,客户的手机号、家庭住址、身份证号全部是明文,清清楚楚。当天下午就有客户打…

作者头像 李华
网站建设 2026/9/19 20:11:26

Textual ListItem 详解:构建 ListView 列表项的核心组件

Textual ListItem 详解:构建 ListView 列表项的核心组件 【免费下载链接】textual The lean application framework for Python. Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal and a web browser. 项目地址: ht…

作者头像 李华