シャッフル主任の進捗報告

興味のあるものを作ります。進捗を不定期にご報告します。

そうだ、教祖になろう。出エジプト記 第3章8節 サーバサイドを自動テストする

銀にはるつぼ


3章もだいぶ長くなりました。
今回も第3章7節 Cloud9のLambdaで共通処理を持つに引き続き、普通の内容かもしれません。

サーバサイドの処理が多くなってきたのでコードをテストします。
これから何回も変更が入るので、いちいち自分でテストなんかしてられません。
自動テスト機構を構築します。

やり方はサーバレスでない通常の開発と同じです。
今回はCloud9のローカル環境でテストを走らせます。
いずれAWSのCodeDeployでデプロイする前にCodeBuildでテストを走らせたいのですが、今のところデプロイはCloud9で十分なので、とりあえず泥臭くやっちゃいます。

Pythonの自動テストはunittestパッケージを使います。

金には炉


まずわかりやすいところで、 第3章4節 Lambdaでサーバサイドを実装するで作ったLifeReaderクラスのテストを作ります。
なお、今回やるのはモジュール単位のテストです。
クライアントも含めた結合テストはあとで考えます。

Lambdaアプリケーション配下にtestフォルダを作成し、配下にテストモジュールとして.pyファイルを作成します。

f:id:chief-shuffle:20191217193800j:plain

いきなりソースを載せてしまいます。

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~メソッドを使って、テスト対象の中でパッケージに渡された値と戻り値をアサートしています。

心を試すのは主


それでは、実行してみましょう。

f:id:chief-shuffle:20191217195108j:plain

Ran 1 test in 0.005s

OK

と表示されてテストが通ったことが分かります。

と簡単にできたように書いてますが、実はモックをここまで準備するのが結構大変でgspreadパッケージのメソッドの引数や戻り値の型を実装しているとき以上に丹念に調べたりします。

悪事をはたらく者は悪の唇に耳を傾け


これをテスト対象のモジュールごとに実装していくのですが、テスト対象をどの程度テストできてるか測定したいですね。
Pythoncoverageパッケージを導入します。

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

f:id:chief-shuffle:20191217203134j:plain

テストモジュールごとに起動するのはかったるいので、存在するテストモジュールをすべて起動したあと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ボタンで実行できますね。

f:id:chief-shuffle:20191217203325j:plain

偽る者は滅亡の舌に耳を向ける


レポートはhtmlconvフォルダに出力されています。
index.htmlをプレビューして結果を見てみましょう。

f:id:chief-shuffle:20191217203421j:plain

モジュールごとのリンクをたどるとテストが通ったところが緑に表示されています。
あとはその他すべてのモジュールのテストモジュールを作るだけです。
カバレッジが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%になりました。

f:id:chief-shuffle:20191217204115j:plain

とりあえず、この辺でサーバサイドはひと段落です。
次はクライアントサイドをやっていこうと思います。