From a7227526b48e5a153912e460033952f89d18651f Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:10:56 -0700 Subject: [PATCH] python: a pytest fixture reaches exactly the tests that receive it (#1537, #1539, #1527, #1528, #1529, #1531, #1512) Fixture visibility now follows the runner's own rules instead of a name match. Engine (framework-behavior/dispatch.dl, knobs.dl): - a fixture belongs to every module it is an attribute of, so a conftest's star import makes it that conftest's fixture (#1537) - a module named in pytest_plugins registers its fixtures for the session (#1537) - the registered name is the decorator's name= when it gives one (#1539) - an argument the test's own parametrize supplies directly requests no fixture; an indirect one still does (#1529) - a fixture a class declares serves that class and its subclasses and shadows the module's fixture of the name (#1512) Impact (axiomcode-impact, dl/impact.dl, graph_sql.py): - a parameter is joined to a fixture through the engine's fixture_injection edge where the engine ran, not by name - a fixture injected by name is no longer credited to every test of its file or class (#1527) - a usefixtures marker on a class, a module's pytestmark and the ini usefixtures option reach the tests they apply to (#1528), including a fixture from a plugin module or a star-imported fixtures module - a declaration decorated @fixture is never a test, and conftest.py is never collected (#1531) Cases: graph/test/python/cases/30-fixture-visibility and tests/cases/python/fixture-reaches-its-requesters, each with near-miss controls (a same-named fixture in a sibling directory, a def name that is not the registered name, an unloaded fixtures module, an indirect argument, a sibling class). --- .../python/engine/config-resolution/knobs.dl | 29 +++ .../engine/framework-behavior/dispatch.dl | 121 +++++++++- graph/python/souffle/decls_all.dl | 8 + .../30-fixture-visibility/src/app/__init__.py | 0 .../30-fixture-visibility/src/app/svc.py | 46 ++++ .../30-fixture-visibility/src/conftest.py | 2 + .../src/other/__init__.py | 0 .../src/other/conftest.py | 3 + .../src/other/more_fixtures.py | 8 + .../src/other/test_other.py | 8 + .../src/tests/__init__.py | 0 .../src/tests/conftest.py | 28 +++ .../src/tests/fixtures.py | 19 ++ .../src/tests/helpers.py | 10 + .../src/tests/test_accounts.py | 36 +++ .../src/tests/test_explicit.py | 12 + .../src/tests/test_params.py | 32 +++ .../src/tests/test_svc.py | 41 ++++ .../src/tests/unused_fixtures.py | 14 ++ .../src/testsupport/__init__.py | 0 .../src/testsupport/plugin.py | 13 ++ .../expected/30-fixture-visibility.edges | 23 ++ .../expected/30-fixture-visibility.entries | 10 + .../expected/30-fixture-visibility.framework | 20 ++ .../expected/30-fixture-visibility.tiers | 28 +++ .../skills/axiomcode/scripts/axiomcode-impact | 125 ++++++++++- .../skills/axiomcode/scripts/dl/impact.dl | 33 ++- .../skills/axiomcode/scripts/graph_sql.py | 8 +- .../fixture-reaches-its-requesters/case.json | 208 ++++++++++++++++++ .../conftest.py | 1 + .../other/__init__.py | 0 .../other/conftest.py | 1 + .../other/more.py | 8 + .../other/test_other.py | 9 + .../fixture-reaches-its-requesters/pytest.ini | 2 + .../shop/__init__.py | 0 .../shop/svc.py | 46 ++++ .../support/__init__.py | 0 .../support/plugin.py | 8 + .../tests/__init__.py | 0 .../tests/conftest.py | 35 +++ .../tests/fixtures.py | 8 + .../tests/test_accounts.py | 26 +++ .../tests/test_forms.py | 16 ++ .../tests/test_images.py | 2 + .../tests/test_marker.py | 12 + .../tests/test_plugin_marker.py | 7 + .../tests/test_pytestmark.py | 7 + .../tests/test_svc.py | 35 +++ 49 files changed, 1085 insertions(+), 23 deletions(-) create mode 100644 graph/test/python/cases/30-fixture-visibility/src/app/__init__.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/app/svc.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/conftest.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/other/__init__.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/other/conftest.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/other/more_fixtures.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/other/test_other.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/__init__.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/conftest.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/fixtures.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/helpers.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/test_accounts.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/test_explicit.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/test_params.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/test_svc.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/unused_fixtures.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/testsupport/__init__.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/testsupport/plugin.py create mode 100644 graph/test/python/expected/30-fixture-visibility.edges create mode 100644 graph/test/python/expected/30-fixture-visibility.entries create mode 100644 graph/test/python/expected/30-fixture-visibility.framework create mode 100644 graph/test/python/expected/30-fixture-visibility.tiers create mode 100644 tests/cases/python/fixture-reaches-its-requesters/case.json create mode 100644 tests/cases/python/fixture-reaches-its-requesters/conftest.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/other/__init__.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/other/conftest.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/other/more.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/other/test_other.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/pytest.ini create mode 100644 tests/cases/python/fixture-reaches-its-requesters/shop/__init__.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/shop/svc.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/support/__init__.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/support/plugin.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/__init__.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/conftest.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/fixtures.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/test_accounts.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/test_forms.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/test_images.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/test_marker.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/test_plugin_marker.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/test_pytestmark.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/test_svc.py diff --git a/graph/python/engine/config-resolution/knobs.dl b/graph/python/engine/config-resolution/knobs.dl index 74c252ab5..0cf83db79 100644 --- a/graph/python/engine/config-resolution/knobs.dl +++ b/graph/python/engine/config-resolution/knobs.dl @@ -124,6 +124,35 @@ py_task_dispatch_method("apply_async"). // the fixture to produce the argument. The parameter name is the only join. py_fixture_deco("fixture"). +// ── py_fixture_name_kw(Keyword): the decorator argument that names the fixture ─ +// `@pytest.fixture(name="client") def client_fixture()` registers `client`, and a test +// requesting `client_fixture` gets nothing. The documented way to keep a fixture from +// shadowing a module-level name. +py_fixture_name_kw("name"). + +// ── py_fixture_plugins_var(Name): the module global that lists plugin modules ─ +// `pytest_plugins = ["pkg.fixtures"]` (a string, a list or a tuple) makes the runner +// import those modules as plugins, and a plugin's fixtures are visible to every test in +// the session, not only to the directory of the module that listed them. +py_fixture_plugins_var("pytest_plugins"). + +// ── py_parametrize_deco(Tail) and its keywords: a value supplied DIRECTLY ──── +// `@pytest.mark.parametrize("user", [...])` passes the value in, and the fixture called +// `user` is NOT run for that test. With `indirect=True`, or with the name in an +// `indirect=[...]` list, the value goes to the fixture instead and the fixture runs. +py_parametrize_deco("parametrize"). +py_parametrize_argnames_kw("argnames"). +py_parametrize_indirect_kw("indirect"). +py_parametrize_indirect_all("True"). + +// ── py_argnames_sep / py_argnames_lead: how "a, b" separates argument names ── +// The runner splits the string on "," and strips each piece. Souffle cannot split, so a +// name is tested as a whole token: led by a separator (with or without the usual single +// space) and followed by one, inside the string wrapped in separators. +py_argnames_sep(","). +py_argnames_lead(","). +py_argnames_lead(", "). + // ── py_test_name_prefix(Prefix) — how a runner recognises a test ───────────── // Injection happens only into a COLLECTED test. Without this, every function whose // parameter happens to share a fixture's name would be wired to it. diff --git a/graph/python/engine/framework-behavior/dispatch.dl b/graph/python/engine/framework-behavior/dispatch.dl index a3f943c01..a2c155e78 100644 --- a/graph/python/engine/framework-behavior/dispatch.dl +++ b/graph/python/engine/framework-behavior/dispatch.dl @@ -69,6 +69,7 @@ .decl py_fw_deco_tail(v:symbol) py_fw_deco_tail(v) :- py_task_register_deco(v). py_fw_deco_tail(v) :- py_fixture_deco(v). +py_fw_deco_tail(v) :- py_parametrize_deco(v). py_fw_deco_tail(v) :- py_signal_receiver_deco(v). py_fw_deco_tail(v) :- py_orm_hook_deco(v). @@ -146,9 +147,20 @@ framework_edge(from, to, "task_dispatch", meth, "registered") :- // The runner matches a test's PARAMETER NAME against the declared fixtures and calls // the fixture to build the argument. The name is the entire join, so the confidence is // `by_name`: it is correct exactly where the runner's scoping agrees with lexical scope. +// +// THE NAME IS THE ONE THE DECORATOR REGISTERS. `@fixture(name="client")` on +// `def client_fixture()` registers `client`, and only a parameter called `client` gets +// it. Keying on the def name wires the wrong parameter and misses the right one. .decl py_fixture_decl(m:symbol, name:symbol) -py_fixture_decl(m, name) :- decorator_target("client", _, m, h), m != "", - py_deco_named(h, v), py_fixture_deco(v), +.decl py_fixture_deco_on(m:symbol, h:symbol) +py_fixture_deco_on(m, h) :- decorator_target("client", _, m, h), m != "", + py_deco_named(h, v), py_fixture_deco(v). +.decl py_fixture_renamed(h:symbol, name:symbol) +py_fixture_renamed(h, name) :- py_fixture_deco_on(_, h), + annotation_arg("client", k, name, "STRING_LITERAL", _, "true", h, _), + py_fixture_name_kw(k), name != "". +py_fixture_decl(m, name) :- py_fixture_deco_on(m, h), py_fixture_renamed(h, name). +py_fixture_decl(m, name) :- py_fixture_deco_on(m, h), !py_fixture_renamed(h, _), method_decl("client", name, _, _, _, m). // The runner calls a requested fixture; nothing in the client does. @@ -191,32 +203,116 @@ py_fixture_requester(m) :- py_fixture_decl(m, _). // strict > excludes it and the commonest place a project keeps shared fixtures // contributes nothing at all. At the root d is "" and the prefix test admits every // requester, which is exactly right. +// +// A FIXTURE BELONGS TO EVERY MODULE IT IS AN ATTRIBUTE OF, not only to the file that +// declares it. The runner registers the fixtures it finds among a module's attributes, +// so `from .fixtures import *` in a conftest, or `from .helpers import helper_fx` in a +// test module, makes the fixture that module's own. Keyed on the declaring file alone, +// a fixture module a conftest star-imports reached no test at all (#1537). +// module_member_method already applies the language's export rule to a star import +// (`__all__`, else no leading underscore), so a name the star does not bind is not +// registered here either. +.decl py_fixture_in_file(fix:symbol, file:symbol) +py_fixture_in_file(fix, p) :- py_fixture_decl(fix, _), method_file("client", p, fix). +py_fixture_in_file(fix, p) :- py_fixture_decl(fix, _), + module_member_method("client", mod, _, fix), module_file("client", p, mod). + .decl py_fixture_conftest(fix:symbol, dir:symbol) -py_fixture_conftest(fix, d) :- py_fixture_decl(fix, _), - method_file("client", p, fix), py_fixture_scope_file(c), +py_fixture_conftest(fix, d) :- py_fixture_in_file(fix, p), py_fixture_scope_file(c), strlen(p) >= strlen(c), substr(p, strlen(p) - strlen(c), strlen(c)) = c, d = substr(p, 0, strlen(p) - strlen(c)). +// py_fixture_plugin(Fixture) -- a fixture of a module some module names in its +// `pytest_plugins` global. A plugin is registered for the whole session, so its fixtures +// are visible to every test, below any conftest or same-module fixture of the name. +// The value is a string, or a list or tuple of strings, each a dotted module name, +// joined to the module by that exact qualified name. +.decl py_fixture_plugin_name(q:symbol) +py_fixture_plugin_name(q) :- py_fixture_plugins_var(n), py_module_level_binding(_, n, b), + expr_binding("client", b, "STORE", tgt), assign_pair("client", tgt, val), + expr_node("client", "LITERAL", _, q, val), q != "". +py_fixture_plugin_name(q) :- py_fixture_plugins_var(n), py_module_level_binding(_, n, b), + expr_binding("client", b, "STORE", tgt), assign_pair("client", tgt, val), + expr_parent("client", val, "ELEMENT", _, el), + expr_node("client", "LITERAL", _, q, el), q != "". +.decl py_fixture_plugin(fix:symbol) +py_fixture_plugin(fix) :- py_fixture_plugin_name(q), module_decl("client", _, q, _, mod), + module_file("client", p, mod), py_fixture_in_file(fix, p). + // A requested parameter: no default, not a receiver, on a test or another fixture. -.decl py_fixture_request(req:symbol, name:symbol) -py_fixture_request(req, name) :- py_fixture_requester(req), +// And NOT an argument the test's own parametrize decorator supplies DIRECTLY: the runner +// passes that value in and never runs the fixture of the name (#1529). An `indirect` +// one is handed to the fixture, which does run, so it stays a request. +.decl py_fixture_param(req:symbol, name:symbol) +py_fixture_param(req, name) :- py_fixture_requester(req), param_decl("client", name, _, _, req, ph), name != "", param_default("client", "false", _, ph), param_kind("client", _, _, "false", ph). -// (a) the test's own module wins outright. +.decl py_parametrize_on(req:symbol, h:symbol) +py_parametrize_on(req, h) :- decorator_target("client", _, req, h), req != "", + py_deco_named(h, v), py_parametrize_deco(v). +// the argnames, as the string "a, b" (first positional, or the keyword) ... +.decl py_parametrize_argnames(h:symbol, s:symbol) +py_parametrize_argnames(h, s) :- py_parametrize_on(_, h), + annotation_arg("client", _, s, "STRING_LITERAL", "0", "false", h, _). +py_parametrize_argnames(h, s) :- py_parametrize_on(_, h), + annotation_arg("client", k, s, "STRING_LITERAL", _, "true", h, _), + py_parametrize_argnames_kw(k). +.decl py_parametrize_argname(req:symbol, h:symbol, name:symbol) +py_parametrize_argname(req, h, name) :- py_fixture_param(req, name), + py_parametrize_on(req, h), py_parametrize_argnames(h, s), + py_argnames_sep(sep), py_argnames_lead(lead), + contains(cat(lead, cat(name, sep)), cat(sep, cat(s, sep))). +// ... or as a list or tuple of strings. +py_parametrize_argname(req, h, name) :- py_fixture_param(req, name), + py_parametrize_on(req, h), decorator_expr("client", de, h), call_arg(de, "0", a), + expr_parent("client", a, "ELEMENT", _, el), + expr_node("client", "LITERAL", _, name, el). +.decl py_parametrize_indirect(h:symbol, name:symbol) +py_parametrize_indirect(h, name) :- py_parametrize_argname(_, h, name), + annotation_arg("client", k, v, _, _, "true", h, _), + py_parametrize_indirect_kw(k), py_parametrize_indirect_all(v). +py_parametrize_indirect(h, name) :- py_parametrize_argname(_, h, name), + decorator_expr("client", de, h), call_kwarg(de, k, a), py_parametrize_indirect_kw(k), + expr_parent("client", a, "ELEMENT", _, el), + expr_node("client", "LITERAL", _, name, el). +.decl py_parametrize_direct(req:symbol, name:symbol) +py_parametrize_direct(req, name) :- py_parametrize_argname(req, h, name), + !py_parametrize_indirect(h, name). + +.decl py_fixture_request(req:symbol, name:symbol) +py_fixture_request(req, name) :- py_fixture_param(req, name), + !py_parametrize_direct(req, name). + +// (0) a fixture a CLASS declares serves the tests of that class and of its subclasses, +// and nothing else; for them it wins over the module's fixture of the name. Read as a +// plain same-module fixture it was also handed to a sibling class's tests, and the +// module fixture it overrides was handed to its own (#1512). +.decl py_fixture_class(fix:symbol, t:symbol) +py_fixture_class(fix, t) :- py_fixture_decl(fix, _), method_owner("client", t, fix), t != "". +.decl py_fixture_in_class(req:symbol, name:symbol, fix:symbol) +py_fixture_in_class(req, name, fix) :- py_fixture_request(req, name), + py_fixture_decl(fix, name), fix != req, py_fixture_class(fix, t), + method_owner("client", u, req), type_self_or_ancestor("client", u, t). +.decl py_fixture_class_shadowed(req:symbol, name:symbol) +py_fixture_class_shadowed(req, name) :- py_fixture_in_class(req, name, _). + +// (a) the test's own module wins next: declared at its top level, or imported into it. .decl py_fixture_same_module(req:symbol, name:symbol, fix:symbol) py_fixture_same_module(req, name, fix) :- py_fixture_request(req, name), - py_fixture_decl(fix, name), fix != req, - method_file("client", p, req), method_file("client", p, fix). + py_fixture_decl(fix, name), fix != req, !py_fixture_class(fix, _), + !py_fixture_class_shadowed(req, name), + method_file("client", p, req), py_fixture_in_file(fix, p). .decl py_fixture_shadowed(req:symbol, name:symbol) py_fixture_shadowed(req, name) :- py_fixture_same_module(req, name, _). +py_fixture_shadowed(req, name) :- py_fixture_class_shadowed(req, name). // (b) otherwise a conftest at or above the requester, NEAREST first. Depth is the // length of the governed directory, so the longest prefix is the nearest ancestor. .decl py_fixture_conftest_cand(req:symbol, name:symbol, fix:symbol, depth:number) py_fixture_conftest_cand(req, name, fix, dl) :- py_fixture_request(req, name), - py_fixture_decl(fix, name), fix != req, + py_fixture_decl(fix, name), fix != req, !py_fixture_class(fix, _), py_fixture_conftest(fix, d), method_file("client", p, req), strlen(p) > strlen(d), substr(p, 0, strlen(d)) = d, dl = strlen(d). .decl py_fixture_nearest(req:symbol, name:symbol, depth:number) @@ -224,9 +320,14 @@ py_fixture_nearest(req, name, m) :- py_fixture_conftest_cand(req, name, _, _), m = max dl : { py_fixture_conftest_cand(req, name, _, dl) }. .decl py_fixture_injection(from:symbol, to:symbol, name:symbol) +py_fixture_injection(req, fix, name) :- py_fixture_in_class(req, name, fix). py_fixture_injection(req, fix, name) :- py_fixture_same_module(req, name, fix). py_fixture_injection(req, fix, name) :- py_fixture_conftest_cand(req, name, fix, dl), !py_fixture_shadowed(req, name), py_fixture_nearest(req, name, dl). +// (c) failing both, a plugin's fixture, which every test sees. +py_fixture_injection(req, fix, name) :- py_fixture_request(req, name), + py_fixture_decl(fix, name), fix != req, py_fixture_plugin(fix), !py_fixture_class(fix, _), + !py_fixture_shadowed(req, name), !py_fixture_nearest(req, name, _). .decl py_fixture_requested(m:symbol) py_fixture_requested(m) :- py_fixture_injection(_, m, _). diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 4a52c2810..6a1396f3e 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -46,6 +46,14 @@ .decl py_fixture_deco(v:symbol) .decl py_fixture_scope_file(v:symbol) .decl py_test_name_prefix(v:symbol) +.decl py_fixture_name_kw(v:symbol) +.decl py_fixture_plugins_var(v:symbol) +.decl py_parametrize_deco(v:symbol) +.decl py_parametrize_argnames_kw(v:symbol) +.decl py_parametrize_indirect_kw(v:symbol) +.decl py_parametrize_indirect_all(v:symbol) +.decl py_argnames_sep(v:symbol) +.decl py_argnames_lead(v:symbol) .decl py_route_table(v:symbol) .decl py_route_entry_fn(v:symbol) .decl py_signal_receiver_deco(v:symbol) diff --git a/graph/test/python/cases/30-fixture-visibility/src/app/__init__.py b/graph/test/python/cases/30-fixture-visibility/src/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/python/cases/30-fixture-visibility/src/app/svc.py b/graph/test/python/cases/30-fixture-visibility/src/app/svc.py new file mode 100644 index 000000000..6bf27ae5d --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/app/svc.py @@ -0,0 +1,46 @@ +"""Production code. Each function is reached from a test ONLY through one fixture body, +so which fixture a test is wired to decides which of these it reaches.""" + + +def make_repo(): + return "repo" + + +def make_service(): + return "service" + + +def make_cache(): + return "cache" + + +def make_client(): + return "client" + + +def make_user(name): + return {"name": name} + + +def make_widget(): + return "widget" + + +def make_orphan(): + return "orphan" + + +def make_helper(): + return "helper" + + +def make_hidden(): + return "hidden" + + +def module_account(): + return "module" + + +def class_account(): + return "class" diff --git a/graph/test/python/cases/30-fixture-visibility/src/conftest.py b/graph/test/python/cases/30-fixture-visibility/src/conftest.py new file mode 100644 index 000000000..59956a286 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/conftest.py @@ -0,0 +1,2 @@ +"""The root conftest, and the one place a `pytest_plugins` list is honoured.""" +pytest_plugins = ["testsupport.plugin"] diff --git a/graph/test/python/cases/30-fixture-visibility/src/other/__init__.py b/graph/test/python/cases/30-fixture-visibility/src/other/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/python/cases/30-fixture-visibility/src/other/conftest.py b/graph/test/python/cases/30-fixture-visibility/src/other/conftest.py new file mode 100644 index 000000000..aab7d61cf --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/other/conftest.py @@ -0,0 +1,3 @@ +"""A sibling directory's conftest, star-importing its own fixture module by its +ABSOLUTE name (tests/conftest.py uses the relative spelling).""" +from other.more_fixtures import * diff --git a/graph/test/python/cases/30-fixture-visibility/src/other/more_fixtures.py b/graph/test/python/cases/30-fixture-visibility/src/other/more_fixtures.py new file mode 100644 index 000000000..07b5de802 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/other/more_fixtures.py @@ -0,0 +1,8 @@ +import pytest + +from app.svc import make_widget + + +@pytest.fixture +def widget(): + return make_widget() diff --git a/graph/test/python/cases/30-fixture-visibility/src/other/test_other.py b/graph/test/python/cases/30-fixture-visibility/src/other/test_other.py new file mode 100644 index 000000000..fc796a332 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/other/test_other.py @@ -0,0 +1,8 @@ +def test_widget(widget): + """other/more_fixtures.py::widget, through this directory's conftest.""" + assert widget + + +def test_service_here(service): + """testsupport/plugin.py::service: a plugin fixture is visible outside tests/ too.""" + assert service diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/__init__.py b/graph/test/python/cases/30-fixture-visibility/src/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/conftest.py b/graph/test/python/cases/30-fixture-visibility/src/tests/conftest.py new file mode 100644 index 000000000..887e35943 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/conftest.py @@ -0,0 +1,28 @@ +"""Four ways a fixture reaches this directory's tests, and one way it does not. + + cache declared here + repo star-imported from .fixtures + client registered under the name given to the decorator; the def is + `client_fixture`, and a test asking for `client_fixture` gets nothing + user requested three ways by tests/test_params.py, one of which shadows it +""" +import pytest + +from app.svc import make_cache, make_client, make_user + +from .fixtures import * + + +@pytest.fixture +def cache(): + return make_cache() + + +@pytest.fixture(name="client") +def client_fixture(): + return make_client() + + +@pytest.fixture +def user(): + return make_user("default") diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/fixtures.py b/graph/test/python/cases/30-fixture-visibility/src/tests/fixtures.py new file mode 100644 index 000000000..487b895ef --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/fixtures.py @@ -0,0 +1,19 @@ +"""Fixtures that reach tests only because tests/conftest.py star-imports this module. + +The runner registers every public attribute of a conftest, and `from .fixtures import *` +makes `repo` one. `_hidden` is not re-exported by a star import (a leading underscore), +so it is not a conftest attribute and no test may reach it through the star. +""" +import pytest + +from app.svc import make_hidden, make_repo + + +@pytest.fixture +def repo(): + return make_repo() + + +@pytest.fixture +def _hidden(): + return make_hidden() diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/helpers.py b/graph/test/python/cases/30-fixture-visibility/src/tests/helpers.py new file mode 100644 index 000000000..25a1fb28e --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/helpers.py @@ -0,0 +1,10 @@ +"""Imported by NAME into one test module, which makes `helper_fx` an attribute of that +module and so a fixture the runner finds there, and only there.""" +import pytest + +from app.svc import make_helper + + +@pytest.fixture +def helper_fx(): + return make_helper() diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/test_accounts.py b/graph/test/python/cases/30-fixture-visibility/src/tests/test_accounts.py new file mode 100644 index 000000000..74ee5a01c --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/test_accounts.py @@ -0,0 +1,36 @@ +"""A fixture a class declares serves that class and its subclasses, and overrides the module's.""" +import pytest + +from app.svc import class_account, module_account + + +@pytest.fixture +def account(): + return module_account() + + +class TestOverride: + @pytest.fixture + def account(self): + return class_account() + + def test_in_class(self, account): + """TestOverride.account only: the class fixture shadows the module one.""" + assert account == "class" + + +class TestOverrideChild(TestOverride): + def test_in_subclass(self, account): + """TestOverride.account, inherited.""" + assert account == "class" + + +class TestPlain: + def test_no_override(self, account): + """the module fixture: a SIBLING class's fixture is not visible here.""" + assert account == "module" + + +def test_module_level(account): + """the module fixture.""" + assert account == "module" diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/test_explicit.py b/graph/test/python/cases/30-fixture-visibility/src/tests/test_explicit.py new file mode 100644 index 000000000..4e8ecf7b4 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/test_explicit.py @@ -0,0 +1,12 @@ +"""A fixture imported by name into the test module is a fixture of this module.""" +from .helpers import helper_fx + + +def test_helper(helper_fx): + """tests/helpers.py::helper_fx, through the import above.""" + assert helper_fx + + +def test_repo_here(repo): + """tests/fixtures.py::repo, through the conftest star import, as in test_svc.py.""" + assert repo diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/test_params.py b/graph/test/python/cases/30-fixture-visibility/src/tests/test_params.py new file mode 100644 index 000000000..1b7dafc3d --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/test_params.py @@ -0,0 +1,32 @@ +"""A DIRECT parametrize argname is a value the runner passes in, not a fixture request. +An indirect one goes through the fixture.""" +import pytest + + +@pytest.mark.parametrize("user", [{"name": "direct"}]) +def test_direct(user): + """NOTHING: `user` is supplied by the parametrize decorator.""" + assert user["name"] == "direct" + + +@pytest.mark.parametrize("user, cache", [({"name": "a"}, "c")]) +def test_direct_pair(user, cache): + """NOTHING for either: both argnames are parametrized directly.""" + assert user and cache + + +@pytest.mark.parametrize("user", ["other"], indirect=True) +def test_indirect(user): + """tests/conftest.py::user, which receives "other" as request.param.""" + assert user + + +@pytest.mark.parametrize("n", [1, 2]) +def test_other_argname(n, user): + """tests/conftest.py::user: only `n` is parametrized.""" + assert user and n + + +def test_plain(user): + """tests/conftest.py::user.""" + assert user diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/test_svc.py b/graph/test/python/cases/30-fixture-visibility/src/tests/test_svc.py new file mode 100644 index 000000000..7009ddbb3 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/test_svc.py @@ -0,0 +1,41 @@ +"""The requesters. Each test names one fixture; the comment says which def runs.""" + + +def test_repo(repo): + """tests/fixtures.py::repo, through the conftest's star import.""" + assert repo + + +def test_service(service): + """testsupport/plugin.py::service, through the root conftest's pytest_plugins.""" + assert service + + +def test_cache(cache): + """tests/conftest.py::cache, declared in the conftest. The control that always worked.""" + assert cache + + +def test_client(client): + """tests/conftest.py::client_fixture, registered as `client`.""" + assert client + + +def test_client_by_def_name(client_fixture): + """NOTHING: the fixture is called `client`, and `client_fixture` names no fixture.""" + assert client_fixture + + +def test_orphan(orphan): + """NOTHING: tests/unused_fixtures.py is never loaded.""" + assert orphan + + +def test_hidden(_hidden): + """NOTHING: a star import does not re-export a leading-underscore name.""" + assert _hidden + + +def test_widget(widget): + """NOTHING: `widget` is star-imported into other/conftest.py, a SIBLING directory.""" + assert widget diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/unused_fixtures.py b/graph/test/python/cases/30-fixture-visibility/src/tests/unused_fixtures.py new file mode 100644 index 000000000..58b1166f3 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/unused_fixtures.py @@ -0,0 +1,14 @@ +"""A fixture module that NOTHING imports and no conftest names. + +`orphan` is declared with the fixture decorator and a test requests a parameter called +`orphan`, but the runner never loads this module, so the request fails. The name alone +is not visibility. +""" +import pytest + +from app.svc import make_orphan + + +@pytest.fixture +def orphan(): + return make_orphan() diff --git a/graph/test/python/cases/30-fixture-visibility/src/testsupport/__init__.py b/graph/test/python/cases/30-fixture-visibility/src/testsupport/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/python/cases/30-fixture-visibility/src/testsupport/plugin.py b/graph/test/python/cases/30-fixture-visibility/src/testsupport/plugin.py new file mode 100644 index 000000000..ce3972c56 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/testsupport/plugin.py @@ -0,0 +1,13 @@ +"""A plugin module, loaded because the root conftest lists it in `pytest_plugins`. + +A plugin's fixtures are registered for the whole session, so every test sees `service`, +including the one under other/, which is outside the conftest's own directory. +""" +import pytest + +from app.svc import make_service + + +@pytest.fixture +def service(): + return make_service() diff --git a/graph/test/python/expected/30-fixture-visibility.edges b/graph/test/python/expected/30-fixture-visibility.edges new file mode 100644 index 000000000..cafafc473 --- /dev/null +++ b/graph/test/python/expected/30-fixture-visibility.edges @@ -0,0 +1,23 @@ +ambiguous_unknown DECORATOR_APPLICATION tests.conftest. -> - +ambiguous_unknown DECORATOR_APPLICATION tests.test_params. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE other.more_fixtures. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.conftest. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.fixtures. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.helpers. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.test_accounts. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.test_accounts.TestOverride. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.unused_fixtures. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE testsupport.plugin. -> - +boundary_lib DECORATOR_CALL tests.conftest. -> external:pytest.fixture +boundary_lib DECORATOR_CALL tests.test_params. -> external:pytest.mark.parametrize +known_edge SIMPLE_CALL other.more_fixtures.widget -> app.svc.make_widget +known_edge SIMPLE_CALL tests.conftest.cache -> app.svc.make_cache +known_edge SIMPLE_CALL tests.conftest.client_fixture -> app.svc.make_client +known_edge SIMPLE_CALL tests.conftest.user -> app.svc.make_user +known_edge SIMPLE_CALL tests.fixtures._hidden -> app.svc.make_hidden +known_edge SIMPLE_CALL tests.fixtures.repo -> app.svc.make_repo +known_edge SIMPLE_CALL tests.helpers.helper_fx -> app.svc.make_helper +known_edge SIMPLE_CALL tests.test_accounts.TestOverride.account -> app.svc.class_account +known_edge SIMPLE_CALL tests.test_accounts.account -> app.svc.module_account +known_edge SIMPLE_CALL tests.unused_fixtures.orphan -> app.svc.make_orphan +known_edge SIMPLE_CALL testsupport.plugin.service -> app.svc.make_service diff --git a/graph/test/python/expected/30-fixture-visibility.entries b/graph/test/python/expected/30-fixture-visibility.entries new file mode 100644 index 000000000..5d5c2f148 --- /dev/null +++ b/graph/test/python/expected/30-fixture-visibility.entries @@ -0,0 +1,10 @@ +── entry_point (9) ── + fixture other.more_fixtures.widget other/more_fixtures.py:7 + fixture tests.conftest.cache tests/conftest.py:17 + fixture tests.conftest.client_fixture tests/conftest.py:22 + fixture tests.conftest.user tests/conftest.py:27 + fixture tests.fixtures.repo tests/fixtures.py:13 + fixture tests.helpers.helper_fx tests/helpers.py:9 + fixture tests.test_accounts.TestOverride.account tests/test_accounts.py:14 + fixture tests.test_accounts.account tests/test_accounts.py:8 + fixture testsupport.plugin.service testsupport/plugin.py:12 diff --git a/graph/test/python/expected/30-fixture-visibility.framework b/graph/test/python/expected/30-fixture-visibility.framework new file mode 100644 index 000000000..94e97b891 --- /dev/null +++ b/graph/test/python/expected/30-fixture-visibility.framework @@ -0,0 +1,20 @@ +── framework_edge (15) ── + fixture_injection by_name other.test_other.test_service_here (other/test_other.py) -> testsupport.plugin.service (testsupport/plugin.py) [service] + fixture_injection by_name other.test_other.test_widget (other/test_other.py) -> other.more_fixtures.widget (other/more_fixtures.py) [widget] + fixture_injection by_name tests.test_accounts.TestOverride.test_in_class (tests/test_accounts.py) -> tests.test_accounts.TestOverride.account (tests/test_accounts.py) [account] + fixture_injection by_name tests.test_accounts.TestOverrideChild.test_in_subclass (tests/test_accounts.py) -> tests.test_accounts.TestOverride.account (tests/test_accounts.py) [account] + fixture_injection by_name tests.test_accounts.TestPlain.test_no_override (tests/test_accounts.py) -> tests.test_accounts.account (tests/test_accounts.py) [account] + fixture_injection by_name tests.test_accounts.test_module_level (tests/test_accounts.py) -> tests.test_accounts.account (tests/test_accounts.py) [account] + fixture_injection by_name tests.test_explicit.test_helper (tests/test_explicit.py) -> tests.helpers.helper_fx (tests/helpers.py) [helper_fx] + fixture_injection by_name tests.test_explicit.test_repo_here (tests/test_explicit.py) -> tests.fixtures.repo (tests/fixtures.py) [repo] + fixture_injection by_name tests.test_params.test_indirect (tests/test_params.py) -> tests.conftest.user (tests/conftest.py) [user] + fixture_injection by_name tests.test_params.test_other_argname (tests/test_params.py) -> tests.conftest.user (tests/conftest.py) [user] + fixture_injection by_name tests.test_params.test_plain (tests/test_params.py) -> tests.conftest.user (tests/conftest.py) [user] + fixture_injection by_name tests.test_svc.test_cache (tests/test_svc.py) -> tests.conftest.cache (tests/conftest.py) [cache] + fixture_injection by_name tests.test_svc.test_client (tests/test_svc.py) -> tests.conftest.client_fixture (tests/conftest.py) [client] + fixture_injection by_name tests.test_svc.test_repo (tests/test_svc.py) -> tests.fixtures.repo (tests/fixtures.py) [repo] + fixture_injection by_name tests.test_svc.test_service (tests/test_svc.py) -> testsupport.plugin.service (testsupport/plugin.py) [service] +── framework_unjoined (0) ── +── remote_edge (0) ── +── remote_unserved (0) ── +── remote_unsent (0) ── diff --git a/graph/test/python/expected/30-fixture-visibility.tiers b/graph/test/python/expected/30-fixture-visibility.tiers new file mode 100644 index 000000000..ea812c21a --- /dev/null +++ b/graph/test/python/expected/30-fixture-visibility.tiers @@ -0,0 +1,28 @@ +distinct call sites emitted: 31 + +--- by tier: edge ROWS, and the distinct SITES they cover --- + 15 rows 15 sites ambiguous_unknown + 5 rows 5 sites boundary_lib + 11 rows 11 sites known_edge + +--- edge rows by call kind --- + 5 DECORATOR_APPLICATION + 10 DECORATOR_ATTRIBUTE + 5 DECORATOR_CALL + 11 SIMPLE_CALL + +--- unresolved reasons --- + 5 decorator_factory_result_untyped + 10 unmodelled_decorator + +--- the engine's own conservation ledger --- + 31 _total_sites + 15 ambiguous_unknown + 5 boundary_lib + 11 known_edge + +--- reconciling rows against the conserved site count --- + edge rows 31 + minus extra rows from multi-target sites 0 + = tier/site pairs 31 + engine's conserved site total 31 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 467ec300e..965b9e33e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -129,8 +129,11 @@ TEST_DECOR = re.compile(r'(^|\.)(\w*Test\w*|Fact|Theory|it|test)$') # `RFC2616PolicyTestMixin`, `StorageTestMixin`, `TestBase` -- declares real tests, collected # through a subclass named Test*. Anchoring the name dropped every one of them. TEST_OWNER = re.compile(r'(Test|Spec|ITCase)') -# a decoration that says the method is a dependency the framework builds, not a test -NON_TEST_DECOR = re.compile(r'^(Bean|Configuration|Component|Provides|Produces|TestConfiguration)$') +# a decoration that says the method is a dependency the framework builds, not a test. A pytest `@fixture` is one +# whatever its name: `def test_image()` under it is built for the tests that request it, never collected (#1531). +NON_TEST_DECOR = re.compile(r'^(Bean|Configuration|Component|Provides|Produces|TestConfiguration|fixture)$') +# a file the runner imports for its fixtures and hooks and never collects tests from +NON_TEST_FILE = re.compile(r'(^|/)conftest\.py$') RET_TYPE = re.compile(r'\)\s*:\s*(.+)$') # a URL path as a router accepts it: path-absolute (RFC 3986 §3.3) with no whitespace and no quote in it. A path # parameter — `{id}`, ``, `:id`, `*` — is left exactly as written: its spelling is per framework, and the @@ -983,6 +986,7 @@ class Impact: # pytest function, a module-level `function testX()`), or a type that is a test class. if not s['name'].startswith(('test', 'it')): return False if any(NON_TEST_DECOR.match(d) for d in decs.get(i, ())): return False + if NON_TEST_FILE.search(s.get('file') or ''): return False own = (s.get('owner') or '').split('.')[-1] if own and not TEST_OWNER.search(own): return False # JUnit 3 reads the convention on `public void testX()`. A method that DECLARES a return @@ -1024,24 +1028,133 @@ class Impact: # Three facts, so the JOIN stays a rule: the parameter names a callable takes, the name and path a fixture # serves under, and the prefixes a file lies under. A fixture in conftest.py serves its directory subtree # (pytest's own rule); any other fixture serves its own file. + # + # WHERE THE ENGINE RESOLVED THE INJECTION, ITS ANSWER IS THE ONE USED. The Python engine's fixture_injection + # edge applies the runner's own resolution: the fixture name the decorator registers (`name=`), the fixtures + # a conftest star-imports or a `pytest_plugins` list loads, the nearest conftest winning, and an argument the + # test's own parametrize supplies directly requesting nothing. The name join below knows none of that, so + # for a file the engine covered it is not written at all: on a Python graph it credited `make_repo`, reached + # through a star-imported fixture, to 0 tests, and a directly parametrized `user` to its fixture (#1537, #1529). fdir = lambda f: f.rsplit('/', 1)[0] + '/' if '/' in f else '' + dtext = collections.defaultdict(list) + for r in (g.q("SELECT owner_id, name, text FROM decorations") if g.has('decorations') else []): dtext[r[0]].append((r[1], r[2] or '')) + engine_fixtures = g.has('ext_framework_edge') + is_py = lambda i: re.search(r'\.pyi?$', (g.sym.get(i) or {}).get('file') or '') is not None + inj = [] + if engine_fixtures: + for c0, c1 in g.q("SELECT c0, c1 FROM ext_framework_edge WHERE c2 = 'fixture_injection'"): + if c0 in g.sym and c1 in g.sym: inj.append((c0, c1)) + W('injects', sorted(set(inj))) params = [] for i, sy in g.sym.items(): if not sy.get('method_id') or sy['kind'] == 'module': continue + if engine_fixtures and is_py(i): continue sig = sy.get('signature') or '' for n in re.findall(r'[A-Za-z_]\w*', sig[sig.find('(') + 1:sig.rfind(')')] if '(' in sig else ''): if n != 'self' and n != 'cls': params.append((i, n)) W('takes_param', sorted(set(params))) + # A fixture pytest INJECTS BY NAME runs for the tests that request it, and for no other test beside it. + # The rules that carry a fixture to every test of its file or its class are right for a setUp, a module + # setup or an autouse fixture, and wrong for this one: a module-level `@pytest.fixture def cart()` was + # credited to every test in its file, requested or not (#1527). Such a fixture reaches tests through + # uses_fixture only. An autouse fixture is not in this set: it does run for every test in its scope. + FIX_NAME = re.compile(r'\bname\s*=\s*[\'"]([^\'"]+)[\'"]') + named = [] + for i, ds in dtext.items(): + if i not in g.sym or not is_py(i): continue + fd = [t for n, t in ds if (n or '').split('.')[-1] == 'fixture'] + if fd and not any(re.search(r'autouse\s*=\s*True', t) for t in fd): named.append((i,)) + W('injected_fixture', sorted(set(named))) + # A fixture is ALSO visible where pytest registers the module holding it: a conftest's `from X import *` makes + # X's fixtures that conftest's (its directory), and a module named in `pytest_plugins` is loaded for the whole + # session (every path). The parameter join reads the engine's resolution; a usefixtures marker names the fixture + # as a string and joins here, so without these rows a marker naming a plugin's fixture reached nothing (#1537). + import ast + pyfiles = sorted({sy.get('file') for sy in g.sym.values() if (sy.get('file') or '').endswith('.py')}) + def module_file(dotted, base=''): + tails = (dotted.replace('.', '/') + '.py', dotted.replace('.', '/') + '/__init__.py') + return [f for f in pyfiles if any(f == base + t or (not base and f.endswith('/' + t)) for t in tails)] + extra_scope = collections.defaultdict(set) + for f in pyfiles: + conf = f.rsplit('/', 1)[-1] == 'conftest.py' + src = '\n'.join(self.lines(f)) + if not conf and 'pytest_plugins' not in src: continue + if conf and 'import' not in src and 'pytest_plugins' not in src: continue + try: tree = ast.parse(src) + except (SyntaxError, ValueError): continue + for st in tree.body: + if conf and isinstance(st, ast.ImportFrom) and any(a.name == '*' for a in st.names): + base = fdir(f) + if st.level: + for _ in range(st.level - 1): base = fdir(base.rstrip('/')) + tgt = module_file(st.module or '', base) if st.module else [] + else: + tgt = module_file(st.module or '') + for t in tgt: extra_scope[t].add(fdir(f)) + tg = st.targets if isinstance(st, ast.Assign) else [] + if any(isinstance(t, ast.Name) and t.id == 'pytest_plugins' for t in tg): + for c in ast.walk(st.value): + if isinstance(c, ast.Constant) and isinstance(c.value, str): + for t in module_file(c.value): extra_scope[t].add('') scope = []; auto = [] - dtext = collections.defaultdict(list) - for r in (g.q("SELECT owner_id, name, text FROM decorations") if g.has('decorations') else []): dtext[r[0]].append((r[1], r[2] or '')) - for i in fx: + # a pytest fixture outside a test file (a plugin module, a fixtures module a conftest star-imports) is still + # one a marker can name: `fx` holds only test-file declarations + for i in sorted(set(fx) | {i for (i,) in named} | {i for i, ds in dtext.items() if i in g.sym and is_py(i) and any((n or '').split('.')[-1] == 'fixture' for n, _ in ds)}): sy = g.sym.get(i) or {} f = sy.get('file') or '' if not sy.get('name'): continue - scope.append((i, sy['name'], fdir(f) if f.rsplit('/', 1)[-1] == 'conftest.py' else f)) + # the name a marker or a parameter asks for is the one the decorator registers, when it gives one + reg = next((m.group(1) for n, t in dtext.get(i, ()) if (n or '').split('.')[-1] == 'fixture' for m in [FIX_NAME.search(t)] if m), sy['name']) + scope.append((i, reg, fdir(f) if f.rsplit('/', 1)[-1] == 'conftest.py' else f)) + scope.extend((i, reg, p) for p in extra_scope.get(f, ())) if any(re.search(r'autouse\s*=\s*True', t) for _, t in dtext.get(i, ())): auto.append((i,)) W('fixture_scope', sorted(set(scope))); W('autouse_fixture', sorted(set(auto))) + # A usefixtures marker that decorates NO declaration: a module's `pytestmark = pytest.mark.usefixtures("x")` + # (or a list of marks) applies to every test in that module, and the ini option `usefixtures = x y` (pytest.ini, + # tox.ini, setup.cfg, pyproject.toml) to every test under the directory holding that file. Neither is a + # decoration, so the marker rule never saw them and the fixture's callees reached no test (#1528). Written as + # (path prefix, fixture name): a file for pytestmark, a directory for the ini. + mark_scope = set() + py_test_files = sorted({(g.sym.get(m) or {}).get('file') or '' for m in tm} - {''}) + py_test_files = [f for f in py_test_files if f.endswith('.py')] + def usefixture_names(node): + for c in ast.walk(node): + if isinstance(c, ast.Call) and (getattr(c.func, 'attr', None) or getattr(c.func, 'id', None)) == 'usefixtures': + for a in c.args: + if isinstance(a, ast.Constant) and isinstance(a.value, str): yield a.value + for f in py_test_files: + src = '\n'.join(self.lines(f)) + if 'pytestmark' not in src: continue + try: tree = ast.parse(src) + except (SyntaxError, ValueError): continue + for st in tree.body: + tg = st.targets if isinstance(st, ast.Assign) else [st.target] if isinstance(st, ast.AnnAssign) and st.value else [] + if any(isinstance(t, ast.Name) and t.id == 'pytestmark' for t in tg): + mark_scope |= {(f, n) for n in usefixture_names(st.value)} + dirs = set() + for f in py_test_files: + parts = f.split('/')[:-1] + for k in range(len(parts) + 1): dirs.add('/'.join(parts[:k])) + import configparser + for d in sorted(dirs): + pre = d + '/' if d else '' + for name, sect in (('pytest.ini', 'pytest'), ('tox.ini', 'pytest'), ('setup.cfg', 'tool:pytest'), ('pyproject.toml', None)): + fp = os.path.join(g.repo, pre + name) + if not os.path.isfile(fp): continue + val = None + try: + if sect: + cp = configparser.RawConfigParser(); cp.read(fp) + if cp.has_option(sect, 'usefixtures'): val = cp.get(sect, 'usefixtures') + else: + t = open(fp, errors='replace').read() + blk = re.search(r'^\[tool\.pytest\.ini_options\]\s*$(.*?)(?=^\[|\Z)', t, re.M | re.S) + mv = blk and re.search(r'^usefixtures\s*=\s*(\[[^\]]*\]|"[^"]*"|\'[^\']*\')', blk.group(1), re.M) + if mv: val = ' '.join(re.findall(r'[\'"]([^\'"]*)[\'"]', mv.group(1))) + except (OSError, configparser.Error, UnicodeDecodeError): + val = None + if val: mark_scope |= {(pre, n) for n in re.split(r'[\s,]+', val) if re.fullmatch(r'[A-Za-z_]\w*', n)} + W('usefixtures_scope', sorted(mark_scope)) fsc = set() for i, sy in g.sym.items(): f = sy.get('file') or '' diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index d7f708168..ba27e97a2 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -89,6 +89,14 @@ .decl fixture_scope(fx:symbol, n:symbol, p:symbol) .input fixture_scope .decl file_scope(f:symbol, p:symbol) .input file_scope .decl autouse_fixture(fx:symbol) .input autouse_fixture +// injects(m, fx) the language engine resolved m's request to fixture fx (Python: the runner's own +// visibility, the decorator's `name=`, star imports and `pytest_plugins`) +// injected_fixture(fx) fx is injected by name and runs only for what requests it: not every test of its file +.decl injects(m:symbol, fx:symbol) .input injects +.decl injected_fixture(fx:symbol) .input injected_fixture +// usefixtures_scope(p, n) a usefixtures marker no declaration carries: a module's `pytestmark` (p is that +// file) or the ini option (p is the directory holding the ini file) +.decl usefixtures_scope(p:symbol, n:symbol) .input usefixtures_scope .decl kind(s:symbol, k:symbol) .input kind .decl decl_file(s:symbol, f:symbol) .input decl_file .decl lex_parent(inner:symbol, outer:symbol) .input lex_parent @@ -774,14 +782,16 @@ imports_file(f, h) :- imports_file(f, g), imports_file(g, h), f != h. // ── tests: a test's own body reaches the change, or a fixture its framework runs before it does ─────────────── .decl test_hit(q:symbol, m:symbol, d:number, via:symbol) test_hit(q, m, d, "") :- reach(q, m, d), test_method(m). -test_hit(q, m, d, fx) :- reach(q, fx, d), fixture(fx), owner(fx, t), scope(t, s), member(s, m, _, _), test_method(m). +test_hit(q, m, d, fx) :- reach(q, fx, d), fixture(fx), !injected_fixture(fx), owner(fx, t), scope(t, s), member(s, m, _, _), test_method(m). test_hit(q, m, d, fx) :- reach(q, fx, d), fixture(fx), typ(fx, _, _), member(fx, m, _, _), test_method(m). // a test file is imported as a whole: a class body or a fixture at its top level runs before every test in it — pytest // injects a fixture by parameter name and a the Python validation library model class is built at import, and neither is a call edge // An OWNED fixture is already carried, scoped to its type, by the rule above. Only a fixture that // belongs to no type -- a conftest.py fixture, a module-level setup -- has the file as its scope; a // constructor is a fixture too, and file-keyed it carried a sibling @Nested class's tests. -test_hit(q, m, d, c) :- reach(q, c, d), decl_file(c, f), decl_file(m, f), test_method(m), !test_method(c), (kind(c, "module") ; (fixture(c), !owner(c, _))). +// A fixture injected BY NAME is not one of these: it runs for the tests that request it (uses_fixture below), and a +// module-level `@pytest.fixture` credited to every test in its file counted tests that never ran it (#1527). +test_hit(q, m, d, c) :- reach(q, c, d), decl_file(c, f), decl_file(m, f), test_method(m), !test_method(c), (kind(c, "module") ; (fixture(c), !owner(c, _), !injected_fixture(c))). // a CLASS body runs before the tests that class declares, and before those of a type nested in it or deriving from // it -- which is what scope/2 already ranges over. Keyed on the file instead, one Java source file is one unit, so a // reached @Nested class carried every test of its SIBLING classes too. @@ -792,7 +802,7 @@ test_hit(q, m, d, c) :- reach(q, c, d), kind(c, "class"), scope(c, s), member(s, // JUnit file holds several @Nested classes, and a helper private to one of them is not written for a sibling's // tests. A helper that belongs to no type -- a module-level function, an arrow beside the describe block -- has no // scope narrower than the file, and keeps it. -test_hit(q, m, d, c) :- reach(q, c, d), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), !test_method(c), decl_file(c, f), is_test_file(f). +test_hit(q, m, d, c) :- reach(q, c, d), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), !test_method(c), !injected_fixture(c), decl_file(c, f), is_test_file(f). // A helper no type owns is scoped by the innermost declaration that lexically encloses it: a // `describe` callback holds its own helpers and its own tests, not a sibling block's; a module holds // its file, which is the file rule kept where it was right. TypeScript has no other scope to use -- @@ -821,10 +831,10 @@ helper_block_test(c, m) :- calls(x, c, _, _, _), encloses_test(x), lex_in(x, m), helper_block_test(c, m) :- calls(x, c, _, _, _), !encloses_test(x), !test_method(x), tblock(b, x), !tighter(b, x), lex_in(b, m), test_method(m). .decl helper_has_block(c:symbol) helper_has_block(c) :- helper_block_test(c, _). -test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !encloses_test(c), helper_has_block(c), helper_block_test(c, m), !test_method(c), decl_file(c, f), is_test_file(f). -test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !encloses_test(c), !helper_has_block(c), lex_parent(c, p), lex_in(p, m), test_method(m), !test_method(c), decl_file(c, f), is_test_file(f). +test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !encloses_test(c), helper_has_block(c), helper_block_test(c, m), !test_method(c), !injected_fixture(c), decl_file(c, f), is_test_file(f). +test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !encloses_test(c), !helper_has_block(c), lex_parent(c, p), lex_in(p, m), test_method(m), !test_method(c), !injected_fixture(c), decl_file(c, f), is_test_file(f). // nothing recorded a span for it, so the file is still the only scope known -test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !lex_parent(c, _), !encloses_test(c), decl_file(c, f), decl_file(m, f), test_method(m), !test_method(c), is_test_file(f). +test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !lex_parent(c, _), !encloses_test(c), decl_file(c, f), decl_file(m, f), test_method(m), !test_method(c), !injected_fixture(c), is_test_file(f). .decl encloses_test(c:symbol) encloses_test(c) :- lex_in(c, m), test_method(m). .decl lex_in(outer:symbol, inner:symbol) @@ -847,7 +857,18 @@ uses_fixture(m, fx) :- takes_param(m, n), fixture_scope(fx, n, p), decl_file(m, (test_method(m) ; fixture(m)), owner(fx, t), owner(m, u), scope(t, u). uses_fixture(m, fx) :- takes_param(m, n), fixture_scope(fx, n, p), decl_file(m, f), file_scope(f, p), m != fx, (test_method(m) ; fixture(m)), !owner(fx, _). +// Where the language engine resolved the request, its edge is the answer (the name join above is then not fed for +// that language). A fixture a class declares stays scoped to that class's tests, as above. +uses_fixture(m, fx) :- injects(m, fx), !owner(fx, _). +uses_fixture(m, fx) :- injects(m, fx), owner(fx, t), owner(m, u), scope(t, u). uses_fixture(m, fx) :- dec_literal(m, "usefixtures", n, _, _), fixture_scope(fx, n, p), decl_file(m, f), file_scope(f, p), m != fx. +// The marker on a CLASS is recorded on the type, and applies to every test the class holds, and to those of a class +// nested in it or deriving from it (scope/2). Joined to the declaration it decorates only, it reached no test (#1528). +uses_fixture(m, fx) :- dec_literal(t, "usefixtures", n, _, _), typ(t, _, _), scope(t, s), member(s, m, _, _), test_method(m), + fixture_scope(fx, n, p), decl_file(m, f), file_scope(f, p), m != fx. +// A module's `pytestmark` and the ini `usefixtures` option apply the marker to every test under their path (#1528). +uses_fixture(m, fx) :- usefixtures_scope(p, n), decl_file(m, f), file_scope(f, p), test_method(m), + fixture_scope(fx, n, q), file_scope(f, q), m != fx. uses_fixture(m, fx) :- autouse_fixture(fx), fixture_scope(fx, _, p), decl_file(m, f), file_scope(f, p), test_method(m), m != fx. // a fixture may request another fixture, and then both run before the test uses_fixture(m, g) :- uses_fixture(m, fx), uses_fixture(fx, g), m != g. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 89ead0e11..b2814e723 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -562,7 +562,9 @@ def _test_sets(q, lines=None, rel=None): tm, fx = set(), set() for sid, name, kind, mid, tid in q("SELECT id, name, kind, method_id, type_id FROM symbols WHERE is_test=1"): d = dec.get(sid, ()) - if mid and kind in ('method', 'function') and (any(TEST_DECOR.search(x) for x in d) or (name or '').startswith(('test', 'it'))): + # a pytest fixture named test_* is built for the tests that request it and never collected (#1531) + if mid and kind in ('method', 'function') and (any(TEST_DECOR.search(x) for x in d) or (name or '').startswith(('test', 'it'))) \ + and not any((x or '').split('.')[-1] == 'fixture' for x in d): tm.add(sid) if (tid and not mid) or kind in ('constructor', 'module') or name in FIXTURE_NAMES or any(FIXTURE_DECOR.match((x or '').split('.')[-1]) for x in d): fx.add(sid) @@ -2431,6 +2433,10 @@ def _has_framework_hops(q, at=None, site_file=None): return True if _has(q, 'symbols') and q("SELECT 1 FROM symbols WHERE file LIKE '%conftest.py' AND method_id IS NOT NULL LIMIT 1"): return True + # a fixture injected by name anywhere, conftest or not: the rules credit it only to the tests that request + # it (`injects` / `injected_fixture`), where this port's file-wide rule credits every test in its file (#1527) + if _has(q, 'decorations') and q("SELECT 1 FROM decorations WHERE name = 'fixture' AND (file LIKE '%.py' OR file LIKE '%.pyi') LIMIT 1"): + return True except Exception: return True return False diff --git a/tests/cases/python/fixture-reaches-its-requesters/case.json b/tests/cases/python/fixture-reaches-its-requesters/case.json new file mode 100644 index 000000000..fe970a661 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/case.json @@ -0,0 +1,208 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a fixture star-imported into conftest.py is that conftest's fixture, and reaches the tests that request it (#1537)", + "run": [ + "impact", + "make_repo", + "--tests" + ], + "want": [ + "test_svc.py::test_repo" + ] + }, + { + "why": "a fixture of a module named in pytest_plugins reaches every test that requests it, in any directory (#1537)", + "run": [ + "impact", + "make_service", + "--tests" + ], + "want": [ + "test_svc.py::test_service" + ] + }, + { + "why": "@pytest.fixture(name=...) is requested by the declared name, and the def name requests nothing (#1539)", + "run": [ + "impact", + "make_client", + "--tests" + ], + "want": [ + "test_svc.py::test_client" + ], + "avoid": [ + "test_client_by_def_name" + ] + }, + { + "why": "a module-level fixture reaches the tests in its file that request it, not every test in the file (#1527)", + "run": [ + "impact", + "make_cart", + "--tests" + ], + "want": [ + "test_forms.py::test_cart" + ], + "avoid": [ + "test_unrelated" + ] + }, + { + "why": "an argument the test parametrizes directly is not a request for the fixture of that name (#1529)", + "run": [ + "impact", + "make_user", + "--tests" + ], + "want": [ + "test_plain_user" + ], + "avoid": [ + "test_direct" + ] + }, + { + "why": "a usefixtures marker on a class reaches the tests the class holds, and not a sibling class's (#1528)", + "run": [ + "impact", + "start_server", + "--tests" + ], + "want": [ + "test_marker.py (1) via server: TestWithServer::test_uses_server" + ], + "avoid": [ + "test_no_server" + ] + }, + { + "why": "a sibling directory's conftest, star import and all, is not visible here", + "run": [ + "impact", + "make_widget", + "--tests" + ], + "want": [ + "test_other.py::test_widget" + ], + "avoid": [ + "test_widget_here" + ] + }, + { + "why": "an argument handed to the fixture with indirect=True still requests it (control for #1529)", + "run": [ + "impact", + "make_user", + "--tests" + ], + "want": [ + "test_indirect" + ] + }, + { + "why": "a fixture a class declares wins over the module fixture it overrides for that class's tests (#1512)", + "run": [ + "impact", + "module_account", + "--tests" + ], + "want": [ + "TestPlain::test_no_override", + "test_accounts.py::test_module_level" + ], + "avoid": [ + "test_in_class" + ] + }, + { + "why": "the class fixture serves its own class's tests only (control for #1512)", + "run": [ + "impact", + "class_account", + "--tests" + ], + "want": [ + "TestOverride::test_in_class" + ], + "avoid": [ + "test_no_override", + "test_module_level" + ] + }, + { + "why": "a fixture named test_* is not a test, and conftest.py is never collected (#1531)", + "run": [ + "impact", + "load_image", + "--tests" + ], + "want": [ + "test_images.py::test_size" + ], + "avoid": [ + "conftest.py::test_image" + ] + }, + { + "why": "a module's pytestmark usefixtures reaches the tests of that module only (#1528)", + "run": [ + "impact", + "make_clean_dir", + "--tests" + ], + "want": [ + "test_pytestmark.py::test_module_marker" + ], + "avoid": [ + "test_uses_server", + "test_repo" + ] + }, + { + "why": "the ini usefixtures option reaches every test that can see the fixture, and not a sibling directory's (#1528)", + "run": [ + "impact", + "load_config", + "--tests" + ], + "want": [ + "test_pytestmark.py::test_module_marker", + "test_svc.py::test_repo" + ], + "avoid": [ + "test_other.py::test_widget" + ] + }, + { + "why": "a pytestmark marker naming a pytest_plugins module's fixture reaches the module's tests (#1528, #1537)", + "run": [ + "impact", + "make_service", + "--tests" + ], + "want": [ + "test_plugin_marker.py::test_marks_plugin_fixture" + ] + }, + { + "why": "a marker naming a star-imported fixture reaches the tests below that conftest, not a sibling directory's (#1537)", + "run": [ + "impact", + "make_widget", + "--tests" + ], + "want": [ + "test_other.py::test_marks_widget" + ], + "avoid": [ + "test_pytestmark.py" + ] + } + ] +} diff --git a/tests/cases/python/fixture-reaches-its-requesters/conftest.py b/tests/cases/python/fixture-reaches-its-requesters/conftest.py new file mode 100644 index 000000000..979a9c965 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/conftest.py @@ -0,0 +1 @@ +pytest_plugins = ["support.plugin"] diff --git a/tests/cases/python/fixture-reaches-its-requesters/other/__init__.py b/tests/cases/python/fixture-reaches-its-requesters/other/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/fixture-reaches-its-requesters/other/conftest.py b/tests/cases/python/fixture-reaches-its-requesters/other/conftest.py new file mode 100644 index 000000000..34f198344 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/other/conftest.py @@ -0,0 +1 @@ +from other.more import * diff --git a/tests/cases/python/fixture-reaches-its-requesters/other/more.py b/tests/cases/python/fixture-reaches-its-requesters/other/more.py new file mode 100644 index 000000000..3feed055f --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/other/more.py @@ -0,0 +1,8 @@ +import pytest + +from shop.svc import make_widget + + +@pytest.fixture +def widget(): + return make_widget() diff --git a/tests/cases/python/fixture-reaches-its-requesters/other/test_other.py b/tests/cases/python/fixture-reaches-its-requesters/other/test_other.py new file mode 100644 index 000000000..7a3ce1171 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/other/test_other.py @@ -0,0 +1,9 @@ +import pytest + +def test_widget(widget): + assert widget + + +@pytest.mark.usefixtures("widget") +def test_marks_widget(): + assert True diff --git a/tests/cases/python/fixture-reaches-its-requesters/pytest.ini b/tests/cases/python/fixture-reaches-its-requesters/pytest.ini new file mode 100644 index 000000000..1549fbcc7 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +usefixtures = config_loaded diff --git a/tests/cases/python/fixture-reaches-its-requesters/shop/__init__.py b/tests/cases/python/fixture-reaches-its-requesters/shop/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/fixture-reaches-its-requesters/shop/svc.py b/tests/cases/python/fixture-reaches-its-requesters/shop/svc.py new file mode 100644 index 000000000..05557ccce --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/shop/svc.py @@ -0,0 +1,46 @@ +def make_repo(): + return "repo" + + +def make_service(): + return "service" + + +def make_client(): + return "client" + + +def make_user(name): + return {"name": name} + + +def make_cart(items): + return list(items) + + +def make_widget(): + return "widget" + + +def start_server(): + return "server" + + +def module_account(): + return "module" + + +def class_account(): + return "class" + + +def load_image(): + return "image" + + +def make_clean_dir(): + return "dir" + + +def load_config(): + return "config" diff --git a/tests/cases/python/fixture-reaches-its-requesters/support/__init__.py b/tests/cases/python/fixture-reaches-its-requesters/support/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/fixture-reaches-its-requesters/support/plugin.py b/tests/cases/python/fixture-reaches-its-requesters/support/plugin.py new file mode 100644 index 000000000..6d0eec7de --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/support/plugin.py @@ -0,0 +1,8 @@ +import pytest + +from shop.svc import make_service + + +@pytest.fixture +def service(): + return make_service() diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/__init__.py b/tests/cases/python/fixture-reaches-its-requesters/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/conftest.py b/tests/cases/python/fixture-reaches-its-requesters/tests/conftest.py new file mode 100644 index 000000000..9dde62b78 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/conftest.py @@ -0,0 +1,35 @@ +import pytest + +from shop.svc import load_config, load_image, make_clean_dir, make_client, make_user, start_server + +from .fixtures import * + + +@pytest.fixture(name="client") +def client_fixture(): + return make_client() + + +@pytest.fixture +def user(): + return make_user("default") + + +@pytest.fixture +def server(): + return start_server() + + +@pytest.fixture +def test_image(): + return load_image() + + +@pytest.fixture +def cleandir(): + return make_clean_dir() + + +@pytest.fixture +def config_loaded(): + return load_config() diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/fixtures.py b/tests/cases/python/fixture-reaches-its-requesters/tests/fixtures.py new file mode 100644 index 000000000..910df7999 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/fixtures.py @@ -0,0 +1,8 @@ +import pytest + +from shop.svc import make_repo + + +@pytest.fixture +def repo(): + return make_repo() diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_accounts.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_accounts.py new file mode 100644 index 000000000..d5be4e0ce --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_accounts.py @@ -0,0 +1,26 @@ +import pytest + +from shop.svc import class_account, module_account + + +@pytest.fixture +def account(): + return module_account() + + +class TestOverride: + @pytest.fixture + def account(self): + return class_account() + + def test_in_class(self, account): + assert account == "class" + + +class TestPlain: + def test_no_override(self, account): + assert account == "module" + + +def test_module_level(account): + assert account == "module" diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_forms.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_forms.py new file mode 100644 index 000000000..4d7cc7d9e --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_forms.py @@ -0,0 +1,16 @@ +import pytest + +from shop.svc import make_cart + + +@pytest.fixture +def cart(): + return make_cart([1]) + + +def test_cart(cart): + assert cart == [1] + + +def test_unrelated(): + assert True diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_images.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_images.py new file mode 100644 index 000000000..519bdac21 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_images.py @@ -0,0 +1,2 @@ +def test_size(test_image): + assert test_image diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_marker.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_marker.py new file mode 100644 index 000000000..5c3c8cfda --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_marker.py @@ -0,0 +1,12 @@ +import pytest + + +@pytest.mark.usefixtures("server") +class TestWithServer: + def test_uses_server(self): + assert True + + +class TestWithoutServer: + def test_no_server(self): + assert True diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_plugin_marker.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_plugin_marker.py new file mode 100644 index 000000000..010810341 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_plugin_marker.py @@ -0,0 +1,7 @@ +import pytest + +pytestmark = pytest.mark.usefixtures("service") + + +def test_marks_plugin_fixture(): + assert True diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_pytestmark.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_pytestmark.py new file mode 100644 index 000000000..b3af9c759 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_pytestmark.py @@ -0,0 +1,7 @@ +import pytest + +pytestmark = pytest.mark.usefixtures("cleandir") + + +def test_module_marker(): + assert True diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_svc.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_svc.py new file mode 100644 index 000000000..2971a8c58 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_svc.py @@ -0,0 +1,35 @@ +import pytest + + +def test_repo(repo): + assert repo + + +def test_service(service): + assert service + + +def test_client(client): + assert client + + +def test_client_by_def_name(client_fixture): + assert client_fixture + + +@pytest.mark.parametrize("user", [{"name": "direct"}]) +def test_direct(user): + assert user["name"] == "direct" + + +@pytest.mark.parametrize("user", ["other"], indirect=True) +def test_indirect(user): + assert user + + +def test_plain_user(user): + assert user["name"] == "default" + + +def test_widget_here(widget): + assert widget