Comment écrivez-vous des tests pour la partie argparse d'un module python? [fermé]

162

J'ai un module Python qui utilise la bibliothèque argparse. Comment écrire des tests pour cette section de la base de code?

pydanny
la source
argparse est une interface de ligne de commande. Écrivez vos tests pour appeler l'application via la ligne de commande.
Homer 6
Votre question rend difficile la compréhension de ce que vous voulez tester. Je soupçonnerais que c'est finalement, par exemple "quand j'utilise les arguments de ligne de commande X, Y, Z alors la fonction foo()est appelée". Se moquer de sys.argvest la réponse si c'est le cas. Jetez un œil au package Python cli-test-helpers . Voir aussi stackoverflow.com/a/58594599/202834
Peterino

Réponses:

214

Vous devez refactoriser votre code et déplacer l'analyse vers une fonction:

def parse_args(args):
    parser = argparse.ArgumentParser(...)
    parser.add_argument...
    # ...Create your parser as you like...
    return parser.parse_args(args)

Ensuite, dans votre mainfonction, vous devez simplement l'appeler avec:

parser = parse_args(sys.argv[1:])

(où le premier élément de sys.argvqui représente le nom du script est supprimé pour ne pas l'envoyer en tant que commutateur supplémentaire pendant l'opération CLI.)

Dans vos tests, vous pouvez ensuite appeler la fonction parser avec la liste d'arguments avec laquelle vous voulez la tester:

def test_parser(self):
    parser = parse_args(['-l', '-m'])
    self.assertTrue(parser.long)
    # ...and so on.

De cette façon, vous n'aurez jamais à exécuter le code de votre application juste pour tester l'analyseur.

Si vous devez modifier et / ou ajouter des options à votre analyseur ultérieurement dans votre application, créez une méthode d'usine:

def create_parser():
    parser = argparse.ArgumentParser(...)
    parser.add_argument...
    # ...Create your parser as you like...
    return parser

Vous pouvez le manipuler ultérieurement si vous le souhaitez, et un test pourrait ressembler à ceci:

class ParserTest(unittest.TestCase):
    def setUp(self):
        self.parser = create_parser()

    def test_something(self):
        parsed = self.parser.parse_args(['--something', 'test'])
        self.assertEqual(parsed.something, 'test')
Viktor Kerkez
la source
4
Merci pour votre réponse. Comment tester les erreurs lorsqu'un certain argument n'est pas passé?
Pratik Khadloya
3
@PratikKhadloya Si l'argument est requis et qu'il n'est pas passé, argparse lèvera une exception.
Viktor Kerkez
2
@PratikKhadloya Oui, le message n'est malheureusement pas vraiment utile: (C'est juste 2... argparsen'est pas très convivial pour les tests car il imprime directement sur sys.stderr...
Viktor Kerkez
1
@ViktorKerkez Vous pourrez peut-être vous moquer de sys.stderr pour rechercher un message spécifique, soit mock.assert_called_with, soit en examinant mock_calls, voir docs.python.org/3/library/unittest.mock.html pour plus de détails. Voir aussi stackoverflow.com/questions/6271947/… pour un exemple de stdin moqueur. (stderr devrait être similaire)
BryCoBat
1
@PratikKhadloya voir ma réponse pour les erreurs de gestion / test stackoverflow.com/a/55234595/1240268
Andy Hayden
25

"argparse portion" est un peu vague, donc cette réponse se concentre sur une partie: la parse_argsméthode. C'est la méthode qui interagit avec votre ligne de commande et obtient toutes les valeurs passées. Fondamentalement, vous pouvez vous moquer de ce qui parse_argsrevient afin qu'il n'ait pas besoin d'obtenir des valeurs à partir de la ligne de commande. Le mock package peut être installé via pip pour les versions 2.6-3.2 de python. Il fait partie de la bibliothèque standard à unittest.mockpartir de la version 3.3.

import argparse
try:
    from unittest import mock  # python 3.3+
