ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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-asyncioTextual 是基于 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-modeauto选项。开启 auto 模式后所有async def test_*函数都会被自动识别为异步测试无需逐个装饰。从一个示例应用开始RGBApp为了演示 Textual 的测试能力官方文档提供了一个简单的 RGB 应用。该应用展示三个按钮分别标注 red、green、blue点击按钮或按下r、g、b键会改变应用背景色。完整源码位于 rgb.pyfrom 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, idred) yield Button(Green, idgreen) yield Button(Blue, idblue) 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_testheadless 模式运行应用要自动化测试上述应用核心 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测试时是否启用 tooltipnotifications测试时是否启用通知message_hook每次消息到达任意 message pump 时被调用的回调可用于观测消息流。其内部流程是创建一个后台任务运行应用的消息循环等待app_ready_event就绪后创建Pilot并yield给测试代码退出上下文时干净地关闭应用并把应用内部捕获的异常重新抛出以便测试框架感知。编写第一个测试按键与点击官方文档提供了配套测试 test_rgb.pyfrom 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 defapp.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.pressPilot.press支持一次传入多个按键字符串每个字符串产生一次按键事件可以用来模拟用户连续输入。例如模拟输入单词 helloawait pilot.press(h, e, l, l, o)按键标识符与按键事件使用的名称一致非打印键使用其名称如enter支持ctrl等修饰键前缀。这些标识符可以通过运行textual keys命令交互式实验。从源码看src/textual/pilot.pypress内部调用app._press_keys(keys)随后等待屏幕处理完所有待处理事件_wait_for_screen确保按键的效果已经生效后再返回因此断言前无需额外等待。模拟点击Pilot.clickPilot.click的完整签名src/textual/pilot.pyasync 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, times2) # Double click await pilot.click(Button, times3) # Triple click源码中还提供了便捷别名double_click()与triple_click()内部即调用click(..., times2/3)。修饰键通过shift、meta、control参数模拟带修饰键的点击。例如模拟 ctrl 点击id为 slider 的 widgetawait pilot.click(#slider, controlTrue)从实现看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.pauseTextual 应用中的某些操作不会立即改变状态。例如消息从发出它的 widget 冒泡到应用需要时间——如果投递消息后立刻assert可能因消息尚未处理而失败。通用的解决办法是调用pause()src/textual/pilot.py它等待所有待处理消息被处理完毕。也可以传入delay参数先插入一段延时再等待待处理消息await pilot.pause() # 等待所有待处理消息处理完毕 await pilot.pause(delay0.5) # 先等 0.5 秒再等待消息处理完毕从实现看pause依赖_wait_for_screen()src/textual/pilot.py它对应用及屏幕所有后代节点逐一注册call_later回调并计数等待计数归零意味着消息队列排空带超时保护超时会抛出WaitForScreenTimeout。delayNone时还会进一步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)正常运行 pytestpytest首次运行时计算器的 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类似按键发生在截图之前。快照文档中的计算器示例就是通过press3,.,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_beforerun_before)run_before接收一个以pilot为参数的异步函数与上文介绍的 Pilot API 完全兼容例如其中的hover方法对应 src/textual/pilot.py 的实现它会在移动鼠标前先pause()让鼠标落定。小结测试 Textual 应用不需要特殊框架只需一个支持 asyncio 的测试框架pytestpytest-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),仅供参考
返回列表