【发布时间】:2022-12-23 04:08:14
【问题描述】:
我有一个包含数百个测试的存储库,这些测试到目前为止已经足够快了,但是随着我们继续增长代码库,我担心它会变得太慢以至于我的团队会陷入等待 CI 运行完成的困境。
我可以做些什么来加快速度并使我的测试在短期和长期都更快?
我需要考虑:
- 可扩展性
- 费用
- 推出
【问题讨论】:
标签: performance pytest github-actions
我有一个包含数百个测试的存储库,这些测试到目前为止已经足够快了,但是随着我们继续增长代码库,我担心它会变得太慢以至于我的团队会陷入等待 CI 运行完成的困境。
我可以做些什么来加快速度并使我的测试在短期和长期都更快?
我需要考虑:
【问题讨论】:
标签: performance pytest github-actions
我们可以使用horizontal and vertical scaling 加速测试运行。为此,我们需要确保并行测试安全。我们还有其他一些 PyTest 问题,我们必须解决这些问题才能完成此任务。我们还可以聪明地为难以实现并行安全的测试采用并行化。
让我们深入挖掘。
过去的测试可能被编写为假定串行执行,即数据库状态在测试运行之前以某种方式存在。这意味着不同的执行顺序可能会不确定地开始失败。您将必须确保每个测试都创建专门针对您的测试的数据库状态,确保设置所有必要的对象,并(可选)在测试完成后拆除这些对象。夹具将成为您的朋友,因为它们可用于创建必要的数据库状态并在之后进行清理.
串行执行中的一种反模式可以是基于数据库中行数的断言。 IE。:
def test_1() -> None:
create_some_rows_in_db()
assert get_rows_in_db() == 1
def test_2() -> None:
create_some_more_rows_in_db()
assert get_rows_in_db() == 2
如果我们以不同的顺序运行这些测试,它们就会失败。相反,我们需要在数据库中创建与我们的测试会话完全对应的行,同样,我们需要从数据库中获取仅用于此测试会话的行。
def test_1() -> None:
scope=uuid4()
create_some_rows_in_db(scope=scope)
assert get_rows_in_db(scope=scope) == 1
def test_2() -> None:
scope=uuid4()
create_some_more_rows_in_db(scope=scope)
assert get_rows_in_db(scope=scope) == 1
有两种方式可以破坏测试顺序:测试名称可能会更改,并且默认情况下测试顺序不按名称排序。
如果您在参数化测试中派生像 UUID 这样的值,这些值在测试运行之间会发生变化,这意味着测试本身的名称会发生变化。这意味着当并行运行测试时,它们的名称将与PyTest will fail to collect不同。幸运的是,删除在运行之间更改的参数化参数的创建很简单。
具体来说,如果您最初的测试看起来像:
@pytest.mark.parametrize("my_arg,...", [(uuid4(), ...), (uuid4(), ...)])
def test_some_code(my_arg: uuid4, ...) -> None:
assert my_arg is not None
然后您将需要更改它以派生函数内部的参数。
@pytest.mark.parametrize("...", [(...),])
def test_some_code(...) -> None:
my_arg = uuid4()
assert my_arg is not None
接下来,我们还需要patch the collection order 参数化测试,这意味着我们将以下内容添加到我们的conftest.py:
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
def param_part(item: pytest.Item) -> str:
# find the start of the parameter part in the nodeid
index = item.nodeid.find("[")
if index > 0:
# sort by parameter name
parameter_index = item.nodeid.index("[")
return item.name[parameter_index:]
# for all other cases, sort by node id as usual
return item.nodeid
# re-order the items using the param_part function as key
items[:] = sorted(items, key=param_part)
接下来,我们可以run our tests in parallel in a single GitHub Action Runner using xdist。这个包的安装和配置很容易完成,GitHub Action Runners 默认有 2 个 CPU 可供我们利用。
将来,它将运行这些测试的机器的will be possible to scale up the size。目前,2 核为我们提供了不错的加速。然而,我们可以走得更远。
垂直缩放提供了不错的加速,但我们真正想要完成的是将我们的测试工作拆分到多个运行器上。幸运的是,PyTest-split 出色地为我们完成了这项工作。
如 here 所示,在您的工作流 .yml 中启用它非常简单,当与 GitHub Matrix Actions 结合使用时,我们可以告诉 PyTest 并行运行所有可用测试的一小部分。
这意味着每个跑步者都会接受所有测试,但选择运行测试的拆分,从而将其余部分留给其他运行程序执行。现在在 matrix 参数中添加或删除跑步者的数量是微不足道的,我们可以增加或减少并行执行的数量以匹配我们的 SLA 和预算。
我还建议使用 PyTest-split 的 test_duration 功能,这样你就可以调整每个运行器中测试的分配,使它们均匀平衡。
说到预算...
如果我们想注意成本,取消之前提交的运行是有利的,如果它们仍在执行,如here所示。这将使我们从现在每个提交的更昂贵的执行成本中收回成本。我建议您从一小部分工人开始,看看您愿意承担多少成本,然后根据需要添加以满足您的周转时间需求。
假设我们没有时间或资源进行迁移全部我们的测试是并行安全的。如果我们想为我们的开发人员提供一个逃生舱口,以防他们每次都想连续运行测试,我们可以使用 clever marking 的测试使用 pytest.mark.serial 来确保某些测试每次都以相同的顺序运行时间。这意味着我们将需要配置我们的 GitHub 工作流 .yml 以独立于我们的 Matrix 运行来执行这些测试,但这很容易实现。
...
# Serial Execution
pytest -vv -x -n 0 -m "serial"
...
# Parallel Execution
pytest -vv -n auto -m "not serial" --splits PARALLELISM --group ${{ matrix.group }}
我们现在有并行安全测试,可以在工程资源允许的情况下随着时间的推移采用,具有垂直和水平扩展能力,同时注意预算。
干杯
【讨论】: