คุณจะเขียนการทดสอบสำหรับส่วน argparse ของโมดูลหลามได้อย่างไร [ปิด]


162

ฉันมีโมดูล Python ที่ใช้ไลบรารี argparse ฉันจะเขียนการทดสอบสำหรับส่วนของรหัสฐานได้อย่างไร


argparse เป็นอินเตอร์เฟสบรรทัดคำสั่ง เขียนการทดสอบของคุณเพื่อเรียกใช้แอปพลิเคชันผ่านบรรทัดคำสั่ง
Homer6

คำถามของคุณทำให้ยากที่จะเข้าใจสิ่งที่คุณต้องการทดสอบ ฉันจะสงสัยว่ามันคือท้ายที่สุดเช่น "เมื่อฉันใช้อาร์กิวเมนต์บรรทัดคำสั่ง X, Y, Z จากนั้นfoo()เรียกใช้ฟังก์ชัน" การเยาะเย้ยของsys.argvคือคำตอบถ้าเป็นกรณี ดูแพ็คเกจ Python ของcli-test-helpers ดูเพิ่มเติมที่stackoverflow.com/a/58594599/202834
Peterino

คำตอบ:


214

คุณควร refactor รหัสของคุณและย้ายการแยกไปยังฟังก์ชั่น:

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

จากนั้นในmainฟังก์ชั่นของคุณคุณควรจะเรียกมันว่า:

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

(โดยที่องค์ประกอบแรกsys.argvที่แทนชื่อสคริปต์จะถูกลบออกเพื่อไม่ให้ส่งเป็นสวิตช์เพิ่มเติมในระหว่างการดำเนินการ CLI)

ในการทดสอบของคุณคุณสามารถเรียกใช้ฟังก์ชันตัวแยกวิเคราะห์ด้วยรายการอาร์กิวเมนต์ที่คุณต้องการทดสอบด้วย:

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

วิธีนี้คุณจะไม่ต้องเรียกใช้รหัสแอปพลิเคชันของคุณเพียงเพื่อทดสอบโปรแกรมแยกวิเคราะห์

หากคุณต้องการเปลี่ยนและ / หรือเพิ่มตัวเลือกในโปรแกรมวิเคราะห์คำในภายหลังในแอปพลิเคชันของคุณให้สร้างวิธีการจากโรงงาน:

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

คุณสามารถจัดการได้ในภายหลังหากคุณต้องการและการทดสอบอาจมีลักษณะดังนี้:

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
ขอบคุณสำหรับคำตอบ. เราจะทดสอบข้อผิดพลาดอย่างไรเมื่อมีการโต้แย้งที่ไม่ผ่าน
Pratik Khadloya

3
@PratikKhadloya หากจำเป็นต้องมีข้อโต้แย้งและไม่ผ่านการโต้แย้งอาร์กิวเมนต์จะเพิ่มข้อยกเว้น
Viktor Kerkez

2
@PratikKhadloya ใช่แล้วข้อความนี้น่าเสียดายที่ไม่มีประโยชน์จริงๆ :( มันเป็นเพียง2... argparseไม่ได้ทดสอบง่ายมากเพราะมันพิมพ์โดยตรงไปยังsys.stderr...
Viktor Kerkez

1
@ViktorKerkez คุณอาจเยาะเย้ย sys.stderr เพื่อตรวจสอบข้อความเฉพาะ mock.assert_called_with หรือโดยการตรวจสอบ mock_calls ดูdocs.python.org/3/library/unittest.mock.htmlเพื่อดูรายละเอียดเพิ่มเติม ดูstackoverflow.com/questions/6271947/…สำหรับตัวอย่างของการล้อเลียน stdin (stderr ควรเหมือนกัน)
BryCoBat

1
@PratikKhadloya ดูคำตอบของฉันสำหรับการจัดการ / ทดสอบข้อผิดพลาดstackoverflow.com/a/55234595/1240268
Andy Hayden

25

"ส่วนแย้ง" เป็นบิตคลุมเครือดังนั้นคำตอบนี้มุ่งเน้นไปที่ส่วนหนึ่ง: parse_argsวิธีการ นี่คือวิธีการโต้ตอบกับบรรทัดคำสั่งของคุณและรับค่าที่ส่งทั้งหมด โดยทั่วไปคุณสามารถเยาะเย้ยสิ่งที่parse_argsส่งกลับเพื่อที่ว่ามันไม่จำเป็นต้องได้รับค่าจากบรรทัดคำสั่ง mock แพคเกจสามารถติดตั้งผ่านทาง pip สำหรับรุ่นหลาม 2.6-3.2 มันเป็นส่วนหนึ่งของunittest.mockไลบรารี่มาตรฐานตั้งแต่เวอร์ชัน 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

คุณต้องรวม args วิธีการคำสั่งทั้งหมดของคุณไว้Namespace แม้ว่าจะไม่ผ่านก็ตาม ให้ args Noneเหล่านั้นมีค่า (ดูเอกสาร ) สไตล์นี้มีประโยชน์ในการทำการทดสอบอย่างรวดเร็วสำหรับกรณีที่ค่าต่าง ๆ ถูกส่งผ่านสำหรับอาร์กิวเมนต์แต่ละวิธี หากคุณเลือกที่จะเยาะเย้ยNamespaceตัวเองสำหรับการโต้แย้งทั้งหมดที่ไม่เชื่อมั่นในการทดสอบของคุณตรวจสอบให้แน่ใจว่ามันมีพฤติกรรมคล้ายกับNamespaceชั้นเรียนจริง

ด้านล่างเป็นตัวอย่างการใช้ข้อมูลโค้ดแรกจากไลบรารี 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())

