2010-10-20

GAE アプリ用に PyUnit を使って単体テストを書く

単体テスト

(「アジャイルソフトウェア開発の奥義」p.34)
テストを最初に書くことによって、これまでとは違った視点で物事を見ざるをえなくなるということだ。テストを書こうとすれば、呼び出す側の立場でプログラムを見るようになる。[...snip...] テストを最初に書くことは、ソフトウェアを呼び出しやすい形式に設計することにつながるのだ。

その上、テストを最初に書こうとすれば、プログラムをテスト可能な形式に設計しようと努力するようになる。[...snip...] テストを最初に書くという行為は、ソフトウェアの分離を強いるのだ

もうひとつの効果は、テストそのものが有益なドキュメントになるということである。[...snip...] この「ドキュメント」はコンパイルも実行も可能であるばかりでなく、常に最新だし、間違いはありえあない。

本体のコードを書く前にテストコードから書き始める、というテスト駆動開発の教義を文字通り実行しないとしても、テストを書くことで違った視点が得られることは確かだ。また、これも良く言われるようにリファクタリングを実行するための後押しにもなる。

そして、何より大事なことは、テストは「動くドキュメント」になるという点だ。

PyUnit

Python には標準添付ライブラリに、単体テストを書き、実行するためのモジュール(unittest)がふくまれている。このモジュールは JUnit の Python 版と言われるだけあって、JUnit を使ったことがあれば、テスト実行の仕組みを理解するのにも、実際にテストを書くのにも苦労しないはずだ。

(単体テストサンプル)
import unittest

# target module 
import foo

class FooTest(unittest.TestCase):
    def test_bar(self):
        self.assert_([...snip...])
    [...snip...]

GAE アプリに適用するには

テスト用のディレクトリを用意する

Python 版の GAE アプリではアプリのソースコードがそのままサーバに送られる。だが、テスト自体のコードをサーバに送るのはムダだ。となれば、アプリのソースとテストはディレクトリを分ける方が良い。

たとえば、こんな風に。

(GAE アプリのディレクトリ構造)
$(APPTOP)/
    +--- src/
    |    +--- app.yaml
    |    +--- main.py
    |    |    ...
    +--- tests/
         +--- run_all_tests.py
         |    ...
モジュールサーチパス

テストをアプリのコードとは別のディレクトリに置く場合、そのままでは import に失敗する。tests ディレクトリにあるテストから src にあるテストの対象となるモジュールを import するには、src をモジュールサーチパスに入れてやらなければならない。

また、GAE のコードであれば、SDK のモジュールをあれこれ import しているはずだから、これもモジュールサーチパスに入れておく必要がある。GData ライブラリのような外部ライブラリを使っているのであればそれも。

今回、モジュールサーチパスを設定するために用意したコードがこれになる。アプリコード用の src に加え、GData ライブラリの atomgdata、そして GAE の SDK のパスも入れてある。

(tests/pathconf.py)
 1: #!/usr/bin/python2.5
 2: 
 3: import os
 4: import sys
 5: 
 6: current_dir = os.path.dirname(__file__)
 7: extra_path = [
 8:     current_dir + '/../src',
 9:     current_dir + '/../src/atom',
10:     current_dir + '/../src/gdata',
11:     '/usr/local/google_appengine',
12:     '/usr/local/google_appengine/lib/antlr3',
13:     '/usr/local/google_appengine/lib/django',
14:     '/usr/local/google_appengine/lib/fancy_urllib',
15:     '/usr/local/google_appengine/lib/ipaddr',
16:     '/usr/local/google_appengine/lib/webob',
17:     '/usr/local/google_appengine/lib/yaml/lib',
18:     ]
19: sys.path = extra_path + sys.path
20: 
21: if __name__ == '__main__':
22:     print sys.path

したがって、実際のテストは以下のように書く。

(tests/skelton_testcase.py)
 1: #!/usr/bin/python2.5
 2: 
 3: import unittest
 4: 
 5: import pathconf
 6: # target module 
 7: import foo
 8: 
 9: class FooTest(unittest.TestCase):
10:     def test_bar(self):
11:         self.assert_(False)
12: 
13: def suite():
14:     return unittest.TestSuite((
15:             unittest.makeSuite(FooTest, 'test'),
16:             ))
17: 
18: if __name__ == '__main__':
19:     unittest.TextTestRunner().run(suite())
テストできない機能も出てくる

