CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: テストフレームワーク nose のプラグインを書いてみる

nose は Python のテストフレームワークのひとつ。 特徴のひとつとしてプラグイン機構があるためテストランナーの挙動をカスタマイズできる点が挙げられる。

プラグインの書き方は次のページに詳しく書かれている。 Writing Plugins — nose 1.3.7 documentation

今回は上記のページを元に簡単なプラグインをいくつか書いてみることにする。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.11.3
BuildVersion:   15D21
$ python --version
Python 3.5.1

プラグインを書いてみる

プラグインは nose_helloworld という名前のパッケージとして用意する。 まずはプロジェクト用のディレクトリを用意しておこう。

$ mkdir nose_helloworld
$ cd nose_helloworld

ディレクトリの中に移動したら、さらに同じ名前でディレクトリを作る。 ただし、今度のディレクトリは Python のパッケージにするためのものだ。 Python のパッケージは __init__.py というファイルの入ったディレクトリとして表現される。

$ mkdir nose_helloworld

ちなみに Python のモジュールとパッケージまわりの話はこちらの記事に書いたので併せて参照してもらいたい。

blog.amedama.jp

それでは、先ほどの説明通りディレクトリの中に __init__.py を作ろう。 これで nose_helloworld ディレクトリは Python の nose_helloworld パッケージとして認識できるようになる。 今回は、この init.py の中に nose のプラグインを書くことにする。

nose のプラグインは nose.plugins.Plugin のサブクラスとして定義する。 書き方にはいくつかの流儀があるけど、基本的にはスーパークラスにあるメソッドのオーバーライドするだけで良い。 他にはクラスの docstring がコマンドラインのヘルプメッセージになるので書いておいた方が良い。 あとはプラグイン名を name メンバに入れておくのも忘れずに。 ちなみに、オーバーライドしたメソッドの finalize() はテストが完了したときに呼び出されるコールバックだ。 最初のプラグインでは、ここでログを出すことにする。

$ cat << 'EOF' > nose_helloworld/__init__.py
# -*- coding: utf-8 -*-

import logging

from nose.plugins import Plugin

LOG = logging.getLogger('nose.plugins.helloworld')


class HelloWorldPlugin(Plugin):
    """My first plugin"""
    name = 'helloworld'

    def finalize(self, result, *args, **kwargs):
        LOG.info('HelloWorld Plugin: finalize')

EOF

プラグインで使えるコールバックは、次の IPluginInterface クラスで確認できる。 この中で <メソッド>._new = True となっているのが使って良いものみたい。 github.com

さて、これでパッケージの中にプラグインが用意できた。 次はパッケージをインストールできるようにする。 なぜなら nose のプラグイン機構は setuptools というパッケージング用の API を使っているためだ。 具体的には pkg_resources というものだけど、これはパッケージをインストールするためのセットアップスクリプトの中に指定する。

次のセットアップスクリプトでは setup() 関数の中の entry_points 引数に 'nose.plugins.0.10' という項目を用意している。 nose のプラグインになるクラスはここで指定することになる。

$ cat << 'EOF' > setup.py
# -*- coding: utf-8 -*-

from setuptools import setup

def main():
    setup(
        name='nose_helloworld',
        version='0.0.1',
        zip_safe=False,
        packages=['nose_helloworld'],
        install_requires=[
            'nose==1.3.7',
        ],
        entry_points = {
            'nose.plugins.0.10': [
                'helloworld = nose_helloworld:HelloWorldPlugin'
            ],
        },
    )


if __name__ == '__main__':
    main()

EOF

パッケージをインストールする

これで nose_helloworld パッケージをインストールする用意ができた。 セットアップスクリプトに install サブコマンドを渡して実行しよう。

$ python setup.py install

これで nose_helloworld パッケージがインストールされた。

$ pip freeze | grep helloworld
nose-helloworld==0.0.1

プラグインを使う

パッケージをインストールすると nose のテストランナーである nosetests コマンドに --with-helloworld というオプションが追加される。 これは先ほどインストールしたパッケージの中に HelloWorldPlugin にある name メンバと docstring から生成されている。 このオプションを有効にすることで作成した HelloWorldPlugin が有効になるという寸法だ。

$ nosetests --help | grep -A 1 helloworld
 --with-helloworld     Enable plugin HelloWorldPlugin: My first plugin for
                       nose [NOSE_WITH_HELLOWORLD]

それでは HelloWorldPlugin を有効にして nosetests を実行してみよう。

$ nosetests -vv --with-helloworld
nose.config: INFO: Ignoring files matching ['^\\.', '^_', '^setup\\.py$']

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK
nose.plugins.helloworld: INFO: HelloWorld Plugin: finalize

nose.plugins.helloworld という名前でログが出力されたことがわかる。 ちゃんとテストが完了したときにプラグインのコードが実行された!

