Wie schreibt man Tests für den Argparse-Teil eines Python-Moduls? [geschlossen]


162

Ich habe ein Python-Modul, das die Argparse-Bibliothek verwendet. Wie schreibe ich Tests für diesen Abschnitt der Codebasis?


argparse ist eine Befehlszeilenschnittstelle. Schreiben Sie Ihre Tests, um die Anwendung über die Befehlszeile aufzurufen.
Homer6

Ihre Frage macht es schwierig zu verstehen, was Sie testen möchten. Ich würde vermuten, dass dies letztendlich der Fall ist, z. B. "Wenn ich Befehlszeilenargumente X, Y, Z verwende, wird die Funktion foo()aufgerufen". Verspotten sys.argvist die Antwort, wenn das der Fall ist. Schauen Sie sich das Python-Paket von cli-test-helpers an. Siehe auch stackoverflow.com/a/58594599/202834
Peterino

Antworten:


214

Sie sollten Ihren Code umgestalten und die Analyse in eine Funktion verschieben:

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

Dann mainsollten Sie es in Ihrer Funktion einfach aufrufen mit:

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

(wobei das erste Element sys.argv, das den Skriptnamen darstellt, entfernt wird, um ihn während des CLI-Vorgangs nicht als zusätzlichen Switch zu senden.)

In Ihren Tests können Sie dann die Parser-Funktion mit einer beliebigen Liste von Argumenten aufrufen, mit denen Sie sie testen möchten:

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

Auf diese Weise müssen Sie niemals den Code Ihrer Anwendung ausführen, nur um den Parser zu testen.

Wenn Sie später in Ihrer Anwendung Optionen ändern und / oder Optionen zu Ihrem Parser hinzufügen müssen, erstellen Sie eine Factory-Methode:

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

Sie können es später bearbeiten, wenn Sie möchten, und ein Test könnte folgendermaßen aussehen:

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')

4
Danke für deine Antwort. Wie testen wir auf Fehler, wenn ein bestimmtes Argument nicht übergeben wird?
Pratik Khadloya

3
@PratikKhadloya Wenn das Argument erforderlich ist und nicht übergeben wird, löst argparse eine Ausnahme aus.
Viktor Kerkez

