ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Selenium+Python unittest的CRM自动化测试框架实战

基于Selenium+Python unittest的CRM自动化测试框架实战

简介:这份资源是一套基于Python Selenium与Unittest框架的CRM系统Web自动化测试实战源码,面向正在学习自动化测试的初中级测试开发人员,也适合需要搭建完整自动化测试项目的从业者参考。内容覆盖CRM系统业务场景下的自动化框架设计,从Web元素定位、Selenium核心API,到Unittest用例组织与断言方法,再到测试报告生成与邮件自动发送,完整串起自动化测试的各个关键环节,可帮助读者快速建立从脚本编写到结果反馈的整链路认知。压缩包共254个文件,以py脚本、pyc缓存、HTML报告、yaml配置、log日志等为主,附带sample样例数据与zip压缩包,另有大量pytest缓存与git仓库内部对象文件,包体约24.19MB。过滤掉工具自动生成的缓存文件后,核心源码规模可观,适合边读边练。目前已有222人学习下载,文件目录结构清晰,便于按模块拆解学习。

1. 为什么拿 CRM 系统练 Selenium 自动化:真实业务比 demo 更锻炼人

我见过不少教程拿登录页反复跑,第一周有新鲜感,第二周就开始觉得"这不就是点两下按钮嘛"。换成 CRM 系统之后,有表单、有弹窗、有日期控件、有列表翻页、有角色权限,selenium 自动化测试 python unittest 这套组合才算真正派上用场。这也是我把它拆成可复现源码包的原因:你拿到的不是跑通就丢的 demo,而是从目录结构、页面对象、测试用例到报告邮件一整条链路。CRM 业务够典型、流程够长,适合练手,也适合直接改造到自己公司的回归场景里。另外提醒一句,有些下载站会把 unittest 错写成 unitest,导入的时候一定用标准库 unittest,否则 import 直接报错。

2. unittest+Selenium基础框架:PO模式与全局配置怎么写

2.1 目录结构设计:为什么按 page/testcase/report 拆分

先把三十个脚本平铺到一个文件夹里,跑完第一轮你连找谁失败都费劲,这是不少自动化项目一开始就会踩的乱摊子。真实 CRM 系统模块少说也有七八个,用例量上一百之后,平铺式管理基本等于给自己埋雷。我现在的拆分习惯是五个目录:page 管元素定位和页面动作,testcase 管业务和断言,report 放执行产物,data 放测试数据,config 放环境参数。这样一个新人接手,打开目录两分钟就能搞清楚该改哪里。

framework/ ├── config/ │ └── config.ini # 环境地址、浏览器类型、账号信息 ├── data/ │ ├── login_data.json # 登录模块测试数据 │ └── customer_data.csv # 客户新增测试数据 ├── page/ │ ├── base_page.py # 公共页面对象,封装查找、点击、输入 │ ├── login_page.py # 登录页面对象 │ ├── customer_page.py # 客户列表/新增页面对象 │ └── contract_page.py # 合同台账页面对象 ├── testcase/ │ ├── base_test.py # 用例基类,统一启动和退出 │ ├── test_login.py # 登录用例 │ ├── test_customer.py # 客户管理用例 │ └── test_contract.py # 合同台账用例 ├── report/ # 生成的 HTML 报告输出目录 └── run_all.py # 用例加载、执行、生成报告、发邮件

这个结构的价值不在目录本身,而在依赖方向。testcase 只依赖 page 和 data,page 只依赖 config,模块之间不互相引用。将来如果要把 unittest 换成 pytest,testcase 层重写断言风格,page 和 data 原封不动可以复用;如果要做多浏览器兼容,只需在 config.ini 增加一个 browser=firefox 配置项,驱动工厂多写一个分支就完事。

配置文件走 ini 还是 yaml 其实都可以,关键是不能把环境地址和账号写死在脚本里。原因很直接:一套脚本往往要在本机、测试服务器、预发布环境各跑一遍,地址不一样,账号密码也分权限。写在 ini 里,每次切换环境只需改一行,不会为了找"是哪个文件里的 IP"翻半天代码。

