2012年7月17日火曜日

web2py でのテスト ② doctestコードの書き方

前回 web2pyでのdoctestの使い方を説明しました。今回はどのように、doctestコードを書いたら良いのか解説してみたいと思います。

ただ、前提条件があります。前回の『テストの必要性』でも説明しましたが、今回のテストコードの目的は「品質向上のためのコード」でも「TDD(テスト駆動開発)のためのコード」でもありません。

目的は、コンパイルチェックの代わりとなる「構文チェック」のためのテストコードです。このため最低限のコードを実行するだけのテストになります。しかし今回説明するコードは、より複雑な目的のためのコードにも利用が可能です。

web2pyコントローラのコードを実行するPythonシェル

doctestに記述するコードは、

  1. Pythonシェルで実行する。
  2. 実行したコマンドとその結果をコピーして、テスト対象の関数のドキュメント文字列などに貼り付ける。
というのが作成手順である。

web2pyでテストコードを作成するためには、コントローラコードが実行可能な Python シェルを立ち上げる必要がある。コントローコードの実行可能シェルは、次のようにオプション S の後に、アプリケーション名と実行したい関数が記述されたコントローラファイル名を付けて起動する。

C:\Users\xxx\web2py>python web2py.py -S myapp/default -M

上記例では、myapp アプリケーションの default コントローラファイルを指定して起動している。

また、このままだとインタラクティブ・シェル(IPython)が立ち上がる。しかし、インタラクティブ・シェルのレスポンスと通常のPythonシェルのレスポンスとでは、返ってくる内容が違う場合がある。このため、オプション P を付けて通常のシェル環境を起動させるようにする。

C:\Users\xxx\web2py>python web2py.py -S myapp/default -MP

起動したPythonシェルで下のように、コントローラの関数に対するテストコードを、入力して実行できることが確認できる。

C:\Users\xxx\web2py>python web2py.py -S myapp/default -MP
web2py Web Framework
Created by Massimo Di Pierro, Copyright 2007-2011
Version 1.99.7 (2012-03-04 22:12:08) stable
Database drivers available: SQLite3, pymysql, pg8000, IMAP
Python 2.7.3 (default, Apr 10 2012, 23:24:47) [MSC v.1500 64 bit (AMD64)] on win
32
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> index().has_key('message')
True
>>>

この環境を使って、テストコードの作成を行っていく。

アクセス制御が設定されている関数に対するdoctest

アクセス制御が設定されている関数に対するテストを考えてみる。例えば、次のような関数だ。

1
2
3
4
5
6
7
8
@auth.requires_login()
def index():
    """
    >>> index().has_key('message')
    True
    """
    response.flash = "Welcome to web2py!"
    return dict(message=T('Hello World'))
@auth.requires_login によって、関数へのアクセス時にログインを要求される。以前のバージョンの web2py だと、特殊な設定をしないと doctestではエラーになった。しかし現在は、事前にログインしていれば(セッション期限に達しない限り)、管理画面上でのテストは正常にパスするようになっている。

しかし前回紹介したコマンドラインでのテストや、Pythonシェル上でのテストコード実行では、次のようなエラーになる。

>>> index().has_key('message')
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "C:\Users\xxx\web2py\gluon\tools.py", line 2565, in f 
    '?_next='+urllib.quote(next))
  File "C:\Users\xxx\web2py\gluon\tools.py", line 70, in call_or_redirect
    redirect(f(*args))
  File "C:\Users\xxx\web2py\gluon\tools.py", line 934, in <lambda>
    settings.on_failed_authentication = lambda x: redirect(x)
  File "C:\Users\xxx\web2py\gluon\http.py", line 128, in redirect
    Location=location)
HTTP: 303 SEE OTHER
>>>

この場合、次のコードを付加すると正常に動作させることが可能だ。

>>> bool(auth.login_bare('a@a.com', 'password'))
True
>>> index().has_key('message')
True

ここで login_bare は、ユーザ(a@a.com)及びパスワード(password)でログインを行う。ログインに成功すると login_bare は該当ユーザの auth_user レコードを返すが、冗長なため、ここでは bool関数でラップしている。

先ほどの関数にコードを追加する。このコードでは、コマンドライン上のテストもパスすることが可能だ(ユーザ及びパスワードは事前に登録しておく必要はある)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@auth.requires_login()
def index():
    """
    >>> bool(auth.login_bare('a@a.com', 'password')) 
    True
    >>> index().has_key('message')
    True
    """
    response.flash = "Welcome to web2py!"
    return dict(message=T('Hello World'))

@auth.requires_membership@auth.requires_permission といったデコレータを使用している場合も、ログインユーザが適切な権限を所持していれば、同様にコードを追加すればテストが可能となる。

@auth.requires_signature() が設定されている関数に対するdoctest

同じアクセス制御でも、@auth.requires_signature() デコレータを使った関数に対するテストは、もう少し複雑になる。requires_signature() の詳細はリンク先を参照してもらいたいが、一口に説明すると、認証されたリンクからのみアクセスを許可する機能だ。

1
2
3
4
5
6
7
8
@auth.requires_signature()
def index():
    """
    >>> index().has_key('message')
    True
    """
    response.flash = "Welcome to web2py!"
    return dict(message=T('Hello World'))
doctestは requires_signature()が設定されている関数に対しては、管理画面上のテストもパスすることができない。

この場合、次のコードを追加すれば、テストをパスすることが可能だ。

>>> bool(auth.login_bare('a@a.com', 'password'))
True
>>> url = URL(args=request.args, user_signature=True)
>>> request.get_vars._signature = url[url.find('signature=')+10:]
>>> index().has_key('message')
True

