たごもりすメモ

コードとかその他の話とか。

テストケースのベースクラス GAETestBase を作った

前に書いた記事からはやくも半月たってしまったが、やっと出してもいいかなという状態になったので公開します。ファイルそのものはこちら

GAETestBaseはPython標準のunittest.TestCase継承クラスとなってます。ユニットテストを書く際に、通常のTestCaseのかわりにGAETestBaseを継承してユニットテスト用のクラスを作り、あとは普通にテストコードが記述できます。
PythonおよびAppEngine SDK以外への依存がゼロで環境を選ばない、はず、なのが売りかな?

ところで出来上がったあと公開用の準備をやってたら、GAE/J側ではslim3kotori組み込んだという話が。おおお。

できること

  • CLIでのテスト実行
    • 開発中のテスト実行が、Python標準のunittestと全く同じようにCLIから実行できます
    • テストケース側で指定があれば各サービスはremote_api経由でproduction環境に接続します*1
  • GAEUnit経由でのテスト実行
    • GAEUnitについてはGAEUnit試してみた - tagomorisのメモ置き場参照のこと
    • dev_appserver.py実行時にブラウザ経由でテスト実行できます
    • productionにアップロード時、指定のあるテストケースについてはproductionの各サービスを使用してテスト実行できます
  • 専用のkindでのテスト実行
    • アプリケーションのコードには一切手を入れずに、テスト実行時のみkindを専用のものに変更して実行します
      • 例えばモデルクラス MyModel に対してkindを t_MyModel として実行*2
      • ただしアプリケーション中のKeyの扱いに注意!
Key.from_path('MyModel', id)       # NG!
Key.from_path(MyModel.kind(), id)  # OK!
    • 特にremote_apiおよびproduction(GAEUnit)での実行時、実際のデータを汚さずにテスト実行が可能です
    • もちろん指定により、実データにアクセスするようなテストの記述も可能です*3
  • テストに使用したkindのクリンナップ
    • テスト実行後、テスト中で使用したKindのエンティティ一括削除が可能です*4
      • そのテストケース内で参照されたKindのみが対象となります
      • 参照だけでクリンナップ対象になっちゃいます
    • 実データへのアクセスと混ぜると大変危険ですのでご注意ください

やってみよう!

ファイル設置とデプロイ

