如何加快 GitHub PyTest 执行速度?

van*_*ser 3 python performance continuous-integration pytest github-actions

我有一个包含数百个测试的存储库,到目前为止已经足够快了,但是随着我们继续扩大代码库,我担心它会变得如此缓慢,以至于我的团队将陷入等待 CI 运行完成的困境。

我可以做些什么来加快速度并使我的测试在短期和长期内都更快?

我需要考虑:

  1. 可扩展性
  2. 成本
  3. 推出

van*_*ser 7

我们可以使用水平和垂直缩放来加速测试运行。为了实现这一目标,我们需要确保我们的测试并行安全。为了实现这一目标,我们还必须解决一些其他 PyTest 问题。我们还可以巧妙地为难以实现并行安全的测试推出并行化。

\n

让我们深入挖掘一下。

\n

\xe2\x9a\x96\xef\xb8\x8f 并行安全

\n

过去的测试可能是为了假设串行执行而编写的,即在运行测试之前数据库状态以某种方式存在。这意味着不同的执行顺序可能会开始不确定地失败。您必须确保每个测试都创建专门针对您的测试的数据库状态,确保设置所有必要的对象,并(可选)在测试完成后拆除这些对象。 夹具将是你的朋友,因为它们对于创建必要的数据库状态和之后的清理很有用

\n

串行执行中的一种反模式可以根据数据库中的行数进行断言。IE:

\n
def test_1() -> None: \n  create_some_rows_in_db()\n  assert get_rows_in_db() == 1\n\ndef test_2() -> None: \n  create_some_more_rows_in_db()\n  assert get_rows_in_db() == 2\n
Run Code Online (Sandbox Code Playgroud)\n

如果我们以不同的顺序运行这些测试,它们就会失败。相反,我们需要在数据库中创建与我们的测试会话完全对应的行,同样,我们需要从数据库中获取仅用于此测试会话的行。

\n
def test_1() -> None: \n  scope=uuid4()\n  create_some_rows_in_db(scope=scope)\n  assert get_rows_in_db(scope=scope) == 1\n\ndef test_2() -> None: \n  scope=uuid4()\n  create_some_more_rows_in_db(scope=scope)\n  assert get_rows_in_db(scope=scope) == 1\n
Run Code Online (Sandbox Code Playgroud)\n

一致有序

\n

有两种方式会破坏测试顺序:测试名称可能会更改,并且默认情况下测试顺序不按名称排序。

\n

如果您在参数化测试中派生诸如 UUID 之类的值,这些值在测试运行之间会发生变化,这意味着测试本身的名称也会发生变化。这意味着当并行运行测试时,它们的名称将会不同,并且PyTest 将无法收集. 幸运的是,删除在运行之间更改的参数化参数的创建非常简单。

\n

具体来说,如果您最初的测试如下所示:

\n
@pytest.mark.parametrize("my_arg,...", [(uuid4(), ...), (uuid4(), ...)])\ndef test_some_code(my_arg: uuid4, ...) -> None:\n  assert my_arg is not None\n
Run Code Online (Sandbox Code Playgroud)\n

然后您需要更改它以导出函数内的参数。

\n
@pytest.mark.parametrize("...", [(...),])\ndef test_some_code(...) -> None:\n  my_arg = uuid4()\n  assert my_arg is not None\n
Run Code Online (Sandbox Code Playgroud)\n

接下来,我们还需要修补参数化测试的收集顺序,这意味着我们将以下内容添加到我们的conftest.py

\n
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:\n    def param_part(item: pytest.Item) -> str:\n        # find the start of the parameter part in the nodeid\n        index = item.nodeid.find("[")\n        if index > 0:\n            # sort by parameter name\n            parameter_index = item.nodeid.index("[")\n            return item.name[parameter_index:]\n\n        # for all other cases, sort by node id as usual\n        return item.nodeid\n\n    # re-order the items using the param_part function as key\n    items[:] = sorted(items, key=param_part)\n
Run Code Online (Sandbox Code Playgroud)\n

\xe2\x86\x95\xef\xb8\x8f 垂直缩放

\n

接下来,我们可以使用 xdist 在单个 GitHub Action Runner 中并行运行测试。这个包的安装和配置很容易完成,GitHub Action Runners 默认有 2 个 cpu 可供我们使用。

\n

将来,可以扩大运行这些测试的机器的规模。目前,2 个核心为我们提供了不错的加速效果。然而我们还可以走得更远。

\n

\xe2\x86\x94\xef\xb8\x8f 水平缩放

\n

垂直扩展提供了不错的加速,但我们真正想要完成的是将我们的测试工作分散到多个运行器上。幸运的是,PyTest-split出色地为我们完成了这一任务。

\n

在工作流程中启用 .yml 非常简单,如此处所示当与GitHub Matrix Actions结合使用时,我们可以告诉 PyTest 并行运行所有可用测试的一小部分。

\n

这意味着每个运行者都会收到所有测试,但选择运行一部分测试,从而将其余测试留给其他运行者执行。现在,在参数中添加或删除运行程序的数量很简单matrix,我们可以增加或减少并行执行的数量以匹配我们的 SLA 和预算。

\n

我还建议使用 PyTest-split 的 test_duration 功能,以便调整每个运行程序中测试的分配,使它们均匀平衡。

\n

说到预算...

\n

取消上一个

\n

如果我们想小心成本,那么取消先前提交的运行(如果它们仍在执行)是有利的,如此处所示。这将使我们能够从现在更昂贵的每次提交的执行成本中收回成本。我建议您从一小部分工人开始,看看您愿意承担哪些成本,然后根据需要添加以满足您的周转时间需求。

\n

采用

\n

假设我们没有时间或资源将所有测试迁移到并行安全。如果我们想为开发人员提供一个逃生出口,以防他们只想每次都串行运行测试,我们可以使用巧妙的测试标记pytest.mark.serial来确保某些测试每次都以相同的顺序运行。这意味着我们需要配置 GitHub 工作流 .yml 来与 Matrix 运行分开执行这些测试,但这很容易实现。

\n
...\n# Serial Execution \npytest -vv -x -n 0 -m "serial"\n\n...\n# Parallel Execution\npytest -vv -n auto -m "not serial" --splits PARALLELISM --group ${{ matrix.group }}\n
Run Code Online (Sandbox Code Playgroud)\n

然后,我们可以简单地标记我们希望保持串行操作的测试,如下所示:

\n
@pytest.mark.serial\ndef my_not_parallel_safe_test() -> None:\n  pass\n
Run Code Online (Sandbox Code Playgroud)\n

\xe2\x9a\xa1\xef\xb8\x8f 摘要

\n

我们现在拥有并行安全测试,可以在工程资源允许的情况下随着时间的推移而采用,具有垂直和水平扩展功能,同时注重预算。

\n

干杯

\n