これは、URLから生成した認証用のハッシュ値をrequest変数に設定することにより、requires_signature()をパスすることが可能となる。また requires_signature() はログインを要求するため、login_bare()も使用している。

このコードを挿入することにより、テストコードを含めた index関数定義は次のように設定できる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@auth.requires_signature()
def index():
    """
    >>> bool(auth.login_bare('a@a.com', 'password')) 
    True
    >>> url = URL(args=request.args, user_signature=True)
    >>> request.get_vars._signature = url[url.find('signature=')+10:]
    >>> index().has_key('message')
    True
    """
    response.flash = "Welcome to web2py!"
    return dict(message=T('Hello World'))
オブジェクトを返す関数に対するdoctest

今まで辞書型データを返す関数に対しては、has_key() を利用した。もちろん、これ以外の属性などのチェックを利用するのもOKだ。それでは辞書型データを返さない関数に対するdcotestは、どう書いたら良いだろうか?。コントローラファイルに、次のような関数定義があったとする。

1
2
def __build_table(query, columns=None):
    return SQLTABLE(db(query).select(), columns=columns)

この関数に対しては、次のテストコードが考えられる。

1
2
3
4
5
6
def __build_table(query, columns=None):
    """
    >>> __build_table(db.auth_user)
    <gluon.sqlhtml.SQLTABLE object at 0x0000000003C6A780>
    """
    return SQLTABLE(db(query).select(), columns=columns)

しかしコードの 0x0000000003C6A780 の部分はメモリのアドレス値なので、テストの度に変更されてしまう。このため次のようにコードを変更する。

1
2
3
4
5
6
def __build_table(query, columns=None):
    """
    >>> __build_table(db.auth_user)      #doctest: +ELLIPSIS
    <gluon.sqlhtml.SQLTABLE object at 0x...>
    """
    return SQLTABLE(db(query).select(), columns=columns)

ここで #doctest: +ELLIPSIS は、doctestのオプション ELLIPSIS の使用を指示している。ELLIPSIS は出力文字列中の任意の文字列を ... で代替する。これによりアドレス値が変更になっても、テストをパスすることができる。

また、ELLIPSIS を使う代わりに、次のように記述することも可能だ。

1
2
3
4
5
6
def __build_table(query, columns=None):
    """
    >>> type(__build_table(db.auth_user))
    <class 'gluon.sqlhtml.SQLTABLE'>
    """
    return SQLTABLE(db(query).select(), columns=columns)
URL変数を使う関数に対するdoctest
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@auth.requires_signature()
def user_name():
    """
    >>> bool(auth.login_bare('a@a.com', 'password')) 
    True
    >>> url = URL(args=request.args, user_signature=True)
    >>> request.get_vars._signature = url[url.find('signature=')+10:]
    >>> user_name().has_key('name')
    True
    """
    r = db.auth_user(request.args(0))
    return dict(name = r.first_name + ',' + r.last_name)

URL変数を使う関数に対しては、テストを実施すると次のようなエラーが出る場合がある。

C:\Users\xxx\web2py>python web2py.py -T myapp
web2py Web Framework
Created by Massimo Di Pierro, Copyright 2007-2011
Version 1.99.7 (2012-03-04 22:12:08) stable
Database drivers available: SQLite3, pymysql, pg8000, IMAP
**********************************************************************
File "C:\Users\xxx\web2py\gluon\tools.py", line 2571, in default.py: user_name
       ・・・中略・・・
      File "applications\myapp\controllers\default.py", line 56, in user_name
        return dict(name = r.first_name + ',' + r.last_name)
    AttributeError: 'NoneType' object has no attribute 'first_name'

この場合次のように、requestオブジェクトに対して値を設定する必要がある(4行目)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@auth.requires_signature()
def user_name():
    """
    >>> request.args.append(1) 
    >>> bool(auth.login_bare('a@a.com', 'password')) 
    True
    >>> url = URL(args=request.args, user_signature=True)
    >>> request.get_vars._signature = url[url.find('signature=')+10:]
    >>> user_name().has_key('name')
    True
    """
    r = db.auth_user(request.args(0))
    return dict(name = r.first_name + ',' + r.last_name)
その他

web2pyでの doctest のテストコードを紹介したが、重要なことを書いてなかった。それは web2py での doctest対象はあくまでも、コントローラが対象になる。モデル定義やモジュールに対しても doctest を実施したい場合、コントローラファイル中にテストコードを記述する必要がある(モジュールでのdoctesの実行方法 を参照のこと)。

また doctest のオプションとして、ELLIPSIS を紹介した。他にも幾つか有用なオプションがあるようだ。例えば、doctest をスキップする SKIP というオプションがある。

1
2
3
4
5
6
7
8
def __build_table(query, columns=None):
    """
    >>> __build_table(db.auth_user) # doctest: +SKIP
    <gluon.sqlhtml.SQLTABLE object at 0x0000000003C6A780>
    >>> __build_table(db.auth_user)      #doctest: +ELLIPSIS
    <gluon.sqlhtml.SQLTABLE object at 0x...>
    """
    return SQLTABLE(db(query).select(), columns=columns)
3行目のコードは dcotest では実行しない。

参考:Python documentation - doctest


以上で今回の記事は終わりです。構文チェックのためにも、コントローラの全ての関数に対して、テストコードを書くことが望ましいです。構文チェックが主目的の場合は、テストパターンとか難しいことを考える必要がなく、コードの最後まで実行するテストコードを書くことを心がければ良いと思います。