软件测试入门课程进行到第四课,很多同学会问:做测试为什么还要学类和模块?答案其实很直接。不管是用 pytest 编写自动化用例、用 Page Object 模式封装页面操作,还是理解被测系统里一个购物车类在什么条件下会产生异常数据,都离不开对类与模块的基本认识。类负责抽象业务和状态,模块负责组织文件和依赖,两者合在一起,决定了一套测试脚本能不能从“能跑”变成“好维护、能复用、可排错”。这篇教程从软件测试项目实战的角度,把类与模块拆开讲清楚,配合一个可运行的测试项目示例,并给出导入失败、用例收集失败、测试状态互相污染等常见问题的排查路径。
1. 测试人员眼中的类与模块:先搞清楚两个基础概念
1.1 模块是什么,为什么测试脚本必须拆成模块
从 Python 的角度看,模块就是一个.py文件。一个文件可以定义变量、函数、类,也可以写一段启动逻辑。当多个.py文件通过import互相引用时,就构成了一个模块化工程。
很多测试初学者习惯把几十个测试用例写在一个文件里。这种做法在小项目中看起来方便,但用例数量超过十个之后问题就会集中爆发:
- 不同测试需要的前置数据互相干扰,改一处动全身。
- 公共方法无法单独复用,只能在每个测试里重复粘贴。
- 定位失败原因时,要在一个很长的文件里上下翻找。
- 别人接手项目时,不知道哪个函数属于哪个模块。
模块化解决的就是“文件的组织方式”问题。把配置放到config模块,把浏览器驱动放到utils模块,把页面操作放到pages模块,把测试用例放到tests模块。每个模块只负责一类事情,导入关系清晰之后,排查问题会快很多。
需要注意,模块和包是两个概念。一个.py文件是模块,一个包含__init__.py文件的目录是包。包可以包含多个子模块。在自动化测试项目中,tests目录通常就是一个包,目录名可以见名知意,模块内部还可以继续拆分。
1.2 类是什么,类与实例的区别
类是一种数据结构,它把相关的属性(数据)和方法(行为)组合在一起。测试中常见的类有两类:一类是描述被测系统的业务对象,比如购物车、订单、用户;另一类是封装测试操作的工具,比如LoginPage、TestCart。
类的核心价值在于:
- 属性描述对象的状态,比如购物车中商品的数量。
- 方法描述对象的行为,比如向购物车添加商品、计算总价。
- 通过实例化可以创建多个独立对象,每个对象维护自己的状态。
类与实例的关系是“模板”和“成品”的关系。Cart是类,cart = Cart()创建实例,cart是具体的一个购物车对象。两个不同实例可以存在不同的商品数据,互不干扰。
容易混淆的地方是:类名一般大写开头,方法名和属性名小写;类中的第一个参数约定写成self,表示当前实例。调用实例方法时不需要手动传self,Python 会自动把实例作为第一个参数传进去。
1.3 测试项目里类和模块的分工
模块和类不是二选一,它们解决的是不同层次的问题:
| 层次 | 作用 | 例子 |
|---|---|---|
| 模块 | 组织文件、控制依赖、划分功能边界 | pages/login_page.py |
| 包 | 把多个模块放入同一目录,形成可复用组件 | pages、tests |
| 类 | 在模块内部抽象状态和行为 | LoginPage、Cart |
| 实例 | 创建独立对象,在真实测试中使用 | page = LoginPage(driver) |
换句话说,模块解决“代码放在哪”,类解决“代码如何抽象”。一个测试文件中可以只有一个函数,也可以有一个测试类。当测试逻辑围绕同一对象或同一业务场景时,建议用类组织;当测试逻辑只是一次性脚本时,直接用函数更轻量。
2. 环境准备:先搭出一个能跑通的最小工程
2.1 Python 版本和依赖
类与模块是 Python 自带语法,不需要额外安装框架就能演示。但软件测试项目通常要配合 pytest 使用,所以环境准备阶段需要确认两件事:
- Python 3.8 及以上版本。实际要按本地环境确认,命令行执行
python --version即可检查。 - pytest 已安装。安装命令为
pip install pytest。
这里的版本号只是常见要求,不同项目可能使用不同版本,落地前要结合自己的依赖锁文件确认。不要一上来就安装最新版框架,先看项目里是否已有requirements.txt或pyproject.toml。
2.2 最小工程目录
先创建一个最小项目结构,用来跑通第一个模块和第一个类:
test_project/ ├── config/ │ └── settings.py ├── utils/ │ ├── __init__.py │ └── log.py ├── tests/ │ ├── __init__.py │ └── test_first.py ├── requirements.txt └── pytest.ini这个结构在测试项目里非常常见。config保存全局配置,utils保存通用工具,tests保存测试用例。pytest.ini是 pytest 的配置文件,用于声明测试路径和命名规则。
创建目录后,先让项目能够被 pytest 正确收集,再写业务代码。
2.3 第一个模块和第一个类
在tests/test_first.py中写一个最小测试类:
class TestDemo: def test_add(self): assert 1 + 1 == 2 def test_join(self): assert ",".join(["a", "b"]) == "a,b"运行命令:
cd test_project python -m pytest tests -v预期输出中会出现两个通过用例。如果项目根目录不是当前目录,运行前先进入项目根目录,否则后面导入其他模块时容易出现路径问题。
注意:运行 pytest 时优先使用
python -m pytest,它会把当前目录加入 Python 的模块搜索路径,能减少很多ModuleNotFoundError。
3. 使用类来组织测试逻辑:从被测类到测试类
3.1 被测类:最小示例
为了直观理解类的使用场景,先写一个被测业务类Cart,模拟购物车核心逻辑:
# cart.py class Cart: def __init__(self): self._items = {} def add_item(self, name, price, quantity=1): if quantity <= 0: raise ValueError("quantity must be positive") old_quantity = self._items.get(name, {}).get("quantity", 0) self._items[name] = { "price": price, "quantity": old_quantity + quantity, } def total_quantity(self): return sum(item["quantity"] for item in self._items.values()) def total_price(self, discount=0): if discount < 0 or discount > 1: raise ValueError("discount must be between 0 and 1") total = sum(item["price"] * item["quantity"] for item in self._items.values()) return round(total * (1 - discount), 2)这个类包含三个关键设计:
_items以私有属性形式保存商品字典,外部不能直接修改。add_item对重复商品做数量累加,并校验非法参数。total_price支持折扣计算,同时校验折扣范围。
外部调用者只能通过公开方法操作购物车,这正是封装的意义。测试人员理解封装后,会更清楚哪些接口是黑盒入口,哪些状态需要通过方法间接验证。
3.2 测试类:pytest 收集规则
针对Cart类编写测试类,文件命名为test_cart.py:
# tests/test_cart.py import pytest from cart import Cart class TestCart: def setup_method(self): self.cart = Cart() def test_add_item_increases_quantity(self): self.cart.add_item("apple", 2.5) self.cart.add_item("apple", 2.5) assert self.cart.total_quantity() == 2 def test_total_price_with_discount(self): self.cart.add_item("apple", 10) self.cart.add_item("banana", 5) assert self.cart.total_price(0.1) == 13.5 def test_invalid_quantity_raises(self): with pytest.raises(ValueError): self.cart.add_item("apple", 1, 0)pytest 收集用例依赖命名规则,而不是像unittest那样要求继承固定基类。默认规则是:
- 文件名的前缀是
test_。 - 类名以
Test开头。 - 方法名以
test_开头。
setup_method会在类内每个测试方法执行前自动运行,这里每个测试都会拿到一个全新实例,避免上一个用例的数据残留。
3.3 封装、继承、多态在测试中的应用
面向对象三大特性在测试项目里都有实际用途。
封装的价值在于控制可变状态。被测类把内部数据隐藏起来,测试只能通过方法触发状态变化,这正好对应黑盒测试思想。测试不能直接改_items,只能调用add_item,所以测试关注的输入是参数,输出是方法返回值或抛出的异常。
继承的价值在于复用公共逻辑。多个测试类如果都要创建浏览器驱动或连接测试数据库,可以写一个BaseTestCase基类,提供公共的setup_method和公共工具方法,子类继承后只需关注自己的用例逻辑。不要把公共代码复制到每个测试类里,否则后期改一处要改多个文件。
多态的价值在于替换实现。接口测试中,同一个通知接口可以有邮件、短信、站内信多种实现。测试环境里使用假实现,生产环境切换真实实现,调用方代码不需要改动。这就是面向接口编程,也是 mock 和依赖注入的基础。
注意:测试类继承基类是为了复用公共逻辑,不是为了“看上去规范”。如果一个基类只有空方法,子类又全部重写,就该考虑是否直接使用 fixture 更合适。
4. 模块化拆分自动化测试项目:一个 Page Object 示例
4.1 为什么用模块组织而不是全写在一个文件
自动化测试项目进入正式工程后,通常会分模块维护。以 Web UI 自动化为例子,完整链路包含浏览器驱动、日志、页面元素定位、测试数据、测试用例和报告。如果全部写在一个文件里,问题定位成本会非常高。
模块化拆分带来的好处:
- 维护有边界:页面元素变动时只改对应页面类。
- 依赖可控:高层的测试用例依赖底层页面类,底层页面类不依赖测试用例。
- 复用容易:一个登录方法可以被多个测试文件调用。
- 并行友好:不同模块的文件可以交给不同成员维护,合并冲突减少。
下面用 Page Object 模式的简化版演示模块化组织方式。Page Object 的核心思想是把页面元素定位和操作封装成一个类,测试用例只关心业务动作,不直接写driver.find_element。
4.2 分层目录结构
推荐目录:
test_project/ ├── config/ │ ├── __init__.py │ └── settings.py ├── utils/ │ ├── __init__.py │ ├── driver.py │ └── log.py ├── pages/ │ ├── __init__.py │ └── login_page.py ├── tests/ │ ├── __init__.py │ ├── conftest.py │ └── test_login.py ├── requirements.txt └── pytest.ini各层职责:
| 目录 | 职责 | 是否允许写测试用例 |
|---|---|---|
| config | 环境地址、账号、超时等配置 | 否 |
| utils | 浏览器驱动、日志、通用工具 | 否 |
| pages | 页面元素定位与操作 | 否 |
| tests | 测试用例、夹具 | 是 |
每层都要有__init__.py,否则从上层导入时可能找不到包。
4.3 用类封装页面操作
登录页封装类:
# pages/login_page.py from utils.log import get_logger class LoginPage: URL = "https://example.com/login" def __init__(self, driver): self.driver = driver self.logger = get_logger(__name__) def input_username(self, username): self.logger.info("fill username: %s", username) self.driver.find_element("id", "username").send_keys(username) def input_password(self, password): self.logger.info("fill password") self.driver.find_element("id", "password").send_keys(password) def click_login(self): self.logger.info("click login button") self.driver.find_element("id", "login_btn").click() def login(self, username, password): self.input_username(username) self.input_password(password) self.click_login()页面类的设计要点:
- 类属性
URL是常量,多个实例共享,不随实例变化。 __init__接收driver对象,这样页面类不负责创建驱动,只负责操作,职责更清晰。login是一个组合方法,把多个低层操作串成业务动作,测试用例只需要调用它。
测试用例不需要知道用户名输入框的 id,也不需要知道登录按钮用什么定位方式。一旦前端调整了按钮 id,只需要改login_page.py,测试用例文件不用动。
4.4 模块之间的导入方式
在tests/test_login.py中导入页面类:
# tests/test_login.py from pages.login_page import LoginPage class TestLogin: def test_login_success(self, driver): page = LoginPage(driver) page.login("tester01", "123456") assert driver.current_url == "https://example.com/home"utils/driver.py负责创建浏览器驱动,示例代码只展示接口,实际浏览器类型和版本需要按本机环境调整:
# utils/driver.py def create_driver(browser="chrome"): # 请根据本机安装的浏览器和驱动版本进行调整 if browser == "chrome": from selenium import webdriver return webdriver.Chrome() raise ValueError(f"unsupported browser: {browser}")这里没有直接在测试用例中创建驱动,而是通过 fixture 提供,见后面的 conftest 部分。这样测试用例可以专注业务动作,驱动生命周期统一由夹具管理。
5. 验证测试结果:运行、断言和日志
5.1 用 pytest 运行并检查断言输出
在项目根目录运行:
python -m pytest tests -v预期输出示例如下:
test_login.py::TestLogin::test_login_success PASSED如果断言失败,pytest 会展示调用栈和断言比较结果。断言是测试的核心表达方式,推荐做法是每条断言关注一个行为,不要在一条断言里把所有条件揉在一起。
把页面类与真实页面配合时,不能只看用例是否通过,还要确认每一步是否真的执行了预期操作。建议在断言之前加入关键步骤的日志或截图。
5.2 使用 logging 记录测试过程
测试代码中的print在 pytest 默认输出中会被捕获,只有用例失败时才显示。更可控的方式是使用 Python 内置logging模块:
# utils/log.py import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) def get_logger(name): return logging.getLogger(name)页面类中已经通过get_logger(__name__)记录了用户填充、点击登录等动作。日志可以帮助回答“用例跑到哪一步失败”的问题,尤其是远程环境或持续集成平台上无法直接观察界面的时候。
5.3 使用 conftest.py 做共享夹具
tests/conftest.py是 pytest 的特殊文件,它不需要被导入,pytest 会自动识别文件中的 fixture。示例:
# tests/conftest.py import pytest from utils.driver import create_driver @pytest.fixture def driver(): d = create_driver() yield d d.quit()yield之前的代码是准备阶段,yield之后是清理阶段。多个测试文件只要用到名为driver的参数,pytest 就会自动调用这个 fixture。这样做的好处是驱动只创建一次,并且测试结束后一定被关闭。
注意:fixture 的命名要与测试方法参数名一致,否则 pytest 不知道把什么对象传进来。
6. 常见问题排查:导入失败、用例收集失败和类状态污染
6.1 ModuleNotFoundError
现象:
ModuleNotFoundError: No module named 'pages'常见原因有:
- 运行命令时所在的目录不在项目根目录。
pages、tests目录缺少__init__.py。- 使用了
python test_login.py直接执行,而当前目录没有正确加入模块搜索路径。
检查方式是在报错前打印模块搜索路径:
python -c "import sys; print(sys.path)"解决方案:
- 回到项目根目录运行
python -m pytest tests -v。 - 为每个包目录补上空的
__init__.py。 - 在
pytest.ini中配置测试路径。
6.2 循环导入
现象:程序启动时出现ImportError: cannot import name 'xxx' from partially initialized module。
原因:模块 A 在模块 B 导入之前就使用 B 中的名字,而 B 又需要导入 A,形成循环依赖。
处理顺序:
- 在报错信息中查看导入链是 A 到 B 再到 A。
- 拆出公共代码到第三个模块,让 A 和 B 都依赖它。
- 如果只是局部需要,把导入语句放到函数内部。
循环导入不是必须完全消灭,但测试项目中应尽量避免。模块依赖方向应当是tests依赖pages,pages依赖utils,不出现反向依赖。
6.3 pytest 收集不到测试用例
现象:运行pytest后提示no tests ran。
按顺序检查:
| 检查项 | 要求 | 修正方式 |
|---|---|---|
| 文件名 | test_xxx.py或xxx_test.py | 重命名为test_login.py |
| 类名 | 以Test开头 | 改为TestLogin |
| 方法名 | 以test_开头 | 改为test_login_success |
| 目录 | 文件必须位于testpaths指定范围内 | 调整pytest.ini的testpaths |
使用收集模式可以快速确认:
python -m pytest --collect-only6.4 类变量和实例变量混淆导致测试互相影响
错误示例:
class TestCart: cart = Cart() def test_first(self): self.cart.add_item("apple", 1) assert self.cart.total_quantity() == 1 def test_second(self): assert self.cart.total_quantity() == 0 # 失败,因为第一个用例残留问题在于cart = Cart()是类变量,多个测试方法共享同一个实例。第一个用例往购物车加了商品,第二个用例仍然看到残留数据。
推荐做法:
class TestCart: def setup_method(self): self.cart = Cart()setup_method会在每个测试方法前执行,保证每个用例拥有独立实例。更推荐使用 pytest fixture,尤其是多个测试类需要相同数据时:
@pytest.fixture def cart(): return Cart()6.5 路径和配置文件问题
有时本地运行通过,换一台机器或进入流水线后却失败。常见原因是路径写死、配置硬编码和依赖版本未固定。
排查清单:
- 项目中是否包含
pytest.ini或pyproject.toml。 rootdir是否为项目根目录,用python -m pytest --trace-config查看。- 测试数据是否依赖固定磁盘路径,如果依赖请改成相对于项目根的路径。
- 依赖是否锁定,建议使用
requirements.txt锁定顶层依赖,重要项目使用精确版本。
7. 最佳实践:软件测试项目中的类和模块规范
7.1 可复用清单
日常编码和评审时可以使用以下清单:
- 命名规范:模块文件小写加下划线,类名使用大驼峰,方法名小写加下划线。
- 模块职责:一个模块只做一类事,不要出现“既管配置又管页面定位”的文件。
- 导入方向:高层模块依赖低层模块,不反向依赖。
- 实例隔离:测试类中不要用类变量保存可变状态,使用
setup_method或 fixture。 - 断言粒度:每条断言只验证一个行为,失败时能快速定位原因。
- 日志关键步骤:用户输入、点击、请求地址、返回状态码都应记录。
- 清理资源:浏览器、数据库连接、临时文件都要在用例结束后释放。
- 数据独立:测试数据不要混用,避免用例顺序影响结果。
7.2 学习环境与生产环境的差异
学习阶段跑通一个最小示例,只需要 Python、pytest 和少量代码即可。正式测试项目进入团队协作时,还需要补齐:
| 关注点 | 学习环境 | 生产测试项目 |
|---|---|---|
| 配置 | 写在代码里 | 外置到配置文件或环境变量 |
| 依赖 | 手动安装 | 锁文件、虚拟环境、统一构建 |
| 运行 | 本地直接执行 | 持续集成流水线 |
| 日志 | 控制台输出 | 落盘、按日期滚动、可采集 |
| 数据 | 固定写死 | 测试数据独立或接口造数 |
| 失败处理 | 断言失败即可 | 截图、保留现场、自动定位 |
刚入门时不要先把工程搞得太复杂,先在单文件里理解 pytest 的收集规则,再逐步拆成模块,最后再加入页面类、配置模块和日志模块。每一步都确认能跑通再继续。
7.3 下一步扩展方向
类与模块只是测试编程基础,下一步可以从几个方向延伸:
- pytest fixture 的
scope参数,理解session、module、class、function的使用场景。 - 数据驱动测试,把测试数据从用例中剥离,使用参数化
@pytest.mark.parametrize。 - 接口自动化测试,使用
requests发送 HTTP 请求,把接口地址和公共头统一放入配置模块。 - Page Object 模式的完整实践,结合
base_page基类封装公共操作。 - 测试报告和持续集成,把 pytest 与 HtmlTestReport、Jenkins 或 GitLab CI 对接。
当你能把一个被测类映射成测试类,把公共逻辑拆成独立模块,并且知道导入失败时从哪里查起,说明你已经具备了搭建小型自动化测试框架的能力。后面再学习接口测试、UI 自动化或性能测试,都会顺畅得多。