แต่ตอนนี้รหัส unittest ของคุณก็ขึ้นอยู่กับระดับargparseของมันNamespaceด้วย Namespaceคุณควรจะเยาะเย้ย
imrek

1
@DunkunkMaster ขอโทษสำหรับเสียงที่น่ากลัว ฉันอัปเดตคำตอบพร้อมคำอธิบายและการใช้ที่เป็นไปได้ ฉันกำลังเรียนรู้ที่นี่เช่นกันดังนั้นถ้าคุณต้องการคุณสามารถ (หรือคนอื่น) จัดทำกรณีที่การเยาะเย้ยค่าตอบแทนเป็นประโยชน์หรือไม่? (หรืออย่างน้อยกรณีที่ไม่ได้เยาะเย้ยค่าตอบแทนเป็นอันตราย)
munsu

1
from unittest import mockตอนนี้เป็นวิธีการนำเข้าที่ถูกต้อง - อย่างน้อยสำหรับ python3
Michael Hall

1
@MichaelHall ขอบคุณ ฉันอัปเดตข้อมูลโค้ดและเพิ่มข้อมูลตามบริบทแล้ว
munsu

1
การใช้Namespaceชั้นเรียนที่นี่เป็นสิ่งที่ฉันกำลังมองหา แม้จะยังคงใช้การทดสอบอยู่argparseแต่ก็ไม่ได้ขึ้นอยู่กับการใช้งานเฉพาะของargparseรหัสภายใต้การทดสอบซึ่งเป็นสิ่งสำคัญสำหรับการทดสอบหน่วยของฉัน นอกจากนี้มันเป็นเรื่องง่ายที่จะใช้pytest's วิธีการทดสอบได้อย่างรวดเร็วรวมกันโต้แย้งต่างๆที่มีการจำลองเทมเพลตที่มีparametrize() return_value=argparse.Namespace(accumulate=accumulate, integers=integers)
อะซิโตน

17

ทำให้main()ฟังก์ชั่นของคุณargvเป็นอาร์กิวเมนต์แทนที่จะปล่อยให้มันอ่านsys.argvตามที่มันจะเป็นค่าเริ่มต้น :

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

จากนั้นคุณสามารถทดสอบได้ตามปกติ

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. เติมรายการ arg ของคุณโดยใช้sys.argv.append()แล้วโทร parse()ตรวจสอบผลลัพธ์และทำซ้ำ
  2. โทรจากไฟล์ batch / bash ด้วยแฟล็กของคุณและแฟล็ก args dump
  3. ใส่อาร์กิวเมนต์ทั้งหมดของคุณในการแยกไฟล์และในการif __name__ == "__main__":แยกวิเคราะห์การโทรและการถ่ายโอนข้อมูล / ประเมินผลแล้วทดสอบสิ่งนี้จากชุดไฟล์ / ทุบตี

9

ฉันไม่ต้องการแก้ไขสคริปต์การแสดงต้นฉบับดังนั้นฉันจึงเยาะเย้ยsys.argvส่วนในการโต้แย้ง

from unittest.mock import patch

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

สิ่งนี้จะหยุดพักหากการใช้งานแบบโต้เถียงเปลี่ยนไป แต่เพียงพอสำหรับสคริปต์ทดสอบอย่างรวดเร็ว ความรู้สึกมีความสำคัญมากกว่าความจำเพาะในสคริปต์ทดสอบต่อไป


6

วิธีง่ายๆในการทดสอบ parser คือ:

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

อีกวิธีหนึ่งคือการปรับเปลี่ยนsys.argvและโทรargs = parser.parse_args()

มีจำนวนมากตัวอย่างของการทดสอบที่มีargparseในlib/test/test_argparse.py


5

parse_argsขว้าง a SystemExitและปริ้นไปที่ stderr คุณสามารถจับทั้งสองอย่างนี้:

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

คุณตรวจสอบ stderr (โดยใช้err.seek(0); err.read()แต่โดยทั่วไปแล้วไม่จำเป็นต้องมีรายละเอียดมากนัก

ตอนนี้คุณสามารถใช้assertTrueหรือทดสอบสิ่งที่คุณต้องการ

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

อีกวิธีหนึ่งคุณอาจต้องการจับและแก้ไขข้อผิดพลาดอื่น (แทน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

เมื่อส่งผลลัพธ์จากargparse.ArgumentParser.parse_argsไปยังฟังก์ชั่นบางครั้งฉันใช้namedtupleเพื่อเยาะเย้ยอาร์กิวเมนต์สำหรับการทดสอบ

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

สำหรับการทดสอบ CLI (อินเตอร์เฟสบรรทัดคำสั่ง) และไม่ใช่เอาต์พุตคำสั่งฉันทำสิ่งนี้

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 == "?"
    ...
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.