except ImportError:
    import mock  # python 2.6-3.2


@mock.patch('argparse.ArgumentParser.parse_args',
            return_value=argparse.Namespace(kwarg1=value, kwarg2=value))
def test_command(mock_args):
    pass

Vous devez inclure tous les arguments de votre méthode de commande Namespace même s'ils ne sont pas passés. Donnez à ces arguments une valeur de None. (voir la documentation ) Ce style est utile pour tester rapidement les cas où différentes valeurs sont passées pour chaque argument de méthode. Si vous choisissez de vous moquer Namespacede la non-dépendance totale de l'argparse dans vos tests, assurez-vous qu'il se comporte de la même manière que la Namespaceclasse réelle .

Vous trouverez ci-dessous un exemple utilisant le premier extrait de code de la bibliothèque argparse.

# test_mock_argparse.py
import argparse
try:
    from unittest import mock  # python 3.3+
except ImportError:
    import mock  # python 2.6-3.2


def main():
    parser = argparse.ArgumentParser(description='Process some integers.')
    parser.add_argument('integers', metavar='N', type=int, nargs='+',
                        help='an integer for the accumulator')
    parser.add_argument('--sum', dest='accumulate', action='store_const',
                        const=sum, default=max,
                        help='sum the integers (default: find the max)')

    args = parser.parse_args()
    print(args)  # NOTE: this is how you would check what the kwargs are if you're unsure
    return args.accumulate(args.integers)


@mock.patch('argparse.ArgumentParser.parse_args',
            return_value=argparse.Namespace(accumulate=sum, integers=[1,2,3]))
def test_command(mock_args):
    res = main()
    assert res == 6, "1 + 2 + 3 = 6"


if __name__ == "__main__":
    print(main())
Munsu
la source
Mais maintenant, votre code unittest dépend également de argparsesa Namespaceclasse. Vous devriez vous moquer Namespace.
imrek
1
@DrunkenMaster s'excuse pour le ton sournois. J'ai mis à jour ma réponse avec des explications et des utilisations possibles. J'apprends ici aussi, donc si vous le voulez, pouvez-vous (ou quelqu'un d'autre) fournir des cas où se moquer de la valeur de retour est bénéfique? (ou du moins les cas où ne pas se moquer de la valeur de retour est préjudiciable)
munsu
1
from unittest import mockest maintenant la bonne méthode d'importation - enfin au moins pour python3
Michael Hall
1
@MichaelHall merci. J'ai mis à jour l'extrait et ajouté des informations contextuelles.
munsu
1
L'utilisation de la Namespaceclasse ici est exactement ce que je recherchais. Bien que le test repose toujours sur argparse, il ne repose pas sur l'implémentation particulière argparsedu code en cours de test, ce qui est important pour mes tests unitaires. De plus, il est facile d'utiliser pytestla parametrize()méthode de tester rapidement diverses combinaisons d'arguments avec une maquette basée sur un modèle qui inclut return_value=argparse.Namespace(accumulate=accumulate, integers=integers).
acétone
17

Faites en main()sorte argvque votre fonction prenne comme argument plutôt que de la laisser lire sys.argvcomme elle le fera par défaut :

# mymodule.py
import argparse
import sys


def main(args):
    parser = argparse.ArgumentParser()
    parser.add_argument('-a')
    process(**vars(parser.parse_args(args)))
    return 0


def process(a=None):
    pass

if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

Ensuite, vous pouvez tester normalement.

import mock

from mymodule import main


@mock.patch('mymodule.process')
def test_main(process):
    main([])
    process.assert_call_once_with(a=None)


@mock.patch('foo.process')
def test_main_a(process):
    main(['-a', '1'])
    process.assert_call_once_with(a='1')
César Bautista
la source
9
  1. Remplissez votre liste d'arguments en utilisant sys.argv.append(), puis appelez parse(), vérifiez les résultats et répétez.
  2. Appel à partir d'un fichier batch / bash avec vos indicateurs et un indicateur de vidage args.
  3. Mettez toute votre analyse d'arguments dans un fichier séparé et dans l' if __name__ == "__main__":analyse des appels et vidage / évaluation des résultats, puis testez-le à partir d'un fichier batch / bash.
