4
votes

Paramétrage paresseux avec pytest

Lors du paramétrage des tests et des fixtures dans pytest, pytest semble évaluer avec empressement tous les paramètres et construire une structure de données de listes de tests avant de commencer à exécuter les tests.

C'est un problème dans 2 situations:

  1. lorsque vous avez de nombreuses valeurs de paramètres (par exemple à partir d'un générateur) - le générateur et le test lui-même peuvent fonctionner rapidement mais toutes ces valeurs de paramètres consomment toute la mémoire
  2. lors du paramétrage d'un appareil avec différents types de ressources coûteuses, où vous ne pouvez vous permettre d'exécuter qu'une seule ressource à la fois (par exemple, parce qu'ils écoutent sur le même port ou quelque chose comme ça)

Ainsi ma question: est-il possible de dire à pytest d'évaluer les paramètres à la volée (c'est-à-dire paresseusement)?


3 Réponses :


2
votes

Quant à votre 2 question - proposée en lien de commentaire vers manual semble être exactement ce qu'il faut faire. Il permet "de configurer des ressources coûteuses comme des connexions à la base de données ou des sous-processus uniquement lorsque le test réel est exécuté." Vous pouvez passer directement le générateur à paramétrer comme ceci:

@pytest.mark.parametrize("one", list_1)
def test_maybe_convert_objects(self, one):
    for two in list_2:
        ...

Mais pytest va list () de votre générateur -> Les problèmes de RAM persistent ici aussi.

J'ai également trouvé des github problèmes que d'éclairer davantage sur pourquoi pytest ne gère pas le générateur paresseusement. Et cela semble être un problème de conception. Donc "il n'est pas possible de gérer correctement la paramétrisation ayant un générateur comme valeur" à cause de

"pytest devrait collecter tous ces tests avec toutes les métadonnées ... la collecte se produit toujours avant l'exécution du test ".

Il y a aussi des références à l ' hypothèse ou aux tests de base de rendement de nose dans de tels cas. Mais si vous voulez toujours vous en tenir à pytest , il existe quelques solutions de contournement:

  1. Si vous connaissiez d'une manière ou d'une autre le nombre de paramètres générés, vous pouvez procéder comme suit:
@pytest.mark.parametrize("one", list_1)
@pytest.mark.parametrize("two", list_2)
def test_maybe_convert_objects(self, one, two):
    ...
# data_generate
# set_up test
pytest.main(['test'])