2
@PratikKhadloya Ja, die Nachricht ist leider nicht wirklich hilfreich :( Es ist nur 2... argparseist nicht sehr testfreundlich, da es direkt an sys.stderr...
Viktor Kerkez

1
@ViktorKerkez Möglicherweise können Sie sys.stderr verspotten, um nach einer bestimmten Nachricht zu suchen, entweder mock.assert_called_with oder indem Sie mock_calls untersuchen. Weitere Informationen finden Sie unter docs.python.org/3/library/unittest.mock.html . Ein Beispiel für das Verspotten von stdin finden Sie unter stackoverflow.com/questions/6271947/… . (stderr sollte ähnlich sein)
BryCoBat

1
@PratikKhadloya siehe meine Antwort für die Behandlung / Test von Fehlern stackoverflow.com/a/55234595/1240268
Andy Hayden

25

"argparse portion" ist etwas vage, daher konzentriert sich diese Antwort auf einen Teil: die parse_argsMethode. Dies ist die Methode, die mit Ihrer Befehlszeile interagiert und alle übergebenen Werte abruft. Grundsätzlich können Sie verspotten, was parse_argszurückgegeben wird, damit keine Werte über die Befehlszeile abgerufen werden müssen. Das mock Paket kann über pip für Python-Versionen 2.6-3.2 installiert werden. Es ist Teil der Standardbibliothek unittest.mockab 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

Sie müssen alle Argumente Ihrer Befehlsmethode einschließen, Namespace auch wenn sie nicht übergeben werden. Geben Sie diesen Argumenten den Wert von None. (siehe Dokumentation ) Dieser Stil ist nützlich, um schnell Tests für Fälle durchzuführen, in denen für jedes Methodenargument unterschiedliche Werte übergeben werden. Wenn Sie sich Namespacein Ihren Tests dafür entscheiden, sich selbst zu verspotten, um sich nicht auf Vertrauen zu verlassen, stellen Sie sicher, dass es sich ähnlich wie die eigentliche NamespaceKlasse verhält .

Unten sehen Sie ein Beispiel mit dem ersten Snippet aus der Argparse-Bibliothek.

# 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())

Aber jetzt hängt Ihr unittest Code auch von argparseund seiner NamespaceKlasse ab. Du solltest dich lustig machen Namespace.
Imrek

1
@ DrunkenMaster entschuldigt sich für den snarky Ton. Ich habe meine Antwort mit Erklärungen und Verwendungsmöglichkeiten aktualisiert. Ich lerne auch hier. Wenn Sie möchten, können Sie (oder jemand anderes) Fälle angeben, in denen es von Vorteil ist, den Rückgabewert zu verspotten? (oder zumindest Fälle, in denen dies nicht der Fall ist es nachteilig ist, den Rückgabewert verspotten)
Munsu

1
from unittest import mockist jetzt die richtige Importmethode - zumindest für Python3
Michael Hall

1
@ MichaelHall danke. Ich habe das Snippet aktualisiert und Kontextinformationen hinzugefügt.
Munsu

1
Die Verwendung der NamespaceKlasse hier ist genau das, wonach ich gesucht habe. Obwohl sich der Test immer noch darauf stützt argparse, hängt er nicht von der speziellen Implementierung des zu argparsetestenden Codes ab, was für meine Komponententests wichtig ist. Darüber hinaus ist es einfach, mit pytestder parametrize()Methode verschiedene Argumentkombinationen schnell mit einem Vorlagen-Mock zu testen, das Folgendes enthält return_value=argparse.Namespace(accumulate=accumulate, integers=integers).
Aceton

17

Lassen Sie Ihre main()Funktion argvals Argument nehmen, anstatt sie standardmäßig lesen zusys.argv lassen :

# 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:]))

Dann können Sie normal testen.

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')

9
  1. Füllen Sie Ihre Arg-Liste mit sys.argv.append()und rufen Sie dann auf parse() , überprüfen Sie die Ergebnisse und wiederholen Sie den Vorgang.
  2. Rufen Sie aus einer Batch- / Bash-Datei mit Ihren Flags und einem Dump-Args-Flag auf.
  3. Fügen Sie alle Ihre Argumente in eine separate Datei und in die if __name__ == "__main__":Aufrufanalyse ein und sichern / bewerten Sie die Ergebnisse. Testen Sie diese dann anhand einer Batch- / Bash-Datei.

9

Ich wollte das ursprüngliche Serving-Skript nicht ändern, also habe ich den sys.argvTeil in Argparse nur verspottet .

from unittest.mock import patch

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

Dies bricht ab, wenn sich die Implementierung von argparse ändert, aber genug für ein schnelles Testskript. Sensibilität ist sowieso viel wichtiger als Spezifität in Testskripten.


6

Eine einfache Möglichkeit zum Testen eines Parsers ist:

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)
...

Eine andere Möglichkeit besteht darin, Änderungen vorzunehmen sys.argvund aufzurufenargs = parser.parse_args()

Es gibt viele Beispiele für Tests argparseinlib/test/test_argparse.py


5

parse_argswirft ein SystemExitund druckt auf stderr, Sie können beide fangen:

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

Sie inspizieren stderr (mit, err.seek(0); err.read()aber im Allgemeinen ist keine Granularität erforderlich.

Jetzt können Sie beliebige assertTrueTests verwenden oder verwenden:

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

Alternativ möchten Sie möglicherweise einen anderen Fehler abfangen und erneut auslösen (anstelle von 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())

2

Wenn argparse.ArgumentParser.parse_argsich Ergebnisse von an eine Funktion übergebe, verwende ich manchmal a namedtuple, um Argumente zum Testen zu verspotten.

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()

0

Zum Testen der CLI (Befehlszeilenschnittstelle) und nicht der Befehlsausgabe habe ich so etwas getan

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 == "?"
    ...
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.