Steve Barnes
la source
9

Je ne voulais pas modifier le script sys.argvde diffusion d' origine, donc je me suis simplement moqué de la partie dans argparse.

from unittest.mock import patch

with patch('argparse._sys.argv', ['python', 'serve.py']):
    ...  # your test code here

Cela s'arrête si l'implémentation d'argparse change mais suffit pour un script de test rapide. La sensibilité est de toute façon beaucoup plus importante que la spécificité dans les scripts de test.

김민준
la source
6

Une façon simple de tester un analyseur est:

parser = ...
parser.add_argument('-a',type=int)
...
argv = '-a 1 foo'.split()  # or ['-a','1','foo']
args = parser.parse_args(argv)
assert(args.a == 1)
...

Une autre façon est de modifier sys.argv et d'appelerargs = parser.parse_args()

Il existe de nombreux exemples de tests argparsedanslib/test/test_argparse.py

hpaulj
la source
5

parse_argsjette un SystemExitet imprime sur stderr, vous pouvez attraper les deux:

import contextlib
import io
import sys

@contextlib.contextmanager
def captured_output():
    new_out, new_err = io.StringIO(), io.StringIO()
    old_out, old_err = sys.stdout, sys.stderr
    try:
        sys.stdout, sys.stderr = new_out, new_err
        yield sys.stdout, sys.stderr
    finally:
        sys.stdout, sys.stderr = old_out, old_err

def validate_args(args):
    with captured_output() as (out, err):
        try:
            parser.parse_args(args)
            return True
        except SystemExit as e:
            return False

Vous inspectez stderr (en utilisant err.seek(0); err.read()mais généralement cette granularité n'est pas requise.

Vous pouvez maintenant utiliser assertTrueou tout autre test que vous aimez:

assertTrue(validate_args(["-l", "-m"]))

Sinon, vous aimeriez peut-être attraper et renvoyer une erreur différente (au lieu de SystemExit):

def validate_args(args):
    with captured_output() as (out, err):
        try:
            return parser.parse_args(args)
        except SystemExit as e:
            err.seek(0)
            raise argparse.ArgumentError(err.read())
Andy Hayden
la source
2

Lorsque argparse.ArgumentParser.parse_argsje passe des résultats à une fonction, j'utilise parfois un namedtuplepour simuler des arguments pour les tests.

import unittest
from collections import namedtuple
from my_module import main

class TestMyModule(TestCase):

    args_tuple = namedtuple('args', 'arg1 arg2 arg3 arg4')

    def test_arg1(self):
        args = TestMyModule.args_tuple("age > 85", None, None, None)
        res = main(args)
        assert res == ["55289-0524", "00591-3496"], 'arg1 failed'

    def test_arg2(self):
        args = TestMyModule.args_tuple(None, [42, 69], None, None)
        res = main(args)
        assert res == [], 'arg2 failed'

if __name__ == '__main__':
    unittest.main()
client
la source
0

Pour tester la CLI (interface de ligne de commande), et non la sortie de commande, j'ai fait quelque chose comme ça

import pytest
from argparse import ArgumentParser, _StoreAction

ap = ArgumentParser(prog="cli")
ap.add_argument("cmd", choices=("spam", "ham"))
ap.add_argument("-a", "--arg", type=str, nargs="?", default=None, const=None)
...

def test_parser():
    assert isinstance(ap, ArgumentParser)
    assert isinstance(ap, list)
    args = {_.dest: _ for _ in ap._actions if isinstance(_, _StoreAction)}
    
    assert args.keys() == {"cmd", "arg"}
    assert args["cmd"] == ("spam", "ham")
    assert args["arg"].type == str
    assert args["arg"].nargs == "?"
    ...
vczm
la source