テスト記述前にまず、以下のみっつの準備が必要です。これだけやれば動くはず。

  1. gaeunit.py設置
    • ファイルをダウンロードして自分のプロジェクトに置いておく
    • テストコードが test/*.py なら中身を書換える必要はないが、違うなら(そして変更できないなら)そこの指定だけ直しておく
  2. app.yaml記述
    • gaeunit用のURIとremote_apiの設定を以下のような感じで済ませておく
- url: /remote_api
  script: $PYTHON_LIB/google/appengine/ext/remote_api/handler.py
  login: admin
- url: /test.*
  login: admin  # This is important if you deploy the test directory in production!
  script: gaeunit.py
  1. test/ 作成とgae_test_base.py設置、各定数の編集
    • テストコードを置くためのディレクトリにgae_test_base.pyを置き、以下の各定数を編集する
      • GAE_HOME: ローカルでのGAE SDKへのパス
      • PROJECT_HOME: テスト対象のプロジェクトへのパス*5
      • APP_ID: アプリケーション名 (APP_ID.appspot.com の APP_ID 部分)
      • REMOTE_API_ENTRY_POINT: app.yamlにおけるremote_api用のURI指定 (上の例だと /remote_api)
    • 既にテストで使用しているdatastore_file_stubのデータファイルがある場合は、その指定を行う
      • gae_test_base.pyのget_dev_apiproxy関数定義内、DatastoreFileStub() の第2、第3引数
ローカルでのテスト実行

さて、ではまず普通にローカルで実行するテストを書いてみましょう。ごくふつーのユニットテストです。ここで test_defs はModelの定義とかしてるだけなので、実際にはテスト対象が記述されているモジュールに置きかえるものとしてください。

from gae_test_base import *

from test_defs import *
from google.appengine.ext import db

class DummyTest1(GAETestBase):

    def test_put(self):
        x1 = XEntity(x="x1")
        k1 = x1.put()
        self.assertEqual(db.get(k1).x, "x1")

    def test_tx(self):
        def tx1(x2key, x2, x3):
            x2 = XEntity(key=x2key, x=x2)
            x3 = XEntity(x=x3, parent=x2)
            x2.put()
            x3.put()
            return (x2.key(), x3.key())
        x2k = db.allocate_ids(db.Key.from_path(XEntity.kind(), -1), 1)[0]
        k2, k3 = db.run_in_transaction(tx1, db.Key.from_path(XEntity.kind(), int(x2k)), "x2", "x3")
        self.assertEqual(db.get(k2).x, "x2")
        self.assertEqual(db.get(k3).x, "x3")

実行内容はごく簡単で、以下のふたつです。以降のテストはすべて同様の内容です。

  1. test_put
    • エンティティをひとつ作成してput()する
    • 返ってきたキーをもとにエンティティを取得し、プロパティ値が同じことを確認する
  2. test_tx
    • 以下の処理を行うトランザクション単位の関数 tx1 を定義する
      • ルートエンティティのキー、ルートエンティティおよび子エンティティのプロパティ値を受け取る
      • ルートエンティティおよび子エンティティを作成
      • 両方put()
      • 両方のキーを返す
    • allocate_idsを使用してルートエンティティ用のidをひとつ取得
    • tx1をトランザクション実行
    • 返ってきたキーふたつをもとにエンティティを取得し、プロパティ値がそれぞれ同じことを確認する

これをCLIで普通のユニットテストとして実行すると以下のような感じ。

$ python2.5 dummy_test.py 
..
                                                                                                                                          • -
Ran 2 tests in 0.020s OK $

ごく普通に実行できる、ただのユニットテストですね。
dev_appserverで実行するテストも普通に実行されるだけです。逆に言うと普通に実行できます。

これはproductionに上げてもdatastore_file_stubで処理されるだけなんで、省略。

remote_apiおよびproductionでのテスト実行

では次に、同様の動作のテストコードをproductionのDatastoreを対象に実行するテストケースを追加してみます。ローカルCLIでの実行時はremote_apiを使用し、production+GAEUnitでの実行時はproductionのDatastoreを使用するよう、TestCaseクラスに指定します。*6

class DummyTest2(GAETestBase):
    USE_PRODUCTION_STUBS = True
    USE_REMOTE_STUBS = True

    def test_put(self):
        y1 = YEntity(y="y1")
        k1 = y1.put()
        self.assertEqual(db.get(k1).y, "y1")

    def test_tx(self):
        def tx1(y2key, y2, y3):
            y2 = YEntity(key=y2key, y=y2)
            y3 = YEntity(y=y3, parent=y2)
            y2.put()
            y3.put()
            return (y2.key(), y3.key())
        y2k = db.allocate_ids(db.Key.from_path(YEntity.kind(), -1), 1)[0]
        k2, k3 = db.run_in_transaction(tx1, db.Key.from_path(YEntity.kind(), int(y2k)), "y2", "y3")
        self.assertEqual(db.get(k2).y, "y2")
        self.assertEqual(db.get(k3).y, "y3")

テスト動作の指定はGAETestBase継承クラスへのクラス変数(定数)により行います。指定されていない項目値はすべてFalseがデフォルトです*7。DummyTest2はDummyTest1に対して、XEntityのかわりにYEntityを使用する、という点(および動作の指定)のみしか変更していません。
これをCLIで実行すると、以下のようになります。

$ python2.5 dummy_test.py 
....
                                                                                                                                          • -
Ran 4 tests in 6.578s OK $

DummyTest1に加えてDummyTest2が実行されているのがわかると思います。*8
なおこの実行時には認証情報が保存されている(appcfg.pyが保存するのと同じ)ため省略されていますが、remote_apiを使用する最初のテストケース実行時にはメールアドレスとパスワードを要求されます。

dev_appserver.pyを起動しブラウザでアクセスしたのがこちら。GAEUnitの画面が確認できますが、このときはDummyTest2はremote_apiを使用していません。

これらのコードをproduction環境にデプロイし、GAEUnitでアクセスしたのがこちら。テスト実行後に管理コンソールのDatastore Viewerから状態を確認すると、以下のように、テストで使用したエンティティが保存されているのがわかります。エンティティのKindはテスト専用の 't_YEntity' となっています。

テストに使用したエンティティのクリンナップ

テストやったあとエンティティが残るのは嫌だ、というあなたのために、エンティティのクリンナップを実行するテストケースを書きましょう。以下のコードを追加します。

class DummyTest3(GAETestBase):
    USE_PRODUCTION_STUBS = True
    USE_REMOTE_STUBS = True
    CLEANUP_USED_KIND = True

    def test_put(self):
        z1 = ZEntity(z="z1")
        k1 = z1.put()
        self.assertEqual(db.get(k1).z, "z1")

    def test_tx(self):
        def tx1(z2key, z2, z3):
            z2 = ZEntity(key=z2key, z=z2)
            z3 = ZEntity(z=z3, parent=z2)
            z2.put()
            z3.put()
            return (z2.key(), z3.key())
        z2k = db.allocate_ids(db.Key.from_path(ZEntity.kind(), -1), 1)[0]
        k2, k3 = db.run_in_transaction(tx1, db.Key.from_path(ZEntity.kind(), int(z2k)), "z2", "z3")
        self.assertEqual(db.get(k2).z, "z2")
        self.assertEqual(db.get(k3).z, "z3")

追加したのは CLEANUP_USED_KIND です。また使用するモデルを ZEntity にしています。
CLIで実行したら以下のようになります。

$ python2.5 dummy_test.py 
......
                                                                                                                                          • -
Ran 6 tests in 19.120s OK $

更に遅くなっているのがわかると思いますが、問題なく実行されています。dev_appserverでも同じく。ここでDatastore Viewerを見てみるとこんな感じ。

ZEntity(正確には t_ZEntity)は全く残っていません。productionにデプロイしてGAEUnitで実行しても、結果から問題なく実行されたのがわかりますが、その後のDatastoreの状態(リンク)を見ても、やはりZEntityがクリンナップされているのがわかると思います。

テストケース毎のエンティティ集合の分離

いらないZEntityが無くなるのはわかったが、テストによってはput()したものを残したい場合もあるんじゃい! 実際のアプリでModel名をそうそう変えられるわけもないだろ! どうしてくれるんだ! というあなたのため、テストケース毎にKind名を変更し、それぞれでクリンナップする/しないを変えられるようになっています。

class DummyTest4(GAETestBase):
    USE_PRODUCTION_STUBS = True
    USE_REMOTE_STUBS = True
    CLEANUP_USED_KIND = False
    KIND_PREFIX_IN_TEST = 'test'

    def test_put(self):
        z1 = ZEntity(z="z1")
        k1 = z1.put()
        self.assertEqual(db.get(k1).z, "z1")

    def test_tx(self):
        def tx1(z2key, z2, z3):
            z2 = ZEntity(key=z2key, z=z2)
            z3 = ZEntity(z=z3, parent=z2)
            z2.put()
            z3.put()
            return (z2.key(), z3.key())
        z2k = db.allocate_ids(db.Key.from_path(ZEntity.kind(), -1), 1)[0]
        k2, k3 = db.run_in_transaction(tx1, db.Key.from_path(ZEntity.kind(), int(z2k)), "z2", "z3")
        self.assertEqual(db.get(k2).z, "z2")
        self.assertEqual(db.get(k3).z, "z3")

このテストケースはDummyTest3と同じくZEntityを使用していますが KIND_PREFIX_IN_TEST として 'test' を指定しています。これにより、このテストケース内ではZEntityのkindは 'test_ZEntity' となります。また CLEANUP_USED_KIND = False となっている*9ので、これらはクリンナップ対象になりません。

CLIの実行結果が以下。

$ python2.5 dummy_test.py 
........
                                                                                                                                          • -
Ran 8 tests in 24.182s OK $

dev_appserver+GAEUnitでの実行結果、production+GAEUnitでの実行結果もそれぞれ、問題ない内容です。その後のエンティティ状態が以下。ちゃんとtest_ZEntityだけ残ってます。

実データへのアクセス

最後に実データへのアクセスを行うテストケースをひとつ作ってみましょう。こんな感じです。

class DummyTest5(GAETestBase):
    USE_PRODUCTION_STUBS = True
    USE_REMOTE_STUBS = True
    CLEANUP_USED_KIND = False
    KIND_NAME_UNSWAPPED = True

    def test_put(self):
        count = PEntity.all().count()
        p1 = PEntity(i=count)
        k1 = p1.put()
        px = db.get(k1)
        self.assertEqual(px.i, count)

KIND_NAME_UNSWAPPED = True とすると、Modelに対するKindの入れ替えを行いません。USE_REMOTE_STUBS や USE_PRODUCTION_STUBS (もしくは両方)の指定と同時に使用することで、production環境の実データにアクセスできます。
テストコードの内容自体は、PEntityを数えて、その結果をもったPEntityを新しくひとつput()するだけ。実行するとそれぞれ以下のようになります。

$ python2.5 dummy_test.py 
.........
                                                                                                                                          • -
Ran 9 tests in 23.045s OK $

dev_appserverで問題なく実行され、productionでも同じ結果となっています。この結果として、DatastoreにはPEntityが残っていることが確認できます。

使用上の注意

productionではテストが並列に走ります (5/17追記)

production環境でGAEUnitからテストを走行させると、全テストが並列に実行されます。
このためテストがシーケンシャルに走ることを前提にしたように書かれていると、dev環境で問題なくパスしていたテストがproductionで失敗するという現象が見られます。注意しましょう。

setUp/tearDownはsuper呼んでね → 呼ばなくてもOKになりました(5/13追記)


setUp/tearDownでapiproxy_stubのセットアップをやってるので、忘れると動かなくなります。setUp/tearDownを定義するときはちゃんとGAETestBaseのsetUp/tearDownを呼びましょう。superの呼び出しはsetUpは最初、tearDownは最後にしてください。いやほんとに。

class HogeTest(GAETestBase):

    def setUp(self):
        super(GAETestBase, self).setUp()
        any.setup.code.here()

    def tearDown(self):
        any.teardown.code.here()
        super(GAETestBase, self).tearDown()


(5/13 追記) 環境の準備と実行はsetUp/tearDownの段階で自動的に呼ばれるように変更しました。テストケース側では普通にテスト内容のための準備だけ書けばOKです。

class HogeTest(GAETestBase):
    def setUp(self):
        any.setup.code.here()

    def tearDown(self):
        any.teardown.code.here()

こうすると以下の順序で実行されます。

  • テストのセットアップ
    1. 環境のセットアップ (gae_test_base内に記述の部分)
    2. HogeTest.setUp() 記述部分
  • テストの後処理
    1. HogeTest.tearDown() 記述部分
    2. 環境の後処理 (gae_test_base内に記述の部分)

このため setUp/tearDown 内の記述においても、GAEの各機能はそのまま使用できます。

エンティティのクリンナップはGAETestBase.tearDown()内で実行されます。あんまり大量のエンティティを出し入れするときっと重いと思うのでほどほどにしましょう。

実データアクセスとエンティティのクリンナップ混ぜるな危険

間違えて本番データ全消ししてしまってもいっさい責任はとれません。

本当はこの組み合わせでクリンナップは一切動かないようにしようかとも思ったけど、うーん、と悩んでそのまま。

remote_apiはたいへん遅い

前述の結果を見てもわかりますが、remote_apiを使うテストケースを大量に書くとテスト実行が死ぬほど遅くなります。ほどほどにしましょう。

これから

  • 誰かこのエントリ英訳してくれない?w
  • テスト環境を作ってみたが走らせるテストコードが手元にあんまりない問題
    • 実アプリ書く前にテスト環境いじりはじめちゃったので、こんなよくわかんない状態に。どうすんだ。
  • DatastoreSimulatorStub作りたいなあ

テスト環境いじるのはこのくらいにして、いいかげんアプリを書こうかと考え中。続きはしばらく先かなあ。

*1:productionの各サービスを使用できるが超絶遅い&Datastoreのトランザクション実装は結局ダミー実装。ただしdev環境用のものに較べればかなりマシ?

*2:デフォルト動作、prefixは変更可能

*3:気をつけてね!

*4:指定のあるテストケースのみ

*5:いまさら思ったがこれ dirname(__file__)/../ でよくね? あとで直そうかな

*6:ローカルGAEUnitでの実行時には認証情報を取得できないため、必ず datastore_file_stub で実行します。

*7:kindのprefix指定を除いて。kindのprefixデフォルト値は 't_' です。

*8:この程度のテスト実行に何秒もかかっているのもわかると思います

*9:実際にはデフォルト False なので、指定を削除すれば良いです