単にモジュールサーチパスを整えただけでは、GAE アプリの実行環境すべてをシミュレートできるわけではない。当然、単体テスト環境では実行できない機能も出てくる。

その制約の中で、いかにテスト対象のコードを GAE の実行環境から独立させるかを考えることが、結果としてコードの独立性を高めることにつながるのだ。

テストを書くだけでも効果がある(かも)

Blogger Glass (以下、BG)にテストを書き始めて(というより、テストの組み込み方をあれこれ試していて)すぐにバグが 1 つ見つかった。BG ではsrc/config.py でアプリの設定を src/config.yaml というファイルから読み込んでいる。その部分のコードは以下のようになっていた。

(src/config.py より)
settings = {}

with open("config.yaml") as f:
    for line in f:
        md = re.search('^([^:]+):\s*([^\s].*)$', line)
        if md:
            settings[md.group(1)] = md.group(2)

これを test ディレクトリに置いたテストケースから import すると、config.yaml が見つからずエラーになる。アプリのコードでは import するコード (最新版では src/util.py) も同じディレクトリにあるためエラーにならない。しかし、それでは config モジュールを利用する側に条件を付けることになってしまう。言葉を換えれば情報隠蔽に失敗している。

このコードの open は、正しくは以下のように書かれるべきだったのだ。

(src/config.py の修正)
with open(os.path.join(os.path.dirname(__file__), "config.yaml")) as f:

これなら、src 以外のディレクトリから import してもエラーにはならない。

テストを書こうとすれば、コードを違った視点で見るようになることも、分離を強いられる(コンポーネントの独立性が高まると言っても良い)ことも間違いないようだ。

参考文献

アジャイルソフトウェア開発の奥義 第2版 オブジェクト指向開発の神髄と匠の技
ロバート・C・マーチン
ソフトバンククリエイティブ ( 2008-07-01 )
ISBN: 9784797347784
おすすめ度:アマゾンおすすめ度

「第4章 テスティング」がテストに関する内容。前半が単体テストを使ったテスト駆動開発について書かれたものになっている。

初めてのPython 第3版
Mark Lutz
オライリージャパン ( 2009-02-26 )
ISBN: 9784873113937
おすすめ度:アマゾンおすすめ度

モジュールサーチパスについては、p.416 〜 421 に記述がある。

関連リンク

関連記事

2010-10-19

Blogger Glass に Google Data Python Library を組み込む #2

今回は、実際に Google Data Python Library (以下、GData ライブラリ) を組み込んでみる。feed/{core,parse,post}.py の代わりに GData ライブラリを使うわけだ。

試してみてわかったことだが、Blogger で作った公開ブログの記事を取得するだけであれば、GAE アプリで GData のための認証は必要ない。

準備

ライブラリのソースをコピーする

GData ライブラリのパッケージを展開すると、src 以下に atomgdata の 2 つのディレクトリが見つかる。これがライブラリ本体。GAE アプリで使う場合は、この 2 つを丸ごとアプリのディレクトリ(app.yaml が置かれているところ)にコピーする。こんな感じ(↓)。

[imac] mnbi% tar xzf ~/Downloads/gdata-2.0.12.tar.gz
[imac] mnbi% cd gdata-2.0.12/src
[imac] mnbi% ls
atom/ gdata/
[imac] mnbi% cp -R atom gdata ~/projects/bloggerglass/src

このようにコピーしたら、リクエストハンドラのコードなどから以下のように import できる。

(GData ライブラリの使い方)
import gdata.blogger.client
クライアントとサービス、どちらを使うべきか?

GData ライブラリの内容を見ていると、XxxxClientXxxxService という 2 系統のクラスが存在していることに気付く。ドキュメントやサンプルにも、Client系を使っているものとService系を使っているものの両方がある。

(Client 系)
atom.client.AtomPubClient
+--- gdata.client.GDClient
     +--- gdata.blogger.client.BloggerClient
(Service 系)
atom.service.AtomService
+--- gdata.service.GDataService
     +--- gdata.blogger.service.BloggerService