ちなみにヘルプメッセージにもあったようにプラグインは環境変数を使って有効にすることもできる。 プラグインを表すクラスの name メンバに 'helloworld' が格納されている場合は NOSE_WITH_HELLOWORLD という環境変数名になる。

$ export NOSE_WITH_HELLOWORLD=1
$ nosetests -vv
nose.config: INFO: Ignoring files matching ['^\\.', '^_', '^setup\\.py$']

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK
nose.plugins.helloworld: INFO: HelloWorld Plugin: finalize

プラグインにオプションを追加する

先ほどの例ではこれ以上ないほどシンプルなプラグインを扱った。 次はプラグインに動作を変更するためのオプションを追加してみよう。

先ほどのプラグインを上書きする形で変更していく。 今度は options() というメソッドと configure() というメソッドをプラグインに実装しよう。 options() メソッドは argparse の API を使ってプラグインが有効なときに使えるコマンドラインオプションが追加できる。 同様に configure() メソッドでは、それらのオプションの値を利用してプラグインを設定できる。 次のプラグインでは --message というオプションを追加した。 先ほどは固定だった finalize() メソッドで出力されるログのメッセージを、このオプションで指定されたものに差し替えてみよう。

$ cat << 'EOF' > nose_helloworld/__init__.py
# -*- coding: utf-8 -*-

import logging

from nose.plugins import Plugin

LOG = logging.getLogger('nose.plugins.helloworld')


class HelloWorldPlugin(Plugin):
    """My first plugin"""
    name = 'helloworld'

    def options(self, parser, env, *args, **kwargs):
        super(HelloWorldPlugin, self).options(parser, env, *args, **kwargs)
        parser.add_option(
            '--message',
            default='Hello, World!',
            help='Greeting message',
        )

    def configure(self, options, conf, *args, **kwargs):
        super(HelloWorldPlugin, self).configure(options, conf, *args, **kwargs)
        self.message = options.message

    def finalize(self, result, *args, **kwargs):
        LOG.info('HelloWorld Plugin: {msg}'.format(msg=self.message))

EOF

プラグインを上書きしたらパッケージをインストールし直そう。

$ python setup.py install

もう一度 nosetests コマンドをプラグインを有効にして実行してみる。

$ nosetests -vv --with-helloworld
nose.config: INFO: Ignoring files matching ['^\\.', '^_', '^setup\\.py$']

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK
nose.plugins.helloworld: INFO: HelloWorld Plugin: Hello, World!

先ほどとは finalize() メソッドで出力されるログのメッセージが変更されていることがわかる。

次にコマンドラインオプションとして --message を追加しよう。 ここに出力してほしいログメッセージを指定する。

$ nosetests -vv --with-helloworld --message 'Hello, Plugin!'
nose.config: INFO: Ignoring files matching ['^\\.', '^_', '^setup\\.py$']

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK
nose.plugins.helloworld: INFO: HelloWorld Plugin: Hello, Plugin!

--message オプションで指定したメッセージが出力されていることがわかる。

別のコールバックも実装してみる

次は nose のテストランナーがそれぞれのテストを実行する直前に呼び出されるコールバックをプラグインに実装してみよう。 これには beforeTest() というメソッドをプラグインに実装する。

$ cat << 'EOF' > nose_helloworld/__init__.py
# -*- coding: utf-8 -*-

import logging

from nose.plugins import Plugin

LOG = logging.getLogger('nose.plugins.helloworld')


class HelloWorldPlugin(Plugin):
    """My first plugin"""
    name = 'helloworld'

    def beforeTest(self, test, *args, **kwargs):
        LOG.info('HelloWorld Plugin: {test}'.format(test=test))

EOF

用意できたらパッケージをインストールし直そう。

$ python setup.py install

動作確認のためにシンプルなテストを用意する。 これは、実際には何もテストしていない。

$ cat << 'EOF' > test_helloworld.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

from nose.tools import ok_


class Test(object):

    def test(self):
        ok_(True)

EOF

準備ができたら nosetests コマンドを実行する。

$ nosetests -vv --with-helloworld
nose.config: INFO: Ignoring files matching ['^\\.', '^_', '^setup\\.py$']
nose.plugins.helloworld: INFO: HelloWorld Plugin: test_helloworld.Test.test
test_helloworld.Test.test ... ok

----------------------------------------------------------------------
Ran 1 test in 0.002s

OK

今度はテストランナーが見つけたテストの test_helloworld.Test.test が実行される直前にコールバックが実行されていることがわかる。

まとめ

今回は Python のテストフレームワーク nose のプラグインを書く方法について調べてみた。 nose は自作パッケージを用意して、そこにプラグインとなるクラスを定義することで挙動をカスタマイズできる。 この仕組みは nose の中でも使われていて、いくつかの組み込みプラグインが用意されている。 例えばテストのカバレッジを調べるための --with-coverage オプションも、今回調べたプラグインの機構を利用しているようだ。