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

Add discover_imports in conf, don't collect imported classes named Test* closes #12749` #12810

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

FreerGit
Copy link

closes #12749

  • Allows a user to define whether or not pytest should treat imported (but not in testpaths) as test classes.
  • Imagine a class is called TestFoo defined in src/ dir, when discover_imports is disabled (default), TestFoo is not treated as a test class.

- Allows a user to define whether or not pytest should treat imported (but not in testpaths) as test classes.
- Imagine a class is called TestFoo defined in src/ dir, when discover_imports is disabled, TestFoo is not treated as a test class.
@psf-chronographer psf-chronographer bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Sep 12, 2024
Copy link
Member

@RonnyPfannschmidt RonnyPfannschmidt left a comment

Choose a reason for hiding this comment

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

Thanks for getting this started

We have to do some considerations for backwards compatibility and type safety

Also I'm not yet sure if the selection approach is generally intuitive (it solving your usecase nicely

parser.addini(
"discover_imports",
"Whether to discover tests in imported modules outside `testpaths`",
default=False,
Copy link
Member

Choose a reason for hiding this comment

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

We need to start with a default of true for backwards compatibility

Alternatively none with a informative warning

Copy link

Choose a reason for hiding this comment

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

If it helps, I'd be happy to argue that the current behavior was a substantial surprise to me, so that defaulting to False would count as removing a bug.

Copy link
Member

Choose a reason for hiding this comment

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

If it helps, I'd be happy to argue that the current behavior was a substantial surprise to me, so that defaulting to False would count as removing a bug.

I understand where you are coming from and I agree this would be the reasonable default if pytest was being born today... but unfortunately in this case I suspect there are test suites which rely on this behavior, so we really should be conservative here.

@@ -741,6 +741,12 @@ def newinstance(self):
return self.obj()

def collect(self) -> Iterable[nodes.Item | nodes.Collector]:
if self.config.getini("discover_imports") == ("false" or False):
Copy link
Member

Choose a reason for hiding this comment

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

We should ensure this is always a boolean or parsed somewhere else to ensure Separation

Copy link
Member

Choose a reason for hiding this comment

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

I think changing the default value for the option to False and changing this to if self.config.getinit("discover_imports"): should be enough.

@FreerGit
Copy link
Author

FreerGit commented Sep 12, 2024

@RonnyPfannschmidt Hi! Thanks for reviewing :)