実装されている機能(メソッド)を見ると、どちらも同じ機能を実装しており、混在させて使う必要はない。XxxxClient を使うなら XxxxService は使わないし、逆もまたしかり。なぜ 2 系統、用意されているのだろう? 公式ドキュメントにはこの理由は書かれていない(少なくともわたしは見つけられなかった)。

BloggerClientBloggerService の機能比較 (抜粋)
BloggerClient BloggerService 機能
get_blogs GetBlogFeed ブログ一覧を取得する。
get_posts GetBlogPostFeed ブログの記事を取得する。
get_post_comments GetPostCommentFeed ブログの記事へのコメントを取得する

ライブラリのソース中のコメントなどから推測すると、どうやら XxxxService は古いライブラリのようだ。いずれ deprecated 扱いになるようで、これは GData のプロトコルのバージョン(1 と 2 がある)と関係があるらしい。

簡単に言えば、XxxxClient を使え、ってことだ。Blogger Data API の場合、gdata.blogger.client.BloggerClient を使うことになる。

リクエストハンドラの変更

一覧表示

基本は gdata.blogger.client.BloggerClient をインスタンス化して、そのメソッド get_posts を呼び出せば OK。Blogger Glass では記事の一覧を一定数ごとにページに分けるので、目的のページに合わせて start_index を調整し、クエリパラメータとして、このメソッドにわたす。

(src/main.py より)
import gdata.blogger.client
[...snip...]
PAGESIZE = 25

class MainHandler(webapp.RequestHandler):
    [...snip...]
    def fill_viewinfo(self, page):
        client = gdata.blogger.client.BloggerClient()
        settings = get_settings()
        start_index = page * PAGESIZE + 1
        q = gdata.blogger.client.Query(start_index=start_index,
                                      max_results=PAGESIZE)
        feed = client.get_posts(settings.get('blog_id'), query=q)
        if feed:
            [...snip...]
            self.viewinfo.content = feed.entry
            [...snip...]

ページごとの記事数をデフォルト値(25)から変更しない限り、max_results を指定する必要はない。ここでは start_index を計算するために記事数(PAGESIZE)を使っているので、そのまま max_results としてわたしている。こうしておけば、PAGESIZE の値を変えることで取得する記事数も変わるようになる。さらに一歩進めて、ページごとの記事数も settings から参照できるようにすれば、実行時に記事数を変えることもできる。ま、それはまた別の機会に。

クエリパラメータとして指定できる項目については、Protocol Reference - Google Data Protocol にまとめられている。実際に、コードでどう指定するかについては、gdata.client.Query のソースに書かれたコメントを参照すること。

ただし、Blogger API では qauthor はサポートされていない、とのこと(→ Blogger query parameters reference - Reference Guide - Blogger APIs)。また、updated-minupdated-max を指定する際には、orderby に "updated" を指定しなければならない、とのこと。

上記の get_posts の戻り値は gdata.blogger.data.BlogPostFeed のインスタンスになる。このインスタンスの entry 属性は gdata.blogger.data.BlogPost のインスタンスのリストになっていて、これが個々の記事の内容になっている。このため、self.viewinfo.contentgdata.blogger.data.BlogPost のリストを指すことになる。

(src/listview.html より)
<div id="content">
<ol>
{% for entry in view.content %}
  <li><a href="/post/?id={{ entry.get_post_id }}">{{ entry.title.text }}</a></li>
{% endfor %}
</ol>
</div>

よって、テンプレート中の記述で entryself.viewinfo.contentgdata.blogger.data.BlogPost のインスタンスを指している。つまり、entry.get_post_identry.title.textgdata.blogger.data.BlogPost の属性参照(とメソッド呼び出し)になっている。このような記述のしかたは、ある意味、バックエンドの仕組みがプレゼンテーション層に漏らしていることになるので、ちょっと気になる。とはいえ、データを別のオブジェクトに詰め直すのもムダだし、GData ライブラリはスタンダード的なものでもあるから、あえて独自のオブジェクトを作る必要はないと判断した。

内容表示

こちらの変更では少し苦労した。というのも、gdata.blogger.client.BloggerClient には、「Post ID」を指定して個別の記事の内容を取得するためのメソッドが用意されていなかったからだ。

