Passer un paramètre à une fonction de fixation

114

J'utilise py.test pour tester du code DLL enveloppé dans une classe python MyTester. À des fins de validation, je dois enregistrer certaines données de test pendant les tests et faire plus de traitement par la suite. Comme j'ai de nombreux fichiers de test _..., je souhaite réutiliser la création d'objet testeur (instance de MyTester) pour la plupart de mes tests.

Comme l'objet testeur est celui qui a obtenu les références aux variables et fonctions de la DLL, je dois transmettre une liste des variables de la DLL à l'objet testeur pour chacun des fichiers de test (les variables à enregistrer sont les mêmes pour un test_ .. . fichier). Le contenu de la liste est utilisé pour enregistrer les données spécifiées.

Mon idée est de le faire comme ceci:

import pytest

class MyTester():
    def __init__(self, arg = ["var0", "var1"]):
        self.arg = arg
        # self.use_arg_to_init_logging_part()

    def dothis(self):
        print "this"

    def dothat(self):
        print "that"

# located in conftest.py (because other test will reuse it)

@pytest.fixture()
def tester(request):
    """ create tester object """
    # how to use the list below for arg?
    _tester = MyTester()
    return _tester

# located in test_...py

# @pytest.mark.usefixtures("tester") 
class TestIt():

    # def __init__(self):
    #     self.args_for_tester = ["var1", "var2"]
    #     # how to pass this list to the tester fixture?

    def test_tc1(self, tester):
       tester.dothis()
       assert 0 # for demo purpose

    def test_tc2(self, tester):
       tester.dothat()
       assert 0 # for demo purpose

Est-il possible d'y parvenir comme ça ou y a-t-il encore une manière plus élégante?

Habituellement, je pourrais le faire pour chaque méthode de test avec une sorte de fonction de configuration (style xUnit). Mais je veux gagner une sorte de réutilisation. Est-ce que quelqu'un sait si cela est possible avec des luminaires?

Je sais que je peux faire quelque chose comme ça: (à partir de la documentation)

@pytest.fixture(scope="module", params=["merlinux.eu", "mail.python.org"])

Mais j'ai besoin de la paramétrisation directement dans le module de test. Est-il possible d'accéder à l'attribut params de l'appareil depuis le module de test?

maggie
la source

Réponses:

101

Mise à jour: Étant donné que c'est la réponse acceptée à cette question et qu'elle est toujours votée parfois, je devrais ajouter une mise à jour. Bien que ma réponse originale (ci-dessous) était le seul moyen de le faire dans les anciennes versions de pytest, comme d' autres l' ont noté, pytest prend désormais en charge la paramétrisation indirecte des appareils. Par exemple, vous pouvez faire quelque chose comme ceci (via @imiric):

# test_parameterized_fixture.py
import pytest

class MyTester:
    def __init__(self, x):
        self.x = x

    def dothis(self):
        assert self.x

@pytest.fixture
def tester(request):
    """Create tester object"""
    return MyTester(request.param)


class TestIt:
    @pytest.mark.parametrize('tester', [True, False], indirect=['tester'])
    def test_tc1(self, tester):
       tester.dothis()
       assert 1
$ pytest -v test_parameterized_fixture.py
================================================================================= test session starts =================================================================================
platform cygwin -- Python 3.6.8, pytest-5.3.1, py-1.8.0, pluggy-0.13.1 -- /usr/bin/python3
cachedir: .pytest_cache
rootdir: .
collected 2 items

test_parameterized_fixture.py::TestIt::test_tc1[True] PASSED                                                                                                                    [ 50%]
test_parameterized_fixture.py::TestIt::test_tc1[False] FAILED

