ちょっとしたコマンドラインツールを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が使える

シェアする

  • このエントリーをはてなブックマークに追加

フォローする

Close Bitnami banner
Bitnami