Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom test grouping and test group order logic #500

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
135 changes: 124 additions & 11 deletions README.rst
Expand Up @@ -80,20 +80,133 @@ that worker and report the failure as usual. You can use the
``--max-worker-restart`` option to limit the number of workers that can
be restarted, or disable restarting altogether using ``--max-worker-restart=0``.

By default, the ``-n`` option will send pending tests to any worker that is available, without
any guaranteed order, but you can control this with these options:
Dividing tests up
^^^^^^^^^^^^^^^^^

In order to divide the tests up amongst the workers, ``pytest-xdist`` first puts sets of
them into "test groups". The tests within a test group are all run together in one shot,
so fixtures of larger scopes won't be run once for every single test. Instead, they'll
be run as many times as they need to for the tests within that test group. But, once
that test group is finished, it should be assumed that all cached fixture values from
that test group's execution are destroyed.

By default, there is no grouping logic and every individual test is placed in its own
test group, so using the ``-n`` option will send pending tests to any worker that is
available, without any guaranteed order. It should be assumed that when using this
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit misleading in the sense that it suggests every test will run in isolation with their own copies of every fixture (including high-scoped fixtures like session), which is not true: it is just that each worker is its own "session", so high-scoped fixtures will live in that session as if in an isolated pytest executing.

We could rewrite that part, but just removing it altogether is an option too. What do you think?

approach, every single test is run entirely in isolation from the others, meaning the
tests can't rely on cached fixture values from larger-scoped fixtures.

Provided test grouping options
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

By default, ``pytest-xdist`` doesn't group any tests together, but it provides some
grouping options, based on simple criteria about a test's nodeid. so you can gunarantee
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
grouping options, based on simple criteria about a test's nodeid. so you can gunarantee
grouping options, based on simple criteria about a test's nodeid, so you can guarantee

that certain tests are run in the same process. When they're run in the same process,
you gunarantee that larger-scoped fixtures are only executed as many times as would
normally be expected for the tests in the test group. But, once that test group is
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The phrase "But, once that test group..." suggests that xdist might be do something special in the sense to destroy the fixtures...

Perhaps we should have a separate section explaining how fixture execution in general works in xdist: each worker is its own session, so high-scope fixtures are bound to that worker, etc. This section applies to xdist in general and is not specific to the test grouping feature.

Back to the docs at hand, we can then just discuss how tests are grouped/sent to workers, without getting into details again regarding fixture setup/teardown.

What do you think? Hope my comments make sense. 😁

finished, it should be assumed that all cached fixture values from that test group's
execution are destroyed.

Here's the options that are built in:

* ``--dist=loadscope``: tests will be grouped by **module** shown in each test's node
for *test functions* and by the **class** shown in each test's nodeid for *test
methods*. This feature was added in version ``1.19``.

* ``--dist=loadfile``: tests will be grouped by the **module** shown in each test's
nodeid. This feature was added in version ``1.21``.

Defining custom load distribution logic
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

``pytest-xdist`` iterates over the entire list of collected tests and usually determines
what group to put them in based off of their nodeid. There is no set number of test
groups, as it creates a new groups as needed. You can tap into this system to define
your own grouping logic by using the ``pytest_xdist_set_test_group_from_nodeid``.

If you define your own copy of that hook, it will be called once for every test, and the
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
If you define your own copy of that hook, it will be called once for every test, and the
If you define your own implementation of that hook, it will be called once for every test, and the

nodeid for each test will be passed in. Whatever it returns is the test group for that
test. If a test group doesn't already exist with that name, then it will be created, so
anything can be used.

For example, let's say you have the following tests::

test/test_something.py::test_form_upload[image-chrome]
test/test_something.py::test_form_upload[image-firefox]
test/test_something.py::test_form_upload[video-chrome]
test/test_something.py::test_form_upload[video-firefox]
test/test_something_else.py::test_form_upload[image-chrome]
test/test_something_else.py::test_form_upload[image-firefox]
test/test_something_else.py::test_form_upload[video-chrome]
test/test_something_else.py::test_form_upload[video-firefox]

In order to have the ``chrome`` related tests run together and the ``firefox`` tests run
together, but allow them to be separated by file, this could be done:

* ``--dist=loadscope``: tests will be grouped by **module** for *test functions* and
by **class** for *test methods*, then each group will be sent to an available worker,
guaranteeing that all tests in a group run in the same process. This can be useful if you have
expensive module-level or class-level fixtures. Currently the groupings can't be customized,
with grouping by class takes priority over grouping by module.
This feature was added in version ``1.19``.
.. code-block:: python

def pytest_xdist_set_test_group_from_nodeid(nodeid):
browser_names = ['chrome', 'firefox']
nodeid_params = nodeid.split('[', 1)[-1].rstrip(']').split('-')
for name in browser_names:
if name in nodeid_params:
return "{test_file}[{browser_name}]".format(
test_file=nodeid.split("::", 1)[0],
browser_name=name,
)

The tests would then be divided into these test groups:

.. code-block:: python