提示:data 目录专门放测试数据,不等于把业务步骤也拆进数据文件。CRM 里删除客户、退出登录这类收尾动作要写在用例里,不要拆散到数据文件中。

2.2 浏览器驱动初始化:复用与独立会话的取舍

初学者喜欢用 setUpClass 加 classmethod,让整个测试类共用一个浏览器,理由是启动快。我的经验是 CRM 这类业务系统页面的状态耦合度太高,登录态残留、缓存覆盖、弹窗遮挡都很难在用例结束时清干净。共用浏览器省下的十几秒,远不够用来排查那种"换个用例执行顺序就变红"的怪问题。所以默认策略是每个用例一个独立会话,先保证稳定,再谈效率。

# driver.py from selenium import webdriver import configparser def create_driver(): conf = configparser.ConfigParser() conf.read("config/config.ini", encoding="utf-8") browser = conf.get("env", "browser", fallback="chrome") if browser == "firefox": options = webdriver.FirefoxOptions() if conf.getboolean("env", "headless", fallback=False): options.add_argument("-headless") driver = webdriver.Firefox(options=options) else: options = webdriver.ChromeOptions() options.add_argument("--window-size=1920,1080") if conf.getboolean("env", "headless", fallback=False): options.add_argument("--headless") driver = webdriver.Chrome(options=options) return driver

代码逻辑说明:create_driver 从 config.ini 里读浏览器类型和 headless 开关,决定启用哪个驱动对象。窗口尺寸固定成 1920*1080,是因为很多 CRM 页面在窗口缩小时会把菜单折叠进汉堡按钮,元素定位直接失败。参数说明:headless 默认关,本地调试不需要它;CI 机器没有显示器的时候才打开。

# testcase/base_test.py import unittest from driver import create_driver class BaseTest(unittest.TestCase): def setUp(self): self.driver = create_driver() self.driver.get("http://localhost:8080/crm/") self.driver.maximize_window() def tearDown(self): if self.driver: self.driver.quit()

代码逻辑说明:setUp 在每个用例执行前都会新开一个浏览器并打开 CRM 首页,tearDown 则无条件关闭会话。即使用例中途断言失败,tearDown 依然会被 unittest 调用,不会留下僵尸浏览器进程。

2.3 页面对象:把定位符集中管理,不散落在用例里

没有页面对象时,用例里满屏都是 WebDriverWait 和 find_element,一个操作重复十遍,页面细节一改所有用例跟着改。把页面抽象成对象之后,用例代码只跟动作打交道,定位符只存在于 page 层。

# page/base_page.py from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import Select class BasePage: def __init__(self, driver): self.driver = driver def find_element(self, locator, timeout=10): return WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located(locator) ) def click(self, locator, timeout=10): self.find_element(locator, timeout).click() def input_text(self, locator, text, timeout=10): element = self.find_element(locator, timeout) element.clear() element.send_keys(text) def select_by_visible_text(self, locator, text, timeout=10): Select(self.find_element(locator, timeout)).select_by_visible_text(text) def get_text(self, locator, timeout=10): return self.find_element(locator, timeout).text

代码逻辑说明:find_element 是用显式等待拿元素,等不到就在超时后抛异常;click、input_text 等动作都复用这一个查找入口。参数说明:locator 是 (By.XPATH, "...") 这样的二元组,timeout 默认 10 秒,元素加载慢的页面可以单独把它调大,不会影响其他操作。select_by_visible_text 解决的是 CRM 里大量下拉框的问题,比如客户等级、合同状态,这类原生 select 必须走 Select 封装。

这里要提一个容易翻车的地方:input_text 里的 clear() 和 send_keys() 顺序不能反。CRM 的客户名称、合同编号这些输入框,有的带着默认提示文字,有的残留上一次编辑的内容,不先 clear 直接 send_keys,新旧内容会拼在一起。金额字段尤其致命,1000 会变成 10001000,断言永远过不了。

3. CRM核心流程用例实现:登录、客户新增、合同台账的脚本拆解

3.1 登录模块:数据文件、断言点、失败信息

