Como você escreve testes para a parte argparse de um módulo python? [fechadas]

162

Eu tenho um módulo Python que usa a biblioteca argparse. Como escrevo testes para essa seção da base de código?

pydanny
fonte
argparse é uma interface de linha de comando. Escreva seus testes para chamar o aplicativo através da linha de comando.
precisa saber é o seguinte
Sua pergunta dificulta a compreensão do que você deseja testar. Eu suspeitaria que, em última análise, seja "quando uso argumentos de linha de comando X, Y, Z, a função foo()é chamada". Zombar de sys.argvé a resposta, se for esse o caso. Dê uma olhada no pacote Python cli-test-helpers . Veja também stackoverflow.com/a/58594599/202834
Peterino 16/11/19

Respostas:

214

Você deve refatorar seu código e mover a análise para uma função:

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

Em sua mainfunção, basta chamá-lo com:

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

(onde o primeiro elemento sys.argvque representa o nome do script é removido para não enviá-lo como uma opção adicional durante a operação da CLI.)

Em seus testes, você pode chamar a função analisador com qualquer lista de argumentos com a qual deseja testá-la:

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

Dessa forma, você nunca precisará executar o código do seu aplicativo apenas para testar o analisador.

Se você precisar alterar e / ou adicionar opções ao seu analisador posteriormente no seu aplicativo, crie um método de fábrica:

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

Posteriormente, você pode manipulá-lo, se quiser, e um teste pode se parecer com:

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
fonte
4
Obrigado pela sua resposta. Como testamos erros quando um certo argumento não é passado?
Pratik Khadloya
3
@PratikKhadloya Se o argumento for obrigatório e não for passado, o argparse gerará uma exceção.
Viktor Kerkez
2
@PratikKhadloya Sim, a mensagem não é, infelizmente, muito útil :( É só 2... argparsenão é muito amigável testar uma vez que imprime diretamente para sys.stderr...
Viktor Kerkez
1
@ViktorKerkez Você pode zombar do sys.stderr para procurar uma mensagem específica, mock.assert_called_with ou examinando mock_calls, consulte docs.python.org/3/library/unittest.mock.html para obter mais detalhes. Consulte também stackoverflow.com/questions/6271947/… para obter um exemplo de zombaria de stdin. (stderr deve ser semelhante)
BryCoBat
1
@PratikKhadloya ver a minha resposta para manusear / erros testando stackoverflow.com/a/55234595/1240268
Andy Hayden
25

"parte argparse" é um pouco vaga, então esta resposta se concentra em uma parte: o parse_argsmétodo. Este é o método que interage com sua linha de comando e obtém todos os valores passados. Basicamente, você pode simular o que parse_argsretorna, para que ele não precise realmente obter valores na linha de comando. O mock pacote pode ser instalado via pip para versões python 2.6-3.2. Faz parte da biblioteca padrão a partir unittest.mockda versão 3.3 em diante.

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

Você precisa incluir todos os argumentos do seu método de comando, Namespace mesmo que não sejam passados. Dê a esses argumentos um valor de None. (consulte a documentação ) Esse estilo é útil para testar rapidamente casos em que valores diferentes são passados ​​para cada argumento do método. Se você optar por zombar de Namespacesi mesmo por não ter total confiança em seus testes, verifique se ele se comporta de maneira semelhante à Namespaceclasse real .

Abaixo está um exemplo usando o primeiro trecho da 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
fonte
Mas agora seu código mais unido também depende argparsee de sua Namespaceclasse. Você deveria zombar Namespace.
imrek
1
O @DrunkenMaster pede desculpas pelo tom sarcástico. Atualizei minha resposta com explicações e possíveis usos. Também estou aprendendo aqui, portanto, se você puder, você (ou outra pessoa) fornecerá casos em que zombar do valor de retorno é benéfico? (ou pelo menos os casos em que não zombando o valor de retorno é prejudicial)
munsu
1
from unittest import mocké agora o método de importação correto - bem, pelo menos para python3
Michael Hall
1
@MichaelHall thanks. Atualizei o snippet e adicionei informações contextuais.
Munsu
1
O uso da Namespaceclasse aqui é exatamente o que eu estava procurando. Apesar do teste ainda depender argparse, ele não se baseia na implementação específica argparsedo código em teste, o que é importante para meus testes de unidade. Além disso, é fácil usar pytesto parametrize()método de testar rapidamente várias combinações de argumentos com uma simulação de modelo que inclui return_value=argparse.Namespace(accumulate=accumulate, integers=integers).
acetona
17

Faça com que sua main()função seja tomada argvcomo argumento, em vez de permitir que ela seja lida sys.argvcomo será por padrão :

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

Então você pode testar 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
fonte
9
  1. Preencha sua lista de argumentos usando sys.argv.append()e, em seguida parse(), ligue , verifique os resultados e repita.
  2. Ligue de um arquivo em lote / bash com suas bandeiras e uma bandeira de dump args.
  3. Coloque todo o seu argumento analisando em um arquivo separado e, na if __name__ == "__main__":análise de chamada, faça o dump / avalie os resultados e teste-o em um arquivo em lotes / bash.
Steve Barnes
fonte
9

Eu não queria modificar o script de veiculação original, então zombei da sys.argvparte em argparse.

from unittest.mock import patch

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

Isso é interrompido se a implementação do argparse mudar, mas o suficiente para um script de teste rápido. A sensibilidade é muito mais importante que a especificidade nos scripts de teste.

김민준
fonte
6

Uma maneira simples de testar um analisador é:

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

Outra maneira é modificar sys.argve chamarargs = parser.parse_args()

Existem muitos exemplos de testes argparseemlib/test/test_argparse.py

hpaulj
fonte
5

parse_argslança a SystemExite imprime em stderr, você pode pegar os dois:

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

Você inspeciona stderr (usando, err.seek(0); err.read()mas geralmente essa granularidade não é necessária.

Agora você pode usar assertTrueou qualquer teste que desejar:

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

Como alternativa, você pode capturar e repetir um erro diferente (em vez 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
fonte
2

Ao passar resultados de argparse.ArgumentParser.parse_argspara uma função, às vezes uso um namedtuplepara zombar de argumentos para teste.

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()
convidado
fonte
0

Para testar a CLI (interface da linha de comandos), e não a saída do comando , fiz algo parecido com isto

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
fonte