{
"test/test_something.py::test_form_upload[chrome]" : [
"test/test_something.py::test_form_upload[image-chrome]",
"test/test_something.py::test_form_upload[video-chrome]"
],
"test/test_something.py::test_form_upload[firefox]": [
"test/test_something.py::test_form_upload[image-firefox]",
"test/test_something.py::test_form_upload[video-firefox]"
],
"test/test_something_else.py::test_form_upload[firefox]": [
"test/test_something_else.py::test_form_upload[image-firefox]",
"test/test_something_else.py::test_form_upload[video-firefox]"
],
"test/test_something_else.py::test_form_upload[chrome]": [
"test/test_something_else.py::test_form_upload[image-chrome]",
"test/test_something_else.py::test_form_upload[video-chrome]"
]
}

You can also fall back on one of the default load distribution mechanism by passing the
arguments for them listed above when you call pytest. Because this example returns
``None`` if the nodeid doesn't meet any of the criteria, it will defer to whichever
mechanism you chose. So if you passed ``--dist=loadfile``, tests would otherwise be
divided up by file name.

Keep in mind, this is a means of optimization, not a means for determinism.

Controlling test group execution order
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Sometimes you may want to have certain test groups start before or after others. Once
the test groups have been determined, the ``OrderedDict`` they are stored in can have
its order modified through the ``pytest_xdist_order_test_groups`` hook. For example, in
order to move the test group named ``"groupA"`` to the end of the queue, this can be
done:

.. code-block:: python

def pytest_xdist_order_test_groups(workqueue):
workqueue.move_to_end("groupA")

* ``--dist=loadfile``: tests will be grouped by file name, and then will be sent to an available
worker, guaranteeing that all tests in a group run in the same worker. This feature was added
in version ``1.21``.
Keep in mind, this is a means of optimization, not a means for determinism or filtering.
Removing test groups from this ``OrderedDict``, or adding new ones in after the fact can
have unforseen consequences.

If you want to filter out which tests get run, it is recommended to either rely on test
suite structure (so you can target the tests in specific locations), or by using marks
(so you can select or filter out based on specific marks with the ``-m`` flag).

Making session-scoped fixtures execute only once
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Expand Down
1 change: 1 addition & 0 deletions changelog/18.feature.rst
@@ -0,0 +1 @@
Allow defining of custom logic for test distribution among test groups, and changing the order in which test groups are passed out to workers.
52 changes: 52 additions & 0 deletions src/xdist/newhooks.py
Expand Up @@ -55,3 +55,55 @@ def pytest_xdist_node_collection_finished(node, ids):
@pytest.mark.firstresult
def pytest_xdist_make_scheduler(config, log):
""" return a node scheduler implementation """


@pytest.mark.trylast
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@pytest.mark.trylast
@pytest.hookspec(trylast=True, firstresult=True)

Besides using the new syntax, I think we should add firstresult here to make it clear in the API that we will only use the first result from the hook.

def pytest_xdist_set_test_group_from_nodeid(nodeid):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def pytest_xdist_set_test_group_from_nodeid(nodeid):
def pytest_xdist_get_test_group_from_nodeid(nodeid):

Perhaps "get" better conveys that we should return the test group? To me "set" conveys that I should set the test group somewhere.

"""Set the test group of a test using its nodeid.

This will determine which tests are grouped up together and distributed to
workers at the same time. This will be called for every test, and whatever
is returned will be the name of the test group that test belongs to. In
order to have tests be grouped together, this function must return the same
value for each nodeid for each test.

For example, given the following nodeids::

test/test_something.py::test_form_upload[image-chrome]
test/test_something.py::test_form_upload[image-firefox]
test/test_something.py::test_form_upload[video-chrome]
test/test_something.py::test_form_upload[video-firefox]
test/test_something_else.py::test_form_upload[image-chrome]
test/test_something_else.py::test_form_upload[image-firefox]
test/test_something_else.py::test_form_upload[video-chrome]
test/test_something_else.py::test_form_upload[video-firefox]

In order to have the ``chrome`` related tests run together and the
``firefox`` tests run together, but allow them to be separated by file,
this could be done::

def pytest_xdist_set_test_group_from_nodeid(nodeid):
browser_names = ['chrome', 'firefox']
nodeid_params = nodeid.split('[', 1)[-1].rstrip(']').split('-')
for name in browser_names:
if name in nodeid_params:
return "{test_file}[{browser_name}]".format(
test_file=nodeid.split("::", 1)[0],
browser_name=name,
)

This would then defer to the default distribution logic for any tests this
can't apply to (i.e. if this would return ``None`` for a given ``nodeid``).
"""

@pytest.mark.trylast
def pytest_xdist_order_test_groups(workqueue):
"""Sort the queue of test groups to determine the order they will be executed in.

The ``workqueue`` is an ``OrderedDict`` containing all of the test groups in the
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The ``workqueue`` is an ``OrderedDict`` containing all of the test groups in the
The ``workqueue`` is an ``OrderedDict`` of ``group => list of node ids`` containing all of the test groups in the

order they will be handed out to the workers. Groups that are listed first will be
handed out to workers first. The ``workqueue`` only needs to be modified and doesn't
need to be returned.

This can be useful when you want to run longer tests first.
"""