登录是所有用例的前提,也是被测系统最容易变化的一层。把登录用例单独拆开,一方面验证认证逻辑本身,另一方面也确保后面所有用例的 setUp 能稳定通过。CRM 系统的登录页通常有四个关键点:用户名输入框、密码输入框、登录按钮、错误提示条。

# page/login_page.py from page.base_page import BasePage from selenium.webdriver.common.by import By class LoginPage(BasePage): username_input = (By.XPATH, "//input[@placeholder='用户名']") password_input = (By.XPATH, "//input[@placeholder='密码']") login_btn = (By.XPATH, "//button[contains(text(), '登 录')]") error_msg = (By.XPATH, "//div[contains(@class, 'login-error')]") def do_login(self, username, password): self.input_text(self.username_input, username) self.input_text(self.password_input, password) self.click(self.login_btn) def get_error(self): return self.get_text(self.error_msg) def is_login_success(self): return "home" in self.driver.current_url

代码逻辑说明:do_login 按"先用户名、再密码、最后点按钮"的顺序执行,操作之间不带强制 sleep,因为 input_text 内部已经做了显式等待。is_login_success 用 URL 判断跳转,比去找首页某个欢迎语更稳定,很多 CRM 首页元素是异步加载的,URL 变化早于 DOM 渲染完成。

登录用例的数据和断言我是这样设计的:

{ "success": {"username": "admin", "password": "123456"}, "wrong_password": {"username": "admin", "password": "123123"}, "locked_user": {"username": "locked_user", "password": "123456"} }
# testcase/test_login.py import json import unittest from testcase.base_test import BaseTest from page.login_page import LoginPage class TestLogin(BaseTest): @classmethod def setUpClass(cls): with open("data/login_data.json", encoding="utf-8") as f: cls.data = json.load(f) def test_login_success(self): d = self.data["success"] page = LoginPage(self.driver) page.do_login(d["username"], d["password"]) self.assertTrue(page.is_login_success()) def test_login_wrong_password(self): d = self.data["wrong_password"] page = LoginPage(self.driver) page.do_login(d["username"], d["password"]) self.assertEqual("用户名或密码错误", page.get_error())

代码逻辑说明:setUpClass 只读一次 json 文件,多个用例共享同一份加载结果,减少重复 IO。test_login_success 断言的是跳转结果,test_login_wrong_password 断言的是页面上的错误提示文本。参数说明:json 里每个键对应一种测试场景,新增锁定时长、验证码错误等场景,只需往 json 加一段,不需要新增用例代码。

提醒一点:错误提示的定位方式尽量用 class 关键词而不是完整固定文本,比如 contains(@class, 'login-error')。原因是不同环境下面该文本可能有语言包差异,但 class 多半相对稳定。

3.2 客户新增与列表查询:表单填写、下拉选择、翻页等待

客户模块是 CRM 里最重要的重复操作场景:新增客户、填写等级、保存后回到列表、断言新记录出现。这个用例能够暴露出一类典型问题——保存操作之后页面要刷新,如果立刻去列表里找刚新增的记录,刷新没完成就会只看到旧数据。

# page/customer_page.py from page.base_page import BasePage from selenium.webdriver.common.by import By class CustomerPage(BasePage): customer_menu = (By.XPATH, "//li[@data-menu='customer']") add_btn = (By.XPATH, "//button[contains(text(), '新增客户')]") name_input = (By.XPATH, "//input[@id='customerName']") level_select = (By.XPATH, "//select[@id='customerLevel']") save_btn = (By.XPATH, "//button[contains(text(), '保 存')]") table_rows = (By.XPATH, "//table[@id='customerTable']/tbody/tr") def goto_customer_list(self): self.click(self.customer_menu) def add_customer(self, name, level): self.click(self.add_btn) self.input_text(self.name_input, name) self.select_by_visible_text(self.level_select, level) self.click(self.save_btn) def get_all_customer_names(self): rows = self.driver.find_elements(*self.table_rows) return [row.text for row in rows]

