Pasar un parámetro a una función de dispositivo

114

Estoy usando py.test para probar algún código DLL envuelto en una clase Python MyTester. Para fines de validación, necesito registrar algunos datos de prueba durante las pruebas y hacer más procesamiento después. Como tengo muchos archivos de prueba _..., quiero reutilizar la creación del objeto de prueba (instancia de MyTester) para la mayoría de mis pruebas.

Como el objeto de prueba es el que tiene las referencias a las variables y funciones de la DLL, necesito pasar una lista de las variables de la DLL al objeto de prueba para cada uno de los archivos de prueba (las variables que se registrarán son las mismas para una prueba_ .. . expediente). El contenido de la lista se utilizará para registrar los datos especificados.

Mi idea es hacerlo de alguna manera así:

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

¿Es posible lograrlo así o hay una forma aún más elegante?

Por lo general, podría hacerlo para cada método de prueba con algún tipo de función de configuración (estilo xUnit). Pero quiero obtener algún tipo de reutilización. ¿Alguien sabe si esto es posible con los accesorios?

Sé que puedo hacer algo como esto: (de los documentos)

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

Pero necesito la parametrización directamente en el módulo de prueba. ¿Es posible acceder al atributo params del dispositivo desde el módulo de prueba?

maggie
fuente

Respuestas:

101

