在pytest测试中使用runpy时如何避免模块/变量缓存?
前言
这是一个示例,用于说明涉及大量无法控制的模块/库的大型脚本所遇到的问题
给定文件
bar.py
barvar = [] def barfun(): barvar.append(1)
foo.py
import bar foovar = [] def foofun(): foovar.append(1) if __name__ == '__main__': foofun() bar.barfun() foovar.append(2) bar.barvar.append(2) print(f'{foovar =}') print(f'{bar.barvar=}')
test_foo.py
import sys import os import pytest import runpy sys.path.insert(0,os.getcwd()) # 让foo.py能import bar @pytest.mark.parametrize('execution_number', range(5)) def test1(execution_number): print(f'\n{execution_number=}\n') sys.argv=[os.path.join(os.getcwd(),'foo.py')] runpy.run_path('foo.py',run_name="__main__")
运行结果
执行命令 pytest test_foo.py -s 后得到如下输出:
======================================================================== platform win32 -- Python 3.10.8, pytest-7.2.0, pluggy-1.0.0 rootdir: C:\Temp plugins: anyio-3.6.2 collected 5 items test_foo.py execution_number=0 foovar =[1, 2] bar.barvar=[1, 2] . execution_number=1 foovar =[1, 2] bar.barvar=[1, 2, 1, 2] . execution_number=2 foovar =[1, 2] bar.barvar=[1, 2, 1, 2, 1, 2] . execution_number=3 foovar =[1, 2] bar.barvar=[1, 2, 1, 2, 1, 2, 1, 2] . execution_number=4 foovar =[1, 2] bar.barvar=[1, 2, 1, 2, 1, 2, 1, 2, 1, 2] . ========================================================================
可见barvar会保留之前的执行内容,这对测试结果的准确性十分不利。
问题
在仍使用runpy的情况下能否避免这种缓存问题?
Python官方文档已明确提示runpy存在副作用:注意:这不是沙箱模块——所有代码都在当前进程中执行,任何副作用(如其他模块的缓存导入)在函数返回后仍会保留。
如果这种方式难以可靠实现,是否有替代方案?
需求背景:需要测试带参数、生成文件的脚本,目前的pytest测试脚本会设置sys.argv,再通过runpy运行目标脚本,最后验证生成的文件,单次测试中会多次调用,因此需要每次运行都保持干净环境。已想到的替代方案是subprocess.run(['python.exe', 'script.py', *arglist])。
解决方案
关于runpy的缓存问题
用runpy很难彻底避免模块缓存和状态残留的问题,因为它是在当前Python进程中执行代码,模块导入后会被缓存到sys.modules字典中,后续调用不会重新加载模块的初始状态。
如果非要坚持用runpy,可以尝试手动清理模块缓存,但这种方式风险较高,容易出现清理不彻底或影响其他测试的情况。示例调整如下:
@pytest.mark.parametrize('execution_number', range(5)) def test1(execution_number): print(f'\n{execution_number=}\n') # 保存测试前的模块列表 initial_modules = set(sys.modules.keys()) sys.argv=[os.path.join(os.getcwd(),'foo.py')] runpy.run_path('foo.py',run_name="__main__") # 删除测试过程中新导入的模块 for mod_name in list(sys.modules.keys()): if mod_name not in initial_modules: del sys.modules[mod_name]
这种方法的局限性很明显:如果脚本依赖的模块被其他测试用例共享,清理操作会破坏其他测试的环境;此外,像barvar这种已被修改的全局变量,即使删除模块缓存,若有其他引用指向该变量对象,残留状态依然无法清除。
推荐替代方案:subprocess.run
subprocess.run是更可靠的选择,因为每个子进程都是完全独立的Python环境,不存在模块缓存或状态残留的问题,完美满足每次测试都需要干净环境的需求。
调整后的测试代码示例:
import sys import os import pytest import subprocess sys.path.insert(0,os.getcwd()) @pytest.mark.parametrize('execution_number', range(5)) def test1(execution_number): print(f'\n{execution_number=}\n') script_path = os.path.join(os.getcwd(), 'foo.py') # 启动子进程运行脚本,可传递命令行参数 run_result = subprocess.run( [sys.executable, script_path], capture_output=True, text=True ) # 打印脚本输出 print(run_result.stdout) # 验证脚本执行成功 assert run_result.returncode == 0
如果需要传递命令行参数,直接在参数列表中追加即可,比如:
run_result = subprocess.run( [sys.executable, script_path, '--arg1', 'value1', '--arg2', 'value2'], capture_output=True, text=True )
虽然启动子进程会带来轻微的性能开销,但对于需要保证测试准确性的场景,这种隔离环境的方式是最稳妥的解决方案。
内容的提问来源于stack exchange,提问作者omasoud

