そうだ、教祖になろう。出エジプト記 第3章8節 サーバサイドを自動テストする
銀にはるつぼ
3章もだいぶ長くなりました。
今回も第3章7節 Cloud9のLambdaで共通処理を持つに引き続き、普通の内容かもしれません。
サーバサイドの処理が多くなってきたのでコードをテストします。
これから何回も変更が入るので、いちいち自分でテストなんかしてられません。
自動テスト機構を構築します。
やり方はサーバレスでない通常の開発と同じです。
今回はCloud9のローカル環境でテストを走らせます。
いずれAWSのCodeDeployでデプロイする前にCodeBuildでテストを走らせたいのですが、今のところデプロイはCloud9で十分なので、とりあえず泥臭くやっちゃいます。
Pythonの自動テストはunittest
パッケージを使います。
金には炉
まずわかりやすいところで、 第3章4節 Lambdaでサーバサイドを実装するで作ったLifeReader
クラスのテストを作ります。
なお、今回やるのはモジュール単位のテストです。
クライアントも含めた結合テストはあとで考えます。
Lambdaアプリケーション配下にtest
フォルダを作成し、配下にテストモジュールとして.py
ファイルを作成します。
いきなりソースを載せてしまいます。
import os import sys sys.path.append(os.path.join(os.path.dirname(__file__), '../lib')) sys.path.append(os.path.join(os.path.dirname(__file__), '../rebirth')) import unittest from unittest import TestCase, mock from unittest.mock import patch, MagicMock import gspread from lifereader import LifeReader class TestLifeReader(TestCase): def test_random_default(self): row_values = [ '', 'アジアの小国の王様', '1000', '500', '10', '年前', '安定した治世で民に敬われながら', '', '40', '60', '1', '歳で' ] worksheet = MagicMock() worksheet.col_values = MagicMock(return_value=['who', 'row2']) worksheet.row_values = MagicMock(return_value=row_values) spreadsheet = MagicMock() spreadsheet.sheet1 = worksheet gclient = MagicMock() gclient.open = MagicMock(return_value=spreadsheet) with patch('gspread.authorize', return_value=gclient): record = LifeReader().random() self.assertEqual(gclient.open.call_args_list[0][0][0], 'シャッフル再生教') self.assertEqual(worksheet.row_values.call_args_list[0][0][0], 2) self.assertEqual(record['who'], 'アジアの小国の王様') self.assertEqual(record['birth-min'], '1000') self.assertEqual(record['birth-max'], '500') self.assertEqual(record['birth-step'], '10') self.assertEqual(record['birth-unit'], '年前') self.assertEqual(record['way-of-life'], '安定した治世で民に敬われながら') self.assertEqual(record['cause-of-death'], '') self.assertEqual(record['death-min'], '40') self.assertEqual(record['death-max'], '60') self.assertEqual(record['death-step'], '1') self.assertEqual(record['death-unit'], '歳で') if __name__ == '__main__': unittest.main()
テスト対象およびテストに必要なクラス群をインポート。
test_メソッド名_default
という疎通用のテストメソッドをとりあえず1つ作りました。
テスト対象のLifeReader().random()
を呼ぶ前にごちゃごちゃやっているのは、モックオブジェクトの準備です。
テスト対象クラスが他のモジュールを呼んでいると、異常系を発生させづらかったり、テストのたびに通信したりするので、呼ぶ先のモジュールをダミーに置き換えています。
これをやってくれるのがmock
パッケージで、戻り値を指定したり、パッケージに渡した引数をアサートしたりできます。
例えば、テスト対象のLifeReader.random()
はちょいと複雑です。
gspread.authorize()
でもらったクライアントオブジェクトでスプレッドシートオブジェクトを取得して、そこからワークシートオブジェクトを取得してメソッドを呼び出すといった具合。
gclient = gspread.authorize(credentials) spreadsheet = gclient.open(settings.GSPREAD['spredsheet-title']) worksheet = spreadsheet.sheet1 count = len(worksheet.col_values(2)) - 1 row = random.randint(2, count + 1) values = worksheet.row_values(row)
これをシミュレートするためにモックのメソッドにモックを設定して、さらにそれを別のモックに設定して、最終的にローカル変数gclient
に集約しています。
この辺のことです。
worksheet = MagicMock() worksheet.col_values = MagicMock(return_value=['who', 'row2']) worksheet.row_values = MagicMock(return_value=row_values) spreadsheet = MagicMock() spreadsheet.sheet1 = worksheet gclient = MagicMock() gclient.open = MagicMock(return_value=spreadsheet) with patch('gspread.authorize', return_value=gclient):
このテストメソッドでは、Googleスプレッドシートのデータが1行だけある状態を想定し、col_values
の戻り値にヘッダ行を含めて2つの要素の配列を返しています。
gclient
オブジェクトをwith
で仕込んだ後でテスト対象を呼び出します。
呼んだ後はself.assert~
メソッドを使って、テスト対象の中でパッケージに渡された値と戻り値をアサートしています。
心を試すのは主
それでは、実行してみましょう。
Ran 1 test in 0.005s OK
と表示されてテストが通ったことが分かります。
と簡単にできたように書いてますが、実はモックをここまで準備するのが結構大変でgspread
パッケージのメソッドの引数や戻り値の型を実装しているとき以上に丹念に調べたりします。
悪事をはたらく者は悪の唇に耳を傾け
これをテスト対象のモジュールごとに実装していくのですが、テスト対象をどの程度テストできてるか測定したいですね。
Pythonのcoverage
パッケージを導入します。
sudo python3.6 -m pip install coverage
パスが通っていないというような警告がでることがありますが、
WARNING: The scripts coverage, coverage-3.6 and coverage3 are installed in '/usr/local/bin' which is not on PATH. Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.
一度ターミナルを開きなおすと認識されています。
ec2-user:~/environment $ coverage Code coverage for Python. Use 'coverage help' for help. Full documentation is at https://coverage.readthedocs.io
レポートを出力してみます。
パッケージ各種を対象外にするため、test
フォルダに.coveragerc
ファイルを作成します。
[run] omit = /home/ec2-user/.local/lib/* /usr/local/* [report]
テストモジュールごとにテストを起動し、結果を結合してから標準出力にレポートします。
ut_lifereader.py
が触るテスト対象とカバレッジ率がリスト化されます。
coverage run -p ut_lifereader.py coverage combine coverage report
テストモジュールごとに起動するのはかったるいので、存在するテストモジュールをすべて起動したあとcoverage
のレポートをHTML出力するようShellを組みました。
最後にFailになったテストモジュール数とテストメソッド名を表示するようにしてあります。
#!/bin/bash fails=0 methods=() for p in `ls ut_*.py` do echo "■ Test Module:" $p stdout=`coverage run --rcfile=.coveragerc -p $p 2>&1` if [ $? != 0 ] then fails=$((fails+1)) methods+=( " - $p: `echo "$stdout" | grep "FAIL: " | cut -d " " -f 2 `") fi echo "$stdout" done coverage combine coverage report coverage html echo "Fail TestModules: " $fails echo "Fail TestMethods: " ${#methods[*]} IFS=$'\n' echo "${methods[*]}" echo echo echo
なぜか最後の2行の標準出力が消えるので、無駄に3行ほどecho
してます。
実行します。
Cloud9は何でもRun
ボタンで実行できますね。
偽る者は滅亡の舌に耳を向ける
レポートはhtmlconv
フォルダに出力されています。
index.html
をプレビューして結果を見てみましょう。
モジュールごとのリンクをたどるとテストが通ったところが緑に表示されています。
あとはその他すべてのモジュールのテストモジュールを作るだけです。
カバレッジが100%に近づいていくと嬉しい感じがしますが、データパターンや異常系の網羅を忘れてはいけません。
通知クラスでSNSオブジェクトを作るときにboto3
パッケージが必要なのでpip
でインストールしておきます。
災いのときに喜ぶ者は赦されない
着々とテストを実装していきますが、難関はデコレータです。
トレースロガーは@classmethod
や@instancemethod
にも適用したかったのですが、callable
ではない、つまりインスタンスメソッドやfunction
と違ってオブジェクトそのものを起動できないらしく、一旦あきらめました。
テストしてみないとわからないことってあるものです。
import os import sys sys.path.append(os.path.join(os.path.dirname(__file__), '../lib')) import unittest from unittest import TestCase, mock from unittest.mock import patch, MagicMock import logging from tracelogger import trace class TestTraceLogger(TestCase): def test_trace_instancemethod(self): info = MagicMock() with patch.object(logging.RootLogger, 'info', info): result = TraceClass().instancemethod('a', 2, third='three', fourth=4) self.assertEqual(result, 'instancemethod') self.assertRegex( info.call_args_list[0][0][0], '.*TraceClass.instancemethod.*\'a\', 2.*\'third\': \'three\', \'fourth\': 4' ) self.assertRegex( info.call_args_list[1][0][0], '.*TraceClass.instancemethod.*time.*result.*instancemethod') def test_trace_function(self): info = MagicMock() with patch.object(logging.RootLogger, 'info', info): result = function('a', 2, third='three', fourth=4) self.assertEqual(result, 'function') self.assertRegex( info.call_args_list[0][0][0], '.* function.*\'a\', 2.*\'third\': \'three\', \'fourth\': 4') self.assertRegex(info.call_args_list[1][0][0], '.* function.*time.*result.*function') class TraceClass(): """ # そもそもデコレータが通らない @trace @staticmethod def statmethod(first, second, third, fourth): return 'staticmethod' @trace @classmethod def clsmethod(cls, first, second, third, fourth): return 'classmethod' """ @trace def instancemethod(self, first, second, third, fourth): return 'instancemethod' @trace def function(first, second, third, fourth): return 'function' if __name__ == '__main__': unittest.main()
カバレッジ100%になりました。
とりあえず、この辺でサーバサイドはひと段落です。
次はクライアントサイドをやっていこうと思います。