ちょっとしたコマンドラインツールをpythonで作る時に便利なargparseのテストで気をつけるべきこと。

スポンサーリンク

ちょっとしたコマンドラインツールをpythonで作る時に便利なargparseなんですが、位置引数ありparserが出す例外をテストするコードを書いて初めて知ったことがありました。

そのメモ書きになります。

位置引数とは

argparseの公式ページにも載っている通り、コマンドライン実行時に必須となる引数のことです。以下引用。

位置引数は次のように作成します:

>>>
>>> parser.add_argument('bar')

parse_args() が呼ばれたとき、オプション引数は接頭辞 - により識別され、それ以外の引数は位置引数として扱われます:

>>>
>>> parser = argparse.ArgumentParser(prog='PROG')
>>> parser.add_argument('-f', '--foo')
>>> parser.add_argument('bar')
>>> parser.parse_args(['BAR'])
Namespace(bar='BAR', foo=None)  
>>> parser.parse_args(['BAR', '--foo', 'FOO'])
Namespace(bar='BAR', foo='FOO')  
>>> parser.parse_args(['--foo', 'FOO'])
usage: PROG [-h] [-f FOO] bar  
PROG: error: too few arguments  

位置引数ありのparserをテストする際に気をつけるべきこと

あれっ と思いました。

位置引数ありのparserをテストするとき、エラーケースのテストを書くにはどうするんだ??と。

例えば以下のようなコードを書いたとして、

import sys  
import argparse

def init(argv=sys.argv[1:]):  
    arg = argparse.ArgumentParser(
        description="main program to test TS-MPPT-60 monitor modules")
    arg.add_argument(
        "host_name",
        type=str,
        help="TS-MPPT-60 host address"
    )
    arg.add_argument(
        "-xa", "--xively-api-key",
        type=str,
        nargs='?', default=None, const=None,
        help="Xively API key string"
    )
    return arg.parse_args(argv) 

以下のようなテストを書くと、

class TestArgParser(unittest.TestCase):  
    def test_default_args(self):
        parsed = argparser.init([])

以下のようなエラーになります。

test_argparser.py: error: the following arguments are required: host_name  
Eusage: test_argparser.py [-h] [-xa [XIVELY_API_KEY]] [-xf [XIVELY_FEED_KEY]]  
                         [-kp [KEENIO_PROJECT_ID]] [-kw [KEENIO_WRITE_KEY]]
                         [-tck [TWITTER_CONSUMER_KEY]]
                         [-tcs [TWITTER_CONSUMER_SECRET]] [-tk [TWITTER_KEY]]
                         [-ts [TWITTER_SECRET]] [-be] [-bl [BATTERY_LIMIT]]
                         [-bs [BATTERY_LIMIT_HOOK_SCRIPT]]
                         [-ch [CHARGE_CURRENT_HIGH]]
                         [-bf [BATTERY_FULL_LIMIT]] [-i INTERVAL]
                         [-l LOG_FILE] [--just-get-status] [--status-all]
                         [--debug]
                         host_name
test_argparser.py: error: the following arguments are required: host_name  
E  
======================================================================
ERROR: test_battery_full_limit (__main__.TestArgParser)  
----------------------------------------------------------------------
Traceback (most recent call last):  
  File "/Users/takashi/Development/solar_monitor/test/test_argparser.py", line 81, in test_battery_full_limit
    parsed = argparser.init(["-bf", ])
  File "/Users/takashi/.anyenv/envs/pyenv/versions/test_py35/lib/python3.5/site-packages/solar_monitor/argparser.py", line 147, in init
    return arg.parse_args(argv)
  File "/Users/takashi/.anyenv/envs/pyenv/versions/3.5.1/lib/python3.5/argparse.py", line 1726, in parse_args
    args, argv = self.parse_known_args(args, namespace)
  File "/Users/takashi/.anyenv/envs/pyenv/versions/3.5.1/lib/python3.5/argparse.py", line 1758, in parse_known_args
    namespace, args = self._parse_known_args(args, namespace)
  File "/Users/takashi/.anyenv/envs/pyenv/versions/3.5.1/lib/python3.5/argparse.py", line 1993, in _parse_known_args
    ', '.join(required_actions))
  File "/Users/takashi/.anyenv/envs/pyenv/versions/3.5.1/lib/python3.5/argparse.py", line 2385, in error
    self.exit(2, _('%(prog)s: error: %(message)s\n') % args)
  File "/Users/takashi/.anyenv/envs/pyenv/versions/3.5.1/lib/python3.5/argparse.py", line 2372, in exit
    _sys.exit(status)
SystemExit: 2  

最後の行に回答があるんですけどね。

SystemExit例外がraiseされることを想定してテストを書けば良いのです。

class TestArgParser(unittest.TestCase):  
    def test_default_args(self):
        self.assertRaises(SystemExit, argparser.init, [])

SystemExit例外とは

これはsys.exit()が送出する例外で、Exceptionを継承した普通の例外とはちょっと扱いが異なります。

詳しくは公式のヘルプに載っていますが、重要な部分だけ引用したのが以下です。

Exception をキャッチするコードに誤ってキャッチされないように、Exception ではなく BaseException を継承しています。

つまり、以下のようなコードではキャッチできないんですね。

try:  
    argparser.init([])
except Exception as e:  
    print("hoge: " + type(e).__name__)

SystemExitをキャッチするにはこうする必要があります。

try:  
    argparser.init([])
except BaseException as e:  
    print("hoge: " + type(e).__name__)

例外階層をみると、SystemExit以外にもKeyboardInterruptGeneratorExitなんかもBaseExceptionを継承しているようです。なるほど。

まとめ

  • 位置引数ありのparserに位置引数を与えずに実行すると、SystemExit例外を出す(要するにsys.exit()する)
  • SystemExitExceptionではなくBaseExceptionを継承している
  • 位置引数ありのparserのSystemExitを出すケースをテストする際には、普通にunittestassertRaisesが使える
comments powered by Disqus