diff --git a/AUTHORS b/AUTHORS index bd1e27cb37d..e7835c9cf15 100644 --- a/AUTHORS +++ b/AUTHORS @@ -187,6 +187,7 @@ Floris Bruynooghe Frank Hoffmann Fraser Stark Freya Bruhin +frodleTian Gabriel Landau Gabriel Reis Gang Eun Lee diff --git a/changelog/8208.doc.rst b/changelog/8208.doc.rst new file mode 100644 index 00000000000..11ffc05aa42 --- /dev/null +++ b/changelog/8208.doc.rst @@ -0,0 +1 @@ +Added an example of a plugin that manages a resource shared by test parametrization and fixtures. diff --git a/doc/en/example/parametrize.rst b/doc/en/example/parametrize.rst index f32358ac255..18b4095e3aa 100644 --- a/doc/en/example/parametrize.rst +++ b/doc/en/example/parametrize.rst @@ -349,6 +349,40 @@ And then when we run the test: The first invocation with ``db == "DB1"`` passed while the second with ``db == "DB2"`` failed. Our ``db`` fixture function has instantiated each of the DB values during the setup phase while the ``pytest_generate_tests`` generated two according calls to the ``test_db_initialized`` during the collection phase. +Managing a resource needed for parametrization +--------------------------------------------------- + +Usually, resources should be created in fixtures, as in the previous example. +However, a plugin may need a resource during collection to discover which +parameters are available. In that case, it can create the resource in +:hook:`pytest_configure` and release it in :hook:`pytest_unconfigure`. + +The following plugin uses an in-memory SQLite database to discover languages +during collection. The same connection supplies the greeting for each language +when the parametrized fixture runs. No external database server is needed. + +.. literalinclude:: ../../../testing/example_scripts/parametrize_resources/conftest.py + :caption: conftest.py + +Registering a plugin instance lets its hook implementations and fixture methods +share the connection through ``self``. The plugin is registered before the table +is populated so that its cleanup hook also runs if populating the database fails. +The cleanup hook runs after fixture teardown, including when tests fail or when +using :option:`--collect-only`. + +``pytest_generate_tests`` only parametrizes tests requesting ``greeting``. +With ``indirect=True``, each language is passed to the fixture as +``request.param`` instead of being passed directly to the test function. + +.. literalinclude:: ../../../testing/example_scripts/parametrize_resources/test_greetings.py + :caption: test_greetings.py + +Save both files in the same directory and run ``pytest -v``. The test runs twice, +with the IDs ``test_greeting[english]`` and ``test_greeting[french]``. +The connection is created once per pytest invocation and closed when pytest +unconfigures the plugin. It is also created for collection-only runs, so use +this pattern only when discovering parameters requires the resource. + Indirect parametrization --------------------------------------------------- diff --git a/testing/example_scripts/parametrize_resources/conftest.py b/testing/example_scripts/parametrize_resources/conftest.py new file mode 100644 index 00000000000..6c11623a54b --- /dev/null +++ b/testing/example_scripts/parametrize_resources/conftest.py @@ -0,0 +1,42 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +import sqlite3 + +import pytest + + +class GreetingPlugin: + def __init__(self): + self.connection = sqlite3.connect(":memory:") + + def pytest_generate_tests(self, metafunc): + if "greeting" in metafunc.fixturenames: + languages = [ + row[0] + for row in self.connection.execute( + "SELECT language FROM greetings ORDER BY language" + ) + ] + metafunc.parametrize("greeting", languages, indirect=True) + + @pytest.fixture + def greeting(self, request): + (message,) = self.connection.execute( + "SELECT message FROM greetings WHERE language = ?", (request.param,) + ).fetchone() + return message + + def pytest_unconfigure(self): + self.connection.close() + + +def pytest_configure(config): + plugin = GreetingPlugin() + config.pluginmanager.register(plugin, "greetings") + plugin.connection.executescript( + """ + CREATE TABLE greetings (language TEXT, message TEXT); + INSERT INTO greetings VALUES ('english', 'Hello'), ('french', 'Bonjour'); + """ + ) diff --git a/testing/example_scripts/parametrize_resources/pytest.ini b/testing/example_scripts/parametrize_resources/pytest.ini new file mode 100644 index 00000000000..e69de29bb2d diff --git a/testing/example_scripts/parametrize_resources/test_greetings.py b/testing/example_scripts/parametrize_resources/test_greetings.py new file mode 100644 index 00000000000..48abc0645ac --- /dev/null +++ b/testing/example_scripts/parametrize_resources/test_greetings.py @@ -0,0 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + +def test_greeting(greeting): + assert greeting in {"Hello", "Bonjour"} diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 685ce506663..86bc17bcbc7 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -6,6 +6,7 @@ import dataclasses import itertools import re +import sqlite3 import sys import textwrap from typing import Any @@ -1275,6 +1276,54 @@ def data_generator() -> Iterator[int]: class TestMetafuncFunctional: + @pytest.mark.parametrize("mode", ["run", "collect", "fail", "configure-error"]) + def test_resource_plugin_example( + self, pytester: Pytester, monkeypatch: pytest.MonkeyPatch, mode: str + ) -> None: + pytester.copy_example("parametrize_resources") + pytester.makepyfile(test_unrelated="def test_unrelated(): pass") + connections = [] + connect = sqlite3.connect + + def track_connection(*args, **kwargs): + connection = connect(*args, **kwargs) + connections.append(connection) + if mode == "configure-error": + connection.execute("CREATE TABLE greetings (language TEXT)") + return connection + + monkeypatch.setattr(sqlite3, "connect", track_connection) + if mode == "fail": + pytester.makepyfile( + test_greetings="def test_greeting(greeting): assert False" + ) + + args = ["-v"] + if mode == "collect": + args.append("--collect-only") + result = pytester.runpytest(*args) + + if mode == "run": + result.assert_outcomes(passed=3) + result.stdout.fnmatch_lines( + [ + "*test_greeting[[]english[]] PASSED*", + "*test_greeting[[]french[]] PASSED*", + ] + ) + elif mode == "collect": + assert result.ret == pytest.ExitCode.OK + result.stdout.fnmatch_lines(["*3 tests collected*"]) + elif mode == "fail": + result.assert_outcomes(passed=1, failed=2) + else: + assert result.ret == pytest.ExitCode.INTERNAL_ERROR + result.stderr.fnmatch_lines(["*table greetings already exists*"]) + + assert len(connections) == 1 + with pytest.raises(sqlite3.ProgrammingError, match="closed database"): + connections[0].execute("SELECT 1") + def test_attributes(self, pytester: Pytester) -> None: p = pytester.makepyfile( """