Actualización: dado que esta es la respuesta aceptada a esta pregunta y a veces todavía se vota a favor, debería agregar una actualización. Aunque mi respuesta original (a continuación) era la única forma de hacer esto en versiones anteriores de pytest, ya que otros han notado que pytest ahora admite la parametrización indirecta de accesorios. Por ejemplo, puede hacer algo como esto (a través de @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

Sin embargo, aunque esta forma de parametrización indirecta es explícita, como @Yukihiko Shinoda señala que ahora es compatible con una forma de parametrización indirecta implícita (aunque no pude encontrar ninguna referencia obvia a esto en los documentos oficiales):

# 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

No sé exactamente cuál es la semántica de esta forma, pero parece que pytest.mark.parametrizereconoce que aunque el test_tc1método no toma un argumento con nombre tester_arg, el testerdispositivo que está usando lo hace, por lo que pasa el argumento parametrizado a través del testerdispositivo.


Tuve un problema similar: tengo un dispositivo llamado test_package, y luego quería poder pasar un argumento opcional a ese dispositivo cuando lo ejecutaba en pruebas específicas. Por ejemplo:

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

(No importa para estos propósitos qué hace el dispositivo o qué tipo de objeto devuelve package).

Entonces sería deseable usar de alguna manera este accesorio en una función de prueba de tal manera que también pueda especificar el versionargumento de ese accesorio para usar con esa prueba. Actualmente, esto no es posible, aunque podría ser una buena característica.

Mientras tanto, fue bastante fácil hacer que mi dispositivo simplemente devuelva una función que hace todo el trabajo que hizo el dispositivo anteriormente, pero me permite especificar el versionargumento:

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

    return make_test_package

Ahora puedo usar esto en mi función de prueba como:

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

y así.

El intento de solución del OP se dirigió en la dirección correcta y, como sugiere la respuesta de @ hpk42 , MyTester.__init__podría simplemente almacenar una referencia a la solicitud como:

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"

Luego use esto para implementar el accesorio como:

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

Si lo desea, la MyTesterclase podría reestructurarse un poco para que su .argsatributo se pueda actualizar después de que se haya creado, para modificar el comportamiento de las pruebas individuales.

Iguananaut
fuente
Gracias por la pista con la función dentro del aparato. Me tomó un tiempo hasta que pude trabajar en esto nuevamente, ¡pero esto es bastante útil!
maggie
2
Una buena publicación corta sobre este tema: alysivji.github.io/pytest-fixures-with-function-arguments.html
maggie
¿No recibe un error que dice: "Los dispositivos no están destinados a ser llamados directamente, sino que se crean automáticamente cuando las funciones de prueba los solicitan como parámetros"?
nz_21
153

En realidad, esto se admite de forma nativa en py.test mediante parametrización indirecta .

En tu caso, tendrías:

@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
imírico
fuente
Ah, esto es bastante bueno (aunque creo que su ejemplo puede estar un poco desactualizado, difiere de los ejemplos en los documentos oficiales). ¿Es esta una característica relativamente nueva? Nunca lo había encontrado antes. Esta también es una buena solución al problema, en algunos aspectos mejor que mi respuesta.
Iguananaut
2
Intenté usar esta solución pero tenía problemas para pasar múltiples parámetros o usar nombres de variables que no fueran la solicitud. Terminé usando la solución de @Iguananaut.
Victor Uriarte
42
Esta debería ser la respuesta aceptada. La documentación oficial para el indirectargumento de la palabra clave es ciertamente escasa y poco amigable, lo que probablemente explica la oscuridad de esta técnica esencial. He rastreado el sitio py.test en múltiples ocasiones en busca de esta misma característica, solo para aparecer vacío, viejo y aturdido. La amargura es un lugar conocido como integración continua. Gracias a Odin por Stackoverflow.
Cecil Curry
1
Tenga en cuenta que este método cambia el nombre de sus pruebas para incluir el parámetro, que puede ser deseado o no. test_tc1se convierte test_tc1[tester0].
jjj
1
Entonces, indirect=Trueentrega los parámetros a todos los dispositivos llamados, ¿verdad? Porque la documentación nombra explícitamente los dispositivos para la parametrización indirecta, por ejemplo, para un dispositivo llamado x:indirect=['x']
winklerrr
11

Puede acceder al módulo / clase / función de solicitud desde las funciones de dispositivo (y, por lo tanto, desde su clase Tester), consulte la interacción con la solicitud de contexto de prueba desde una función de dispositivo . Por lo tanto, puede declarar algunos parámetros en una clase o módulo y el dispositivo de prueba puede recogerlos.

hpk42
fuente
3
Sé que puedo hacer algo como esto: (de los documentos) @ pytest.fixture (scope = "module", params = ["merlinux.eu", "mail.python.org"]) Pero necesito hacerlo en el módulo de prueba. ¿Cómo puedo agregar parámetros dinámicamente a los accesorios?
maggie
2
El punto no es tener que interactuar con la solicitud de contexto de prueba de una función de dispositivo, sino tener una forma bien definida de pasar argumentos a una función de dispositivo. La función Fixture no debería tener que ser consciente de un tipo de contexto de prueba de solicitud solo para poder recibir argumentos con nombres acordados. Por ejemplo, a uno le gustaría poder escribir @fixture def my_fixture(request)y luego @pass_args(arg1=..., arg2=...) def test(my_fixture)obtener estos argumentos de my_fixture()esta manera arg1 = request.arg1, arg2 = request.arg2. ¿Es posible algo como esto en py.test ahora?
Piotr Dobrogost
7

No pude encontrar ningún documento, sin embargo, parece funcionar en la última versión 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
fuente
Gracias por señalar esto, esta parece la solución más limpia de todas. No creo que esto solía ser posible en versiones anteriores, pero está claro que ahora lo es. ¿Sabes si este formulario se menciona en algún lugar de los documentos oficiales ? No pude encontrar nada parecido, pero claramente funciona. Actualicé mi respuesta para incluir este ejemplo, gracias.
Iguananaut
1
Creo que no será posible en la función, si echas un vistazo a github.com/pytest-dev/pytest/issues/5712 y el PR relacionado (combinado).
Nadège
Esto se revirtió github.com/pytest-dev/pytest/pull/6914
Maspe36
1
Para aclarar, @ Maspe36 está indicando que el PR vinculado por Nadègefue revertido. Por lo tanto, esta característica indocumentada (¿creo que todavía está indocumentada?) Aún vive.
blthayer
6

Para mejorar un poco la respuesta de imiric : otra forma elegante de resolver este problema es crear "accesorios de parámetros". Personalmente lo prefiero a la indirectfunción de pytest. Esta función está disponible en pytest_cases, y la idea original fue sugerida por 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

Tenga en cuenta que pytest-casestambién proporciona @pytest_fixture_plusque le permiten usar marcas de parametrización en sus dispositivos, y @cases_dataque le permiten obtener sus parámetros de funciones en un módulo separado. Consulte el documento para obtener más detalles. Por cierto soy el autor;)

smarie
fuente
1
Esto parece funcionar ahora también en pytest simple (tengo v5.3.1). Es decir, pude hacer que esto funcionara sin param_fixture. Vea esta respuesta . Sin embargo, no pude encontrar ningún ejemplo como este en los documentos; ¿Sabes algo sobre esto?
Iguananaut
gracias por la información y el enlace! No tenía idea de que esto fuera factible. Esperemos una documentación oficial para ver qué tienen en mente.
smarie
2

Hice un decorador divertido que permite escribir accesorios como este:

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

Aquí, a la izquierda /tiene otros dispositivos, y a la derecha tiene parámetros que se suministran mediante:

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

Esto funciona de la misma manera que funcionan los argumentos de función. Si no proporciona el ageargumento, en su 69lugar se utiliza el predeterminado . si no proporciona nameu omite el dog.argumentsdecorador, obtiene el habitual TypeError: dog() missing 1 required positional argument: 'name'. Si tiene otro accesorio que tiene discusión name, no entra en conflicto con este.

También se admiten accesorios asíncronos.

Además, esto le brinda un buen plan de instalación:

$ 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 ejemplo completo:

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"

El código del decorador:

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
ardilla
fuente