Donc ici, vous paramétrez sur index (ce qui n'est pas si utile - il suffit d'indiquer le nombre d'exécutions pytest) doit être fait) et générer des données dans la prochaine exécution. Vous pouvez également l'envelopper dans memory_profiler:

@pytest.mark.parametrize('param2', [15, 'asdb', 1j])
def test_two(fix, param2):
    data = fix
    ...

Et comparer avec simple:

data_gen = get_data(N)
@pytest.fixture(scope='module', params=len_of_gen_if_known)
def fix():
    huge_data_chunk = next(data_gen)
    return huge_data_chunk


@pytest.mark.parametrize('other_param', ['aaa', 'bbb'])
def test_one(fix, other_param):
    data = fix
    ...

Ce qui 'mange' beaucoup plus de mémoire:

Results (48.11s):
    3000 passed
Filename: run_test.py

Line #    Mem usage    Increment   Line Contents
================================================
     5     40.7 MiB     40.7 MiB   @profile
     6                             def to_profile():
     7    409.3 MiB    368.6 MiB       pytest.main(['test.py'])
  1. Si vous voulez paramétrer votre test sur un autre paramètre en même temps, vous pouvez faire une petite généralisation de la clause précédente comme ceci:
@pytest.mark.parametrize('data', data_gen)
def test_yield(data):
    assert data

Nous utilisons donc fixture ici au niveau de la portée du module afin de "prérégler" nos données pour un test paramétré. Notez qu'ici, vous pouvez ajouter un autre test et il recevra également les données générées. Ajoutez-le simplement après test_two:

Results (46.53s):
    3000 passed
Filename: run_test.py

Line #    Mem usage    Increment   Line Contents
================================================
     5     40.6 MiB     40.6 MiB   @profile
     6                             def to_profile():
     7     76.6 MiB     36.1 MiB       pytest.main(['test.py'])

REMARQUE: si vous ne connaissez pas le nombre de données générées, vous pouvez utiliser cette astuce: définissez une valeur approximative (mieux si c'est un peu supérieur au nombre de tests générés) et 'marquer' les tests réussis s'il s'arrête avec StopIteration qui se produira lorsque toutes les données sont déjà générées.

  1. Une autre possibilité est d'utiliser les Usines comme accessoires . Ici, vous intégrez votre générateur dans l'appareil et essayez le rendement de votre test jusqu'à ce qu'il ne se termine pas. Mais voici un autre inconvénient: pytest le traitera comme un test unique (avec éventuellement un tas de vérifications à l'intérieur) et échouera si l'une des données générées échoue. En d'autres termes, si vous comparez à l'approche de paramétrage, toutes les statistiques / fonctionnalités de pytest ne sont pas accessibles.

  2. Et encore, l'un pour l'autre consiste à utiliser pytest.main () dans la boucle quelque chose comme ceci:

@pytest.mark.parametrize('ind', range(N))
def test_yield(ind):
    data = next(data_gen)
    assert data
  1. Ne concerne pas les itérateurs en lui-même mais plutôt le moyen de gagner plus de temps / RAM si on a paramétré le test: Déplacez simplement quelques paramètres dans les tests. Exemple:
import pytest

def get_data(N):
    for i in range(N):
        yield list(range(N))

N = 3000
data_gen = get_data(N)

Changer en:

@pytest.mark.parametrize('data', data_gen)
def test_gen(data):
    ...

C'est similaire aux usines mais encore plus facile à implémenter. En outre, cela réduit non seulement la RAM plusieurs fois, mais également le temps de collecte des méta-informations. Inconvénients ici - pour pytest, ce serait un test pour toutes les valeurs deux . Et cela fonctionne bien avec des tests "simples" - si l'on a des xmark spéciaux à l'intérieur ou quelque chose, il peut y avoir des problèmes.

J'ai également ouvert le problème correspondant. apparaissent quelques informations / modifications supplémentaires sur ce problème.


0 commentaires

1
votes

Cette solution de contournement peut vous être utile:

========================================================================= test session starts ==========================================================================
platform darwin -- Python 3.7.7, pytest-5.3.5, py-1.8.1, pluggy-0.13.1 -- /usr/local/opt/python/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/apizarro/tmp
collected 2 items                                                                                                                                                      

test_that.py::test_it[<lambda>0] 
2020-04-14 18:34:08.700531
2020-04-15 18:34:08.700550
PASSED
test_that.py::test_it[<lambda>1] 
2020-04-15 18:34:09.702914
2020-04-16 18:34:09.702919
PASSED

========================================================================== 2 passed in 2.02s ===========================================================================

Exemple de sortie:

from datetime import datetime, timedelta
from time import sleep

import pytest


@pytest.mark.parametrize(
    'lazy_params',
    [
        lambda: (datetime.now() - timedelta(days=1), datetime.now()),
        lambda: (datetime.now(), datetime.now() + timedelta(days=1)),
    ],
)
def test_it(lazy_params):
    yesterday, today = lazy_params()

    print(f'\n{yesterday}\n{today}')
    sleep(1)

    assert yesterday < today


1 commentaires

Veuillez envisager de publier un exemple de sortie sous forme de texte (au lieu d'une capture d'écran recadrée) pour des raisons d'accessibilité, à l'avenir.



1
votes

EDIT: ma première réaction serait "c'est exactement ce à quoi servent les appareils paramétrés": un appareil à portée fonctionnelle est une valeur paresseuse appelée juste avant que le nœud de test ne soit exécuté, et par paramétrage l'appareil, vous pouvez prédéfinir autant de variantes (par exemple à partir d'une liste de clés de base de données) que vous le souhaitez.

from functools import partial
from random import random

import pytest
from pytest_cases import lazy_value

database = [random() for i in range(10)]

def get_param(i):
    return database[i]


def make_param_getter(i, use_partial=False):
    if use_partial:
        return partial(get_param, i)
    else:
        def _get_param():
            return database[i]

        return _get_param

many_lazy_parameters = (make_param_getter(i) for i in range(10))

@pytest.mark.parametrize('a', [lazy_value(f) for f in many_lazy_parameters])
def test_foo(a):
    print(a)

Cela étant dit, dans certaines (rares) situations, vous avez encore besoin de valeurs paresseuses dans un paramètre fonction, et vous ne souhaitez pas que ce soit les variantes d'un appareil paramétré. Pour ces situations, il existe désormais une solution également dans pytest-cases , avec lazy_value . Avec lui, vous pouvez utiliser des fonctions dans les valeurs des paramètres, et ces fonctions ne sont appelées que lorsque le test en cours est exécuté.

Voici un exemple montrant deux styles de codage (changez l'argument booléen use_partial sur True en activer l'autre alternative)

from pytest_cases import fixture_plus

@fixture_plus
def db():
    return <todo>

@fixture_plus
@pytest.mark.parametrize("key", [<list_of keys>])
def sample(db, key):
    return db.get(key)

def test_foo(sample):
    return sample

Notez que lazy_value a également un argument id si vous souhaitez personnaliser les identifiants de test . La valeur par défaut consiste à utiliser la fonction __name__ , et la prise en charge des fonctions partielles est en route .

Vous pouvez paramétrer les appareils de la même manière, mais n'oubliez pas que vous devez utiliser @fixture_plus au lieu de @ pytest.fixture . Consultez la documentation pytest-cases pour détails.

Je suis l'auteur de pytest-cases au fait;)


0 commentaires