Selenium+Unittest构建CRM自动化测试框架:从环境搭建到报告推送
简介面向测试开发工程师与Web自动化测试入门者这份CRM系统项目实战源码完整呈现了基于Selenium与unittest搭建自动化测试框架的典型流程可解决从零搭建、用例组织困难、测试结果反馈滞后等常见问题适合作为课程设计、简历项目或团队内部培训资料。压缩包共254个文件约24.19MB核心由40个Python脚本含测试用例、页面对象、工具函数、15个HTML报告模板、7个YAML配置文件、12个sample样例以及日志、浏览器驱动、ini配置等辅助文件组成目录结构清晰便于对照学习。项目以CRM业务系统为载体依次展示了Selenium八大定位方法、常用操作封装、unittest测试套件运行规则、断言与日志记录直至生成可视化测试报告并通过邮件自动发送的完整闭环知识链路环环相扣。已有222人学习读者既可通读源码吃透整套框架的设计思想也能直接复用邮件发送模块、报告模板、浏览器驱动配置等成熟组件快速落地属于自己的自动化测试工程少走弯路。1. 从标题看门道这个 CRM 自动化项目到底在解决什么问题接手 CRM 系统的回归测试时最折磨人的不是逻辑复杂而是每次发版都要把“登录—新增客户—填表单—保存—查询”这套流程手动点一遍。一个版本回归下来一上午就没了点到最后眼睛都花了。标题里的这套项目就是典型的 Web 自动化测试落地场景用 selenium 驱动浏览器模拟人工操作用 unittest 组织和管理测试用例跑完自动生成一份 HTML 测试报告再把这封报告通过邮件推送到项目组邮箱。标题里的 unitest 其实就是 Python 自带的 unittest 测试框架下面统一用正确拼写。这套组合非常适合正在做 Web 测试、想从手工点点点转向自动化的从业者也适合想拿一个完整项目练手的人——它不是让你学单个工具而是把定位元素、组织用例、生成报告、发送邮件这一整条链路串起来每一步都有明确的产出。接下来我从环境搭建开始讲把实现路径和踩过的坑一次说清楚。2. 搭环境与定结构让 selenium unittest 从能跑变成好维护2.1 环境依赖版本匹配是第一个坑这个项目对 Python 版本不挑剔3.7 到 3.9 跑起来都很稳我一般用 3.8 作为基线。真正容易翻车的是 selenium 和浏览器驱动之间的版本匹配。selenium 4.x 对驱动管理更友好但前提是你的 Chrome 浏览器版本和 chromedriver 版本要能对上。常见做法是直接用 webdriver-manager 这个第三方库自动处理驱动下载省去手动找版本的麻烦。先看依赖安装# 建议使用虚拟环境避免污染全局 Python python -m venv venv source venv/bin/activate # 安装核心依赖 pip install selenium4.15.0 pip install webdriver-manager4.0.1版本锁定是血泪经验。直接pip install selenium装最新版有可能和你本地的 Chrome 版本不兼容跑起来第一个报错就是 session 创建失败。webdriver-manager 会自动读取当前 Chrome 版本并下载匹配的驱动比手动折腾省心得多。装完验证一下python -c from selenium import webdriver; from webdriver_manager.chrome import ChromeDriverManager; driver webdriver.Chrome(ChromeDriverManager().install()); driver.get(http://localhost:8080/crm/login); print(driver.title)能打印出页面标题说明环境通了。这里要重点关注两个参数ChromeDriverManager().install()会自动定位驱动路径driver.get()里的 CRM 系统地址要根据你本地部署情况调整如果 CRM 跑在别的端口改这里就行。2.2 项目目录结构把页面、用例、报告各归其位环境通了之后别急着写用例。先把目录结构定好否则十几个用例文件散落在一起后续维护的成本会指数级上涨。我一般按这个结构组织crm_auto_test/ ├── config/ │ └── config.py # 存放 URL、账号、超时时间等全局配置 ├── pages/ │ ├── login_page.py # 登录页面的元素定位和操作封装 │ └── customer_page.py # 客户管理页面的元素定位和操作封装 ├── testcases/ │ ├── test_login.py # 登录模块测试用例 │ └── test_add_customer.py # 新增客户测试用例 ├── utils/ │ ├── report.py # 报告生成组件 │ └── send_mail.py # 邮件发送组件 ├── reports/ # 生成的 HTML 测试报告存放目录 ├── run_all.py # 测试入口串联执行、报告、邮件 └── requirements.txt这个结构的分层逻辑很清晰pages层只负责和页面元素打交道testcases层只负责写业务逻辑和断言utils层管报告和邮件这些公共能力。改前端元素时只动pages层用例层完全不受影响。config 单独拎出来是因为 CRM 的地址、测试账号这些信息不应该硬编码在代码里集中在一个文件里管理换环境时只改一处。实际项目里我见过很多人把所有代码堆在一个脚本里跑是能跑但一个元素改了要全局搜索替换非常痛苦。3. 写第一个 CRM 测试用例从页面封装到业务链路跑通3.1 为什么不直接在用例里写 find_element很多初学者习惯在用例里直接写driver.find_element(By.ID, username).send_keys(admin)看起来简单直接。但真实项目里CRM 前端改版的频率不低今天按钮 class 是btn-login下周可能就改成btn-primary。如果每个用例里都有一段硬编码的定位改一次按钮要翻遍所有用例文件。这就是常见的 Page Object 模式的价值把页面元素和操作封装成类用例层只调用方法不关心底层定位。# pages/login_page.py from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class LoginPage: 登录页面封装元素定位和操作都集中在这里 def __init__(self, driver): self.driver driver # 元素定位器统一放在初始化方法里方便维护 self.username_input (By.NAME, username) self.password_input (By.NAME, password) self.login_button (By.CSS_SELECTOR, button.btn-login) def input_username(self, username): # 用显式等待代替 sleep等元素可见再输入 WebDriverWait(self.driver, 10).until( EC.visibility_of_element_located(self.username_input) ).send_keys(username) def input_password(self, password): self.driver.find_element(*self.password_input).send_keys(password) def click_login(self): self.driver.find_element(*self.login_button).click() def login(self, username, password): 组合操作供用例直接调用 self.input_username(username) self.input_password(password) self.click_login()这个封装看起来简单但解决了三个实际问题。第一所有定位器集中在类头部改动只动一处。第二WebDriverWait配合expected_conditions替代了生硬的time.sleep()元素没加载完就一直等不会因为网络慢导致偶发失败。第三login()组合方法把登录这个动作收口后续所有用例要登录一行调用即可。这里有个细节要注意find_element(*self.username_input)里的星号是把元组解包成两个参数等价于find_element(By.NAME, username)不熟练的人容易在这里写错。3.2 新增客户主链路把登录到新增客户串起来CRM 系统的核心业务流程往往就是新增客户、编辑客户、查询客户这几件事。我们用 unittest 写一个新增客户的用例把登录、进入客户管理、填写表单、保存、断言这一整套串起来。这是整个项目实战里最核心的一段代码。# testcases/test_add_customer.py import unittest import time from selenium import webdriver from pages.login_page import LoginPage from pages.customer_page import CustomerPage class TestAddCustomer(unittest.TestCase): 新增客户流程测试 def setUp(self): 每个用例执行前的准备启动浏览器并登录 self.driver webdriver.Chrome() self.driver.get(http://localhost:8080/crm/login) self.driver.maximize_window() # 用时间戳生成唯一的客户名避免重复执行时数据冲突 self.customer_name 客户_A str(int(time.time())) LoginPage(self.driver).login(admin, admin123) def test_add_customer_success(self): 新增客户成功后列表应能查询到该客户 customer_page CustomerPage(self.driver) customer_page.enter_customer_menu() customer_page.add_customer(self.customer_name, 13800138000, 某科技公司) # 关键断言回到列表页面查询确认数据真正入库 self.assertTrue( customer_page.is_customer_exist(self.customer_name), 新增客户后列表中未找到客户 {}.format(self.customer_name) ) def tearDown(self): 每个用例执行后的清理关闭浏览器 self.driver.quit() if __name__ __main__: unittest.main()重点看三个地方。第一setUp和tearDown是 unittest 的生命周期方法保证每个用例跑之前都有干净的浏览器环境跑完就关闭用例之间互不影响。第二客户名用时间戳拼接这是数据隔离的关键——不这么干的话第二次跑同一个用例时客户名称已经存在CRM 系统会提示重复断言就会失败。第三断言不是只看保存成功的 toast 提示而是重新查询列表确认数据真实存在这样才够稳健。is_customer_exist方法在CustomerPage里实现内部会做显式等待和列表遍历。3.3 CustomerPage 封装定位新窗口和表单填写的细节客户管理页面比登录页复杂因为涉及菜单点击、弹窗表单、列表搜索几个动作。封装的时候要把这些动作拆成独立方法组合给用例调用。# pages/customer_page.py from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class CustomerPage: 客户管理页面封装 def __init__(self, driver): self.driver driver self.customer_menu (By.XPATH, //span[contains(text(), 客户管理)]) self.add_button (By.CSS_SELECTOR, button.btn-add) self.name_input (By.NAME, customerName) self.phone_input (By.NAME, phone) self.company_input (By.NAME, company) self.save_button (By.CSS_SELECTOR, button.btn-save) self.search_input (By.NAME, keyword) self.search_button (By.CSS_SELECTOR, button.btn-search) def enter_customer_menu(self): # 点击左侧菜单等页面跳转完成 WebDriverWait(self.driver, 10).until( EC.element_to_be_clickable(self.customer_menu) ).click() def add_customer(self, name, phone, company): self.driver.find_element(*self.add_button).click() # 弹窗出现后等输入框可操作再填写 WebDriverWait(self.driver, 10).until( EC.visibility_of_element_located(self.name_input) ).send_keys(name) self.driver.find_element(*self.phone_input).send_keys(phone) self.driver.find_element(*self.company_input).send_keys(company) self.driver.find_element(*self.save_button).click() def is_customer_exist(self, name): 按客户名搜索判断列表中是否存在 self.driver.find_element(*self.search_input).clear() self.driver.find_element(*self.search_input).send_keys(name) self.driver.find_element(*self.search_button).click() # 搜索结果可能为空用 is_displayed 判断存在性时不报错 elements self.driver.find_elements( By.XPATH, //td[contains(text(), {})].format(name) ) return len(elements) 0这里最容易翻车的是弹窗类的表单元素。很多 CRM 的前端框架在弹窗打开时输入框是在 iframe 里的直接find_element会报找不到元素。如果遇到 iframe需要先driver.switch_to.frame()切进去操作完再切回来。另外搜索时用find_elements而不是find_element因为搜索结果可能为空用前者不会抛异常返回空列表即可。这个习惯能省很多排查时间。4. 生成报告发邮件让测试结果自动送上门4.1 用 HTMLTestRunner 生成可视化报告unittest 自带的命令行输出只有几行文字失败信息也不直观不适合项目组评审。常见做法是引入 HTMLTestRunner 这个第三方报告组件生成带样式、统计图表和失败截图的 HTML 报告。Python 3 环境需要做一些兼容性改动我会直接把它当作项目内的公共组件放在utils下。# run_all.py 测试入口 import unittest import datetime from utils import HTMLTestRunner from utils.send_mail import send_report if __name__ __main__: # discover 自动发现 testcases 目录下所有 test_ 开头的测试文件 suite unittest.defaultTestLoader.discover( testcases, patterntest_*.py ) # 报告文件名带时间戳方便留档 now datetime.datetime.now().strftime(%Y%m%d_%H%M%S) report_path reports/test_report_{}.html.format(now) with open(report_path, wb) as fp: runner HTMLTestRunner( streamfp, titleCRM系统自动化测试报告, descriptionselenium unittest 回归执行结果 ) runner.run(suite) # 执行完立即发送邮件 send_report(report_path)discover是 unittest 里最实用的一个方法它会自动扫描指定目录下所有符合 pattern 的文件不需要手动一个个添加 TestCase。报告文件名带上时间戳的好处是历史报告可以留档对比看趋势。HTMLTestRunner 的输出会包含用例总数、通过数、失败数、运行时长、每个用例的详细信息、失败时的完整堆栈。这个组件以类文件形式引入同一个 Python 版本环境不会报大问题。4.2 smtplib email 发送带附件的邮件报告生成后最后一步是把它发出去。Python 发送邮件通常用smtplib加email.mime这套组合支持 HTML 正文、附件、多个收件人。# utils/send_mail.py import os import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.header import Header # 这些配置建议从环境变量读取不要写死在代码里 SMTP_HOST smtp.example.com SMTP_PORT 465 # SSL 端口 SENDER senderexample.com AUTH_CODE os.getenv(MAIL_AUTH_CODE, your_auth_code) RECEIVERS [qaexample.com, devexample.com] def send_report(report_path): 发送测试报告邮件报告作为附件 msg MIMEMultipart() msg[From] SENDER msg[To] ,.join(RECEIVERS) msg[Subject] Header(CRM系统自动化测试报告, utf-8) # 正文说明 body MIMEText(请查收附件中的自动化测试报告。, plain, utf-8) msg.attach(body) # 附件读取报告文件按 base64 编码发送 with open(report_path, rb) as fp: attachment MIMEText(fp.read(), base64, utf-8) attachment[Content-Type] application/octet-stream attachment[Content-Disposition] attachment; filename{}.format( os.path.basename(report_path) ) msg.attach(attachment) # SSL 方式连接邮箱服务器 smtp smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT) smtp.login(SENDER, AUTH_CODE) smtp.sendmail(SENDER, RECEIVERS, msg.as_string()) smtp.quit()这一段的重点在三个地方。第一AUTH_CODE是邮箱的授权码不是登录密码很多邮箱尤其是国内主流邮箱必须开启 SMTP 服务并生成授权码才能用第三方客户端发送直接用密码登录会报SMTPAuthenticationError。第二附件通过MIMEText以 base64 编码读取必须设置Content-Transfer-Encoding相关的头信息否则收到的附件可能是一堆乱码文本。第三用SMTP_SSL连接 465 端口这是最稳定的方式如果用 25 端口明文发送在部分网络环境下会被运营商拦截。邮件正文和主题都用utf-8编码避免中文标题变成问号。5. 五个常见坑与排查思路5.1 元素定位不稳定明明手工能点脚本就是找不到现象脚本在某个按钮上抛NoSuchElementException或ElementClickInterceptedException但手工打开浏览器元素就在那里。原因最常见的两个。一是元素所在的页面区域是异步加载的点击菜单后弹窗内容还没渲染完脚本就去找输入框了。二是按钮位置被弹出层遮挡比如 CRM 系统有悬浮的消息提示盖住了目标按钮。解决前端代码写send_keys之前统一用WebDriverWait等待元素可见且可点击遇到遮挡先关闭浮动弹层再操作。我会在CustomerPage里加一个close_float_windows()方法专门处理这类干扰。5.2 邮件报告里的中文全部变成乱码或问号现象收到的 HTML 报告能打开但标题和用例描述全部是乱码邮件主题显示为?UTF-8?B?...?这类字符串。原因HTMLTestRunner 生成报告时编码没对齐写入文件用的是默认编码邮件附件在MIMEText构造时没指定字符集或者指定了gbk而实际内容是utf-8。解决生成报告时用open(report_path, wb)二进制模式写入邮件构造统一用MIMEText(..., base64, utf-8)邮件主题用Header(..., utf-8)。这两个地方都改对中文就不会有问题。5.3 邮件发送失败认证错误或超时现象smtp.login()报SMTPAuthenticationError或者连接超时SSL 握手失败。原因授权码错误、发送服务器要验证发件人域名与登录账号一致、以及公司网络对 465 端口做了封锁。解决先确认邮箱已开启 SMTP 服务并生成了授权码如果公司网络封锁端口改用公司内部的邮件中继服务器地址本地调试时可以先连smtp.example.com的 25 端口测试连通性再切回 SSL。5.4 用例第二次跑就失败测试数据污染现象第一次跑全绿第二次同样的用例挂在了断言上提示“客户名称已存在”。原因CRM 系统对客户名称做了唯一性约束第一次执行时写入的数据没有清理第二次再写入就冲突了。解决测试数据用时间戳或随机数后缀保证每次执行时数据都是全新的也可以在tearDown里去删除创建的数据但很多 Web 系统没有批量删除的能力时间戳方案更通用。5.5 unittest 用例执行顺序不可控现象查询客户、编辑客户这类用例单独跑都通过但和新增客户用例一起跑时偶尔新增还没执行完查询就已经开始了导致断言失败。原因unittest 默认按照测试类和方法名的字母序执行不会保证你写在文件里的先后顺序也不感知业务依赖。解决让每个用例足够独立——查询用例不依赖其他用例先插入数据而是在setUp里直接调用接口或 SQL 准备数据如果确实需要控制顺序用TestSuite手动addTest指定执行顺序但这是下策治标不治本。6. 让这套框架变得更好用三个进阶取舍6.1 等待策略把 sleep 全部换掉初版脚本里到处是time.sleep(3)跑起来慢不说网络慢的时候照样失败。我后来统一改成显式等待WebDriverWait(driver, 10).until(EC.visibility_of_element_located(...))。这带来的收益很直接——原来跑一遍要 15 分钟换成条件等待后压缩到 8 分钟而且稳定性明显提升。等你对expected_conditions熟悉了还会用到presence_of_element_located、element_to_be_clickable、staleness_of这些覆盖绝大多数场景。6.2 数据驱动把测试数据和代码分离现在的用例里客户名、手机号、公司名都是写死的。测试数据一旦多起来比如要测不同长度、不同格式的手机号就得复制一堆方法。改进方向是用unittest.subTest或者把数据放到外部文件里一份 JSON 存一组测试数据用例循环读取。import json import unittest class TestCustomerDataDriven(unittest.TestCase): def test_add_customer_with_multi_data(self): 从 JSON 文件读取测试数据循环执行 with open(config/test_data.json, r, encodingutf-8) as fp: test_data json.load(fp) for case in test_data[add_customer]: with self.subTest(casecase): # 这里调用 CustomerPage 执行新增并断言 self.assertTrue(case[expect] success)6.3 失败重跑与持续集成跑自动化测试最怕的是偶发失败——同一个用例上次跑是绿的这次因为网络抖动挂了。后来我加了失败重跑机制收集失败用例按原顺序再跑一遍第二次通过了就标记为 flaky单独在报告里标注。这个机制不用在用例层处理而是包装TestResult记录失败用例后重新执行。至于持续集成把run_all.py挂到流水线里每天定时跑一次报告自动推送到企业邮箱。在我现在的习惯里这套 CRM 自动化项目已经被我当作一个标准模板任何 Web 系统的回归测试都往里套。它带来的收益很明确回归测试从半天缩短到十几分钟而且报告自动送达不用自己盯着屏幕等结果。如果你也在维护类似系统先把主链路跑通再逐步补齐异常场景用例比一次性追求覆盖率更实际这个方向值得投入。希望帮到你。本文还有配套的精品资源点击获取