そこで、gdata.blogger.client.BloggerClient を継承した BloggerPostClient を作り、そこに「Post ID」で記事の内容を取得するメソッド get_one_post を定義した。肝になるのは get_feed の呼び出しで desired_classgdata.blogger.data.BlogPost を指定している部分。GData プロトコルで「Post ID」を指定したときに返ってくるデータは、単純にブログ記事を要求したときとは違う形式になっている。GData ライブラリを使うときにはどういうデータが返ってくるのかに合わせて、どのデータクラスを使うかを変えなければならない。ちなみに、これらのデータクラスは gdata.blogger.data モジュールで定義されている。

(src/postview.py より; #1)
class BloggerPostClient(gdata.blogger.client.BloggerClient):
    def get_one_post(self, blog_id, post_id):
        """Get one post data specified with post_id,
        then return it as an instance of BlogPost.
        """
        if post_id != 0:
            url = (gdata.blogger.client.BLOG_POST_URL % blog_id) + ('/%s' % post_id)
            return self.get_feed(url, auth_token=None,
                                 desired_class=gdata.blogger.data.BlogPost,
                                 query=None)
        else:
            # when post_id was not specified, get the latest post.
            q = gdata.blogger.client.Query(max_results=1)
            feed = self.get_posts(blog_id, query=q)
            # Since 'get_posts' returns an instance of BlogPostFeed,
            # the 1st item (an instance of BlogPost) of BlogPostFeed.entry
            # must be return.
            if feed:
                return feed.entry[0]
            else:
                return None   

    GetOnePost = get_one_post

リクエストハンドラ(PostViewHandler)の方は、↑のクラスをインスタンス化して、get_one_post を呼び出すだけ。

(src/postview.py より; #2)
class PostViewHandler(webapp.RequestHandler):
    [...snip...]
    def fill_viewinfo(self, post_id):
        client = BloggerPostClient()
        settings = get_settings()
        entry = client.get_one_post(settings.get('blog_id'), post_id)
        if entry:
            self.viewinfo.title = entry.title.text
            self.viewinfo.permalink = entry.get_html_link().href
            self.viewinfo.content = entry.content.text

ここでは、テンプレートにわたす情報として gdata.blogger.data.BlogPost そのものではなく、PostViewInfo (のインスタンス)に「詰め直し」をしている。こうすることでテンプレート側は修正する必要がなくなったが、一方で、一覧表示の方との実装方針(上述)に齟齬をきたしているとも言える。ま、些細なことなんだがね。

おまけ: GAE アプリで favicon.ico を指定する

GAE の管理コンソールでは、エラーの発生したリクエスト(の URI)を見ることができる。Blogger Glass を稼動させて以来、ずっと /favicon.ico がエラーになっていた。これが気になったので、favicon を置くことにした。ちなみに、→ がそのデザイン(意匠)だ。

ただし、単に favicon.ico をアプリのトップディレクトリに置いてもダメ。ググってみたところ、GAE 公式ドキュメントの FAQ 「Google App Engine の一般的質問」の中に favicon.ico の置き方が書かれていた。

Blogger Glass では、favicon.icoimages ディレクトリに置き、app.yaml に以下の記述を追加した。

(src/app.yaml より)
- url: /favicon.ico
  static_files: images/favicon.ico
  upload: images/favicon.ico

関連リンク

関連記事

2010-10-18

Blogger Glass に Google Data Python Library を組み込む #1

まずは調査。Google Data Python Library (以下、GData ライブラリ)ってどうやって使うのか? もちろん、GAE のアプリに組み込むことを前提として。

GData ライブラリとは?

(「Developer's Guide Overview - Google Data Protocol」より)
The Google Data Protocol provides a secure means for external developers to write new applications that let end users access and update the data stored by many Google products. External developers can use the Google Data Protocol directly, or they can use any of the supported programming languages provided by the client libraries.

Blogger や Google Docs を使ってユーザが蓄えたデータを見たり、編集したりするプログラムを作るための仕組み(プロトコル)が GData だ。それを使うために各種プログラミング言語用にライブラリが用意されていて、Python 用のものもその 1 つ。

GData (プロトコル) の基本は、HTTP (と XML) で GET や POST のリクエストを投げれば、結果が AtomPub XML で返ってくる、というものだ。このとき、肝になるのは以下の 3 つ。

  1. 認証
  2. リソースの指定
  3. データのフォーマット
認証

公開されているデータ(Blogger のブログ記事とか)は別として、ユーザのデータを閲覧、更新できるのは、それを作ったユーザ自身(と特別に許可された他のユーザ)だけだ。このため GData でデータをやったり取ったりするためには、まずそのプログラムが正当な許可を持っていることを確認しなければならない。それが「認証」。

GData で利用できる認証方式は以下の 3 つ。

このうち、ClientLogin はローカルな環境(例: Mac や iPhone)で動くプログラムで使う方式で、他の 2 つはウェブアプリ(例: GAE アプリ)で使うためのものだ。それぞれの詳細は、上記の各項目に張ったリンク先を参照のこと。

今回 Blogger Glass に組み込むのは、GAE 公式ドキュメントの記事で使われている AuthSub 方式にする。

AuthSub は、簡単に言うと、ウェブアプリの URL に対して認証を要求し、その証明としてトークンと呼ばれるデータを受け取る、という方式だ。受け取ったトークンは GData に対する HTTP リクエストのヘッダに埋め込んで利用する。

このあたりのことは、↑で挙げた GAE 公式ドキュメントの記事にサンプルコードとともに示されている。

リソースの指定

目的が閲覧にせよ、更新にせよ、まずは対象になるデータを特定しなければならない。この、対象となるデータの特定に利用する仕組みがクエリだ。クエリでは「○○という条件に合ったデータ」というように指定する。条件としては、個々のデータに割り当てられた ID であったり、カテゴリ(Blogger ではラベルと呼んでいる)であったり、作成日時や更新日時で絞り込むものなどがある。また、これらの条件はリクエストの URL として表現するので、ウェブアプリ中にリンクとして埋め込める。

Blogger のデータの場合、以下のような URL を用いることになる。

ブログ一覧
http://www.blogger.com/feeds/[user_id]/blogs
ブログの記事一覧
http://www.blogger.com/feeds/[blog_id]/posts/default

と、まあ、これはすでに Blogger Glass でも使っているものだ。GData ライブラリを使えば、これらの URL を知る必要はなくなる。gdata.blogger.BloggerClient のインスタンスに対して、get_blogs (ブログ一覧)や get_posts (記事一覧) を呼び出すだけだ。

データのフォーマット

データのフォーマットは、わたすのも受け取るのも Atom 形式になる。

Blogger Glass では Atom の解析を(必要最小限の分だけ)自前でやっているが、GData ライブラリを使うようになれば、そこもライブラリ任せにできる。

まとめ

GAE のドキュメントやサンプルコードを調べてわかったことは、これまで Blogger Glass の実装で、自前でやっていたフィードの取得や解析を GData ライブラリに任せてしまえる、ということだ。また、認証については、公開されているデータ(Blogger の記事など)であれば必要ない。なので、以下のようなコードで、BLOG_ID のブログの最新記事のデータを取得できる。

(GData ライブラリの利用方法)
import gdata.blogger.client
[...snip...]
client = gdata.blogger.client.BloggerClient()
query = gdata.blogger.client.Query(max_results=1)
feed = client.get_posts(BLOG_ID, query=query)

関連リンク

関連記事

twitter より (2010-10-17)

  • 14:29  「0を1に」「1を10に」「10を100に」っていう分類が(分類そのものではなく、その表現が)おもしろい。もう少し下世話に表現するなら「発明家」「起業家」「経営者」って感じかな。→ http://docs.komagata.org/4633
  • 14:34  なくなるというならケータイに乗り換えても良いんだけど(もうあきらめた)、電話番号だけはそのまま使いたい。あちこちに連絡先として登録してあるから変更するのがメンドウだよ。 → http://bit.ly/99vJnd
Powered by twtr2src.

2010-10-17

Blogger Glass に Google Data Python Library を組み込む #0

今日は予告だけ。

Blogger Glass に対する次の変更は、機能追加ではなく、Blogger からの記事データの取得を Google Data Python Client Library を使って書き直すこと。

それで何が変わるかはまだわからない。記事の検索(ラベルを使った検索)あたりを実装しやすくなると期待しているんだが……。

何事も練習だから。

関連リンク

関連記事