¿Cómo se escriben las pruebas para la parte argparse de un módulo de Python? [cerrado]

162

Tengo un módulo de Python que usa la biblioteca argparse. ¿Cómo escribo pruebas para esa sección de la base del código?

pydanny
fuente
argparse es una interfaz de línea de comandos. Escriba sus pruebas para invocar la aplicación a través de la línea de comando.
Homer6
Su pregunta hace que sea difícil entender lo que quiere probar. Sospecharía que es en última instancia, por ejemplo, "cuando uso los argumentos de línea de comando X, Y, Z, entonces foo()se llama a la función ". Burlarse sys.argves la respuesta si ese es el caso. Eche un vistazo al paquete Python de cli-test-helpers . Ver también stackoverflow.com/a/58594599/202834
Peterino

Respuestas:

214

Debería refactorizar su código y mover el análisis a una función:

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

Luego, en su mainfunción, debe llamarlo con:

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

(donde el primer elemento sys.argvque representa el nombre del script se elimina para no enviarlo como un interruptor adicional durante la operación CLI).

En sus pruebas, puede llamar a la función del analizador con cualquier lista de argumentos con los que desee probarla:

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

De esta manera, nunca tendrá que ejecutar el código de su aplicación solo para probar el analizador.

Si necesita cambiar y / o agregar opciones a su analizador más adelante en su aplicación, cree un método de fábrica:

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

Luego puede manipularlo si lo desea, y una prueba podría verse así:

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
fuente
44
Gracias por tu respuesta. ¿Cómo probamos los errores cuando no se pasa cierto argumento?
Pratik Khadloya
3
@PratikKhadloya Si el argumento es obligatorio y no se pasa, argparse generará una excepción.
Viktor Kerkez
2
@PratikKhadloya Sí, desafortunadamente el mensaje no es realmente útil :( Es solo que 2... argparseno es muy fácil de probar ya que se imprime directamente en sys.stderr...
Viktor Kerkez
1
@ViktorKerkez Puede burlarse de sys.stderr para verificar si hay un mensaje específico, ya sea mock.assert_called_with o examinando mock_calls, consulte docs.python.org/3/library/unittest.mock.html para obtener más detalles. Consulte también stackoverflow.com/questions/6271947/… para ver un ejemplo de burlarse de stdin. (stderr debería ser similar)
BryCoBat
1
@PratikKhadloya vea mi respuesta para manejar / probar errores stackoverflow.com/a/55234595/1240268
Andy Hayden
25

La "parte argparse" es un poco vaga, por lo que esta respuesta se centra en una parte: el parse_argsmétodo. Este es el método que interactúa con su línea de comando y obtiene todos los valores pasados. Básicamente, puede burlarse de lo que parse_argsdevuelve para que no sea necesario obtener valores de la línea de comando. El mock paquete se puede instalar a través de pip para las versiones de python 2.6-3.2. Es parte de la biblioteca estándar a partir unittest.mockde la versión 3.3 en adelante.

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

Debe incluir todos los argumentos de su método de comando Namespace incluso si no se pasan. Dale a esos argumentos un valor de None. (vea los documentos ) Este estilo es útil para realizar pruebas rápidamente en casos en los que se pasan valores diferentes para cada argumento de método. Si opta por burlarse Namespacede la total falta de confianza argparse en sus pruebas, asegúrese de que se comporta de manera similar a la Namespaceclase real .

A continuación se muestra un ejemplo con el primer fragmento de la biblioteca 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
fuente
Pero ahora su código unittest también depende de argparsey su Namespaceclase. Deberías burlarte Namespace.
imrek
1
@DrunkenMaster se disculpa por el tono sarcástico. Actualicé mi respuesta con explicación y posibles usos. Estoy aprendiendo aquí también, así que si lo hicieras, ¿puedes (u otra persona) proporcionar casos en los que burlarse del valor de retorno sea beneficioso? (o al menos casos donde no burlarse del valor de retorno es perjudicial)
munsu
1
from unittest import mockahora es el método de importación correcto, al menos para python3
Michael Hall
1
@MichaelHall gracias. Actualicé el fragmento y agregué información contextual.
munsu
1
El uso de la Namespaceclase aquí es exactamente lo que estaba buscando. A pesar de que la prueba aún se basa argparse, no se basa en la implementación particular argparsedel código bajo prueba, que es importante para las pruebas de mi unidad. Además, es fácil usar pytestel parametrize()método para probar rápidamente varias combinaciones de argumentos con una simulación con plantilla que incluye return_value=argparse.Namespace(accumulate=accumulate, integers=integers).
acetona
17

Haga que su main()función tome argvcomo argumento en lugar de dejar que se lea sys.argvcomo lo hará de manera predeterminada :

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

Entonces puedes probar normalmente.

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')
Ceasar Bautista
fuente
9
  1. Rellene su lista de argumentos usando sys.argv.append()y luego llame parse(), verifique los resultados y repita.
  2. Llame desde un archivo por lotes / bash con sus banderas y una bandera de volcado de argumentos.
  3. Ponga todos sus análisis de argumentos en un archivo separado y en el if __name__ == "__main__":análisis de llamadas y volcar / evaluar los resultados y luego probar esto desde un archivo por lotes / bash.
Steve Barnes
fuente
9

No quería modificar el script de publicación original, así que simplemente me sys.argvburlé de la parte en argparse.

from unittest.mock import patch

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

Esto se rompe si la implementación de argparse cambia pero es suficiente para una secuencia de comandos de prueba rápida. La sensibilidad es mucho más importante que la especificidad en los scripts de prueba de todos modos.

김민준
fuente
6

Una forma simple de probar un analizador es:

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

Otra forma es modificar sys.argvy llamarargs = parser.parse_args()

Hay muchos ejemplos de pruebas argparseenlib/test/test_argparse.py

hpaulj
fuente
5

parse_argslanza SystemExitay imprime en stderr, puedes atrapar ambos:

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

Usted inspecciona stderr (usando err.seek(0); err.read()pero en general no se requiere granularidad.

Ahora puede usar assertTrueo cualquier prueba que desee:

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

Alternativamente, le gustaría atrapar y volver a lanzar un error diferente (en lugar 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
fuente
2

Cuando paso resultados de argparse.ArgumentParser.parse_argsa una función, a veces uso a namedtuplepara simular argumentos para la prueba.

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()
invitado
fuente
0

Para probar CLI (interfaz de línea de comando), y no salida de comando , hice algo como esto

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
fuente