To be clear if I would set True to default (as you suggested) this would mean that the base case is that a class like "Testament" in a seperate folder would make pytest error out (see #12749). Then a user would opt in (set to false) to make sure that the discovery does not happen for those classes. Your suggestion seems more reasonable I think.

I saw some checks failed, which didn't happen locally for some reason.
I will look over your comments and try and solve this tommorrow!

@@ -78,6 +78,11 @@ def pytest_addoption(parser: Parser) -> None:
type="args",
default=[],
)
parser.addini(
"discover_imports",
Copy link
Member

Choose a reason for hiding this comment

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

Is the idea that discover_imports is really only active if 'testpaths' is also defined in pytest.ini?

@@ -0,0 +1,3 @@
Add :confval:`discover_imports`, when disabled (default) will make sure to not consider classes which are imported by a test file and starts with Test.
Copy link
Member

Choose a reason for hiding this comment

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

Nice, changelog entry.

We also need to add documentation for that confval in reference.rst.

I'm not sure about the name too, but at the moment I don't have another suggestion (however we really should strive to find a more appropriate name).

Copy link
Author

Choose a reason for hiding this comment

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

treat_imports_as_tests?
imports_are_tests?
crawl_imports?

Copy link
Member

@nicoddemus nicoddemus Sep 13, 2024

Choose a reason for hiding this comment

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

As per my comment, perhaps we should focus on being more "strict" about what we collect from modules.

collect_?something? = "all"/"local-only"

But we should wait to see what other maintainers think.

Copy link
Author

Choose a reason for hiding this comment

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

Alright, now after a some hours (more than id like to admit) I now have something that works. Both for functions and classes. Let me know how we wanna handle the toggle of the feature. Have a good weekend!

@nicoddemus
Copy link
Member

I also wonder if we should be using a more general approach, and not only classes: shouldn't we also account for test_ functions imported from somewhere else (src)?

@nicoddemus
Copy link
Member

Thought about this some more:

I think this should actually be expanded to the general idea/feature of "pytest will only collect objects from modules if they are defined in that module" (by inspecting its __module__ attribute). So if the user enables that option, classes/functions that appear in a module will only be collected if their __module__ points to that module.

@nicoddemus
Copy link
Member

nicoddemus commented Sep 15, 2024

Another thing that occurred to me: __test__ should also be handled by the new option correctly as way to opt-in for something to be considered a test, regardless of where it was defined.

@pytest-dev/core how do you feel about this feature, should we go ahead with it or are there other concerns to discuss?

@Zac-HD
Copy link
Member

Zac-HD commented Sep 15, 2024

I'm completely unsurprised that classes or functions imported into a test file are collected, but I'm also obviously atypical of pytest users, and so having a setting for this could make sense.

If we do, I think the setting should be named collect_imported_tests; it's not about discovery at all.

I think this should actually be expanded to the general idea/feature of "pytest will only collect objects from modules if they are defined in that module" (by inspecting its __module__ attribute). So if the user enables that option, classes/functions that appear in a module will only be collected if their __module__ points to that module.

Agree with all of this, __test__ = definitely takes priority over a global option. I'd reverse the phrasing a little though; we should collect anything where __module__ is unset or none even though that doesn't match the current module.


Finally, I'm concerned that this opens up another avenue for the worst kind of testing bug, where you think you have tests but they can't actually fail (because they're not collected!) and so the whole exercise is silently useless. I'm unsure whether the benefits are worth this risk, given that there are plenty of other workarounds based on our existing features - including collection patterns, plugins, etc.

@nicoddemus
Copy link
Member

I'm unsure whether the benefits are worth this risk, given that there are plenty of other workarounds based on our existing features - including collection patterns, plugins, etc.

I'm not sure about this hiding bugs etc (the behavior seems somewhat more sane for those not used to how pytest works I think), but of course I might be wrong. However I do agree with the general sentiment of "yet another flag" that this brings. In this case however seems reasonable to me (but not that strongly TBH), because if I were designing pytest from the ground up today, without backward compatibility concerns, I would vote that it should behave this way (only collecting classes/functions defined in their own module).

@wpietri
Copy link

wpietri commented Sep 23, 2024

Yeah, as the report of this issue, I found it wildly surprising, and spent a lot of time trying to find a fix. I'd rather have there be a clear option for this, as that's what I first looked for. But failing that, "plenty of other workarounds" was part of the problem for me, as I had to try a lot of stuff and couldn't find one that let me fix exactly the issue once in a clear and clean way. I am fine with me having to fix it once, but didn't want it to be something every engineer had to learn and remember every time they created a new domain class (or function) with test in the name.

So if something like this doesn't make it in, then I'd at least suggest a clear, official workaround recommendation the docs where people can solve it in one spot permanently for their project.

@FreerGit
Copy link
Author

There seems to be some disagreement about the issue, either way I pushed the new implementation. Maybe it helps in the discussion :)

self.trace("genitems", node)
if isinstance(node, nodes.Item):
node.ihook.pytest_itemcollected(item=node)
if self.config.getini("collect_imported_tests"):
Copy link
Member

Choose a reason for hiding this comment

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

The logic for the variable is inverted: if collect_imported_tests is True, we are actually excluding imported tests instead of collecting them.

So the default for collect_imported_tests should be True for backward compatibility as discussed previously, and the logic here should be inverted:

Suggested change
if self.config.getini("collect_imported_tests"):
if not self.config.getini("collect_imported_tests"):

yield node
else:
assert isinstance(node, nodes.Collector)
keepduplicates = self.config.getoption("keepduplicates")
# For backward compat, dedup only applies to files.
handle_dupes = not (keepduplicates and isinstance(node, nodes.File))
rep, duplicate = self._collect_one_node(node, handle_dupes)

if self.config.getini("collect_imported_tests"):
Copy link
Member

Choose a reason for hiding this comment

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

My other comment applies here as well.

@@ -0,0 +1,3 @@
Add :confval:`collect_imported_tests`, when enabled (default is disabled) will make sure to not consider classes/functions which are imported by a test file and contains Test/test_*/*_test.
Copy link
Member

Choose a reason for hiding this comment

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

As per my comment, this description needs an update:

Suggested change
Add :confval:`collect_imported_tests`, when enabled (default is disabled) will make sure to not consider classes/functions which are imported by a test file and contains Test/test_*/*_test.
New :confval:`collect_imported_tests`: when enabled (the default) pytest will collect classes/functions in test modules even if they are imported from another file.
Setting this to True will make pytest collect classes/functions from test files only if they are defined in that file (as opposed to imported there).

Also, we should describe this option in https://github.com/pytest-dev/pytest/blob/main/doc/en/reference/reference.rst in a new .. conf-val:: block.

Copy link
Member

@nicoddemus nicoddemus left a comment

Choose a reason for hiding this comment

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

Thanks @Bjwebb,

Unfortunately we are not quite there yet.

However feel free to hold on to updating the PR in case other maintainers want to comment further before we move this along.

@@ -1839,9 +1839,17 @@ passed multiple times. The expected format is ``name=value``. For example::
pytest testing doc


.. confval:: tmp_path_retention_count
.. confval:: collect_imported_tests
Copy link
Member

Choose a reason for hiding this comment

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

Can you move this closer to the other options alphabetically?

@@ -1839,9 +1839,17 @@ passed multiple times. The expected format is ``name=value``. For example::
pytest testing doc


.. confval:: tmp_path_retention_count
.. confval:: collect_imported_tests

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
.. versionadded:: 8.4

.. confval:: tmp_path_retention_count
.. confval:: collect_imported_tests

Setting this to `false` will make pytest collect classes/functions from test
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
Setting this to `false` will make pytest collect classes/functions from test
Setting this to ``false`` will make pytest collect classes/functions from test

Setting this to `false` will make pytest collect classes/functions from test
files only if they are defined in that file (as opposed to imported there).

.. code-block:: ini
Copy link
Member

@nicoddemus nicoddemus Oct 1, 2024

Choose a reason for hiding this comment

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

RST is fidgety with spaces, this needs to be indented according to the block above otherwise it will not render correctly:

image

(Compare with tmp_path_retention_count).

Suggested change
.. code-block:: ini
.. code-block:: ini


[pytest]
collect_imported_tests = false
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
collect_imported_tests = false
collect_imported_tests = false
Default: ``true``

self.trace("genitems", node)
if isinstance(node, nodes.Item):
node.ihook.pytest_itemcollected(item=node)
if not self.config.getini("collect_imported_tests"):
Copy link
Member

Choose a reason for hiding this comment

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

Previously this code was placed in _pytest.python, which I think is the correct place... as it is implemented here, we are calling pytest_itemcollected but then discarding the item if the option is set and the item is not defined in its module, which I think is incorrect: pytest_itemcollected should only really be called if we did collect the item.

In fact, it is important for this to be tested too: the following collection hooks should not receive any item that was discarded due to collect_imported_tests:

  • pytest_itemcollected
  • pytest_collectreport
  • pytest_collection_modifyitems

Copy link
Author

Choose a reason for hiding this comment

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

Hi! Thought I would pick up this issue again after some wait. Is there any examples on how I could test these hooks? Im unsure on how to extract that information from the hooks. Im assuming this is problematic because extensions to pytest could be listening to these hooks?

Copy link
Member

Choose a reason for hiding this comment

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

Is there any examples on how I could test these hooks? Im unsure on how to extract that information from the hooks.

Usually we write a conftest.py file in the tests that implement these hooks, and then set/update some global variable when they are called.

Im assuming this is problematic because extensions to pytest could be listening to these hooks?

Exactly.

yield node
else:
assert isinstance(node, nodes.Collector)
keepduplicates = self.config.getoption("keepduplicates")
# For backward compat, dedup only applies to files.
handle_dupes = not (keepduplicates and isinstance(node, nodes.File))
rep, duplicate = self._collect_one_node(node, handle_dupes)

if not self.config.getini("collect_imported_tests"):
Copy link
Member

Choose a reason for hiding this comment

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

Same comment here, this logic should be moved to _pytest.python instead of main.

def test_collect_function_imports_disabled(pytester: Pytester) -> None:
pytester.makeini("""
[pytest]
# testpaths = "tests"
Copy link
Member

Choose a reason for hiding this comment

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

I think this was meant to be:

Suggested change
# testpaths = "tests"
testpaths = "tests"

But I don't think testpaths is related to the issue anymore?

Copy link
Author

Choose a reason for hiding this comment

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

I ment delete that commented line but forgot. You're right testpaths is not related and I wanted to make a test to ensure that.

Copy link
Member

Choose a reason for hiding this comment

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

Oh in that case make sure to add a comment/docstring mentioning that if you want to keep the test.

Comment on lines 209 to 228
# print("Reports: ----------------")
# print(reports)
# for r in reports:
# print(r)

# TODO this is want I want, I think....
# <CollectReport '' lenresult=1 outcome='passed'>
# <CollectReport 'tests/foo_test.py::TestDomain' lenresult=1 outcome='passed'>
# <CollectReport 'tests/foo_test.py' lenresult=1 outcome='passed'>
# <CollectReport 'tests' lenresult=1 outcome='passed'>
# <CollectReport '.' lenresult=1 outcome='passed'>

# TODO
# assert(reports.outcome == "passed")
# assert(len(reports.result) == 1)

# print("Items collected: ----------------")
# print(items_collected)
# print("Modified : ----------------")

Copy link
Author

Choose a reason for hiding this comment

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

Im not completely sure what is the correct behaviour yet, I'll try and figure it out. WIP and all.

- pytest_collectreport hook currently gets more reports (TODO)
- `pytest_collection_modifyitems` and `pytest_itemcollected` is correct.
Comment on lines 310 to 328
for x in default["reports"]:
print("def", x.__dict__)

for x in collect_off["reports"]:
print("off", x.__dict__)

# The two above loops prints:

# def {'nodeid': '', 'outcome': 'passed', 'longrepr': None, 'result': [<Dir test_hook_behaviour0>], 'sections': []} # noqa: E501
# def {'nodeid': 'tests/foo_test.py::TestDomain', 'outcome': 'passed', 'longrepr': None, 'result': [<Function test_important>], 'sections': []} # noqa: E501
# def {'nodeid': 'tests/foo_test.py', 'outcome': 'passed', 'longrepr': None, 'result': [<Class TestDomain>], 'sections': []} # noqa: E501
# def {'nodeid': 'tests', 'outcome': 'passed', 'longrepr': None, 'result': [<Module foo_test.py>], 'sections': []} # noqa: E501
# def {'nodeid': '.', 'outcome': 'passed', 'longrepr': None, 'result': [<Dir tests>], 'sections': []} # noqa: E501
# off {'nodeid': '', 'outcome': 'passed', 'longrepr': None, 'result': [<Dir test_hook_behaviour1>], 'sections': []} # noqa: E501
# off {'nodeid': 'src', 'outcome': 'passed', 'longrepr': None, 'result': [], 'sections': []}
# off {'nodeid': 'tests/foo_test.py::TestDomain', 'outcome': 'passed', 'longrepr': None, 'result': [<Function test_important>], 'sections': []} # noqa: E501
# off {'nodeid': 'tests/foo_test.py', 'outcome': 'passed', 'longrepr': None, 'result': [<Class TestDomain>], 'sections': []} # noqa: E501
# off {'nodeid': 'tests', 'outcome': 'passed', 'longrepr': None, 'result': [<Module foo_test.py>], 'sections': []} # noqa: E501
# off {'nodeid': '.', 'outcome': 'passed', 'longrepr': None, 'result': [<Dir src>, <Dir tests>], 'sections': []} # noqa: E501
Copy link
Author

Choose a reason for hiding this comment

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

At this point, I need some guidance.

The problem now is that while the CollectReport's for classes and functions are correct, the directory gets a report (even though no classes/functions were collected from it). What should be the expected behaviour in this case? Since the directory will be collected before any of the classes/functions I would need to filter it out later on somehow. Not sure what the correct approach is here.

I do feel like collecting the report for the directory (even though it's not "used") makes sense but I would need a maintainer to check.

Copy link
Member

@nicoddemus nicoddemus Nov 18, 2024

Choose a reason for hiding this comment

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

The problem now is that while the CollectReport's for classes and functions are correct, the directory gets a report (even though no classes/functions were collected from it). What should be the expected behaviour in this case?
I do feel like collecting the report for the directory (even though it's not "used") makes sense but I would need a maintainer to check.

You are correct -- currently on main if we implement pytest_collectreport and run pytest on a bunch of directories without any tests, we get collection reports for all directories, even though no test items were collected.

My previous concern was that we were generating collection reports for items that were in fact being discarded later, and that would definitely be a problem -- but collection reports for directories it is expected that some of them might turn out to not collect anything.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bot:chronographer:provided (automation) changelog entry is part of PR
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Don't auto-discover tests in particular folders
5 participants