まえがき
私は、弊社の案件で運用しているAIシステムで、主にバッチ処理を定期的に動かしたり、リリースを管理したりと、アルゴリズム開発というよりは主にインフラ側の保守を担当しています。
納期が短かったため、開発当時は「本番環境でバッチが動けばオッケー牧場!」というマインドで運用していました。
事前テストも、あらかじめ本番環境に近い環境で動かして、正常に動くことを目視確認していました。
本当は、モジュールも1つずつテストしたいんですよね。
なんでテストが必要なの?
でも、テストって、ぶっちゃけめんどくさいですよね、書くの。
コードの実装がすでに頭に浮かんでいて、さっさと実コードを書いてしまいたい場合が多いと思います。
「動いてんだからいいじゃん!」実際、私もそうでした。
しかし、ことデータシステムにおいては、次のようなケースがよくあることがわかりました。
- タイムゾーン無しの時刻型で処理していたと思いきや、タイムゾーンありだった
- 型体系の違いで、INT型で来るものと思って処理していたら、実はDOUBLEだった
- 処理の途中で、DataFrameに空行が入ってた
特にAIを扱うシステムですから、翌日には扱うデータはガラリと変わっているわけで、1回正常に動いたからと言って翌日も正常に動くとは限りません。
こういった原因で本番環境でバグが起き、処理が止まってしまうと、自分たちが大変なだけではなく、このバッチ処理システムを使っている事業部の人々にも影響が出ます。
だからテストを書くんだね
本番で起こるエラーは、主に「プログラム自体の間違い」か「メモリ枯渇や権限などのインフラ的な問題」です。
このうち「プログラム自体の間違い」をなくすことは、テストを書くことで善処していくことができます。
なので、テストがないプログラムは、今からでもテストを書いていった方がいいと思いました。
1日1テスト。3日で3テスト。
ちなみに、テスティングフレームワークはpytest
を使っています。
テストの種類
テストには、大きく分けて2種類あります。
- ユニットテスト
- 結合テスト
ユニットテストって?
今開発しているプロジェクトの、個々のパーツが正しく動作するかどうかを確認する作業です。
個々のパーツは、例えば
- 入力ファイルをpickleに変換して、別の場所に出力する。
- DataFrameに対して演算をし、結果を新しい列として追加する
- 学習処理をして、指定の場所にモデルを出力する。
など、入出力が決まっており、pytest
などのテストフレームワークを使えば、目視で確認しなくとも自動でできるようになっています。
結合テストって?
これはつまり、外部システムとの連携も含めて、正常に動作するかというテストです。たとえば、ほかのアプリケーションにリクエストを出したりします。
この記事では解説の対象としません。
ユニットテストはいいぞ
私の経験上、テストを書くと次のようなメリットがあります。
1. レビュー負荷を削減できる。
多くのテストは自動化できますので、かなりの負荷軽減になります。
また、coverage
を合わせて使えば、コードのカバレッジ(全コードに対する、テストされたコードの割合)も確認できます。
これが100%になるまでテストケースを書き、テストを実行するのを繰り返していけば、どこがテストされていないかわかりやすいです。
2. 精神的負荷を軽減できる。
テストがないと、プログラムのロジックの正当性は人間が確認しないといけません。しかし人間は間違いを犯す生き物ですから、正しくまんべんなくレビューするのは難しく、テストがないと、リリースした後などに「あのコード、本当にちゃんと動くかしら・・」と不安になってしまいます。(実際私もそうでした。)
テストを書くことで、この精神的な負荷を軽減できます。ユニットテストさえ通れば、少なくともリリースするコードは正しく動くことが保証できます。「テストが間違っていたら意味ないだろう!」という声が聞こえてきそうですが、しらみつぶしに1つ1つ、手で確認するよりは負荷はずっと低いはずです。テストが間違っていて、リリース後に不具合が発生した場合は、テストケースを追加して、それが通るように修正すればよいのです。
3. テスト仕様書の代わりに使える。
テストケースは、テスト対象のモジュールに対して、入力と、それに期待する出力を定義して、正しく出力するかを確認する作業ですので、これはそのまま「モジュールの仕様」をテストで定義したことになります。
ね?いいことたくさんでしょ?
もちろん、これらのメリットを享受するために、「テスト可能な」実装をしていく必要があります。
pytestでできること
ここからはドキュメントの解説みたいになりますが・・・。
「pytest
はいいぞ!」ということが伝わってくれれば本望です。
基本のテストケース
例えば、年齢を入力すると、お酒が飲める年齢かどうかを返すメソッド can_drink()
を考えましょう。
引数を1つ受け取って、20以上であれば"あなたはお酒が飲めます"
と返し、19以下であれば"お酒は二十歳になってから"
と出力するメソッドをテストすることを考えます。
普通、ルートから見てsrc/can_drink.py
にあるコードのテストは、tests/src/test_can_drink.py
のように、
-
tests/
ディレクトリの下に - 実コードと同じ構成で
- ファイル名の頭に
test_
をつけて
作成します。
# tests/src/test_can_drink.py
def test_can_drink():
assert can_drink(33) == "あなたはお酒が飲めます"
assert can_drink(15) == "お酒は二十歳になってから"
assert can_drink(20) == "あなたはお酒が飲めます" # 境界値
assert can_drink(19) == "お酒は二十歳になってから" # 境界値
これで、テストケースが完成しました。実行するときはpytest
と実行すればOKです。
pip install pytest # 入れてなければ
pytest
テストケースがいっぱいある場合は、いちいちassert
って書くのめんどくさくない?
そんなあなたに@pytest.mark.parametrize
これを使うと、メソッドが同じでパラメータだけが違うテストケースがたくさんある場合に、ひとまとめに書くことができるのです!便利でしょー。
# tests/src/test_can_drink.py
import pytest
OSAKE_OK = "あなたはお酒が飲めます"
OSAKE_NG = "お酒は二十歳になってから"
# ここ!ここです!
@pytest.mark.parametrize([
"age, expected",
[
(33, OSAKE_OK), # 入力パラメータと期待する出力を、引数で与えてあげる
(15, OSAKE_NG),
(20, OSAKE_OK),
(19, OSAKE_NG),
]
])
def test_can_drink(age:int, expected:str):
assert can_drink(age) == expected # 引数で比較できるので、テスト本体はひとまとめにできる。
- pytest parametrize -> https://docs.pytest.org/en/6.2.x/parametrize.html
前処理入れたいときとかどうするの?
そんなあなたにconftest.py
conftest.py
というファイルをtests/
ディレクトリ内に置いておくと、その場所より深い場所にあるテストケースすべてに対して、conftest.py
に書いた前処理が使えるようになります。
例えば、テスト時にはテスト用の環境変数を使いたい場合などは、便利です。
# tests/conftest.py
import pytest
@pytest.fixture(scope="session")
def set_test_env():
os.environ["ENV"] = "test"
# 環境変数を設定した後、テストケースに入る.
yield
# テストケースが終わったら、セットした環境変数を削除する
del os.environ["ENV"]
テストケース側は、次のように引数に呼び出したい前処理を入れるだけです。
def test_something(set_test_env):
# このテストは`set_test_env`というfixtureにより
# 環境変数ENV=testが設定された状態で実行される。
assert os.environ["ENV"] == "test"
ね、便利でしょ?いちいちimport
して呼び出したりしなくてもよいのですから。
ちなみに、こういった、前処理後の環境を、テストケースの引数として受け取る機能をfixture
とpytestでは呼んでいます。
これはWebアプリ開発などで、テスト用クライアントを使いたい場合などで威力を発揮します。
Flaskの公式ドキュメントに解説があります。⇒https://flask.palletsprojects.com/en/2.0.x/testing/#the-testing-skeleton
- pytest conftest.py -> https://docs.pytest.org/en/6.2.x/fixture.html#scope-sharing-fixtures-across-classes-modules-packages-or-session
ログがちゃんと出るかどうか確認したいんだけど?
そんなあなたにcaplog
たとえば、先ほどのお酒の例でいうと、年齢が150歳以上など、人間の寿命的に考えにくい入力が与えられた場合に「本当に正しい年齢ですか?」と警告を出したい場合がありますね。
その場合、次のように書くことができます。
def test_warn_under20(caplog): # caplogはimportしなくてよい!引数名に書くだけ!
# どのログレベルまでキャプチャするか設定する
caplog.set_level(logging.WARN)
can_drink(150)
assert len(caplog.records) == 1
for record in caplog.records: #python標準のLogRecordとして参照できる
assert record.levelname == "WARN"
assert record.msg.find("150歳は本当に正しい年齢ですか?") >= 0
caplog.records
は、Python標準のlogging.LogRecord
のリストになっているので、いつものロガーのように扱えます。
[pytext] caplog -> https://docs.pytest.org/en/6.2.x/reference.html?highlight=caplog#caplog
実行したくないメソッドがあるんだけど・・・
そんなあなたにpytest-mock
これはpython 3の標準モジュールにもあるモックをfixtureとして使えるようにしたものです。
そもそもMockというのは、実コード中のある関数を別の関数に置き換えることができるものです。
なので、mockした関数が
- 呼ばれたのかどうか
- 何回呼ばれたのか
- どんな引数で呼ばれたのか
をテストすることができます。それも、実際に実行することなく。これは非常に助かる!!
Pytest-mockはpytestの拡張モジュールであり、追加でインストールする必要があります。
使い方の例を作ろうと思いましたが、良い例が思いつかず、、
公式ドキュメントに掲載されている例がわかりやすいかもしれません。
https://github.com/pytest-dev/pytest-mock
まとめ
こうしてみると、pytestそれ自身で、テストを書く工数が下がるような機能がたくさんあることがわかります。
私自身ものぐさなので、「おっ、これは助かるなー」と思うような機能もたくさんありました。
この記事で紹介しきれなかった機能もたくさんあります。公式ドキュメントのAPIリファレンスを眺めるだけでもかなりの数がありますね。
まずは自分が勉強して、「テストって素晴らしい」と思ってもらえるように、一緒に頑張っていきましょう。