代码逻辑说明:customer_menu 定位用的是># testcase/test_customer.py from testcase.base_test import BaseTest from page.login_page import LoginPage from page.customer_page import CustomerPage class TestCustomer(BaseTest): def setUp(self): super().setUp() LoginPage(self.driver).do_login("admin", "123456") def test_add_customer_success(self): name = "测试客户A_20250220" page = CustomerPage(self.driver) page.goto_customer_list() page.add_customer(name, "重要客户") page.goto_customer_list() self.assertIn(name, page.get_all_customer_names())

代码逻辑说明:setUp 里先做登录,保证每个客户用例进来时都有有效会话。add_customer 完成后再次调 goto_customer_list,是为了触发表格刷新,然后 assertIn 去查新增记录是否出现在列表里。参数说明:客户名称带上日期后缀,是为了避免和上一轮跑出的历史数据重名,也让报告里的人一看就知道是哪次回归新增的。

注意:不要在用例里固定 sleep 3 秒等表格刷新。可以改用"某个预期行文本出现"的显式等待,再进一步做列表查询;如果你手里的 CRM 前端组件是异步表格,建议在 BasePage 里预留一个 wait_row_count 方法,等待表格行数从 N 变成 N+1。

3.3 合同台账:日期控件、金额边界、保存后的状态流转

合同台账比客户新增更麻烦一点:日期控件通常是自研弹层,金额字段可能有千分位格式化,保存后状态从"草稿"跳到"待审批"。这三个点每个都能让脚本写完后翻车。

# page/contract_page.py from page.base_page import BasePage from selenium.webdriver.common.by import By class ContractPage(BasePage): contract_name = (By.XPATH, "//input[@id='contractName']") sign_date_input = (By.XPATH, "//input[@id='signDate']") amount_input = (By.XPATH, "//input[@id='amount']") save_btn = (By.XPATH, "//button[contains(text(), '保存')]") def add_contract(self, name, sign_date, amount): self.input_text(self.contract_name, name) self.click(self.sign_date_input) self.click((By.XPATH, f"//td[@data-date='{sign_date}']")) self.input_text(self.amount_input, str(amount)) self.click(self.save_btn) return self.get_text((By.XPATH, "//div[contains(@class, 'contract-state')]"))

代码逻辑说明:日期输入框再点击后会弹出日历面板,面板里每天都是一个 td,用>def test_add_contract_amount_rounded(self): page = ContractPage(self.driver) state = page.add_contract("采购合同20250220", "2025-02-20", 1000) self.assertIn("待审批", state)

代码逻辑说明:test_add_contract_amount_rounded 验证的是"保存后进入待审批状态",这正是合同台账里最有业务价值的流转断言。参数说明:如果断言失败,第一步先看保存时页面上是否出现了金额校验提示,第二步再确认日期是否选到了正确月份;这两类问题在合同模块里占八成的失败原因。

4. 测试报告与邮件发送:HTML报告和SMTP配置的完整链路

4.1 HTMLTestRunner 报告:生成、分类、保留现场

unittest 自带的 TextTestRunner 输出到控制台,跑完就没了;CRM 系统回归要给别人看,需要一份能保存下来的报告。HTMLTestRunner 是单文件工具,将 unittest 的 result 渲染成带统计和截图的 HTML 页面。虽然它很多年没有更新,但依然是最简单、不需要额外安装的方案。

# run_all.py import unittest from HTMLTestRunner import HTMLTestRunner from datetime import datetime if __name__ == "__main__": suite = unittest.defaultTestLoader.discover("testcase", pattern="test_*.py") report_path = f"report/{datetime.now().strftime('%Y%m%d_%H%M%S')}_report.html" with open(report_path, "wb") as f: runner = HTMLTestRunner( stream=f, title="CRM系统自动化回归报告", description="浏览器:Chrome 执行环境:测试环境" ) runner.run(suite)

代码逻辑说明:defaultTestLoader.discover 会自动发现 testcase 目录里所有 test_ 开头的 py 文件,不需要手动一个一个加用例。报告文件名带上时间戳,是为了保留多次执行的历史现场,避免每次执行都覆盖上一份。参数说明:title 和 description 会显示在报告页头,建议把浏览器版本和执行环境写进去,隔一个月再翻报告时能想起来"这份是在哪台机器跑的"。

