尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

测试自动化必备:类与模块让测试代码告别混乱

测试自动化必备:类与模块让测试代码告别混乱 很多测试同学在入门自动化时都会遇到一个很奇怪的瓶颈单个功能写脚本完全没有问题但只要功能一多、用例一多脚本就开始失控。今天这篇教程要讲的内容就是打破这个瓶颈的两个基础概念——类与模块。它们不是程序员专属的“高深理论”而是测试代码能否工程化的分水岭。我的判断是类和模块解决的不是“能不能写出来”的问题而是“能不能长期维护”的问题。一个人手写的测试脚本如果没有合理的类设计和模块拆分十个用例以内还能忍一百个用例以上基本就是灾难。这篇文章会从软件测试的实际场景出发先讲清楚类与模块到底解决什么痛点再用三个递进的示例从“函数脚本”进化到“类封装”再进化到“模块化项目”最后用一个可以本地跑通的小型接口测试项目收尾。读完你会明白同样一段测试逻辑用什么样的代码组织方式决定了日后维护的成本。1. 这篇文章真正要解决的问题先看三个测试同学最常遇到的真实场景。第一个场景是脚本越来越长。刚开始学自动化时很多人喜欢把所有步骤写在一个文件里登录、点击、断言、清理数据全部顺序往下排。功能少的时候没有问题但一旦需要验证多条用例脚本就会膨胀到几百行甚至上千行每次定位失败原因都要上下翻找。第二个场景是复用基本靠复制粘贴。今天测试登录明天测试下单后天测试支付每个脚本里都有一段“初始化浏览器/初始化请求会话”“输入用户名密码”“点击登录按钮”的代码。多数人第一反应是复制上一份脚本然后改几个参数。这样看起来很快但隐患在于一旦登录逻辑发生变化比如新增了一个登录前的验证码输入步骤你就要把所有复制过这段代码的文件全部找出来逐个修改漏掉一个就是线上事故。第三个场景是一改全崩。因为代码之间没有清晰的边界所有函数都可以互相调用所有变量都暴露在同一个全局作用域里。一个简单的功能变更可能牵连到好几个原本不相关的测试脚本。“修复一个bug引入两个新bug”在测试代码里同样常见。这篇文章要解决的问题就是上面三种现象背后的根因测试代码缺乏结构。类能帮你把数据和操作数据的动作封装在一起模块能帮你把不同职责的代码拆分到不同文件中两者结合起来测试脚本才能从“一次性脚本”变成“可持续维护的自动化资产”。什么样的读者最应该读这篇文章如果你是零基础准备入行软件测试或者已经会用 Python 写简单脚本、但写出来的代码自己都嫌乱又或者正在准备测试开发相关岗位的笔面试那这篇文章就是给你准备的。类与模块是 Python 面试中出现频率极高的话题也是后续学习 pytest、Selenium、Requests 等测试框架绕不开的基础。2. 类与模块测试代码工程化的起点2.1 类是什么把数据和行为绑在一起的模板用一句话概括类就是把数据和对数据的操作打包在一起的一种代码组织方式。在这个定义里“数据”在 Python 中叫属性“对数据的操作”叫方法。比如你测试一个登录接口登录功能涉及的数据是用户名、密码、服务器地址涉及的操作是发送登录请求、判断登录是否成功。把这一组数据和操作放在一起就得到一个“登录客户端”类。不过要特别说明类中的属性和方法都是“模板级别”的它不会在定义类的时候真正执行而是等你创建对象实例化之后才生效。这个设计背后的原因是你希望同一套登录逻辑可以被多个测试用例复用每一个测试用例只需要传入不同的用户名和密码就能得到一个独立的登录实例互不干扰。新手最容易误解的一点是类是一种语法而不是一种必须的规则。小脚本里不用类也没问题就像你家里东西少不需要定制收纳柜一样。但当测试用例变多、被测系统变复杂没有类的封装你的脚本会退化成一段段彼此纠缠的顺序代码。类是测试脚本从“能用”走向“好维护”的第一步。2.2 模块是什么把相关代码放进一个文件里模块的定义比类更简单一个.py文件就是一个模块它可以包含变量、函数、类也可以包含一段可以执行的逻辑。当你需要复用另一个文件里的代码时用import导入即可。模块解决的痛点和类不同。类解决的是“同一组逻辑如何复用”模块解决的是“不同职责的代码如何拆分”。打个比方类像是车间里的一台加工设备模块像是整个车间里划分出的不同功能区原料区、加工区、质检区。你可以把原料区和加工区放在一个房间里但那样一乱起来就很难管理。在软件测试项目中合理的模块拆分通常遵循一个直观原则和界面打交道的代码放一个模块和断言计算打交道的代码放一个模块纯测试用例放一个模块。这样任何一个模块发生变化其他模块都不会受牵连。2.3 类和模块的分工对比维度类模块粒度较小描述一组相关数据与操作较大包含多个类、函数和变量解决的问题代码复用、封装、状态管理代码组织、职责分离、命名空间语法关键词classimport、from ... import在测试项目中的角色封装页面对象、接口客户端、断言工具组织配置文件、工具库、用例集类比一台加工设备一个车间功能区类与模块从来不是二选一的关系。一个良好的测试项目中模块用来划分大边界类用来在每一个边界内部做更细粒度的封装。两者一起用代码才会既有清晰的分层又有足够的复用性。3. 环境准备与基础约定在开始写代码之前先把环境准备好。本文所有示例都以 Python 3.x 为演示语言具体版本请以你实际安装的版本为准。之所以选 Python是因为它已经是当前软件测试自动化领域最主流的语言之一无论是接口测试、Web UI 测试还是测试脚本工具Python 都有非常成熟的生态。建议你在本机安装一个虚拟环境避免项目之间的依赖相互冲突。创建虚拟环境的命令如下# 创建名为 venv 的虚拟环境 python3 -m venv venv # 激活虚拟环境Windows venv\Scripts\activate # 激活虚拟环境macOS / Linux source venv/bin/activate在后面的综合项目实战中我们需要安装 Flask、Requests 和 pytest 这三个库。Flask 用来启动一个本地 Mock 被测服务Requests 用来发送 HTTP 请求pytest 用来收集和执行测试用例。安装命令如下版本请以实际安装时的最新稳定版为准pip install flask requests pytest除 IDE 外你不需要再安装其他工具。IDE 方面PyCharm Community Edition 和 VS Code 都足够关键是要能识别 Python 解释器。如果你用 VS Code建议在项目根目录下创建.vscode/settings.json指定当前项目的解释器路径避免打开项目后使用错误的全局环境。4. 第一步从混乱的函数脚本到类4.1 反例一个典型的“面条式”测试脚本假设我们要测试一个用户登录接口。没有使用类之前很多人写出来的脚本长这样# 文件路径demo_no_class.py # 注意这个示例刻意写得重复用来展示不使用类的痛点 import requests def test_user_login_success(): url http://127.0.0.1:5000/api/login payload {username: admin, password: 123456} response requests.post(url, jsonpayload) data response.json() if data.get(code) 200: print(登录成功) else: print(登录失败) def test_user_login_wrong_password(): url http://127.0.0.1:5000/api/login payload {username: admin, password: wrong} response requests.post(url, jsonpayload) data response.json() if data.get(code) 401: print(密码错误返回符合预期) else: print(密码错误场景校验失败) test_user_login_success() test_user_login_wrong_password()这段代码能运行但问题很明显url、payload、response.json()这些操作在每一个用例里都重复出现。如果接口地址变了你要改两处如果登录接口的返回结构从code变成了status_code你还要改两处。这还只是两个用例如果是二十个用例呢这段脚本真正的缺陷不是代码长度而是“变量和操作被绑死在每一个函数里”你没有任何一个地方可以统一修改公共逻辑。这个问题的本质就是缺少类的封装。4.2 正例用类封装登录操作我们引入一个LoginClient类把登录接口的地址、请求构造、结果解析都封装起来。测试用例只负责传入参数和判断结果不再关心网络请求的细节# 文件路径demo_with_class.py import requests class LoginClient: 封装登录接口的公共逻辑 def __init__(self, base_url): self.base_url base_url self.session requests.Session() def login(self, username, password): url f{self.base_url}/api/login payload {username: username, password: password} response self.session.post(url, jsonpayload) return response.json() def is_login_success(self, data): return data.get(code) 200 if __name__ __main__: client LoginClient(http://127.0.0.1:5000) result client.login(admin, 123456) print(登录成功 if client.is_login_success(result) else 登录失败)这段代码的价值体现在三个地方。第一base_url只初始化一次后续所有用例共用同一个客户端实例第二登录请求被封装成login方法调用方不需要关心 URL 拼接和请求头构造第三如果登录逻辑变化只需要修改LoginClient内部代码所有调用它的测试用例不用动。很多新手看这段代码会觉得“也没少几行啊”那是因为示例规模太小。请想象一下当你的测试代码里有页面元素定位、数据库连接、文件读写、日志记录、断言工具时类封装带来的维护收益是指数级增长的。这里真正容易踩坑的地方是__init__方法里的self如果不写self.base_url base_url后面的方法就拿不到这个变量。4.3 面试高频self 到底是什么面试时面试官经常问“Python 类中self的作用是什么”。从测试代码的实际运行角度看self指向当前实例对象本身。你创建client LoginClient(...)时Python 会自动把client作为第一个参数传给__init__和后续所有实例方法所以你在方法里写的self.base_url本质上是“当前这个实例自己的 base_url”。这个概念在测试代码中有非常大的实际意义多个测试用例可以各自创建不同的LoginClient实例分别连接不同的环境地址互不干扰。这就是为什么类封装比全局变量更安全——一个实例改了自己的状态不会影响另一个实例。5. 第二步模块拆分与导入5.1 一个文件装不下所有代码类把同一组逻辑封装好了但如果你把断言工具、页面封装、测试用例全部塞进同一个文件当项目变大后这个文件依然会变得难以维护。模块化就是把不同职责的代码放到不同的.py文件里通过import把它们连接起来。以一个简单的登录测试为例我们可以拆成三个模块assert_utils.py放通用断言方法和具体业务无关。login_page.py放登录接口的封装属于业务操作层。test_login.py放具体测试用例只关心测试数据和预期结果。5.2 断言工具模块# 文件路径assert_utils.py 通用的断言工具模块 class AssertUtils: staticmethod def assert_equals(actual, expected, message): assert actual expected, f{message}实际值: {actual}期望值: {expected}这里的AssertUtils类里只有一个静态方法assert_equals。静态方法意味着不需要创建实例就能直接调用它的作用是把“断言相等”这个动作统一收口。将来如果公司规范要求断言失败必须附带日志只需要改这里一处。5.3 接口封装模块# 文件路径login_page.py 登录接口封装模块隔离底层请求细节 import requests class LoginPage: def __init__(self, base_url): self.base_url base_url self.session requests.Session() def login(self, username, password): url f{self.base_url}/api/login response self.session.post(url, json{ username: username, password: password, }) return response.json() def get_error_message(self, data): return data.get(message, )这个模块的名字叫LoginPage灵感来自测试领域非常经典的“页面对象模型”Page Object Model。即使你测的是接口不是网页这种“把一个业务入口封装成一个对象”的思路也完全适用。它的好处是测试用例层根本不需要知道请求是用requests发的还是用其他库发的。5.4 测试用例模块# 文件路径test_login.py 测试用例模块只关注测试数据和预期结果 from login_page import LoginPage from assert_utils import AssertUtils def test_login_success(): page LoginPage(http://127.0.0.1:5000) data page.login(admin, 123456) AssertUtils.assert_equals(data.get(code), 200, 登录成功场景) def test_login_wrong_password(): page LoginPage(http://127.0.0.1:5000) data page.login(admin, wrong) AssertUtils.assert_equals(data.get(code), 401, 密码错误场景)在测试用例模块中只出现了“创建页面对象”“调用业务方法”“断言结果”三类动作。这就是模块化想达到的效果测试用例阅读起来像一份产品验收文档而不是一段网络请求代码。5.5 导入时的常见坑模块化之后最常见的异常是ModuleNotFoundError。这个错误通常发生在三种情况第一你运行脚本的目录和模块文件不在同一个目录下第二模块名和 Python 内置模块重名第三两个模块互相导入。最稳妥的解决方法是把项目根目录设置为根路径所有模块之间的导入都从根目录开始写。比如上面的from login_page import LoginPage要求你运行测试时的工作目录在项目根目录下。如果使用 pytest通常会从根目录收集用例因此这类问题会少很多。6. 第三步综合项目实战——小型接口测试项目前面两步是基础拆解这一节要把类和模块组合起来完成一个可以完整运行的小型接口测试项目。为了让读者不需要依赖外部公司环境就能跑通我们会先写一个基于 Flask 的本地 Mock 服务把它当成“被测系统”然后编写接口测试脚本对它进行测试。6.1 项目结构api_test_project/ ├── app.py # Flask Mock 被测服务 ├── config.py # 公共配置 ├── api_client.py # 接口客户端封装类 ├── test_user_api.py # pytest 测试用例 ├── pytest.ini # pytest 配置 └── requirements.txt # 依赖清单6.2 被测 Mock 服务# 文件路径api_test_project/app.py 基于 Flask 的本地 Mock 服务模拟被测系统的用户管理接口 from flask import Flask, request, jsonify app Flask(__name__) USERS { admin: {password: 123456, nickname: 管理员}, } app.route(/api/login, methods[POST]) def login(): data request.get_json(forceTrue) username data.get(username) password data.get(password) user USERS.get(username) if user and user[password] password: return jsonify({code: 200, message: 登录成功, token: mock-token-xxxx}) return jsonify({code: 401, message: 用户名或密码错误}), 401 app.route(/api/users/username, methods[GET]) def get_user(username): user USERS.get(username) if not user: return jsonify({code: 404, message: 用户不存在}), 404 return jsonify({code: 200, data: {username: username, nickname: user[nickname]}}) if __name__ __main__: app.run(host127.0.0.1, port5000, debugFalse)这个 Mock 服务非常小但已经覆盖了接口测试中常见的两类请求POST 登录接口和 GET 用户查询接口。其中登录失败时会返回 401 状态码这是刻意设计的用来演示测试中“既要验证业务成功、也要验证业务失败”的常见场景。6.3 公共配置模块# 文件路径api_test_project/config.py 公共配置模块环境相关配置统一放到这里维护 BASE_URL http://127.0.0.1:5000 TIMEOUT 5把BASE_URL和TIMEOUT单独放在一个配置模块里是一个非常重要的工程习惯。将来测试环境从本地切换到测试服务器只需要改config.py这一个文件所有调用方都会自动生效。如果测试脚本里到处硬编码地址换环境时就会像前面说的那样满项目找字符串替换。6.4 接口客户端封装# 文件路径api_test_project/api_client.py 接口客户端封装用类组织登录和用户查询的请求逻辑 import requests import config class UserApiClient: def __init__(self, base_urlconfig.BASE_URL, timeoutconfig.TIMEOUT): self.base_url base_url self.timeout timeout self.session requests.Session() def login(self, username, password): url f{self.base_url}/api/login response self.session.post( url, json{username: username, password: password}, timeoutself.timeout, ) return response.status_code, response.json() def get_user(self, username, tokenNone): url f{self.base_url}/api/users/{username} headers {} if token: headers[Authorization] fBearer {token} response self.session.get(url, headersheaders, timeoutself.timeout) return response.status_code, response.json()UserApiClient是整个测试项目中最核心的类。它把“登录”“获取用户信息”两个业务操作封装成方法并且返回一个包含status_code和response.json()的元组方便测试用例拿到状态码和响应体分别断言。session对象是复用连接的关键如果接口有跨请求关联比如登录后保存 Cookie 或 Token使用同一个 session 可以模拟出真实的用户会话状态。6.5 pytest 测试用例# 文件路径api_test_project/test_user_api.py pytest 测试用例关注结果验证不关注请求细节 from api_client import UserApiClient from assert_utils import AssertUtils def setup_function(): # 每个测试用例执行前创建一个新的客户端实例 global client client UserApiClient() def test_login_success(): status_code, data client.login(admin, 123456) AssertUtils.assert_equals(status_code, 200, 登录接口状态码) AssertUtils.assert_equals(data.get(code), 200, 登录业务码) assert token in data, 登录成功响应中应包含 token def test_login_wrong_password(): status_code, data client.login(admin, wrong) AssertUtils.assert_equals(status_code, 401, 错误密码状态码) AssertUtils.assert_equals(data.get(code), 401, 错误密码业务码) def test_get_user_success(): _, login_data client.login(admin, 123456) token login_data.get(token) status_code, data client.get_user(admin, tokentoken) AssertUtils.assert_equals(status_code, 200, 查询用户状态码) assert data.get(data, {}).get(nickname) 管理员, 昵称应为管理员 def test_get_user_not_found(): status_code, data client.get_user(nobody) AssertUtils.assert_equals(status_code, 404, 不存在的用户状态码) AssertUtils.assert_equals(data.get(code), 404, 不存在的用户业务码)注意看test_get_user_success这个用例它先调用login拿到 token再带着 token 去查询用户信息。这种“用例间通过返回结果传递数据”的方式比把 token 存成全局变量更安全因为它完全依赖对象的方法返回值不会出现测试用例顺序干扰的问题。这也是类封装带来的直接收益每个实例的状态是独立的测试用例之间的关系是显式的。6.6 pytest 配置和依赖清单# 文件路径api_test_project/pytest.ini [pytest] testpaths . python_files test_*.py python_functions test_*# 文件路径api_test_project/requirements.txt flask requests pytestpytest.ini的作用是告诉 pytest 在哪些目录下找测试文件、哪些文件算测试文件、哪些函数算测试函数。如果不配置pytest 也有默认规则比如文件名以test_开头。这里显式配置的目的是为了避免测试目录中出现其他.py文件被误收集。7. 运行结果与效果验证接口测试项目的运行需要两个步骤先启动 Mock 服务再执行 pytest 测试。开发过程中我建议开两个终端窗口。第一个终端窗口启动被测服务cd api_test_project python app.py如果 Flask 安装成功你会看到类似下面的输出* Serving Flask app app * Debug mode: off * Running on http://127.0.0.1:5000看到Running on http://127.0.0.1:5000说明 Mock 服务已经就绪。这时不要关闭窗口保持它在后台运行。第二个终端窗口执行测试cd api_test_project pytest -v正常情况下预期输出类似 test session starts collected 4 items test_user_api.py::test_login_success PASSED test_user_api.py::test_login_wrong_password PASSED test_user_api.py::test_get_user_success PASSED test_user_api.py::test_get_user_not_found PASSED 4 passed in 0.43s 看到4 passed说明全部用例通过。如果某一个用例失败pytest 会直接打印失败位置和断言对比信息。第一步应该检查你自己的断言是否符合 Mock 服务的实际返回而不是急着改代码。因为很多时候“测试失败”恰恰说明被测代码或测试数据有问题这正是测试脚本存在的意义。如果所有用例都失败优先检查 Flask 是否还在运行以及config.py里的BASE_URL是否正确。常见的错误是把127.0.0.1写成了localhost在某些系统上这不会造成问题但在个别代理环境下会导致请求失败。8. 常见问题与排查思路问题现象可能原因排查方式解决方案ModuleNotFoundError: No module named xxx模块文件不在当前路径或模块名拼写错误检查文件目录结构确认import路径调整项目根目录后重新运行避免模块名与 Python 内置模块重名AttributeError: UserApiClient object has no attribute session忘记在__init__中初始化self.session查看构造方法是否完整在__init__中给属性赋值后其他方法才能通过self.xxx访问TypeError: login() missing 1 required positional argument: self直接使用类名调用了实例方法检查调用代码确认是否创建了实例先实例化client UserApiClient()再调用client.login(...)pytest 收集到 0 个测试用例文件命名或函数命名不符合 pytest 规则运行pytest --collect-only查看收集结果将测试文件和函数以test_开头或在pytest.ini中配置匹配规则Flask 端口被占用上一次运行的进程未关闭查看端口占用或换一个端口关闭旧进程或修改app.run(port5001)并同步修改配置接口返回 500 错误Mock 服务内部异常或get_json解析失败查看 Flask 终端日志检查请求是否使用了正确的Content-Type请求体是否为合法 JSON排查时建议遵循一个顺序先看错误信息的第一行再定位到对应的文件与行号最后看是导入问题、语法问题还是逻辑问题。不要一上来就搜索“为什么报错”很多错误信息已经直接告诉了你修改方向。9. 最佳实践与工程建议9.1 命名规范要统一类名使用大驼峰命名法例如UserApiClient函数名和变量名使用小写下划线命名法例如get_user、base_url常量使用全大写例如BASE_URL。测试模块尽量用test_开头测试函数也用test_开头这样 pytest 可以自动收集不需要手工维护测试清单。命名规范在这个阶段看起来是小事但当你进入团队协作时它会直接影响代码 Review 效率和信息检索效率。一个测试项目中如果一半人用驼峰、一半人用下划线搜索代码时会非常痛苦。9.2 每个类只做一件事UserApiClient只负责接口请求AssertUtils只负责断言app.py只负责模拟被测服务。如果一个类既处理接口请求又处理数据库校验又负责生成测试报告那么任何一处需求变化都会导致你改动这个类回归风险随之上升。在测试领域这一点与被测系统设计中的“单一职责原则”完全一致。9.3 测试代码与生产代码分离Mock 服务、测试用例、公共配置都要放在独立的项目目录中不要和被测试的业务系统混在一起。测试代码中的依赖和生产代码无关混在一起会导致双方的依赖管理互相污染。在真实的测试开发团队中自动化测试通常有独立的代码仓库和独立的 CI 流水线。9.4 配置与测试数据分离config.py里只放环境地址、超时时间这类配置不要把具体的用户名、密码、业务数据写死在这里。测试数据建议放到独立的 JSON 或 YAML 数据文件中或者直接写在测试用例函数中。这样做的目的是隔离变化配置变化和环境相关测试数据变化和具体场景相关两者的修改频率和触发条件完全不同。9.5 安全边界意识在接口测试中你可能会接触到用户名密码、Token、数据库连接串等敏感信息。测试代码也是代码同样要遵循最小权限原则。不要使用生产环境的超管账号跑测试不要在代码仓库中提交真实密码和密钥Mock 环境尽量使用本机回环地址避免暴露到公网。如果涉及数据库操作一定要在测试库或临时库中执行并且保证测试数据可回收。9.6 从项目一开始就使用虚拟环境Python 项目最常见的依赖冲突往往不是代码逻辑问题而是不同项目依赖了同一个第三方库的不同版本。从项目第一天就创建虚拟环境把依赖清单固化到requirements.txt可以避免很多莫名其妙的“我本地明明能跑怎么别人那里就报错”问题。10. 总结与后续学习方向这一篇教程把类和模块放到了软件测试的真实场景里讲核心收获可以归纳成三点类把同一组数据和操作封装在一起模块把不同职责的代码拆分到不同文件里两者结合才能支撑起可持续维护的自动化测试项目。你在阅读时不需要一开始就追求“写出完美架构”先从小例子改起把一个函数脚本逐步改造成类封装和模块拆分过程中体会维护成本的变化比背任何理论都有效。下一步建议你按这个顺序继续深入先把本文的项目实战代码在本地跑通然后把UserApiClient扩展成更多业务接口的封装再接触 pytest 的固件机制和参数化特性最后学习 Requests 的高级用法和实际接口联调中的鉴权、重试、日志处理。类与模块之后还有一个非常值得深挖的方向是“测试分层”也就是把 UI 测试、接口测试、单元测试的组织方式分开设计。如果你正在准备软件测试的笔试面试可以把本文中的LoginClient、UserApiClient这类代码当成手写题素材重点理解self的作用、静态方法与实例方法的区别、模块导入的路径规则。这些知识点在面试中出现的频率极高而且一旦理解了背后的逻辑你不需要死记硬背就能用自己的话表达清楚。希望这篇教程能成为你测试之路上的一个实用路标。
返回列表