英文:
How can I prevent cached modules/variables when using runpy in pytest tests?
问题
给定这些文件:
# 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()) # so that "import bar" in foo.py works
@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])
是我能想到的另一种选项。
谢谢。
英文:
(Preface: This is a toy example to illustrate an issue that involves much larger scripts that use a ton of modules/libraries that I don't have control over)
Given these files:
# 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()) # so that "import bar" in foo.py works
@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__")
If I now run pytest test_foo.py -s
I will get:
========================================================================
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]
.
========================================================================
So barvar
is remembering its previous content. This is obviously detrimental to testing.
Can it be prevented while still using runpy
?
Understandably, python docs warn about runpy
side effects:
> Note that this is not a sandbox module - all code is executed in the current process, and any side effects (such as cached imports of other modules) will remain in place after the functions have returned.
If this is tricky or too complicated to do reliably, are there alternatives? I am looking for the convenience of testing scripts that take arguments and produce stuff (usually files). My typical pytest
test script sets up arguments via sys.argv
then runs via runpy
the target script (very large programs with lots of imports), then validates the generated files (e.g., compare against a baseline for regression testing). There are many invocations within a single test run; hence the need for a clean slate.
subprocess.run(['python.exe', 'script.py', *arglist])
is another option I can think of.
Thanks.
答案1
得分: 1
简单实用的解决方案,在测试设置中清除“cached” bar 模块(如果存在):
@pytest.fixture(autouse=True)
def evict_bar():
sys.modules.pop("bar", None)
英文:
Simple pragmatic solution, evict the "cached" bar module, if any, in test setup:
@pytest.fixture(autouse=True)
def evict_bar():
sys.modules.pop("bar", None)
答案2
得分: 0
如果您无法重构您的代码,或者不想从sys.modules
中删除模块(这两种解决方案都有效),您可以通过在每次测试执行时将barvar
变量的初始状态设置为空列表来进行修补。
from unittest import mock
@pytest.mark.parametrize('execution_number', range(5))
def test1(execution_number):
with mock.patch('bar.barvar', new_callable=list):
print(f'\n{execution_number=}\n')
sys.argv = [os.path.join(os.getcwd(), 'foo.py')]
runpy.run_path('foo.py', run_name="__main__", init_globals={'bar.barvar': []})
不过 ...
我真诚建议您考虑删除全局变量,如果您的代码在测试中表现不如预期,那么您应该修复代码,而不是测试,因为这就是测试的目的。
根据您的测试,您将多次运行您的脚本,并期望在每次运行的开始时barvar
为空列表。
所以,您的测试是正确的,需要修复您的代码。
英文:
If you are not able to refactor your code or you don't want to remove the module from sys.modules
(both solutions that would work). You could just patch the barvar
variable setting the initial state so an empty list for each test execution.
from unittest import mock
@pytest.mark.parametrize('execution_number', range(5))
def test1(execution_number):
with mock.patch('bar.barvar', new_callable=list):
print(f'\n{execution_number=}\n')
sys.argv=[os.path.join(os.getcwd(),'foo.py')]
runpy.run_path('foo.py',run_name="__main__", init_globals={'bar.barvar': []})
Having said that ...
I truly recommend you consider removing the global variables, if for the test you design your code is not behaving as expected, then you should fix the code, not the test, that's all tests are about.
Looking at your test, you will be running your script more that once, and you expect at the start of every run to have barvar
being an empty list.
So, your test is Ok, you need to fix your code.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论