苍穹外卖 UI 自动化测试实践
在软件测试领域,UI 自动化测试是保障产品质量、提高回归测试效率的重要手段。本文以 “苍穹外卖” 系统的菜品管理模块为例,详细介绍 UI 自动化测试的设计与实现过程,包括框架搭建、核心功能测试、问题解决方案等,希望能为同行提供参考。
一、项目背景与测试目标
1. 项目简介
“苍穹外卖” 是一款模拟餐饮外卖管理的系统,包含菜品管理、订单管理、用户管理等核心模块。其中,菜品管理模块负责菜品的新增、查询、编辑、删除等操作,是系统的核心功能之一,对稳定性和准确性要求极高。
2. 测试目标
- 实现菜品管理模块核心功能的自动化测试,覆盖增删改查场景;
- 解决人工回归测试效率低、重复劳动多的问题;
- 生成直观的测试报告,便于定位问题;
- 为其他模块(如订单管理)提供可复用的自动化测试框架。
3. 技术栈选择
结合项目特点和团队技术栈,选择以下工具 / 框架:
- 编程语言:Python(简洁易读,测试库丰富)
- 自动化工具:Selenium(主流 UI 自动化工具,支持多浏览器)
- 测试框架:Pytest(灵活的测试用例管理,支持参数化、Fixture 等)
- 报告工具:Allure(生成美观、详细的测试报告)
- 数据管理:YAML(用于存储测试用例数据,实现数据与代码分离)
- 浏览器驱动:EdgeDriver(适配 Edge 浏览器,与系统兼容性好)
二、自动化测试框架设计
采用Page Object 模式(PO 模式) 设计框架,将页面元素定位与业务逻辑分离,提高代码复用性和可维护性。框架结构如下:
selenium├── commons│ ├── excel_utils.py│ └── yaml_utils.py├── poms│ ├── __init__.py│ ├── base_page.py│ ├── dish_page.py│ └── login_page.py├── reports├── temps├── test_case│ ├── test_dish_management│ │ ├── img│ │ ├── test_add_invalid.yaml│ │ ├── test_add_valid.yaml│ │ ├── test_delete.yaml│ │ ├── test_dish_management.py│ │ └── test_search.yaml│ └── __init__.py├── conftest.py├── dish_case.xlsx├── msedgedriver.exe├── pytest.ini├── requirements.txt└── run.py
1.基础层(BasePage)
封装通用操作(点击、输入、等待等),所有页面类继承此类:
# poms/base_page.pyfrom selenium.webdriver.support.ui import WebDriverWaitfrom selenium.webdriver.support import expected_conditions as ECclass BasePage: def __init__(self, driver): self.driver = driver self.wait = WebDriverWait(driver, 10) # 显式等待超时时间 def click(self, locator): \"\"\"点击元素(显式等待元素可点击)\"\"\" element = self.wait.until(EC.element_to_be_clickable(locator)) element.click() def input_text(self, locator, text): \"\"\"输入文本(显式等待元素可见)\"\"\" element = self.wait.until(EC.visibility_of_element_located(locator)) element.clear() element.send_keys(text) def get_element_text(self, locator): \"\"\"获取元素文本\"\"\" return self.wait.until(EC.visibility_of_element_located(locator)).text
2. 页面对象层(Page Object)
以菜品管理页面(DishPage
)为例,封装页面元素和业务操作:
import timefrom selenium.common import NoSuchElementExceptionfrom selenium.webdriver.common.by import Byfrom poms.base_page import BasePageclass DishPage(BasePage): # 所有定位符全部改为元组形式 (By.XPATH, \"表达式\") dish_a_loc = (By.XPATH, \"//a[@href=\'#/dish\']\") name_loc = (By.XPATH, \"//input[@placeholder=\'请填写菜品名称\']\") # category_loc = (By.XPATH, \"//*[@id=\'app\']/div/div[2]/section/div/div/div[1]/div[2]/div[1]/input\") category_loc = (By.XPATH, \"//input[@placeholder=\'请选择\'][1]\") status_loc = (By.XPATH, \"//*[@id=\'app\']/div/div[2]/section/div/div/div[1]/div[3]/div/input\") # status_loc = (By.XPATH, \"//input[@placeholder=\'请选择\'][2]\") query_loc = (By.XPATH, \"//*[@id=\'app\']/div/div[2]/section/div/div/div[1]/button\") delete_batch_loc = (By.XPATH, \"//*[@id=\'app\']/div/div[2]/section/div/div/div[1]/div[4]/span\") dish_items_loc = (By.XPATH, \"//*[@id=\'app\']/div/div[2]/section/div/div/div[2]/div[3]/table/tbody/tr\") next_page_loc = (By.XPATH, \"//*[@id=\'app\']/div/div[2]/section/div/div/div[3]/button[2]\") # 单个条目下的相对路径定位符(仅保存XPath字符串,使用时需结合行元素) relative_item_name_loc = \"./td[2]/div\" relative_item_delete_btn_loc = \"./td[8]/div/button[2]\" relative_item_select_loc = \"./td[1]/div/label/span\" # 删除操作确认取消对话框 delete_dialog_cancel = (By.XPATH, \"/html/body/div[2]/div/div[3]/button[1]/span\") delete_dialog_confirm = (By.XPATH, \"/html/body/div[2]/div/div[3]/button[2]\") # 顶部提示信息(根据实际情况补充完整XPath) # delete_single_toast_msg_loc = (By.XPATH, \"/html/body/div[3]/p\") # 单个删除提示 # delete_batch_toast_msg_loc = (By.XPATH, \"/html/body/div[2]/p\") # 批量删除提示 # toast_success_loc = (By.CSS_SELECTOR, \".el-message.el-message--success.el-message__content\") # toast_error_loc = (By.CSS_SELECTOR, \".el-message.el-message--error.el-message__content\") toast_msg_loc = (By.CSS_SELECTOR, \".el-message__content\") # 新增菜品界面 add_page_loc = (By.XPATH, \"//*[@id=\'app\']/div/div[2]/section/div/div\") add_error_loc = (By.CSS_SELECTOR, \".el-form-item__error\") add_btn_loc = (By.XPATH, \"//*[@id=\'app\']/div/div[2]/section/div/div/div[1]/div[4]/button\") add_category_loc = (By.XPATH, \"//input[@placeholder=\'请选择菜品分类\']\") add_price_loc = (By.XPATH, \"//input[@placeholder=\'请设置菜品价格\']\") add_upload_loc = (By.XPATH, \"//input[@class=\'el-upload__input\']\") add_img_loc = (By.XPATH, \"//img[@class=\'avatar\']\") add_cancel_loc = (By.XPATH, \"//*[@id=\'app\']/div/div[2]/section/div/div/form/div[6]/button[1]\") add_save_loc = (By.XPATH, \"//*[@id=\'app\']/div/div[2]/section/div/div/form/div[6]/button[2]\") def get_add_error_text(self): \"\"\"获取新增菜品页面的错误信息\"\"\" return self.get_text(self.add_error_loc) def add_dish(self, name, category_val, price, picture, confirm=True): self.switch_dish() self.click(self.add_btn_loc) # 等待页面加载 # self.find_element(self.add_page_loc) if category_val: self.select_dropdown_option(self.add_category_loc, category_val) if price: self.send_keys(self.add_price_loc, price) if name: self.send_keys(self.name_loc, name) if picture: self.upload_file(self.add_upload_loc, picture) time.sleep(0.5) if confirm: self.click(self.add_save_loc) else: self.click(self.add_cancel_loc) def get_toast_text(self): \"\"\"获取页面提示文本\"\"\" return self.get_text(self.toast_msg_loc).strip() def is_dish_exist(self, dish_name): return self.find_dish_row(dish_name) is not None def switch_dish(self): self.click(self.dish_a_loc) def input_dish_name(self, dishname): self.send_keys(self.name_loc, dishname) def select_dish_category(self, category_val): self.select_dropdown_option(self.category_loc, category_val) def select_dish_status(self, statu_val): self.select_dropdown_option(self.status_loc, statu_val) def search_dish(self, name=\"\", category=\"\", status=\"\"): \"\"\"条件搜索菜品\"\"\" if name: self.input_dish_name(name) if category: self.select_dish_category(category) if status: self.select_dish_status(status) self.click(self.query_loc) def get_result_count(self): \"\"\"统计当前页面的搜索结果数量\"\"\" total_count = 0 while True: # 查找所有结果行 result_rows = self.find_elements(self.dish_items_loc) total_count += len(result_rows) # 没有结果直接退出 if len(result_rows) == 0: break # 检查是否有下一页的按钮 if self.is_element_exist(self.next_page_loc): attr_disabled = self.get_element_attribute(self.next_page_loc, \"disabled\") if attr_disabled is None: self.click(self.next_page_loc) else: break else: break return total_count def find_dish_row(self, dish_name): \"\"\"定位菜品行\"\"\" # 1. 获取所有菜品行 rows = self.find_elements(self.dish_items_loc) for row in rows: try: # 2. 在当前行中定位菜品名元素(相对路径) name_element = row.find_element(By.XPATH, self.relative_item_name_loc) current_name = name_element.text.strip() # 3. 匹配目标菜品名 if current_name == dish_name: return row except NoSuchElementException: # 忽略不含菜品名的行 continue return None def delete_single(self, dish_name, confirm=True): \"\"\"删除单个菜品\"\"\" row = self.find_dish_row(dish_name) if not row: raise ValueError(f\"菜品\'{dish_name}\'不存在\") # 点击行内删除按钮(相对路径) row.find_element(By.XPATH, self.relative_item_delete_btn_loc).click() self._handle_confirm(confirm) def delete_batch(self, confirm=True): \"\"\"批量删除菜品\"\"\" self.click(self.delete_batch_loc) self._handle_confirm(confirm) def select_dish(self, dish_name): row = self.find_dish_row(dish_name) check_box = row.find_element(By.XPATH, self.relative_item_select_loc) checked = check_box.get_attribute(\"is-checked\") if checked is None: check_box.click() def select_dishes(self, dish_names): for dish_name in dish_names: self.select_dish(dish_name) def _handle_confirm(self, confirm): if self.is_element_exist(self.delete_dialog_confirm): \"\"\"处理确认对话框\"\"\" if confirm: self.click(self.delete_dialog_confirm) else: self.click(self.delete_dialog_cancel)
3. 测试用例层
以 “菜品条件搜索”为例,使用 Pytest 参数化管理测试数据,结合 Allure 标记用例:
import pytestimport allurefrom commons.yaml_utils import get_testcase_yaml@allure.epic(\"苍穹外卖项目\")@allure.feature(\"菜品管理模块\")@pytest.mark.usefixtures(\"dish_case_fixture\")class TestDishManagement(): dish_page = None def setup_method(self): # 每个用例执行前,回到菜品管理首页并刷新 self.dish_page.switch_dish() self.dish_page.driver.refresh() # 刷新页面,清除临时状态(如toast、弹窗) @allure.story(\"菜品条件搜索\") @pytest.mark.parametrize(\"case_info\", get_testcase_yaml(\'./test_case/test_dish_management/test_search.yaml\')) @pytest.mark.search @pytest.mark.run(order=1) def test_search_valid_dish(self, case_info): allure.dynamic.title(case_info[\'case_name\']) self.dish_page.search_dish(case_info[\'name\'], case_info[\'category\'], case_info[\'status\']) actual_count = self.dish_page.get_result_count() # 断言 expected_count = case_info[\'expected_count\'] assert actual_count == expected_count, \\ f\"结果数量不匹配!预期: {expected_count}, 实际: {actual_count}\"
三、测试用例设计
以菜品条件查询为例
1.需求分析
搜索菜品共有三个查询条件,分别是名称,分类,售卖状态,其中菜品分类和售卖状态均为下拉选择框,菜品名称为文本输入框。
根据等价类划分法,名称可以分为空查询,不存在菜品的查询,存在菜品的查询,其中不存在菜品的查询为无效,其他为有效;分类和状态均分为空查询和有效查询,因为下拉框只有选择和不选择两种可能,组合之后正向测试用例为2*2*2 = 8 条,反向测试用例为1条
2.用例设计
菜品条件查询的测试用例设计如下
3.yaml数据存储
测试用例通过yaml文件存储,实现数据和代码分离,当要添加测试用例时,改动yaml文件即可
# 搜索数据- case_name: \"默认搜索\" name: \"\" category: \"\" status: \"\" expected_count: 30- case_name: \"搜索菜品名\" name: \"鱼\" category: \"\" status: \"\" expected_count: 14- case_name: \"搜索分类\" name: \"\" category: \"传统主食\" status: \"\" expected_count: 3- case_name: \"搜索状态\" name: \"\" category: \"\" status: \"停售\" expected_count: 6- case_name: \"搜索菜品名+分类+状态\" name: \"金汤\" category: \"蜀味牛蛙\" status: \"启售\" expected_count: 1- case_name: \"搜索菜品名+分类\" name: \"面\" category: \"传统主食\" status: \"\" expected_count: 1- case_name: \"搜索菜品名+状态\" name: \"金汤\" category: \"\" status: \"启售\" expected_count: 1- case_name: \"搜索分类+状态\" name: \"\" category: \"酒水饮料\" status: \"启售\" expected_count: 3- case_name: \"搜索无效菜品名\" name: \"可乐\" category: \"\" status: \"\" expected_count: 0
四、测试执行
1.fixture夹具
testcase目录下的conftest.py文件为测试配置了夹具,代码如下
import timeimport pytestfrom selenium import webdriverfrom selenium.webdriver.common.by import Byfrom poms.dish_page import DishPagefrom poms.login_page import LoginPage@pytest.fixture(scope=\"session\", autouse=True)def all_case_fixture(): driver = login_sky() yield driver logout_sky(driver)# 新增:自动执行的 fixture,在 setup_method 前初始化 dish_page@pytest.fixture(scope=\"class\")def dish_case_fixture(all_case_fixture, request): # 1. 初始化 dish_page dish_page = DishPage(all_case_fixture) # 2. 获取当前测试类的实例(request.node 是测试类) test_class = request.node.cls # 3. 将 dish_page 绑定到测试类的属性(供 setup_method 使用) test_class.dish_page = dish_page yield # 执行测试类的所有方法后,此处可添加清理逻辑def login_sky(): # 设置浏览器不自动关闭 options = webdriver.EdgeOptions() options.add_experimental_option(\"detach\", True) # # 创建浏览器对象 driver = webdriver.ChromiumEdge(options=options) driver.maximize_window() # 最大化窗口 driver.get(\"http://localhost/#/login\") LoginPage(driver).login(\"admin\", \"123456\") return driverdef logout_sky(driver): time.sleep(3) driver.quit()
其中,all_case_fixture为模块级别的配置,在整个测试模块的执行前后执行,进行浏览器启动,外卖平台的登录,和浏览器的退出操作,dish_case_fixture为类级别的夹具,专为菜品模块设计,用于初始化菜品页面对象,并将其绑定到菜品管理测试类的属性上,dish_case_fixture中嵌套在all_case_fixture中使用
此外,在测试类中,通过定义前置方法setup_method,用以在测试方法前执行,setup_method的定义能够解决测试用例之间的干扰问题,通过回到菜品管理页面并刷新,可以保证上一次的用例失败不会影响这次用例的执行,实现代码如下:
@allure.epic(\"苍穹外卖项目\")@allure.feature(\"菜品管理模块\")@pytest.mark.usefixtures(\"dish_case_fixture\")class TestDishManagement(): dish_page = None def setup_method(self): # 每个用例执行前,回到菜品管理首页并刷新 self.dish_page.switch_dish() self.dish_page.driver.refresh() # 刷新页面,清除临时状态(如toast、弹窗)
因此,总结来说各个层级的方法执行顺序为:
测试执行启动 ——> 会话级Fixture执行(浏览器启动,平台登录)——> 类级Fixture执行(初始化页面管理对象)——> 测试类启动 ——> 测试类的前置处理方法执行(跳转到被测模块页面并刷新)——> 测试方法执行
当然,还有函数级 Fixture,测试类也有前置处理类方法、后置处理类方法、后置处理方法,它们的区别在于执行的层级(会话级、类级、函数级)和在测试执行过程中所处的时间节点不同,并且在测试执行中起到不同的作用和影响。
2.pytest配置 pytest.ini
# show cur file is the config of pytest[pytest]# -vs : command line will show the location of test# --allure 生成临时json报告放在temps包下addopts = -vs --alluredir ./temps --clean-alluredirtestpaths = ./test_casepython_files = test_*.pypython_classes = Test*python_functions = test_*# mark the special test case to be exec. if you wanna exec some cases, add the mark to themmarkers = smoke:冒烟测试 delete: search: add_valid: add_invalid:
规则解读如下
[pytest]
pytest
的。addopts
pytest
的命令行参数,这里设置了以下参数:-
-vs
:-v
表示详细模式,会显示每个测试用例的执行结果;-s
表示显示测试用例中的打印信息。-
--alluredir ./temps
:指定 Allure
报告的临时数据存储目录为 ./temps
,Allure
是一个用于生成测试报告的工具。-
--clean-alluredir
:在每次运行测试之前,清理 Allure
报告的临时数据目录,确保每次生成的报告都是最新的。testpaths
pytest
搜索测试用例的目录,这里设置为 ./test_case
,表示 pytest
会在 ./test_case
目录下搜索测试用例。python_files
pytest
识别的测试文件的命名规则,这里设置为 test_*.py
,表示 pytest
会识别所有以 test_
开头且以 .py
结尾的文件为测试文件。python_classes
pytest
识别的测试类的命名规则,这里设置为 Test*
,表示 pytest
会识别所有以 Test
开头的类为测试类。python_functions
pytest
识别的测试函数的命名规则,这里设置为 test_*
,表示 pytest
会识别所有以 test_
开头的函数为测试函数。markers
-
smoke
:冒烟测试,用于标记关键的、基础的测试用例。-
delete
:用于标记与删除操作相关的测试用例。-
search
:用于标记与搜索操作相关的测试用例。-
add_valid
:用于标记与有效新增操作相关的测试用例。-
add_invalid
:用于标记与无效新增操作相关的测试用例。当需要单独执行某个功能模块的测试时,可以在测试的方法前通过pytest.mark.smoke来标记,然后在addopts中加入 -m \"smoke\"
3.执行结果
测试执行完成后可以打开allure目录下的报告网页,这是我的测试报告,可以看到各个功能的测试通过率,具体测试时的步骤,以及耗时,当然还可以进行更加详细的配置,比如失败时,自动截图,在报告中展示
还有一些用例没有通过(),需要后续去解决。
五、问题与总结
在开发过程中,遇到的最多的问题其实是测试用例之间的干扰,页面的不稳定性,比如在执行删除功能测试时,前一条测试用例执行后出现删除成功的提示栏,紧接着再次删除,弹出确认对话框时,上次的提示栏并没有消失,因此造成了本次的用例对话框中按钮无法点击,我通过单独执行每条测试用例发现用例本身是没问题的,但是一起执行就有麻烦了,我是先采用固定等待,但是这样效率不高,影响效率,所以我定义了setup_method,每执行一条测试用例,就刷新一次页面,这样就可以避免用例之间的干扰,但是具体还会有哪些隐藏的问题我还没有发现,期待大家评论区讨论。
在这个过程中,我深刻体会到:UI 自动化的核心不仅是 “用代码模拟操作”,更重要的是框架的可维护性和用例的稳定性。合理的设计模式(如 PO 模式)、严谨的异常处理和完善的日志报告,是自动化测试成功的关键。
后续将继续优化框架,逐步实现全模块覆盖,为苍穹外卖系统的质量保驾护航。