4.2 SMTP 邮件发送:HTML 附件与登录鉴权

报告生成后,如果不发出去,测试执行的价值就少了一大半。这个项目里的最终流程是:跑完所有用例、生成 HTML 报告、再通过 SMTP 发给相关人。邮件部分我用 Python 标准库 smtplib 和 email 实现,这两兄弟不需要额外装包。

# send_report.py import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.header import Header from datetime import datetime def send_report(report_path): with open(report_path, "rb") as f: content = f.read() msg = MIMEMultipart() msg["From"] = "sender@example.com" msg["To"] = "leader@example.com" msg["Subject"] = Header(f"CRM自动化测试报告 {datetime.now():%Y-%m-%d}", "utf-8") msg.attach(MIMEText(content, "html", "utf-8")) smtp = smtplib.SMTP_SSL("smtp.example.com", 465) smtp.login("sender@example.com", "your_auth_code") smtp.send_message(msg) smtp.quit()

代码逻辑说明:MIMEMultipart 允许同一邮件里附带 HTML 正文和后续要加的附件;SMTP_SSL 直接走 465 端口加密通道,现在大多数邮箱都要求 SSL 登录。参数说明:登录密码一般不是邮箱登录密码,而是服务商提供的授权码;如果你所在网络禁止 465,可以把 587 配合 starttls 作为备选。

4.3 批量回归与定时执行:接入 CI 的入口脚本

当用例数量超过十几个以后,手动在 IDE 里点 run 就不现实了。这个项目最后 run_all.py 的调用方式设计成"能被命令行或定时任务直接拉起"。结合 cron 或 Windows 任务计划,每天夜里跑一遍回归,第二天早上打开邮箱看报告,这是最省心的工作方式。

# 先在源码包根目录安装依赖,再执行主脚本 pip install -r requirements.txt python run_all.py

代码逻辑说明:pip install 把 selenium、webdriver-manager 这类依赖装齐;python run_all.py 完成"加载用例、执行、生成报告、发送邮件"四条动作。参数说明:如果你把 run_all.py 放到 CI 平台上跑,建议在脚本前面加一个环境变量判断,比如只允许 TEST_ENV=dev 时发送邮件,避免开发环境的数据不小心发给所有人。

邮件发送失败时不要直接让整个任务 fail 掉。常见做法是把发邮件放到 report 生成之后的独立函数里,发信失败打印日志但不影响报告文件本身;报告留在服务器上,人还能去手动拿。把"测试执行"和"结果通知"解耦,是这一层设计的原则。

5. 自动化测试避坑指南:定位失效、等待超时与数据污染的排查记录

5.1 找不到元素:多半不是路径写错,而是 iframe 没切换

现象:脚本跑 CRM 里的客户列表,click 新增客户按钮时报 NoSuchElementException;但手动在浏览器里操作,按钮明明就在那里。

原因:不少 CRM 的后台页面把内容区域放进 iframe,新增按钮在 iframe 里,而 selenium 默认只在主文档里找元素。定位符写得再精确,也够不到 iframe 内部的元素。

解决:先通过 name 或 id 切入 iframe,操作完成后再切回主文档。常见做法是给 BasePage 加一个 switch_iframe 方法,调用方传入 iframe 的定位符,用完立刻切回 default_content。我在这个源码包里就是这么处理的,新增客户和合同台账两个页面都要走这一步。

5.2 显式等待设了 10 秒,还是没等到数据加载

现象:登录成功之后直接去点客户菜单,偶尔能过,偶尔报元素不可点击;加了 WebDriverWait 以后依然隔三差五失败。

原因:页面元素确实出现了,但背后的数据接口还没返回,表格区域是一片空白。Selenium 的可见性等待只判断 DOM 上元素的 CSS 状态,不判断业务数据是否渲染完成。

解决:在 BasePage 增加一个 wait_text_appear 方法,等待表格中某个关键文本出现,再去执行下一步。比如客户列表用例先等"客户名称"表头出现,再点新增按钮。这里还有一个习惯:不要用 time.sleep 去兜底,sleep 只会把问题从"随机失败"变成"每次都多等三秒"。

