Python单元测试因__init__.py覆盖无法正确patch类问题
单元测试patch类时被__init__.py星号导入遮蔽的问题
问题场景
编写代码库单元测试时,需要patch一个数据库抽象类,但该类被__init__.py的导入逻辑遮蔽,无法正确打补丁。
项目文件结构
. ├── __init__.py ├── tools │ ├── __init__.py │ ├── test │ │ ├── __init__.py │ │ └── test_tool1.py │ ├── tool1.py │ └── tool2.py └── utils ├── __init__.py └── sql_client.py
各文件现有代码
tools/__init__.py:
from .tool1 import * # SQLClient随tool1的导出被导入到tools命名空间 from .tool2 import * # SQLClient再次随tool2的导出被导入,覆盖前一次的导入结果
tools/tool1.py:
from utils import SQLClient class A(object): ... def run(self, **kwargs): # 待测试的目标方法 sql = SQLClient("some id")
tools/tool2.py:
from utils import SQLClient ...
utils/__init__.py:
from sql_client import *
utils/sql_client.py:
class SQLClient(object): # 需要被patch的数据库抽象类
测试统一使用绝对导入,方便后续将所有测试文件移出源码目录,测试文件tools/test/test_tool1.py现有代码如下:
from unittest.mock import MagicMock from utils import SQLClient from tools import A class MockSQLClient(MagicMock): def __init__(self, *args, **kwargs): super().__init__(spec=SQLClient) self._mocks = {"select *": "rows"} def make_query(query): return self._mocks[query] def test_run_func(monkeypatch): monkeypatch.setattr("tools.SQLClient", MockSQLClient) a = A() a.run() # 剩余测试逻辑
现存问题
tools/__init__.py通过import *从所有子模块批量导入内容,导致tool1中引入的SQLClient被tool2中引入的同名SQLClient覆盖,最终monkeypatch实际patch的是tool2.SQLClient,不符合测试预期。
尝试直接通过monkeypatch.setattr("tools.tool1.SQLClient")的方式patch tool1模块内的SQLClient,但受tools/__init__.py导入逻辑影响,运行时会抛出tools模块下不存在tool1属性的错误,无法生效。
解决方案
核心要记住Python mock的基本原则:永远patch测试代码中实际引用目标名字的位置,而不是定义目标类的源位置。
遇到的两个问题本质都是踩了Python导入规则的坑,按下面两步修改即可解决:
- 显式导入
tools.tool1模块,触发子模块注册
之前直接传字符串"tools.tool1.SQLClient"报错,是因为tools/__init__.py没有把tool1模块本身导入到tools的命名空间,只是把tool1里的内容拉到了tools命名空间下。只需要在测试文件开头显式import一次tools.tool1,解释器就会把tool1注册为tools模块的属性,后续patch路径就能正常识别,不会再抛属性不存在的错误。 - 直接patch
tools.tool1模块命名空间下的SQLClient
目标方法A.run是在tool1.py的代码里直接调用SQLClient,这个名字属于tool1模块自己的命名空间,不管外层__init__.py怎么覆盖导入,只要patch的是tool1模块自己持有的SQLClient引用,run方法调用时拿到的就是替换后的Mock类,不会被tools包层级被覆盖的引用影响。
修改后的测试代码核心部分如下:
from unittest.mock import MagicMock # 显式导入tools.tool1,触发子模块注册 import tools.tool1 from utils import SQLClient from tools import A class MockSQLClient(MagicMock): def __init__(self, *args, **kwargs): super().__init__(spec=SQLClient) self._mocks = {"select *": "rows"} # 原测试代码此处漏写self参数,已修正 def make_query(self, query): return self._mocks[query] def test_run_func(monkeypatch): # 直接patch tool1模块命名空间下的SQLClient monkeypatch.setattr(tools.tool1, "SQLClient", MockSQLClient) a = A() a.run() # 剩余测试逻辑
额外提示:当前__init__.py里无限制使用import *的写法本身就很容易触发同名覆盖问题,后续维护很容易踩类似的坑,条件允许的话尽量改成显式导入需要对外暴露的名字,或者给模块加__all__字段明确星号导入的内容范围,避免无意义的命名冲突。
内容的提问来源于stack exchange,提问作者anishtain4
相关产品推荐
相关产品推荐

