1. 项目概述为什么需要一个“最简”的自动化测试项目做UI自动化测试的同行估计都经历过这样的阶段一开始雄心勃勃想搭建一个“大而全”的测试框架把页面对象模型PO、数据驱动、测试报告、持续集成一股脑全塞进去。结果往往是框架还没搭完项目需求已经变了或者框架过于复杂新同事上手要花一周时间看文档最后大家宁愿回归手动测试。我自己带团队踩过这个坑所以后来我们定了个规矩任何新项目的自动化都必须从一个“最简可行产品”MVP开始。这个“最简 pytest 自动化测试项目”就是基于这个理念设计的。它的核心目标不是展示多么高深的技术而是解决一个最实际的问题如何用最小的启动成本让UI自动化测试立刻跑起来并具备可持续扩展的骨架。我们选择pytest作为测试框架而不是unittest原因很简单pytest的夹具fixture机制、丰富的插件生态比如pytest-html生成报告、pytest-xdist并行测试以及更简洁的断言写法能让测试代码更干净、更易维护。对于UI测试我们通常会搭配Selenium或Playwright这里为了极致简单和现代性我会以Playwright为例因为它开箱即用无需额外管理浏览器驱动。这个项目适合谁呢如果你是测试新手想快速了解UI自动化测试的完整流程如果你是开发人员需要为自己负责的模块补充一些冒烟测试或者你是测试负责人正在为团队寻找一个轻量级、不折腾的标准化起点——那么这个最简项目都能给你一个清晰的、可立即执行的蓝图。它剥离了所有“高级”但可能过早优化的部分只保留核心路径打开浏览器、执行操作、断言结果、生成报告。2. 项目骨架设计与核心思路拆解2.1 为什么是“pytest Playwright”这个组合在开始写代码之前我们先聊聊选型。UI自动化测试框架有很多老牌的Selenium依然强大新兴的Cypress专注于WebAppium负责移动端。这里选择Playwright是经过实战权衡的。首先安装和配置的简易性是“最简项目”的首要考量。Selenium需要你额外下载对应浏览器版本的WebDriver并配置路径这对新手是个门槛也容易在团队协作时因环境不一致而出错。Playwright通过playwright install命令一键安装浏览器Chromium, Firefox, WebKit所有依赖都封装在库内保证了环境的一致性。其次自动等待机制。Selenium的隐式/显式等待需要手动编写写不好就是满屏的time.sleep(10)测试既慢又不稳定。Playwright的大多数操作如click,fill内置了智能等待它会等待元素可操作可见、可点击、稳定后再执行极大地减少了因页面加载或动画导致的“元素找不到”的失败。再者多浏览器、多上下文支持。Playwright原生支持无头模式、模拟移动设备、拦截网络请求等这些能力在后续测试场景扩展时非常有用。而pytest的夹具系统能完美地管理Playwright的浏览器、上下文和页面实例的生命周期让测试代码既独立又高效。这个组合的思路是用pytest组织测试用例和提供支撑设施夹具、参数化用Playwright作为稳定可靠的浏览器操作引擎。我们先搭建一个能跑通的“hello world”再逐步往里添加必要的“肌肉”。2.2 最简项目目录结构解析一个清晰、标准的目录结构是项目可维护性的基础。它不需要复杂但必须逻辑分明。下面是我们这个最简项目的骨架ui_auto_project/ ├── conftest.py # pytest 共享夹具定义核心配置文件 ├── requirements.txt # 项目依赖清单 ├── pages/ # 可选预留页面对象模型目录 │ └── __init__.py ├── test_cases/ # 测试用例存放目录 │ ├── __init__.py │ └── test_login.py # 示例测试模块 ├── fixtures/ # 可选预留自定义夹具目录 │ └── __init__.py ├── reports/ # 测试报告输出目录自动生成 └── utils/ # 可选预留工具函数目录 └── __init__.py现在看起来有点空但别急我们一步步填充。关键文件只有三个conftest.py,requirements.txt, 和test_cases/test_login.py。其他目录pages,fixtures,utils是为你后续扩展预留的位置在“最简”版本里我们可以先不创建它们或者创建空目录和__init__.py文件占位。reports目录会在首次执行测试并生成报告时自动创建。注意conftest.py这个文件名是pytest的约定它会被自动发现并加载其中定义的夹具可以被整个项目包括子目录的测试用例使用。这是实现“最简”的关键我们将浏览器和页面的管理逻辑都封装在这里。3. 环境搭建与核心依赖安装3.1 一步到位的依赖管理让我们从创建虚拟环境开始这是Python项目的最佳实践可以避免包版本冲突。打开你的终端命令行执行以下步骤# 1. 创建项目目录并进入 mkdir ui_auto_project cd ui_auto_project # 2. 创建虚拟环境以venv为例也可用conda python -m venv venv # 3. 激活虚拟环境 # Windows: venv\Scripts\activate # Linux/Mac: source venv/bin/activate # 4. 创建 requirements.txt 文件并写入核心依赖requirements.txt文件的内容如下pytest7.0.0 playwright1.40.0 pytest-html4.0.0 pytest-xdist3.0.0pytest: 测试框架本体。playwright: 浏览器自动化库。pytest-html: 用于生成美观的HTML测试报告。pytest-xdist: 用于后续实现测试用例并行执行提升效率。虽然“最简项目”初期可能用不上但提前安装好为扩展留出接口。接着安装这些依赖并让Playwright安装它所需的浏览器# 5. 安装Python依赖包 pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple # 6. 安装Playwright的浏览器Chromium, Firefox, WebKit playwright install chromium # 我们主要用Chromium足够轻量实操心得playwright install可能会因为网络问题下载缓慢或失败。可以尝试设置环境变量来加速例如在终端执行set PLAYWRIGHT_DOWNLOAD_HOSThttps://npmmirror.com/mirrors/playwright(Windows) 或export PLAYWRIGHT_DOWNLOAD_HOSThttps://npmmirror.com/mirrors/playwright(Linux/Mac) 后再执行安装命令。这一步是很多新手卡住的地方务必确保浏览器安装成功。3.2 核心灵魂conftest.py 的编写conftest.py是这个项目的“大脑”它定义了测试的运行时环境。我们将在这里创建两个核心夹具一个用于管理浏览器实例一个用于为每个测试用例提供全新的页面Page对象。# conftest.py import pytest from playwright.sync_api import Page, Browser, BrowserContext, sync_playwright pytest.fixture(scopesession) def browser() - Browser: 会话级别的夹具整个测试会话只启动一次浏览器。 使用 yield 实现 teardown测试全部结束后关闭浏览器。 playwright sync_playwright().start() # 启动Chromium浏览器headlessFalse表示有界面便于调试。正式运行可设为True。 browser playwright.chromium.launch(headlessFalse, slow_mo500) # slow_mo 让操作变慢方便观察 yield browser # 将浏览器实例提供给测试用例 browser.close() playwright.stop() pytest.fixture def page(browser: Browser) - Page: 函数级别的夹具每个测试函数都会获得一个全新的页面上下文和Page对象。 这确保了测试之间的隔离性一个测试的cookie、localStorage不会影响另一个。 context browser.new_context() # 创建新的浏览器上下文 page context.new_page() # 在新上下文中创建新页面 yield page # 测试结束后自动关闭上下文清理环境 context.close()为什么这样设计browser夹具session作用域启动和关闭浏览器是非常耗时的操作。如果每个测试用例都开一次浏览器测试总时间会变得不可接受。将其设为session级别所有用例共享一个浏览器进程极大提升了执行速度。page夹具function作用域UI测试最怕用例间状态污染。A用例登录了B用例可能因为已登录状态而跳过登录步骤导致测试逻辑错误。为每个用例创建全新的context和page就像每次都用隐身模式打开一个新窗口保证了测试的独立性和可重复性。slow_mo参数在调试阶段将headless设为False并加上slow_mo500单位毫秒可以让每个Playwright操作延迟半秒执行你能清晰地看到自动化脚本每一步在做什么对于定位问题非常有帮助。线上运行时再改为headlessTrue。4. 编写第一个可运行的测试用例4.1 测试用例设计以登录场景为例理论说再多不如跑一个。我们以一个最常见的Web登录场景作为第一个测试用例。假设我们有一个测试用的登录页面这里使用https://www.saucedemo.com/一个公开的测试网站。在test_cases目录下创建test_login.py# test_cases/test_login.py def test_successful_login(page): 测试成功登录流程。 步骤1. 访问登录页 2. 输入用户名密码 3. 点击登录 4. 断言跳转后的页面包含特定元素 # 1. 导航到登录页面 page.goto(https://www.saucedemo.com/) # 2. 定位元素并操作 # Playwright 使用 CSS Selector, XPath, text 等多种定位方式这里用最直观的 placeholder 文本 page.locator([data-testusername]).fill(standard_user) page.locator([data-testpassword]).fill(secret_sauce) page.locator([data-testlogin-button]).click() # 3. 断言 - 登录成功后页面应跳转到库存页并且能看到购物车图标 # 使用 pytest 自带的 assert也可以使用 Playwright 的 expect assert page.is_visible([data-testshopping-cart-link]) # 更精确的断言检查URL是否包含/inventory.html assert /inventory.html in page.url def test_failed_login_with_wrong_password(page): 测试使用错误密码登录的失败场景。 预期页面显示错误信息且停留在登录页。 page.goto(https://www.saucedemo.com/) page.locator([data-testusername]).fill(standard_user) page.locator([data-testpassword]).fill(wrong_password) page.locator([data-testlogin-button]).click() # 断言错误信息出现 error_message page.locator([data-testerror]) assert error_message.is_visible() # 断言错误信息文本内容符合预期 assert Username and password do not match in error_message.inner_text() # 断言URL没有变化仍在登录页 assert page.url https://www.saucedemo.com/这个文件定义了两个测试函数。pytest会自动发现以test_开头的函数或方法并执行。每个函数都接收一个page参数这个参数就是我们之前在conftest.py中定义的page夹具pytest会自动注入。4.2 运行测试并查看结果现在激动人心的时刻到了。在项目根目录ui_auto_project下打开终端确保虚拟环境已激活然后运行pytest你会看到类似以下的输出 test session starts platform win32 -- Python 3.9.13, pytest-7.4.0, pluggy-1.0.0 rootdir: C:\path\to\ui_auto_project plugins: html-4.0.0, xdist-3.0.0 collected 2 items test_cases\test_login.py .. [100%] 2 passed in 8.12s 两个绿色的点..表示两个测试用例都通过了一个最简的、可工作的UI自动化测试项目已经搭建完成。你会看到一个Chromium浏览器窗口弹出自动完成登录操作然后关闭。注意事项第一次运行可能会稍慢因为Playwright需要初始化。如果测试失败请检查网络是否能正常访问https://www.saucedemo.com/。浏览器是否成功安装playwright install chromium是否执行成功。网站页面结构是否发生变化虽然这个测试站比较稳定但也不是绝对的。这时就需要更新你的元素定位器了。5. 增强项目生成HTML报告与常用配置5.1 生成直观的HTML测试报告控制台输出对于调试是好的但对于给领导或团队分享结果就不够直观了。pytest-html插件可以生成漂亮的HTML报告。我们修改一下运行命令pytest --htmlreports/report.html --self-contained-html--htmlreports/report.html: 指定HTML报告的输出路径和文件名。--self-contained-html: 将CSS样式等内嵌到HTML文件中生成单个文件方便分享。运行后打开reports/report.html文件你会看到一个包含测试通过率、耗时、每个用例状态通过/失败/跳过的详细报告。如果用例失败报告还会包含失败瞬间的截图需要额外配置和错误堆栈这对于排查问题至关重要。5.2 创建 pytest.ini 统一运行配置每次都输入一长串命令行参数很麻烦。我们可以在项目根目录创建一个pytest.ini文件将常用配置固化下来。# pytest.ini [pytest] # 自动发现测试文件的路径 testpaths test_cases # 定义命令行默认参数 addopts -v # 显示详细结果 --htmlreports/report.html --self-contained-html --captureno # 实时打印print信息调试时有用 # 配置日志 log_cli true log_cli_level INFO创建了这个文件后以后在项目根目录下只需要简单地输入pytest它就会自动应用addopts中的所有选项直接生成HTML报告。5.3 实现失败自动截图UI测试失败时光有错误日志是不够的我们需要知道当时的页面长什么样。我们可以通过扩展conftest.py中的夹具在测试失败时自动截图。# 在 conftest.py 中新增或修改 page 夹具 import pytest from playwright.sync_api import Page, Browser, sync_playwright import datetime pytest.fixture def page(browser: Browser, request) - Page: # 注意这里增加了 request 参数 context browser.new_context() page context.new_page() yield page # 在 teardown 阶段检查测试是否失败 if request.node.rep_call.failed if hasattr(request.node, rep_call) else False: # 生成带有时间戳的截图文件名 timestamp datetime.datetime.now().strftime(%Y%m%d_%H%M%S) test_name request.node.name screenshot_path freports/screenshots/failure_{test_name}_{timestamp}.png # 确保截图目录存在 import os os.makedirs(os.path.dirname(screenshot_path), exist_okTrue) page.screenshot(pathscreenshot_path, full_pageTrue) print(f\n*** 测试失败截图已保存至: {screenshot_path} ***) context.close() # 这个钩子函数用于在测试调用后存储结果 pytest.hookimpl(tryfirstTrue, hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield rep outcome.get_result() # 将结果存储到节点中供 page 夹具访问 setattr(item, rep_ rep.when, rep)这段代码做了几件事为page夹具增加了request参数它可以访问当前测试用例的信息。定义了一个pytest_runtest_makereport钩子它在每个测试阶段setup, call, teardown后运行并将结果报告对象附加到测试用例节点item上。在page夹具的teardownyield之后部分检查测试的call阶段是否失败rep_call.failed。如果失败则使用page.screenshot()截取整个页面的图片并以测试名和时间戳命名保存到reports/screenshots/目录下。现在当任何测试失败时你都能在控制台看到截图保存的路径并在reports/screenshots/目录下找到它直观地看到失败时的页面状态。6. 从“最简”到“可用”关键模式与最佳实践6.1 元素定位策略与等待机制元素定位是UI自动化的基石不稳定的定位是测试脚本最大的天敌。Playwright提供了多种定位器Locator比Selenium的find_element更强大。1. 优先使用语义化属性避免使用脆弱的XPath或易变的CSS类名。优先使用开发专门为测试添加的属性如># 好使用自定义测试属性最稳定 page.locator([data-testidlogin-button]).click() # 中使用角色role和名称name适用于ARIA元素 page.locator(button, has_text登录).click() # 结合文本 page.get_by_role(button, name登录).click() # Playwright推荐的新API # 差依赖可能变化的类名或复杂XPath page.locator(.btn.btn-primary.mt-20).click() page.locator(//*[idroot]/div/div[2]/form/button).click()2. 拥抱内置等待告别time.sleepPlaywright的定位器和操作click,fill,check等默认会等待元素可操作最多30秒。你几乎不需要手动写等待。# 这是正确的做法click内部会等待按钮可点击 page.locator([data-testsubmit]).click() # 除非必要否则不要这样做 import time time.sleep(5) # 死等效率低下且不可靠 page.locator([data-testsubmit]).click()如果确实需要等待特定条件如元素出现、消失、包含特定文本使用page.wait_for_selector或locator.wait_for。6.2 测试数据的管理与参数化硬编码的测试数据如用户名密码不利于维护和扩展。pytest的pytest.mark.parametrize装饰器是进行数据驱动测试的利器。# test_cases/test_login_parametrize.py import pytest # 将测试数据从测试逻辑中分离 login_test_data [ (standard_user, secret_sauce, True, 登录成功), (locked_out_user, secret_sauce, False, 用户被锁定), (problem_user, secret_sauce, True, 登录成功但用户有bug), (standard_user, wrong, False, 密码错误), ] pytest.mark.parametrize(username, password, expected_success, description, login_test_data) def test_login_with_multiple_users(page, username, password, expected_success, description): 使用参数化一次性运行多组登录数据测试。 page.goto(https://www.saucedemo.com/) page.locator([data-testusername]).fill(username) page.locator([data-testpassword]).fill(password) page.locator([data-testlogin-button]).click() if expected_success: assert page.is_visible([data-testshopping-cart-link]), f{description} 断言失败 else: # 失败时应该能看到错误信息框 assert page.is_visible([data-testerror]), f{description} 断言失败运行这个测试文件pytest会根据login_test_data列表的长度自动生成并运行4个独立的测试用例。在报告中每个用例都会有对应的参数显示一目了然。这是提升测试覆盖率和脚本复用性的核心手段。6.3 面向未来的架构预留Page Object Model (PO) 思想虽然我们的“最简项目”没有强制使用完整的页面对象模型PO但了解其思想并预留接口至关重要。PO的核心是将页面元素定位和页面操作行为封装成类测试脚本只调用这些类的方法不与具体的page.locator直接交互。这样做的好处是当页面UI发生变化时你只需要修改对应的PO类中的元素定位器所有测试用例都无需改动维护成本大大降低。我们可以在pages目录下预留一个登录页的PO类示例# pages/login_page.py class LoginPage: def __init__(self, page): self.page page self.username_input page.locator([data-testusername]) self.password_input page.locator([data-testpassword]) self.login_button page.locator([data-testlogin-button]) self.error_message page.locator([data-testerror]) def navigate(self): self.page.goto(https://www.saucedemo.com/) def login(self, username: str, password: str): self.username_input.fill(username) self.password_input.fill(password) self.login_button.click() def get_error_text(self) - str: if self.error_message.is_visible(): return self.error_message.inner_text() return 然后在测试用例中这样使用# test_cases/test_login_with_po.py from pages.login_page import LoginPage def test_login_with_po(page): login_page LoginPage(page) login_page.navigate() login_page.login(standard_user, secret_sauce) assert page.is_visible([data-testshopping-cart-link])可以看到测试用例变得非常简洁只关心业务逻辑“登录”不关心具体如何输入、点击。当登录按钮的定位器从[data-testlogin-button]变成[data-testidsubmit]时你只需要修改LoginPage类中的一行代码。这就是PO模式的价值。在项目初期你可以先不实现完整的PO但要有意识地将复杂的页面操作封装成函数为将来重构为PO做好准备。7. 常见问题排查与实战技巧实录7.1 “元素找不到”或“操作超时”问题深度排查这是UI自动化中最常见的问题没有之一。当你的测试报告TimeoutError时请按以下清单排查检查元素定位器是否唯一且正确动作在浏览器的开发者工具F12中使用CtrlF在Elements面板搜索你的定位器如[data-testusername]。预期应该高亮显示唯一的一个目标元素。如果匹配到多个或零个就需要调整定位器。技巧Playwright提供了一个无敌的调试工具——playwright codegen。在终端运行playwright codegen https://www.saucedemo.com它会打开一个浏览器和一个录制器。你在浏览器里的操作会被自动转换成代码并给出它推荐的定位器这是学习定位器写法的最佳方式。检查页面是否加载完成问题脚本执行太快页面或框架如React, Vue还没渲染完元素。解决page.goto()默认会等待load事件。但对于单页应用SPA可能需要等待更具体的条件。# 等待某个特定元素出现作为页面加载完成的标志 page.goto(https://example.com) page.wait_for_selector(#main-content, statevisible) # 等待主内容区可见 # 或者等待网络基本空闲 page.goto(https://example.com, wait_untilnetworkidle)检查是否有iframe、Shadow DOM或新窗口iframe需要先切换到iframe上下文才能操作其中的元素。# 通过iframe的name或选择器定位 frame page.frame(namelogin-frame) # 或者 frame page.frame_locator(iframe[titleLogin]).content_frame # 然后在frame上操作 frame.locator(input).fill(text)新窗口/标签页操作会打开新窗口需要切换上下文。# 点击一个打开新窗口的链接 with page.expect_popup() as popup_info: page.locator(a[target_blank]).click() new_page popup_info.value # 现在可以在 new_page 上操作了 new_page.locator(h1).inner_text()检查是否有动态内容或复杂交互有些元素是在用户执行某些操作如鼠标悬停后才出现的。你需要模拟这个操作。# 鼠标悬停触发下拉菜单 menu page.locator(.nav-menu) menu.hover() # 先悬停 # 等待下拉菜单出现后再点击其中的项 page.locator(.dropdown-item).click()7.2 测试稳定性提升技巧为操作增加重试机制对于某些偶发性的失败如网络波动可以简单重试。import tenacity from tenacity import retry, stop_after_attempt, wait_fixed retry(stopstop_after_attempt(3), waitwait_fixed(1)) def click_submit_with_retry(page): page.locator([data-testsubmit]).click() # 在测试中调用 click_submit_with_retry(page)但注意重试应谨慎使用它可能掩盖真正的bug。更好的方法是优化定位器和等待条件。使用更稳定的定位器组合不要依赖单一的定位策略。# 组合使用多种定位方式提高鲁棒性 # 优先用 testid没有则用 rolename再没有则用文本CSS选择器兜底 submit_btn (page.get_by_test_id(submit-button) or page.get_by_role(button, name提交) or page.locator(button.primary, has_text提交)) submit_btn.click()在CI/CD中稳定运行在无头模式headlessTrue下运行时可以配置一些浏览器启动参数来增加稳定性。# 在 conftest.py 的 browser 夹具中 browser playwright.chromium.launch( headlessTrue, args[ --disable-dev-shm-usage, # 克服Docker等环境下的资源限制问题 --no-sandbox, --disable-gpu, --window-size1920,1080 # 设置固定窗口大小 ] )7.3 测试报告与结果分析优化为报告添加更多上下文使用pytest的pytest.mark装饰器为测试用例打标签如pytest.mark.smoke冒烟测试、pytest.mark.regression回归测试。运行时可使用pytest -m smoke只运行冒烟用例。集成Allure报告如果团队对报告有更高要求可以集成Allure它能生成更美观、交互性更强的报告支持附件截图、日志、步骤Step描述、用例分层等。pip install allure-pytest pytest --alluredir./allure-results allure serve ./allure-results # 生成并打开本地报告失败重跑机制有些失败是环境偶发问题导致的可以使用pytest-rerunfailures插件自动重跑失败的用例。pip install pytest-rerunfailures pytest --reruns 2 --reruns-delay 1 # 失败后重试2次每次间隔1秒这个“最简项目”就像一颗种子它包含了UI自动化测试最核心的DNA环境隔离、用例组织、报告生成和基本的稳定性处理。围绕它你可以根据实际项目需求生长出数据驱动、页面对象、API混合测试、视觉回归测试、性能监控等丰富的分支。记住最好的框架不是一开始就设计得尽善尽美而是在解决真实问题的过程中逐步演化出来的。