5.3 数据没清理干净,用例之间互相污染

现象:单独跑新增客户用例全绿,把登录用例和客户用例连起来跑,新增后断言通过,但列表里多了一条历史遗留客户,数量断言永远对不上。

原因:上一轮测试数据残留在数据库里,没有在用例开头或结尾做清理。CRM 的客户列表又是分页的,因为数据多了,新加的记录被挤到了第二页,第一次用例只能看到第一页。

解决:新增客户的用例必须在执行前先删除同名记录,或者客户名称加唯一时间戳,并在断言时跳过历史数据。我在代码里用了"测试客户A_日期时间"这种带时间戳的名字,断言改成 assertIn 而不是 assertEquals。从那以后,这类"换顺序就红"的问题基本没有再现过。

5.4 报告文件被写入占用,第二次运行报错

现象:第二次运行 run_all.py 时,报告文件写入失败,报 PermissionError。

原因:上一次运行生成的 HTML 报告被浏览器或者编辑器打开占用了;Windows 下文件被占用时,无法用同名文件覆盖写入。

解决:报告文件名加时间戳,生成新文件而不是覆盖旧文件,历史报告就永远不会冲突。如果公司有报告保留策略,再加一个定时清理脚本,只保留最近 30 天的报告文件。这个改动成本极低,收益却很高,至少不用每次跑之前先手工去看哪个文件被谁占了。

5.5 SMTP 连接失败:不是账号问题,先确认端口策略

现象:邮件发送逻辑在本地可以跑通,放到某台测试服务器上就报 timeout。

原因:企业办公网络或云环境常常把 25 端口、465 端口拦掉一部分,只放开常用端口。

解决:先确认服务器出网策略,再选择对应端口;一般先用 465 SSL 连一次,连不上再用 587 加 starttls 试试。发送失败时捕获异常并记录日志,不要让整个回归任务因为这个报错直接崩溃。这种"换个端口就能用"的坑,排查顺序一定是从网络端口开始,而不是先怀疑授权码写错。

6. 把脚本变工程:数据驱动与失败重跑技巧

6.1 用 JSON 数据驱动扩用例,不用改代码

接新需求时最麻烦的事是"加一条用例要复制一整段代码"。把数据从用例里拿出来后,新增场景只要往 JSON 里加一条记录。比如客户等级,今天只测"重要客户",明天加一个"普通客户",改 JSON 就行。

{ "customer_levels": [ {"name": "测试客户A_{ts}", "level": "重要客户"}, {"name": "测试客户B_{ts}", "level": "普通客户"} ] }
# 在test_customer.py中做数据驱动读取 for item in CustomerData["customer_levels"]: name = item["name"].replace("{ts}", str(int(time.time()))) page.add_customer(name, item["level"]) self.assertIn(name, page.get_all_customer_names())

代码逻辑说明:列表里的每一条数据都当一次独立场景执行,名字里的 {ts} 在执行时替换成时间戳,避免不同轮次的数据重复。

6.2 失败用例自动重跑,但别把重跑当成遮羞布

CRM 回归最容易遇到"偶发失败",比如弹窗动画还没结束点快了。我会给断言加一层轻量重试,第二次还失败才上报。实现上写一个装饰器,包住测试函数,失败后先截图再重跑一次。

# 简洁版:用例失败后自动重跑一次 def rerun(func): def wrapper(self): try: func(self) except Exception: self.driver.save_screenshot("report/failure.png") func(self) return wrapper

代码逻辑说明:第一次失败先截图留证据,然后重置状态再跑第二次;还失败就把异常按原样抛出给报告统计。参数说明:重跑次数设成 1 就够,重跑次数太多会把报告里真实失败刷成绿色,领导看到绿就以为版本没问题,这是很危险的习惯。从那以后我每个版本上线前跑全量回归,都只看"去掉重跑后仍失败的用例"这份列表,那才是真正要修的。这个源码包从目录到邮件都串好了,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进