如何避免运行不必要的测试






4.80/5 (8投票s)
一种用于集成测试框架的方法,用于验证框架内的更改,而无需在每次更改后执行所有测试套件。
引言
如果您是专注于大型、集成或系统级测试的自动化工程师,那么您大部分时间都在使用自己的代码库,该代码库与产品代码是分开的。有时,被测产品和集成测试库的代码在同一个代码库中,但问题依然存在:如何验证测试或与测试密切相关的库(框架)中的更改?通常,自动化团队会简单地运行开发人员使用的相同测试套件,以确保对测试的更改不会影响开发人员的持续集成 (CI) 流程。但是,如果这些测试套件需要大量的资源并且需要很长时间才能完成怎么办?是否有可能只运行那些受测试库中相应更改影响的测试?确实有。让我们来讨论如何实现这一点。
在本文中,我们重点关注 Python 编写的框架和库,但这种方法足够通用,也可以应用于其他编程语言。
那么,使用 Python 这样的动态语言来确定要运行哪些测试是否真的可行且可靠?pytest 有一个名为 pytest-testmon 的插件,它通过在测试运行后收集代码覆盖率来实现这一点。这种方法在处理开发人员及其代码时效果很好。但是,当涉及到集成级测试框架时,收集此类覆盖率需要运行整个测试套件以了解哪些代码被调用,这会抵消其优势。因此,我们需要一个不同的解决方案。
可靠性问题
关于可靠性,确定要运行哪些测试是否真的至关重要,尤其是在考虑对测试框架的更改时?在最坏的情况下,如果我们无法真正了解哪些测试受到了影响,我们总是可以运行整个测试套件并确保安全。但是,还有另一种最坏的情况——意外跳过一些真正受影响的测试。其可能性应该尽可能地最小化。尽管如此,当我们考虑最佳情况时,这并没有太大影响。例如,如果我们修改了一个仅在一个测试中使用的函数,我们可以简单地运行该特定测试并节省大量时间。
IDE 来帮忙
现在,如果我们不需要 100% 可靠的解决方案,我们该如何解决我们的问题?让我们来看看 IDE。如果您有 VSCode 或 PyCharm,当您单击一个函数时,IDE 会尝试确定该函数在何处被使用,并提供使用情况或引用的列表。在我们的方法中,我们将做一些与 IDE 类似的事情。幸运的是,有一些库可以在这方面帮助我们。在 Python 世界中,我们可以使用 Jedi。
处理 Git 更改
但首先,我们需要以某种方式解析 Git 更改。另一个用于此目的的优秀库是 GitPython,它帮助我们获取 diff
列表以供进一步处理。
repo = Repo(repo_path)
diffs = repo.merge_base(repo.head.commit, 'main')[0].diff(repo.head.commit)
这里,repo.head.commit
是提议更改的提交哈希,main
是我们要合并新添加代码的分支。这些 diff
提供了以下有用的字段:
diff.a_blob # contents before
diff.b_blob # contents after
如果 diff.a_blob
和 diff.b_blob
都存在,则表示文件已更改。如果只有一个存在,则表示文件已被删除或添加。为了正确处理 diff
,最好使用 difflib 库,因为它为我们的情况提供了更多功能。
diff_res = difflib.unified_diff(
file_contents_before_change.split("\n"),
file_contents_after_change.split("\n"),
lineterm="",
n=0,
)
使用 Jedi
现在,让我们深入研究一下 Jedi 相关的代码,以提供一个如何使用它的示例。首先,我们需要初始化 Jedi 的 Project 对象。
project = jedi.Project(path=code_project_path)
之后,我们需要一些东西来表示我们更改过的代码。
script = jedi.Script(path=changed_file_path, project=project)
以获取更改代码确切位置的上下文。
jedi_name = script.get_context(line=changed_line, column=changed_file)
这里的“context
”指的是函数名、类名、变量名等,它是文件中代码块背后的逻辑实体。我们将使用它来查找该受影响实体的实际引用。要查找引用,有两种方法:
jedi_names = script.get_references(line=changed_line, column=changed_column)
第一种方法有效,但由于 Python 的动态特性,有时可能会遗漏有效的引用。为了克服这个问题,可以使用另一种方法:
project.search(context_name, all_scopes=True)
此方法搜索整个项目中指定名称的所有引用。同时使用这两种方法几乎可以确保有效的引用。在我的项目中有大约 1000 个测试用例,我遇到了一个情况,即受影响的测试没有运行。
使用 ast 处理“特殊”情况
但是,拥有有效的引用是不够的。我们需要以不同的方式处理一些“特殊”情况,例如更改具有“session”作用域和“autouse”参数的 pytest 夹具或 pytest 钩子——对于这些,我们需要运行所有测试,而不管更改。为了处理这个问题,我们可以使用 ast 库,它提供了我们所需的一切:更改的文件路径和需要处理的逻辑实体。调用
ast.parse(code)
将为我们提供已解析的文件,我们可以在其中搜索夹具、钩子等。
“规则”逻辑
除了这些“特殊”情况,引入某种“规则”逻辑来指定在更改非 Python 文件时该怎么做也是有益的。在这种情况下,在获取 Git 更改后,我们只需要查找一些“特殊”文件,如果它们存在,就决定是否采取行动。
Pytest 插件
pytest 的最后一步是创建一个利用上述功能并修改钩子的插件。
def pytest_collection_modifyitems(session, config, items):
affected = config.getoption("affected")
if not affected:
return
changes = get_changes_from_git(
config.getoption("git_path"), config.getoption("git_branch")
)
if not changes:
return
rules = get_rules(config.getoption("affected_rules"))
test_filenames = get_affected_test_filenames(config.getoption("project"), changes)
test_filenames.update(
process_not_python_files(
config.getoption("git_path"), rules, changes
)
)
selected = []
deselected = []
for item in items:
item_path = item.location[0]
if any(item_path in test_filename for test_filename in test_filenames):
selected.append(item)
continue
deselected.append(item)
items[:] = selected
if deselected:
config.hook.pytest_deselected(items=deselected)
最后一点——如果您遇到任何异常或超时(Jedi 有时可能会很慢,尤其是在大型项目中,因此引入超时是一个好主意),您总是可以运行所有测试并感到满意。
结束语
在我们的项目(有 1000 个测试用例)中实现了上述方法后,合并我们的测试更改所需的时间显著减少。在一个拥有 10 人且基础设施资源有限的团队中,我们之前每周最多只能合并 3-5 个拉取请求。之后,我们能够处理 10-20 个拉取请求,加快了所有测试自动化流程,并缩短了新版本发布所需的时间。反过来,这导致发布时间从 3-6 个月显著缩短到仅一个月。因此,这种方法对我们所有的流程都产生了深远的影响。如果您发现自己在运行大量测试而没有明确的原因,请尝试上述方法,观察它会给您带来哪些好处。
历史
- 2023 年 10 月 11 日:初始版本