背景
- python接口运行c++ 共享库,c++ so 共享库segfaults 后会导致整个python 解释器退出,影响后续测试用例运行
- c++ so库有内存泄漏,长时间运行测试用例,导致内存不足。
解决方案
- 针对第一个问题,使用pytest-xdist, 每个worker crash后xdist可以重启一个worker进行后续测试
- 使用pytest-forked. pytest-forked官网
pytest-forked支持每条测试用例运行在单独forked的子进程里,这样每条测试用例运行结束后可以释放占用的资源。 但是因为测试用例在forked的子进程里运行,父进程的allure报告监听不到子进程运行的测试用例的状态。分析原因如下:
pytest-forked入口在site-packages/pytest_forked/_ init _.py
@pytest.hookimpl(tryfirst=True)
def pytest_runtest_protocol(item):
if item.config.getvalue("forked") or item.get_closest_marker("forked"):
ihook = item.ihook
ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location)
reports = forked_run_report(item)
for rep in reports:
ihook.pytest_runtest_logreport(report=rep)
ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location)
return True
def forked_run_report(item):
# for now, we run setup/teardown in the subprocess
# XXX optionally allow sharing of setup/teardown
from _pytest.runner import runtestprotocol
EXITSTATUS_TESTEXIT = 4
import marshal
def runforked():
try:
reports = runtestprotocol(item, log=False)
except KeyboardInterrupt:
os._exit(EXITSTATUS_TESTEXIT)
return marshal.dumps([serialize_report(x) for x in reports])
ff = py.process.ForkedFunc(runforked) # forked子进程里运行测试用例,由于子进程复制父进程资源,对allure test result进行的更改不会影响父进程的test result, 导致父进程的allure test result收集不到子进程运行case的状态
result = ff.waitfinish()
if result.retval is not None:
report_dumps = marshal.loads(result.retval)
return [runner.TestReport(**x) for x in report_dumps]
else:
if result.exitstatus == EXITSTATUS_TESTEXIT:
pytest.exit(f"forked test item {item} raised Exit")
return [report_process_crash(item, result)]
对源码进行更改:
子进程中返回allure test result
def forked_run_report(item):
# for now, we run setup/teardown in the subprocess
# XXX optionally allow sharing of setup/teardown
from _pytest.runner import runtestprotocol
EXITSTATUS_TESTEXIT = 4
import marshal
def runforked():
try:
allure_listener = item.config.pluginmanager.get_plugin("allure_listener")
uuid = allure_listener._cache.get(item.nodeid) #获取item对应的uuid
test_result = allure_listener.allure_logger.get_test(uuid) # 获取item对应的test_result
reports = runtestprotocol(item, log=False)
except KeyboardInterrupt:
os._exit(EXITSTATUS_TESTEXIT)
return marshal.dumps([serialize_report(x) for x in reports]+[test_result]) # 子进程除了返回report外,额外返回allure test result. 注意需要对test_result进行序列化操作
ff = py.process.ForkedFunc(runforked)
result = ff.waitfinish()
if result.retval is not None:
report_dumps = marshal.loads(result.retval) # 对子进程返回的内容进行反序列化,有与子进程返回的list最后一个元素添加了allure test result,所以此时需要对子进程的返回list处理
return [runner.TestReport(**x) for x in report_dumps[:-1]], report_dumps[-1] # 最后一个元素为额外返回的allure test result
else:
if result.exitstatus == EXITSTATUS_TESTEXIT:
pytest.exit(f"forked test item {item} raised Exit")
return [report_process_crash(item, result)], None
父进程中,使用子进程返回的allure test result替换原来的值
@pytest.hookimpl(tryfirst=True)
def pytest_runtest_protocol(item):
if item.config.getvalue("forked") or item.get_closest_marker("forked"):
ihook = item.ihook
ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location)
reports, test_result = forked_run_report(item) # 除了report还额外返回了allure test_result
for rep in reports:
ihook.pytest_runtest_logreport(report=rep)
if test_result:
allure_listener = item.config.pluginmanager.get_plugin("allure_listener")
if allure_listener:
uuid = allure_listener._cache.get(item.nodeid)
allure_listener.allure_logger.schedule_test(uuid, deserialize_report(test_result)) # test_report进行marshal.load后为dict,需要反序列化为allure_common.model2.TestResult对象
ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location)
return True
序列化及反序列化report和test result
# copied from xdist remote
def serialize_report(rep):
import py
d = rep.__dict__.copy()
if isinstance(rep, TestReport):
if hasattr(rep.longrepr, "toterminal"):
d["longrepr"] = str(rep.longrepr)
else:
d["longrepr"] = rep.longrepr
for name in d:
if isinstance(d[name], py.path.local):
d[name] = str(d[name])
elif name == "result":
d[name] = None # for now
elif isinstance(rep, TestResult):
if hasattr(rep, "attachments"):
d["attachments"]=str(rep.attachments)
if hasattr(rep, "labels"):
d["labels"]=str(rep.labels)
if hasattr(rep, "links"):
d["links"]=str(rep.links)
if hasattr(rep, "steps"):
d["steps"]=str(rep.steps)
if hasattr(rep, "parameters"):
d["parameters"]=str(rep.parameters)
if hasattr(rep, "statusDetails") and rep.statusDetails:
d["statusDetails"]=str([rep.statusDetails])
return d
def deserialze_report(rep_dict): #将字典反序列化为allure_common.model2.TestResult
d= rep.copy()
if "attachments" in d:
d["attachments"] = eval(d["attachments"])
if "labels" in d:
d["labels"] = eval(d["labels"])
if "links" in d:
d["links"] = eval(d["links"])
if "steps" in d:
d["steps"] = eval(d["steps"])
if "parameters" in d:
d["parameters"] = eval(d["parameters"])
if "statusDetails" in d and d["statusDetails"]:
d["statusDetails"] = eval(d["statusDetails"])[0]
test_result = TestResult(**d)
return test_result
对crash的处理:
def report_process_crash(item, result):
from _pytest._code import getfslineno
path, lineno = getfslineno(item)
info = "%s:%s: running the test CRASHED with signal %d" % (
path,
lineno,
result.signal,
)
from _pytest import runner
# pytest >= 4.1
has_from_call = getattr(runner.CallInfo, "from_call", None) is not None
if has_from_call:
call = runner.CallInfo.from_call(lambda: 0 / 0, "???")
else:
call = runner.CallInfo(lambda: 0 / 0, "???")
#call.excinfo = info 源码代码会报错,所以注释,修改为以下内容:
call.excinfo.value.args=(info,1)
#更新allure test result 结果, 添加以下内容
allure_listener = item.config.pluginmanager.get_plugin("allure_listener")
if allure_listener:
uuid = allure_listener._cache.get(item.nodeid) #获取item对应的uuid
test_result = allure_listener.allure_logger.get_test(uuid) # 获取item对应的test_result
test_result.status="failed"
test_result.statusDetails=StatusDetails(message=info)
#添加内容结束
rep = runner.pytest_runtest_makereport(item, call)
if result.out:
rep.sections.append(("captured stdout", result.out))
if result.err:
rep.sections.append(("captured stderr", result.err))
xfail_marker = item.get_closest_marker("xfail")
if not xfail_marker:
return rep
rep.outcome = "skipped"
rep.wasxfail = (
"reason: {xfail_reason}; "
"pytest-forked reason: {crash_info}".format(
xfail_reason=xfail_marker.kwargs["reason"],
crash_info=info,
)
)
warnings.warn(
"pytest-forked xfail support is incomplete at the moment and may "
"output a misleading reason message",
RuntimeWarning,
)
return rep