テストケースのベースクラス GAETestBase を作った
前に書いた記事からはやくも半月たってしまったが、やっと出してもいいかなという状態になったので公開します。ファイルそのものはこちら。
GAETestBaseはPython標準のunittest.TestCase継承クラスとなってます。ユニットテストを書く際に、通常のTestCaseのかわりにGAETestBaseを継承してユニットテスト用のクラスを作り、あとは普通にテストコードが記述できます。
PythonおよびAppEngine SDK以外への依存がゼロで環境を選ばない、はず、なのが売りかな?
ところで出来上がったあと公開用の準備をやってたら、GAE/J側ではslim3がkotoriを組み込んだという話が。おおお。
できること
- CLIでのテスト実行
- GAEUnit経由でのテスト実行
- GAEUnitについてはGAEUnit試してみた - tagomorisのメモ置き場参照のこと
- dev_appserver.py実行時にブラウザ経由でテスト実行できます
- productionにアップロード時、指定のあるテストケースについてはproductionの各サービスを使用してテスト実行できます
- 専用のkindでのテスト実行
- アプリケーションのコードには一切手を入れずに、テスト実行時のみkindを専用のものに変更して実行します
- 例えばモデルクラス MyModel に対してkindを t_MyModel として実行*2
- ただしアプリケーション中のKeyの扱いに注意!
- アプリケーションのコードには一切手を入れずに、テスト実行時のみkindを専用のものに変更して実行します
Key.from_path('MyModel', id) # NG! Key.from_path(MyModel.kind(), id) # OK!
やってみよう!
ファイル設置とデプロイ
テスト記述前にまず、以下のみっつの準備が必要です。これだけやれば動くはず。
- gaeunit.py設置
- ファイルをダウンロードして自分のプロジェクトに置いておく
- テストコードが test/*.py なら中身を書換える必要はないが、違うなら(そして変更できないなら)そこの指定だけ直しておく
- app.yaml記述
- 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
- test/ 作成とgae_test_base.py設置、各定数の編集
- テストコードを置くためのディレクトリにgae_test_base.pyを置き、以下の各定数を編集する
- 既にテストで使用している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")
実行内容はごく簡単で、以下のふたつです。以降のテストはすべて同様の内容です。
- test_put
- エンティティをひとつ作成してput()する
- 返ってきたキーをもとにエンティティを取得し、プロパティ値が同じことを確認する
- test_tx
これをCLIで普通のユニットテストとして実行すると以下のような感じ。
$ python2.5 dummy_test.py ..
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
ごく普通に実行できる、ただのユニットテストですね。
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 ....
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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 ......
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
更に遅くなっているのがわかると思いますが、問題なく実行されています。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 ........
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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 .........
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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()
こうすると以下の順序で実行されます。
- テストのセットアップ
- 環境のセットアップ (gae_test_base内に記述の部分)
- HogeTest.setUp() 記述部分
- テストの後処理
- HogeTest.tearDown() 記述部分
- 環境の後処理 (gae_test_base内に記述の部分)
このため setUp/tearDown 内の記述においても、GAEの各機能はそのまま使用できます。
エンティティのクリンナップはGAETestBase.tearDown()内で実行されます。あんまり大量のエンティティを出し入れするときっと重いと思うのでほどほどにしましょう。
実データアクセスとエンティティのクリンナップ混ぜるな危険
間違えて本番データ全消ししてしまってもいっさい責任はとれません。
本当はこの組み合わせでクリンナップは一切動かないようにしようかとも思ったけど、うーん、と悩んでそのまま。
これから
- 誰かこのエントリ英訳してくれない?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 なので、指定を削除すれば良いです