上周有个做测试开发的朋友跟我诉苦他们项目的自动化脚本已经攒了一百多个Python文件但每次回归要么手动挑着跑要么跑挂了看半天不知道是哪一步出的问题隔两天再跑居然又全绿了。我说你这不缺脚本缺的是一个能把用例“管起来”的框架。他问用什么我说先把Python自带的Unittest吃透再去折腾那些花哨的Runner和平台。Unittest不是什么新东西它是Python标准库自带的单元测试框架从Python 2.1时代就有了也是很多测试框架比如pytest的思想源头。正因为它够老、够稳、够基础反而值得每个做自动化测试的人认真过一遍。这篇文章我不打算给你讲太多抽象概念直接用一个完整的Web登录功能自动化测试实例从环境搭建、用例编写、批量执行到接入持续集成把Unittest的核心用法和实现流程全部串一遍。适合刚入门自动化测试的工程师也适合已经在用pytest但没系统学过Unittest、想补一补底层设计思路的同学。1. 为什么自动化测试必须有个“框架”而不是一堆能跑的脚本很多人刚开始做自动化测试的时候状态是这样的写了一个脚本能跑通登录觉得很爽又写一个脚本能跑通创建订单也很爽。等脚本攒到几十个、上百个问题就来了——每个脚本都是独立运行的没有一个统一入口跑到后面根本搞不清楚今天改了A脚本会不会把B脚本带崩。这个阶段我经历过教训很深刻。1.1 脚本堆砌的四大痛点第一没有统一的用例编排。你需要在某个特定场景下按顺序跑十几个用例脚本之间没有依赖关系管理只能写一个Shell脚本按文件名一个一个调用跑失败了也不知道具体卡在哪个断言上。第二没有结果统计。脚本输出的是print(登录成功)还是print(登录失败)完全看个人心情没有pass/fail的概念跑完连个成功率都算不出来。第三没有前置与清理机制。每个脚本要准备测试数据时都是复制粘贴一坨Selenium代码去登录用完也不清理测试数据越攒越脏最后影响下一次执行。第四没有断言规范。有人用if 欢迎 in page_source有人用assert 欢迎 in page_source还有人什么都不写直接肉眼观察浏览器这类脚本根本谈不上自动验证。1.2 Unittest解决什么Unittest之所以能解决上面这些问题是因为它天然把测试过程分成了四个组件TestCase测试用例、TestSuite测试套件、TestRunner测试执行器、TestFixture测试夹具。你可以把TestCase当成一个会自检的单元TestSuite当成一个能排序、能分组的容器TestRunner当成那个按规则把容器里的用例跑起来并记录结果的人TestFixture就是在每个用例前后自动做的“准备动作”和“清理动作”。这四件事恰恰是自动化测试工程化最基本的四块拼图。没有这套结构你的测试脚本攒再多都是散沙有了这套结构哪怕不用更强大的pytest也一样能把项目打理得清清楚楚。1.3 Unittest和pytest怎么选市场上确实有大量团队已经转向pytest了pytest在断言写法、Fixture机制和插件生态上确实是更好的工具。但我依然觉得Unittest不能跳过原因有三它是标准库不需要额外安装在任何一台装好Python的机器上都能跑它的测试发现机制discover和用例组织方式是所有Python测试框架的公共基础pytest的很多理念都源于它很多大厂的历史项目用的是Unittest你要接手这类项目不会Unittest连用例都看不懂。从学习性价比看先花两三天把Unittest搞明白再切换到pytest你的理解会比直接上手pytest深得多。2. 从零跑通第一个Unittest用例工程结构和四要素光说不练没有意义。我们直接搭建一个最小可运行的Unittest工程把上面说的四个组件全部落到实际代码里。2.1 最小工程目录长什么样以一个测试项目为例我习惯的目录结构是这样的auto_test/ ├── config/ │ └── settings.py ├── pages/ │ ├── base_page.py │ └── login_page.py ├── test_cases/ │ ├── __init__.py │ └── test_login.py ├── utils/ │ └── screenshot.py ├── report/ └── run.pyconfig放全局配置比如测试地址、账号密码、浏览器类型pages页面对象层封装每个页面的定位与操作用例遵循Page Object模式test_cases测试用例层只关心“怎么做断言”不关心底层元素怎么找utils公共方法比如截图、日志、邮件发送report存放测试报告run.py整个项目的执行入口。很多初学者喜欢把所有代码堆在一个文件里这在Demo阶段没问题但一旦用例超过10条这种写法就是灾难。Page Object模式的核心理念是把“页面元素操作”和“测试逻辑”分离比如登录页面上用户名输入框的定位变了你只需要改LoginPage一个类所有用到它的测试用例自动跟着变。2.2 一个最简单的TestCase新建test_cases/test_demo.pyimport unittest class TestMathOperations(unittest.TestCase): def test_add(self): self.assertEqual(1 1, 2) def test_subtract(self): self.assertEqual(5 - 3, 2) if __name__ __main__: unittest.main()在命令行执行python -m unittest test_cases.test_demo -v你会看到类似输出test_add (test_cases.test_demo.TestMathOperations) ... ok test_subtract (test_cases.test_demo.TestMathOperations) ... ok ---------------------------------------------------------------------- Ran 2 tests in 0.001s OK完成这一步你就已经用上了Unittest最核心的机制unittest.main()会自动收集当前模块里所有继承unittest.TestCase的类把类中以test_开头的所有方法当作测试用例执行逐条统计结果。2.3 TestCase、TestSuite、TestRunner是怎么协作的很多人第一次看Unittest的源码结构会蒙其实它的协作逻辑特别像“厨师做菜”TestCase就是菜谱上的一道菜菜怎么做、最后怎么验证味道断言都写在这一个类里TestSuite就是一本菜谱合集它会告诉你今天这桌饭先做哪道、后做哪道可以按模块分组TestRunner就是掌勺的大厨他拿着菜谱合集按顺序做菜做完之后把结果记录到一张表里比如成功几道、失败几道、花了多长时间TestFixture就是做菜的准备工作——切菜、洗锅、预热还有做完之后收拾灶台。用代码展示这三者的协作import unittest from test_cases.test_demo import TestMathOperations def suite(): test_suite unittest.TestSuite() tests [TestMathOperations(test_add), TestMathOperations(test_subtract)] test_suite.addTests(tests) return test_suite if __name__ __main__: runner unittest.TextTestRunner(verbosity2) runner.run(suite())这段代码手动创建了TestSuite把两个用例装进去然后用TextTestRunner执行。虽然比直接unittest.main()啰嗦但它展示了一个关键原理用例的发现和用例的执行是可以拆开的。后面我们做大项目时往往是TestLoader自动发现用例TestRunner统一执行这两者的分工从这一步就已经埋下了。2.4 FixturesetUp和tearDown的正确打开方式如果每个用例都需要“打开浏览器-登录-执行断言-关闭浏览器”直接在每个测试方法里重复写一遍就是最蠢的做法。Unittest提供了Fixture体系setUp()每个测试方法执行前运行tearDown()每个测试方法执行后运行setUpClass()整个测试类执行前运行一次需加classmethodtearDownClass()整个测试类执行后运行一次需加classmethod。举个实际例子import unittest class TestDatabase(unittest.TestCase): classmethod def setUpClass(cls): # 耗时操作比如建数据库连接池只做一次 print(连接数据库) classmethod def tearDownClass(cls): print(关闭数据库连接) def setUp(self): # 每个用例前都要执行的准备比如插入测试数据 print(为当前用例准备数据) def tearDown(self): # 每个用例后执行比如清理脏数据 print(清理当前用例数据) def test_insert(self): print(执行插入用例) def test_delete(self): print(执行删除用例)我刚学自动化测试的时候一直没理解setUpClass和setUp到底有什么区别后来吃了好几次“每个用例都重新登录一遍跑得巨慢”的亏才记住类级别的Fixture是重操作方法级别的Fixture是轻操作。比如“连接数据库”属于重操作只做一次就好而“插入一条测试数据”属于轻操作每个用例前做一遍保证每个用例数据独立。3. 完整的UI自动化测试实例登录功能的用例设计到落地理论部分差不多了下面我们用Selenium加Unittest实现一个用户登录页面的UI自动化测试。为了便于复盘我把它拆成五个小步骤。3.1 需求梳理与用例设计测试对象是一个普通网站登录框需求就两条输入正确账号密码能跳转到首页输入错误密码会提示错误信息。但设计用例时不能只写两个用例至少要覆盖这几类用例编号前置条件输入数据预期结果TC_LOGIN_001无正确用户名正确密码登录成功跳转首页TC_LOGIN_002无正确用户名错误密码登录失败页面出现错误提示TC_LOGIN_003无空用户名登录失败提示用户名不能为空TC_LOGIN_004无空密码登录失败提示密码不能为空自动化用例设计有一条黄金法则每条用例之间尽量不要有依赖。如果你把“必须先登录成功”作为另一条用例的前置条件那一旦登录功能挂了后面所有用例都会跟着失败排查问题时会非常痛苦。3.2 封装页面对象层先封装一个BasePage把所有公共的Selenium操作放进去from selenium import webdriver from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class BasePage: def __init__(self, driver): self.driver driver def find_element(self, locator, timeout10): 显式等待元素出现locator格式为(方法, 值)如(By.ID, username) return WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located(locator) ) def input_text(self, locator, text): element self.find_element(locator) element.clear() element.send_keys(text) def click(self, locator): self.find_element(locator).click()然后封装LoginPagefrom selenium.webdriver.common.by import By from pages.base_page import BasePage class LoginPage(BasePage): username_locator (By.ID, username) password_locator (By.ID, password) login_button_locator (By.ID, loginBtn) error_message_locator (By.CLASS_NAME, error-tip) def login(self, username, password): self.input_text(self.username_locator, username) self.input_text(self.password_locator, password) self.click(self.login_button_locator) def get_error_message(self): return self.find_element(self.error_message_locator).text这里有一个关键点Locator必须集中管理。很多人写自动化测试喜欢在每一条用例里直接写driver.find_element(By.ID, xxx)一旦前端改了元素ID你得去几十个文件里找。把Locator作为类属性放在页面对象里改起来就是一个文件的事情。3.3 编写测试用例import unittest from selenium import webdriver from pages.login_page import LoginPage class TestLoginPage(unittest.TestCase): classmethod def setUpClass(cls): cls.driver webdriver.Chrome() def setUp(self): self.driver.get(https://example.com/login) self.login_page LoginPage(self.driver) def tearDown(self): # 每次用例结束清理一下本地存储避免上一条用例影响下一条 self.driver.execute_script(window.sessionStorage.clear();) classmethod def tearDownClass(cls): cls.driver.quit() def test_login_success(self): self.login_page.login(test_user, correct_password) self.assertIn(home, self.driver.current_url) def test_login_wrong_password(self): self.login_page.login(test_user, wrong_password) self.assertIn(用户名或密码错误, self.login_page.get_error_message()) def test_empty_username(self): self.login_page.login(, correct_password) self.assertIn(请输入用户名, self.login_page.get_error_message()) def test_empty_password(self): self.login_page.login(test_user, ) self.assertIn(请输入密码, self.login_page.get_error_message()) if __name__ __main__: unittest.main()执行后Unittest会自动告诉你四个用例里哪几个通过、哪几个失败失败的原因会精确到具体哪一个断言这就是我们上文说的“自带结果统计”。3.4 Selenium元素定位和等待策略的避坑心得写UI自动化测试最大的坑有两个元素找不对和元素找得太快。元素找不对通常是因为前端用了动态ID或者过于复杂的XPath。我的经验是定位优先级ID Name CSS Selector XPathXPath尽量少用能用相对路径就不要用/html/body/div[3]/...这种从根部一路写到底的写法页面前端稍微加一个div就全断了。元素找得太快典型表现是页面元素还没渲染完代码就已经执行到click()了然后就抛NoSuchElementException。解决这个问题推荐用显式等待比如我们BasePage里封装的WebDriverWait它会轮询等待元素出现超时才报错。隐式等待driver.implicitly_wait(10)也能用但它是全局生效的在某些复杂页面会拖慢执行速度所以更推荐显式等待配合Page Object使用。4. 用例越来越多之后用TestSuite和TestLoader做批量执行当测试用例超过几十条时你肯定不希望每次都在命令行敲一长串unittest.moduel.ClassName.test_method。Unittest提供了两种批量策略手动组装的TestSuite和自动发现的TestLoader。4.1 手动构建TestSuite的场景手动构建适合那些对顺序和分组有要求的场景。比如上线前你只关心冒烟用例那就建一个smoke_suiteimport unittest from test_cases.test_login import TestLoginPage from test_cases.test_register import TestRegisterPage def smoke_suite(): suite unittest.TestSuite() suite.addTests([ TestLoginPage(test_login_success), TestLoginPage(test_login_wrong_password), TestRegisterPage(test_register_success), ]) return suite这样做的好处是执行顺序完全由你控制坏处是每新增一条用例都要回来手动加维护成本高。所以它更适合“核心主流程”这种相对固定的用例集合。4.2 TestLoader自动发现用例自动发现的逻辑是指定一个目录和文件名规则Unittest会递归遍历这个目录下所有符合规则的文件把里面的测试类全部收集起来。import unittest if __name__ __main__: test_dir ./test_cases suite unittest.defaultTestLoader.discover( start_dirtest_dir, patterntest_*.py ) runner unittest.TextTestRunner(verbosity2) runner.run(suite)discover有一个细节容易忽略它只会在那些能从当前目录导入的模块中搜索用例。如果你在test_cases目录下没有加__init__.py或者你的运行入口不在项目根目录discover常常会“明明文件里有用例却一个都找不到”。遇到这种问题先去检查__init__.py和运行路径十有八九是这两个原因。4.3 生成HTML报告TextTestRunner的输出到终端还行但要发给团队看或者接入CI平台还是需要一份HTML报告。Unittest本身不自带HTML报告常用的是第三方的HTMLTestRunner部署方式有两种直接pip安装现成版本或者把源码文件放入项目目录后导入。我个人推荐后者因为HTMLTestRunner有好几个历史版本pip装的版本有时和你本地Python版本不兼容。一个支持失败截图、执行耗时统计的报告生成脚本核心逻辑如下import unittest import time from utils.html_report import HTMLTestRunner if __name__ __main__: test_dir ./test_cases suite unittest.defaultTestLoader.discover(test_dir, patterntest_*.py) now time.strftime(%Y%m%d-%H%M%S) report_path f./report/test_report_{now}.html with open(report_path, wb) as f: runner HTMLTestRunner( streamf, titleWeb自动化测试报告, descriptionUnittest Selenium 渲染的用例执行结果 ) runner.run(suite)这里想提醒一句报告只是一个展示层真正有价值的是报告里暴露的失败用例和异常堆栈。不要把时间花在把报告做得花里胡哨上先把失败原因看清楚报告才有意义。5. 用数据驱动减少重复代码一个类跑几十组数据登录用例如果只是换个用户名密码就要复制粘贴一大段代码那自动化测试的维护成本会把你拖垮。数据驱动的思路很简单用例逻辑只写一遍把测试数据抽出来外置一条数据对应一个测试场景。5.1 用subTest实现轻量数据驱动Unittest自带一个subTest可以在一个test方法里跑多组子测试每组数据失败不会影响其他组继续执行。import unittest class TestLoginDataDriven(unittest.TestCase): test_data [ (test_user, correct_password, 登录成功), (test_user, wrong_password, 用户名或密码错误), (, correct_password, 请输入用户名), (test_user, , 请输入密码), ] def test_login_with_multi_data(self): for username, password, expected_tip in self.test_data: with self.subTest(usernameusername, passwordpassword): result login(username, password) self.assertIn(expected_tip, result) if __name__ __main__: unittest.main()执行后如果第四组数据失败了Unittest会单独标注subTest (username, passwordcorrect_password)失败信息定位很精确其他组数据照常执行。5.2 用ddt库让每条数据变成独立用例subTest的缺点是所有数据都在一个大用例下统计粒度不够细。如果你希望每条数据都成为一个独立的test用例出现在报告里可以用ddt库import unittest from ddt import ddt, data, unpack ddt class TestLoginDataDriven(unittest.TestCase): data( (test_user, correct_password, 登录成功), (test_user, wrong_password, 用户名或密码错误), (, correct_password, 请输入用户名), ) unpack def test_login_ddt(self, username, password, expected_tip): result login(username, password) self.assertIn(expected_tip, result)ddt库的内部原理并不复杂它其实是在装饰器的处理阶段把test_login_ddt这一个方法复制成了多个带编号的方法然后逐一执行。搞清楚这一点你就知道它和subTest最大的区别在于报告里能看到独立的用例编号。5.3 测试数据从Excel或JSON来数据驱动更进阶的用法是把数据放到外部文件里。比如把登录数据放在login_data.json[ { username: test_user, password: correct_password, expected_tip: 登录成功 }, { username: test_user, password: wrong_password, expected_tip: 用户名或密码错误 } ]测试代码里读取这个文件再交给ddt或subTest执行。这样前端同学或者测试同事要加一组测试数据直接改JSON文件就行完全不用碰Python代码。自动化测试做得好不好很重要一个衡量标准就是业务人员能不能独立维护用例数据。6. 让自动化测试在持续集成里稳定跑起来本地跑通只是第一步。如果自动化测试不能定时、无人值守地跑它对团队的帮助就非常有限。把Unittest脚本接到CI平台上核心工作有两块一是配置触发策略二是解决稳定性问题。6.1 CI平台的接入流程以Jenkins为例在项目中配置Python环境的思路是新建一个自由风格的项目源码管理里指向Git仓库地址构建环境里选择“Add timestamps to the Console Output”方便看日志时间构建步骤选“Execute shell”或“Execute Windows batch command”输入python run.py构建触发器选择“Build periodically”填写类似H 2 * * *表示每天凌晨两点跑一次构建后操作里选择“Publish HTML report”指定报告目录然后团队成员每次打开CI页面就能直接看到最新一次测试的结果。接入CI最需要注意的坑是环境一致性。本地跑得好好的CI上跑挂了大概率是Python版本不一致、浏览器驱动没装、或者环境变量PATH不对。所以建议CI执行机上所有依赖一律用requirements.txt固定版本浏览器驱动用webdriver-manager自动匹配pip install -r requirements.txt python run.py执行机上尽量只保留一套Python环境或者用虚拟环境安装依赖避免不同项目互相污染。6.2 稳定性治理的五板斧UI自动化最大的敌人是“不稳定”。我参与过的项目里测试用例平均通过率能从70%提到95%以上靠的不是把用例写得更漂亮而是下面五件事第一统一等待策略。不要到处time.sleep(3)统一用显式等待等待元素可点击、可见、存在。sleep是固定时间网络快慢变化后脚本就会假失败即使不是功能问题也会被误当作Bug去报告。第二失败必须留证据。每次断言失败、元素找不到时自动截图并保存日志。没有截图你看到CI上一条失败用例只能干瞪眼完全不知道当时页面发生了什么。截图代码可以封装到一个工具模块在tearDown里判断测试结果再调用from utils.screenshot import take_screenshot def tearDown(self): result self._outcome.success if not result: take_screenshot(self.driver, nameformat_time())第三失败自动重试。对于网络波动导致的偶发失败可以用flaky装饰器或者自己写重试机制。注意重试不是用来掩盖Bug的它只对“环境因素”有效如果代码断言本身写错了重试多少次都是白搭。第四测试数据隔离。登录测试一定要用专用的测试账号别用生产账号也别和其他人的测试共用账号。曾经遇到过两个团队共用一套测试数据互相清数据导致用例大面积失败的情况最后只能为每个团队单独建账号、单独准备一批测试数据。第五日志打到位。在关键步骤后面加logger.info比如“已输入账号”、“已点击登录按钮”、“当前URL为xxx”。很多人看不起这种日志觉得啰嗦真到出问题时日志要比截图更能还原执行过程。6.3 常见问题与处理方式问题现象常见原因处理方式连不上浏览器Chrome版本与chromedriver版本不匹配使用webdriver-manager自动匹配版本元素定位不到页面Ajax加载太慢或元素在iframe内显式等待 切换到iframe再定位跑完用例浏览器不关tearDownClass里忘记driver.quit()确保在tearDownClass中执行quitCI上用例全部失败环境变量或Path里缺少浏览器驱动CI执行机安装驱动并加入系统Path用例随机失败用例之间存在数据耦合拆分独立测试账号清理脏数据另外多说一句最近AI辅助生成测试脚本的工具越来越多比如结合LLM的自动化测试Agent能根据自然语言描述生成初步的用例骨架这确实能减少一部分重复劳动。但我的观点是AI能帮你写代码不能帮你理解测试思想。你如果自己都搞不懂Fixture的级别、断言怎么设计、数据怎么隔离AI生成出来的脚本出了问题你也无从下手。把Unittest这套基本功练扎实了再去看AI工具生成的代码才能判断它写得对不对该往哪个方向改。7. 一些个人体会框架不是终点工程化才是写自动化测试这几年我最深的体会是Unittest能不能用好不在于你背诵了多少API而在于你愿不愿意把“测试”当成一个正经工程来做。目录结构、页面封装、数据分离、日志截图、CI集成这些东西没有一样是Unittest强制你做的但每一样都会在用例规模扩大之后成为质量的分水岭。如果只是想让“脚本能跑”Selenium就够了如果你想让“测试能持续稳定地守护整个项目”那框架与工程化这套组合拳才是真正的答案。