Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ Floris Bruynooghe
Frank Hoffmann
Fraser Stark
Freya Bruhin
frodleTian
Gabriel Landau
Gabriel Reis
Gang Eun Lee
Expand Down
1 change: 1 addition & 0 deletions changelog/8208.doc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added an example of a plugin that manages a resource shared by test parametrization and fixtures.
34 changes: 34 additions & 0 deletions doc/en/example/parametrize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------------------------------------------

Expand Down
42 changes: 42 additions & 0 deletions testing/example_scripts/parametrize_resources/conftest.py
Original file line number Diff line number Diff line change
@@ -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');
"""
)
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# mypy: allow-untyped-defs
from __future__ import annotations


def test_greeting(greeting):
assert greeting in {"Hello", "Bonjour"}
49 changes: 49 additions & 0 deletions testing/python/metafunc.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import dataclasses
import itertools
import re
import sqlite3
import sys
import textwrap
from typing import Any
Expand Down Expand Up @@ -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(
"""
Expand Down
Loading