Cependant, bien que cette forme de paramétrisation indirecte soit explicite, comme le souligne @Yukihiko Shinoda , elle prend désormais en charge une forme de paramétrisation indirecte implicite (bien que je n'ai pas trouvé de référence évidente à cela dans la documentation officielle):

# test_parameterized_fixture2.py
import pytest

class MyTester:
    def __init__(self, x):
        self.x = x

    def dothis(self):
        assert self.x

@pytest.fixture
def tester(tester_arg):
    """Create tester object"""
    return MyTester(tester_arg)


class TestIt:
    @pytest.mark.parametrize('tester_arg', [True, False])
    def test_tc1(self, tester):
       tester.dothis()
       assert 1
$ pytest -v test_parameterized_fixture2.py
================================================================================= test session starts =================================================================================
platform cygwin -- Python 3.6.8, pytest-5.3.1, py-1.8.0, pluggy-0.13.1 -- /usr/bin/python3
cachedir: .pytest_cache
rootdir: .
collected 2 items

test_parameterized_fixture2.py::TestIt::test_tc1[True] PASSED                                                                                                                   [ 50%]
test_parameterized_fixture2.py::TestIt::test_tc1[False] FAILED

Je ne sais pas exactement quelle est la sémantique de cette forme, mais il semble que cela pytest.mark.parametrizereconnaisse que bien que la test_tc1méthode ne prenne pas d'argument nommé tester_arg, l' testerappareil qu'elle utilise le fait, donc il transmet l'argument paramétré à travers l' testerappareil.


J'ai eu un problème similaire - j'ai un appareil appelé test_package, et je voulais plus tard pouvoir passer un argument optionnel à cet appareil lors de son exécution dans des tests spécifiques. Par exemple:

@pytest.fixture()
def test_package(request, version='1.0'):
    ...
    request.addfinalizer(fin)
    ...
    return package

(Peu importe à ces fins ce que fait l'appareil ou le type d'objet renvoyé package ).

Il serait alors souhaitable d'utiliser d'une manière ou d'une autre cet appareil dans une fonction de test de telle sorte que je puisse également spécifier le version argument de cet appareil à utiliser avec ce test. Ce n'est actuellement pas possible, mais pourrait constituer une fonctionnalité intéressante.

En attendant, il était assez facile de faire en sorte que mon appareil renvoie simplement une fonction qui effectue tout le travail effectué précédemment par l'appareil, mais me permet de spécifier l' versionargument:

@pytest.fixture()
def test_package(request):
    def make_test_package(version='1.0'):
        ...
        request.addfinalizer(fin)
        ...
        return test_package

    return make_test_package

Maintenant, je peux utiliser ceci dans ma fonction de test comme:

def test_install_package(test_package):
    package = test_package(version='1.1')
    ...
    assert ...

etc.

La solution tentée par l'OP allait dans la bonne direction, et comme le suggère la réponse de @ hpk42 , le MyTester.__init__pourrait simplement stocker une référence à la demande comme:

class MyTester(object):
    def __init__(self, request, arg=["var0", "var1"]):
        self.request = request
        self.arg = arg
        # self.use_arg_to_init_logging_part()

    def dothis(self):
        print "this"

    def dothat(self):
        print "that"

Ensuite, utilisez ceci pour implémenter l'appareil comme:

@pytest.fixture()
def tester(request):
    """ create tester object """
    # how to use the list below for arg?
    _tester = MyTester(request)
    return _tester

Si vous le souhaitez, la MyTesterclasse pourrait être un peu restructurée afin que son .argsattribut puisse être mis à jour après sa création, afin de modifier le comportement des tests individuels.

Iguananaut
la source
Merci pour l'indication avec la fonction à l'intérieur de l'appareil. Cela a pris du temps avant de pouvoir travailler à nouveau dessus, mais c'est assez utile!
maggie
2
Un joli petit article sur ce sujet: alysivji.github.io/pytest-fixures-with-function-arguments.html
maggie
n'obtenez-vous pas une erreur disant: "Les appareils ne sont pas destinés à être appelés directement, mais sont créés automatiquement lorsque les fonctions de test les demandent comme paramètres."?
nz_21
153

Ceci est en fait pris en charge nativement dans py.test via une paramétrisation indirecte .

Dans votre cas, vous auriez:

@pytest.fixture
def tester(request):
    """Create tester object"""
    return MyTester(request.param)


class TestIt:
    @pytest.mark.parametrize('tester', [['var1', 'var2']], indirect=True)
    def test_tc1(self, tester):
       tester.dothis()
       assert 1
imirique
la source
Ah, c'est assez sympa (je pense que votre exemple est peut-être un peu obsolète - il diffère des exemples dans la documentation officielle). Est-ce une fonctionnalité relativement nouvelle? Je ne l'ai jamais rencontré auparavant. C'est aussi une bonne solution au problème - à certains égards mieux que ma réponse.
Iguananaut
2
J'ai essayé d'utiliser cette solution mais j'avais des problèmes pour passer plusieurs paramètres ou utiliser des noms de variables autres que request. J'ai fini par utiliser la solution de @Iguananaut.
Victor Uriarte
42
Cela devrait être la réponse acceptée. La documentation officielle de l' indirectargument du mot - clé est certes rare et peu conviviale, ce qui explique probablement l'obscurité de cette technique essentielle. J'ai parcouru le site py.test à plusieurs reprises à la recherche de cette fonctionnalité - seulement pour devenir vide, plus ancien et embrouillé. L'amertume est un lieu connu sous le nom d'intégration continue. Merci Odin pour Stackoverflow.
Cecil Curry
1
Notez que cette méthode modifie le nom de vos tests pour inclure le paramètre, qui peut être souhaité ou non. test_tc1devient test_tc1[tester0].
jjj
1
Alors indirect=Trueremet les paramètres à tous les appareils appelés, non? Parce que la documentation nomme explicitement les appareils pour la paramétrisation indirecte, par exemple pour un appareil nommé x:indirect=['x']
winklerrr
11

Vous pouvez accéder au module / à la classe / à la fonction de demande à partir des fonctions de fixation (et donc à partir de votre classe Tester), voir interagir avec la demande de contexte de test à partir d'une fonction de fixation . Ainsi, vous pouvez déclarer certains paramètres sur une classe ou un module et l'appareil de test peut le récupérer.

hpk42
la source
3
Je sais que je peux faire quelque chose comme ceci: (à partir de la documentation) @ pytest.fixture (scope = "module", params = ["merlinux.eu", "mail.python.org"]) Mais je dois le faire dans le module de test. Comment puis-je ajouter dynamiquement des paramètres aux appareils?
maggie
2
Le but n'est pas d'avoir à interagir avec la demande de contexte de test à partir d'une fonction de fixation, mais d'avoir une manière bien définie de passer des arguments à une fonction de fixation. La fonction Fixture ne devrait pas avoir à connaître un type de contexte de test demandant juste pour pouvoir recevoir des arguments avec des noms convenus. Par exemple, on aimerait pouvoir écrire @fixture def my_fixture(request)et ensuite @pass_args(arg1=..., arg2=...) def test(my_fixture)et obtenir ces arguments my_fixture()comme ceci arg1 = request.arg1, arg2 = request.arg2. Est-ce que quelque chose comme ça est possible dans py.test maintenant?
Piotr Dobrogost
7

Je n'ai trouvé aucun document, cependant, il semble fonctionner dans la dernière version de pytest.

@pytest.fixture
def tester(tester_arg):
    """Create tester object"""
    return MyTester(tester_arg)


class TestIt:
    @pytest.mark.parametrize('tester_arg', [['var1', 'var2']])
    def test_tc1(self, tester):
       tester.dothis()
       assert 1
Yukihiko Shinoda
la source
Merci de l'avoir signalé - cela semble être la solution la plus propre de toutes. Je ne pense pas que cela était possible dans les versions précédentes, mais il est clair que c'est maintenant le cas. Savez-vous si ce formulaire est mentionné quelque part dans la documentation officielle ? Je n'ai rien trouvé de tel, mais cela fonctionne clairement. J'ai mis à jour ma réponse pour inclure cet exemple, merci.
Iguananaut le
1
Je pense que ce ne sera pas possible dans la fonctionnalité, si vous jetez un coup d'œil à github.com/pytest-dev/pytest/issues/5712 et aux relations publiques (fusionnées) associées.
Nadège le
1
Pour clarifier, @ Maspe36 indique que le PR lié par a Nadègeété annulé. Ainsi, cette fonctionnalité non documentée (je pense qu'elle est toujours non documentée?) Vit toujours.
blthayer le
6

Pour améliorer un peu la réponse d'imiric : une autre manière élégante de résoudre ce problème est de créer des "fixtures de paramètres". Personnellement, je le préfère à la indirectfonctionnalité de pytest. Cette fonctionnalité est disponible à partir de pytest_cases, et l'idée originale a été suggérée par Sup3rGeo .

import pytest
from pytest_cases import param_fixture

# create a single parameter fixture
var = param_fixture("var", [['var1', 'var2']], ids=str)

@pytest.fixture
def tester(var):
    """Create tester object"""
    return MyTester(var)

class TestIt:
    def test_tc1(self, tester):
       tester.dothis()
       assert 1

Notez que pytest-casesfournit également @pytest_fixture_plusque vous permettre d'utiliser les marques de paramétrisation sur vos appareils, et @cases_dataqui vous permettent à la source de vos paramètres de fonctions dans un module distinct. Voir doc pour plus de détails. Je suis l'auteur au fait;)

smarie
la source
1
Cela semble fonctionner maintenant aussi dans pytest ordinaire (j'ai la version 5.3.1). Autrement dit, j'ai pu faire fonctionner cela sans param_fixture. Voyez cette réponse . Je n'ai cependant pas trouvé d'exemple comme celui-ci dans la documentation; Sais tu quelque chose à propos de cela?
Iguananaut le
merci pour l'info et le lien! Je n'avais aucune idée que c'était faisable. Attendons une documentation officielle pour voir ce qu'ils ont en tête.
smarie le
2

J'ai fait un décorateur amusant qui permet d'écrire des accessoires comme celui-ci:

@fixture_taking_arguments
def dog(request, /, name, age=69):
    return f"{name} the dog aged {age}"

Ici, à gauche de /vous avez d'autres appareils, et à droite vous avez des paramètres qui sont fournis en utilisant:

@dog.arguments("Buddy", age=7)
def test_with_dog(dog):
    assert dog == "Buddy the dog aged 7"

Cela fonctionne de la même manière que les arguments de fonction. Si vous ne fournissez pas l' ageargument, celui par défaut,, 69est utilisé à la place. si vous ne fournissez pas nameou omettez le dog.argumentsdécorateur, vous obtenez le régulier TypeError: dog() missing 1 required positional argument: 'name'. Si vous avez un autre appareil qui prend la disputename , il n'est pas en conflit avec celui-ci.

Les appareils Async sont également pris en charge.

De plus, cela vous donne un bon plan d'installation:

$ pytest test_dogs_and_owners.py --setup-plan

SETUP    F dog['Buddy', age=7]
...
SETUP    F dog['Champion']
SETUP    F owner (fixtures used: dog)['John Travolta']

Un exemple complet:

from plugin import fixture_taking_arguments

@fixture_taking_arguments
def dog(request, /, name, age=69):
    return f"{name} the dog aged {age}"


@fixture_taking_arguments
def owner(request, dog, /, name="John Doe"):
    yield f"{name}, owner of {dog}"


@dog.arguments("Buddy", age=7)
def test_with_dog(dog):
    assert dog == "Buddy the dog aged 7"


@dog.arguments("Champion")
class TestChampion:
    def test_with_dog(self, dog):
        assert dog == "Champion the dog aged 69"

    def test_with_default_owner(self, owner, dog):
        assert owner == "John Doe, owner of Champion the dog aged 69"
        assert dog == "Champion the dog aged 69"

    @owner.arguments("John Travolta")
    def test_with_named_owner(self, owner):
        assert owner == "John Travolta, owner of Champion the dog aged 69"

Le code pour le décorateur:

import pytest
from dataclasses import dataclass
from functools import wraps
from inspect import signature, Parameter, isgeneratorfunction, iscoroutinefunction, isasyncgenfunction
from itertools import zip_longest, chain


_NOTHING = object()


def _omittable_parentheses_decorator(decorator):
    @wraps(decorator)
    def wrapper(*args, **kwargs):
        if not kwargs and len(args) == 1 and callable(args[0]):
            return decorator()(args[0])
        else:
            return decorator(*args, **kwargs)
    return wrapper


@dataclass
class _ArgsKwargs:
    args: ...
    kwargs: ...

    def __repr__(self):
        return ", ".join(chain(
               (repr(v) for v in self.args), 
               (f"{k}={v!r}" for k, v in self.kwargs.items())))


def _flatten_arguments(sig, args, kwargs):
    assert len(sig.parameters) == len(args) + len(kwargs)
    for name, arg in zip_longest(sig.parameters, args, fillvalue=_NOTHING):
        yield arg if arg is not _NOTHING else kwargs[name]


def _get_actual_args_kwargs(sig, args, kwargs):
    request = kwargs["request"]
    try:
        request_args, request_kwargs = request.param.args, request.param.kwargs
    except AttributeError:
        request_args, request_kwargs = (), {}
    return tuple(_flatten_arguments(sig, args, kwargs)) + request_args, request_kwargs


@_omittable_parentheses_decorator
def fixture_taking_arguments(*pytest_fixture_args, **pytest_fixture_kwargs):
    def decorator(func):
        original_signature = signature(func)

        def new_parameters():
            for param in original_signature.parameters.values():
                if param.kind == Parameter.POSITIONAL_ONLY:
                    yield param.replace(kind=Parameter.POSITIONAL_OR_KEYWORD)

        new_signature = original_signature.replace(parameters=list(new_parameters()))

        if "request" not in new_signature.parameters:
            raise AttributeError("Target function must have positional-only argument `request`")

        is_async_generator = isasyncgenfunction(func)
        is_async = is_async_generator or iscoroutinefunction(func)
        is_generator = isgeneratorfunction(func)

        if is_async:
            @wraps(func)
            async def wrapper(*args, **kwargs):
                args, kwargs = _get_actual_args_kwargs(new_signature, args, kwargs)
                if is_async_generator:
                    async for result in func(*args, **kwargs):
                        yield result
                else:
                    yield await func(*args, **kwargs)
        else:
            @wraps(func)
            def wrapper(*args, **kwargs):
                args, kwargs = _get_actual_args_kwargs(new_signature, args, kwargs)
                if is_generator:
                    yield from func(*args, **kwargs)
                else:
                    yield func(*args, **kwargs)

        wrapper.__signature__ = new_signature
        fixture = pytest.fixture(*pytest_fixture_args, **pytest_fixture_kwargs)(wrapper)
        fixture_name = pytest_fixture_kwargs.get("name", fixture.__name__)

        def parametrizer(*args, **kwargs):
            return pytest.mark.parametrize(fixture_name, [_ArgsKwargs(args, kwargs)], indirect=True)

        fixture.arguments = parametrizer

        return fixture
    return decorator
écureuil
la source