第四课是这套软件测试入门到精通系列教程里最关键的一次转折:前几课还在讲变量、分支、循环这些"单打独斗"的语法,从这节课开始,我们进入 Python 工程化的核心——类与模块。
为什么这一课对所有想转自动化测试、接口测试的零基础同学特别重要?因为你只要打开任何一家公司的测试框架,第一眼看到的必然是class TestLogin、from common.api_client import APIClient这类代码。看不懂类和模块,你就只能复制粘贴别人写好的用例模板,出了问题根本不知道去哪排查。
这节课我会拆成三块来讲:先讲类的定义、对象创建和self的本质,再讲模块的导入机制与if __name__ == '__main__'的底层逻辑,最后用一个登录接口测试的实战案例,把类和模块串成一套最小可用的自动化测试项目。学完之后,你至少能独立读懂 pytest 测试类、PO 模式页面对象类,并且能自己动手搭建一套测试项目的目录结构。
1. 本课学习地图:类与模块到底解决什么问题
很多零基础同学学 Python 时最迷惑的一点是:我明明用函数就能写测试脚本,为什么还要学类?函数把一段逻辑封装起来,类把"数据 + 操作数据的方法"封装起来,模块则把相关的类、函数、变量继续归档。
放在软件测试里,区别非常明显:
| 对比维度 | 只用函数 | 使用类 + 模块 |
|---|---|---|
| 测试数据组织 | 靠全局变量或函数传参 | 类的属性天然承载数据 |
| 公共操作复用 | 复制粘贴函数调用 | 继承基类复用全部能力 |
| 命名空间 | 大量同名函数互相覆盖 | 类名 + 方法名双重隔离 |
| 项目结构 | 一个脚本堆到底 | 按模块拆分,多人协作 |
| 排查问题 | 靠人肉搜索 | 按模块、类、方法逐层定位 |
| pytest 组织 | fixture 到处散落 | 测试类内聚、conftest 统一管理 |
本课核心目标可以浓缩成四句话:
- 能写出规范的测试类,交给 pytest 自动收集执行。
- 能设计一个 API 封装类,把接口请求和断言分开。
- 能理解 Python 模块导入机制,解决
ModuleNotFoundError。 - 能用包结构搭建一套可维护的测试工程,而不是把所有代码塞进一个
.py文件。
下面进入正题。顺序是:先会写类,再会用类,最后把类装进模块。
2. 类和对象:把测试数据变成结构体
2.1 为什么测试数据需要一个"容器"
先看一个最常见的测试场景:登录接口需要用户名、密码、期望状态码、期望提示信息。如果只用变量,你会写出这种东西:
username = "admin" password = "123456" expected_code = 200 expected_msg = "登录成功"这个写法的问题是:如果系统里有 10 个测试用户,你得写 40 个变量;如果要传给函数,得按顺序传参,稍不留神就把password和username传反。类在这里的作用,是把一组相关的数据打包成一个整体,不需要关心内部每个字段的顺序。
class LoginUser: def __init__(self, username, password, expected_code, expected_msg): self.username = username self.password = password self.expected_code = expected_code self.expected_msg = expected_msg user1 = LoginUser("admin", "123456", 200, "登录成功") user2 = LoginUser("tester", "abc123", 200, "登录成功") user3 = LoginUser("locker", "wrong_pwd", 401, "用户名或密码错误") print(user1.username) # admin print(user3.expected_msg) # 用户名或密码错误这就是类的第一个价值:它是测试数据的结构体。你不再面向一堆散落的变量编程,而是面向一个个有名字的测试对象编程。
2.2 类的定义与__init__构造函数
所有 Python 类都通过class 类名:定义,类名习惯使用大驼峰命名。
__init__是构造函数,在创建对象时自动执行。它的作用是初始化对象的状态,也就是给对象填上"出生自带的数据"。
class TestCaseInfo: def __init__(self, case_id, title, module, preconditions): self.case_id = case_id self.title = title self.module = module self.preconditions = preconditions case = TestCaseInfo("TC001", "验证正确用户名密码可以登录", "登录模块", "系统已初始化")注意__init__的第一个参数永远是self。self不是 Python 关键字,它只是一个约定俗成的名字,代表"当前正在创建的对象自己"。当你写case.title时,Python 会自动把case当作self传进去。
2.3 实例属性与类属性
继续用测试场景说明这两种属性的区别。
class TestConfig: # 类属性:所有对象共享 base_url = "https://api.example.com" timeout = 5 def __init__(self, env): # 实例属性:每个对象独立 self.env = env config_dev = TestConfig("dev") config_prod = TestConfig("prod") print(config_dev.base_url) # https://api.example.com print(config_prod.base_url) # https://api.example.com类属性相当于所有对象共用的默认配置,实例属性相当于每个对象各自的环境变量。
2.4 常见的面试考察点
软件测试面试题里经常出现一个对比:cls、self、实例方法、类方法、静态方法。这里先看代码:
class TestDemo: version = "1.0" def __init__(self, name): self.name = name def instance_method(self): # 实例方法:能访问实例属性,也能访问类属性 return f"实例方法:{self.name},版本 {self.version}" @classmethod def class_method(cls): # 类方法:只能访问类属性,不能访问实例属性 return f"类方法:版本 {cls.version}" @staticmethod def static_method(msg): # 静态方法:与类和实例都没有绑定关系 return f"静态方法:{msg}" obj = TestDemo("登录用例") print(obj.instance_method()) print(TestDemo.class_method()) print(TestDemo.static_method("不访问任何对象属性"))在测试框架中最常用的是实例方法:因为实例方法可以同时拿到当前测试对象的数据(self.name)和公共配置(self.version)。类方法和静态方法一般用于工具类的封装,后面实战部分会遇到。
3. 面向对象三大特性在自动化测试中的应用
3.1 封装:把接口请求细节藏起来
接口测试最忌讳每个用例都从头写一遍requests.post()。利用类的封装特性,可以把 HTTP 请求统一封装到一个ApiClient类里,调用方只关心client.post()返回什么,不关心底层怎么发请求。
import requests class ApiClient: def __init__(self, base_url, token=None): self.base_url = base_url self.headers = {"Authorization": f"Bearer {token}" if token else ""} def post(self, path, data=None, json=None): url = self.base_url + path response = requests.post(url, data=data, json=json, headers=self.headers, timeout=10) return response def get(self, path, params=None): url = self.base_url + path response = requests.get(url, params=params, headers=self.headers, timeout=10) return response这样一来,具体的测试用例完全不用关心requests库的细节,只管调用api.post("/login", json=payload)。排查问题时的范围也被压缩了:请求层出错看ApiClient,业务层出错看用例本身。
3.2 继承:让测试用例共享公共逻辑
当你有多个测试类都需要"登录后拿 token"这个前置操作时,不需要在每个类里重复写一遍,而是抽一个BaseTest基类:
class BaseTest: def setup(self): self.client = ApiClient("https://api.example.com") self.token = self.login_and_get_token() def login_and_get_token(self): response = self.client.post("/login", json={"username": "admin", "password": "123456"}) return response.json().get("token") class TestLogin(BaseTest): def test_login_success(self): assert self.token is not None class TestUserInfo(BaseTest): def test_get_user_info(self): response = self.client.get("/user/info") assert response.status_code == 200TestLogin和TestUserInfo都继承了BaseTest,各自不需要再写setup里的重复逻辑。这就是继承在测试项目里的最大价值:把公共前置步骤上浮到基类。
3.3 多态:不同测试数据源走同一个接口
如果你需要支持从 JSON 文件、Excel 表格、YAML 配置中读取测试数据,多态可以让调用方用完全一致的方式处理不同的数据源:
class DataReader: def read(self): raise NotImplementedError class JsonReader(DataReader): def __init__(self, file_path): self.file_path = file_path def read(self): import json with open(self.file_path, "r", encoding="utf-8") as f: return json.load(f) class ExcelReader(DataReader): def __init__(self, file_path): self.file_path = file_path def read(self): # 实际项目中用 openpyxl 或 pandas 实现 return [{"username": "admin", "password": "123456"}] def load_test_cases(reader: DataReader): return reader.read() json_cases = load_test_cases(JsonReader("data/test_data.json")) excel_cases = load_test_cases(ExcelReader("data/test_data.xlsx"))调用方只依赖DataReader.read()这个统一入口,具体是 JSON 还是 Excel,由传入的对象决定。这段代码在面试中经常作为"请简述多态在测试框架中的应用"的答案模板。
4. 特殊方法:让断言和日志更顺手
Python 类里还有一批以双下划线开头和结尾的特殊方法,在测试场景中有三个非常实用。
4.1__str__与__repr__:让测试对象可以友好打印
如果你直接打印一个测试数据对象,看到的是<__main__.LoginUser object at 0x...>,完全不可读。实现__str__后,打印结果变得直观:
class LoginUser: def __init__(self, username, password, expected_code, expected_msg): self.username = username self.password = password self.expected_code = expected_code self.expected_msg = expected_msg def __str__(self): return f"LoginUser({self.username}, {self.expected_msg})" __repr__ = __str__ user = LoginUser("admin", "123456", 200, "登录成功") print(user) # LoginUser(admin, 登录成功)在 pytest 的断言失败信息里,repr输出会直接出现在日志中,能帮助你更快定位是哪条测试数据出了问题。
4.2__eq__:自定义对象比较逻辑
断言assert resp1 == resp2时,如果两边都是自定义类对象,Python 默认比较的是内存地址,结果永远为 False。通过__eq__可以定义两个对象相等时应该比较哪些字段:
class ApiResponse: def __init__(self, status_code, body): self.status_code = status_code self.body = body def __eq__(self, other): if not isinstance(other, ApiResponse): return False return self.status_code == other.status_code and self.body == other.body resp1 = ApiResponse(200, {"message": "success"}) resp2 = ApiResponse(200, {"message": "success"}) print(resp1 == resp2) # True4.3__call__:让测试对象可以像函数一样调用
如果在自动化测试中要描述一个"可重复执行的检查动作",可以让类的实例拥有函数一样的调用方式:
class StatusCodeChecker: def __init__(self, expected_code): self.expected_code = expected_code def __call__(self, response): assert response.status_code == self.expected_code, \ f"期望状态码 {self.expected_code},实际 {response.status_code}" check_200 = StatusCodeChecker(200) check_200(response_obj)这种写法在复杂的断言链路中可以让代码非常简洁,不过刚入门时不用强行使用,了解即可。
5. 模块机制:拆分公共逻辑的正确姿势
5.1 模块是什么
模块就是一个.py文件。当你把class ApiClient写进api_client.py时,api_client就是一个模块。其他文件可以通过import api_client或from api_client import ApiClient使用它。
先看一个最小示例:
# 文件:config.py BASE_URL = "https://api.example.com" TIMEOUT = 5 # 文件:test_login.py import config def test_login(): print(config.BASE_URL)模块的作用有两个层面:语义层面把功能分组,工程层面把命名空间隔离。如果没有模块隔离,你很难在一个大项目里同时使用两个都叫login的函数。
5.2if __name__ == '__main__'的底层逻辑
模块在被导入时,Python 解释器会把__name__变量设为模块名;当模块作为主程序直接运行时,则把__name__设为字符串'__main__'。
所以下面这段代码是测试工程里的标准写法:
# 文件:api_client.py import requests class ApiClient: pass if __name__ == '__main__': # 只有直接运行 python api_client.py 时才执行 client = ApiClient("https://api.example.com") response = client.get("/health") print(response.status_code)这样做的好处是:test_tools.py被test_login.py导入时,不会自动执行测试代码,只有主动运行python api_client.py时才会执行内部的自测逻辑。
5.3 模块的搜索路径与导入方式
Python 导入模块时会依次搜索:
- 当前脚本所在目录。
PYTHONPATH环境变量中指定的目录。- Python 安装目录下的
site-packages等标准库路径。
如果找不到模块,会抛出ModuleNotFoundError。排查方式就是打印sys.path:
import sys for path in sys.path: print(path)导入方式选型:
# 方式一:导入整个模块 import config print(config.BASE_URL) # 方式二:从模块导入具体对象 from config import BASE_URL print(BASE_URL) # 方式三:导入多个对象 from config import BASE_URL, TIMEOUT # 方式四:起别名,解决命名冲突 import requests as http_client注意不要使用from module import *这种导入方式。它会污染当前命名空间,让代码里出现大量不知道来源的变量,在测试工程里非常不利于排查问题。
5.4 常用内置模块在测试中的典型用法
Python 标准库里已经有很多直接支撑测试工作的模块。下面是面试和实际项目里出现频率最高的几个:
import os import json import logging from pathlib import Path from datetime import datetime # os:拼接路径、读取环境变量 project_dir = os.path.dirname(os.path.abspath(__file__)) report_dir = os.path.join(project_dir, "report") # pathlib:更现代的路径操作 project_path = Path(__file__).resolve().parent report_path = project_path / "report" # json:接口请求与响应转换 payload = {"username": "admin", "password": "123456"} json_str = json.dumps(payload, ensure_ascii=False) payload_dict = json.loads(json_str) # datetime:生成时间戳命名的测试报告 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") # logging:给测试代码加日志 logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") logging.info("开始执行登录接口测试")内置模块不需要额外安装,是测试脚本中最常用的工具集。随着课程深入,后面还会接触到subprocess、threading、unittest.mock等模块在测试中的应用。
6. 包与测试项目目录:从零搭一套可维护的框架
6.1 包和普通目录的区别
包就是一个包含__init__.py文件的目录。__init__.py告诉 Python:这个目录是一个可以导入的包,而不是普通的文件夹。
一个典型的自动化测试项目结构如下:
auto_test/ ├── config/ │ ├── __init__.py │ └── setting.py ├── common/ │ ├── __init__.py │ ├── api_client.py │ ├── data_reader.py │ └── logger.py ├── data/ │ └── login_data.json ├── testcase/ │ ├── __init__.py │ ├── conftest.py │ └── test_login.py ├── report/ ├── logs/ └── main.py如果目录里没有__init__.py,Python 3 在某些情况下仍能通过命名空间包导入,但为了让代码在各种环境下行为一致,强烈建议每个包目录都保留这个文件。
6.2 不同层级之间的导入写法
在testcase/test_login.py里要导入common/api_client.py中的ApiClient类,正确写法是:
from common.api_client import ApiClient from config.setting import BASE_URL注意,要以common.api_client方式导入,运行命令必须从项目根目录auto_test下执行:
python -m pytest testcase -v6.3conftest.py的模块级共享
pytest 的conftest.py是一种特殊的模块,它存放 fixture,可以让同一目录及子目录下的多个测试模块共用。
import pytest @pytest.fixture() def api_client(): from common.api_client import ApiClient return ApiClient("https://api.example.com") @pytest.fixture() def login_token(api_client): response = api_client.post("/login", json={"username": "admin", "password": "123456"}) return response.json().get("token")测试模块可以直接把api_client或login_token当作参数传入:
def test_user_info(login_token): assert login_token is not None7. 实操:用类与模块完成一个登录接口测试
现在把前几节的知识串起来,完成一个真实的接口测试场景。假设有一个登录接口:
- 地址:
POST https://api.example.com/login - 请求体:
{"username": "admin", "password": "123456"} - 成功响应:状态码 200,返回
{"token": "xxx"} - 失败响应:状态码 401,返回
{"message": "用户名或密码错误"}
7.1 第一步:封装 API Client 类
创建common/api_client.py:
import requests class ApiClient: def __init__(self, base_url, timeout=10): self.base_url = base_url self.timeout = timeout self.session = requests.Session() def post(self, path, json=None, data=None): url = self.base_url + path response = self.session.post(url, json=json, data=data, timeout=self.timeout) return response def get(self, path, params=None): url = self.base_url + path response = self.session.get(url, params=params, timeout=self.timeout) return response def close(self): self.session.close() if __name__ == '__main__': client = ApiClient("https://api.example.com") resp = client.post("/login", json={"username": "admin", "password": "123456"}) print(resp.status_code, resp.json()) client.close()7.2 第二步:使用 pytest 测试类组织用例
创建testcase/test_login.py:
import pytest from common.api_client import ApiClient class TestLogin: @pytest.fixture(autouse=True) def setup(self): self.client = ApiClient("https://api.example.com") yield self.client.close() def test_login_success(self): payload = {"username": "admin", "password": "123456"} response = self.client.post("/login", json=payload) assert response.status_code == 200 assert response.json()["token"] def test_login_wrong_password(self): payload = {"username": "admin", "password": "wrong"} response = self.client.post("/login", json=payload) assert response.status_code == 401 assert response.json()["message"] == "用户名或密码错误"这里的setup方法使用了 pytest 的 fixture 机制,在每个测试方法执行前后自动调用。注意文件命名必须以test_开头,测试类必须以Test开头,pytest 才能自动收集。
7.3 第三步:把测试数据独立成模块
直接写在用例里的测试数据属于硬编码。更好的方式是放到data/login_data.json:
[ { "username": "admin", "password": "123456", "expected_code": 200, "expected_message": "登录成功" }, { "username": "admin", "password": "wrong", "expected_code": 401, "expected_message": "用户名或密码错误" } ]在common/data_reader.py中封装一个读取模块:
import json class JsonDataReader: @staticmethod def read(file_path): with open(file_path, "r", encoding="utf-8") as f: return json.load(f)改造后的测试类:
import pytest from common.api_client import ApiClient from common.data_reader import JsonDataReader class TestLogin: @pytest.fixture(autouse=True) def setup(self): self.client = ApiClient("https://api.example.com") yield self.client.close() @pytest.mark.parametrize("case", JsonDataReader.read("data/login_data.json")) def test_login_with_data(self, case): payload = {"username": case["username"], "password": case["password"]} response = self.client.post("/login", json=payload) assert response.status_code == case["expected_code"]这个版本已经具备数据驱动的雏形:用例逻辑不变,新增测试场景只需要在 JSON 文件里加一组数据。
7.4 第四步:统一入口模块
创建项目根目录下的main.py,让整个测试可以一键执行:
import pytest import os if __name__ == '__main__': report_dir = "report" os.makedirs(report_dir, exist_ok=True) pytest.main([ "testcase", "-v", "--html=report/report.html", "--self-contained-html" ])运行方式:
python main.py到这里,一条完整的链路已经闭环:ApiClient类负责请求,TestLogin类负责测试组织,JsonDataReader模块负责数据读取,main.py负责统一调度。
8. 常见问题与排查
类与模块相关的报错,占了测试新手排错的三分之一以上。下面是最高频的问题清单:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
ModuleNotFoundError: No module named 'common' | 运行目录不在项目根目录 | 打印sys.path查看搜索路径 | 回到根目录运行,或通过sys.path.insert(0, project_dir)临时解决 |
| pytest 收集不到测试用例 | 测试文件没有以test_开头,测试类没有以Test开头 | 用pytest --collect-only检查收集结果 | 按 pytest 命名规范重命名 |
import导入了同名的另一个文件 | 模块名和内置模块或第三方库冲突 | 打印被导入模块的__file__属性 | 修改模块名,不叫requests.py、json.py这类名字 |
__init__方法没有self参数 | 忘记写self | 查看报错提示的构造函数签名 | 构造函数第一个参数写self |
| 修改一个对象的属性后,其他对象也被改了 | 把可变对象放在了类属性位置 | 在__init__里用self创建实例属性 | 默认列表、字典等对象写入__init__ |
循环导入:a.py导入b.py,b.py又导入a.py | 模块之间依赖关系形成闭环 | 查看报错中的 ImportError 栈 | 把公共代码下沉到更低层模块,或延迟导入 |
| 直接运行模块时输出了测试代码 | 没有使用if __name__ == '__main__' | 检查模块底部代码缩进 | 把自测逻辑放进if __name__ == '__main__':代码块 |
AttributeError / 'TestLogin' object has no attribute 'client' | fixture 或 setup 方法执行顺序异常 | 添加日志确认 setup 是否执行 | 检查 fixture 作用域,确认类内部 setup 是否被正确识别 |
| 两个模块里出现同名函数,后导入的覆盖了先导入的 | 使用了from module import * | 检查是否还有*导入 | 使用明确的名称或模块级导入 |
排查建议:遇到异常先看最后一行异常类型,再往上翻两行看具体代码,不要凭记忆猜测原因。
9. 测试框架中的类与模块最佳实践
9.1 命名规范
| 对象 | 规范 | 示例 |
|---|---|---|
| 类名 | 大驼峰命名 | TestLogin、ApiClient、JsonDataReader |
| 方法名 | 小写字母加下划线 | login_and_get_token、read_test_data |
| 模块名 | 小写字母加下划线 | api_client.py、data_reader.py |
| 测试文件 | 以test_开头 | test_login.py、test_user_info.py |
9.2 设计要点
- 一个模块只做一件事。
api_client.py里不要写登录用例,test_login.py里不要封装 requests 请求。 - 类应该面向"业务角色"设计。测试类对应"测试场景",页面对象类对应"页面操作",API 封装类对应"接口能力"。
- 优先使用实例方法,谨慎使用
staticmethod。实例方法可以拿到对象状态,排查问题时更容易通过self追踪数据流。 - 测试用例和测试数据分离。用例只描述"做什么验证",数据由 JSON、YAML、Excel 等提供。
- 公共配置放入独立模块。如
config/setting.py中的BASE_URL、TIMEOUT、DB_CONFIG等,避免在用例里散落硬编码数值。 - 日志模块单独封装。便于后续接入日志文件、日志轮转和测试报告。
9.3 面试中如何回答"类与模块在测试中的作用"
可以按下面这个思路组织回答:
类在测试中主要解决组织问题:测试类把同一功能的用例集中管理,API 封装类把请求层和用例层隔离,PO 模式把页面元素和测试逻辑解耦。模块解决的是工程问题:公共方法、配置、数据读取逻辑分别拆成独立文件,通过 import 组合使用。这样可以在团队协作中减少冲突、提高复用、方便维护。
10. 总结与下一步
第四课的两个关键字必须刻进记忆里:类是"数据和行为的组合体",模块是"代码的组织单元"。写自动化测试时,面向对象思想不是拿来装的,而是真实解决重复代码、数据混乱、项目难维护的问题。pytest 测试类、PO 模式、API 封装类的底层全部建立在类和模块之上。
建议你现在动手做三件事:
- 把 7.1 到 7.4 的代码敲一遍,跑通一个最小接口测试项目。
- 在
data/login_data.json里加一组测试数据,观察用例数是否自动增长。 - 故意制造一个
ModuleNotFoundError,再通过打印sys.path排查修复,把排错流程练熟。
这套系列下一课会进入 pytest 断言、fixture 作用域和测试报告生成。届时你会发现,今天学的类和模块会像地基一样支撑起整个进阶框架。建议把这一课的例子保存到本地练习目录,后面每学一个新知识点就回来改造一次代码,效果比单纯看课要好得多。