見出し画像

【python】pytestでpythonのテストを自動化する

今まで長らく

if __name__ == '__main__':
  # 単体テストを書く

という大層原始的な方法でテストというか動作確認してやり過ごしてきたのですが、そろそろちゃんとテストしないと二進も三進も行かない案件がコンニチワしてきたのでちゃんとテストを自動化しようと思います。

0・なんでテスト自動化?

if-mainを使う方法だと、

・1ファイルずつテストしないといけない(ドメイン駆動設計ちゃんとやってれば、まあまあ1ファイルずつのテストでも何とかなるっちゃなるんだけど、思わぬところに副作用が出ている可能性がないとは言い切れない)
・3層アーキテクチャちゃんとやってると、Domain層からInfra層をimportする必要があったり、ServiceからDomain呼ぶ部分なんかのimport管理が超面倒 or importできなくて詰む

などなど不都合が多く、わたしはもう嫌になりました

1・パッケージ構成をちゃんとする

pytestでテストする準備段階として、パッケージ構成を推奨に合わせる必要があります。今まで適当に雰囲気でやってたんですが、ちゃんとテストやるなら、importの処理を自動でやってもらうためにちゃんとした構成にする必要があります。

推奨パッケージ構成は下記の通り。PROJECTフォルダはプロジェクト名に置き換えます。

/project_root
    └─ PROJECT
    │   └─ __main__.py
    │   └─ Presentation
    │   │   └─ controller.py
    │   │   └─ __init__.py
    │   └─ Service
    │   │   └─ service.py
    │   │   └─ __init__.py
    │   └─ Domain
    │       └─ class1.py
    │       └─ class2.py
    │       └─ __init__.py
    └─ tests
        └─ __init__.py
        └─ Domain
            └─ __init__.py
            └─ test_class1.py

プロジェクトフォルダのルート内にもう一つPROJECTフォルダを作って、ソースコードはその中に集約します。※このとき、PROJECTフォルダ直下には__init__.pyを置かない。
で、テスト用のコードはルート内にtestsフォルダを作ってそちらに集約します。
一応ソースフォルダと同一のフォルダ構成にするようにしてみていますが、多分ソースとテストを1:1にしてテスト書くのはあんまり現代的じゃないと思うので、その辺は要研究。

2・pytestをインストール

プロジェクトルート配下に仮想環境を作成しておいて、

$ pip install pytest

でpytestをインストールすればOK

3・テストコードを書く

testsフォルダ内に「test_」で始まる.pyファイルを作ります。

# test_calss1.py
from PROJECT.Domain.class1 import Class1

でテストしたいクラスや関数をインポートできます。
このとき、「これでちゃんとimportできる?」と不安になりますが、フォルダ構成をちゃんとしてればpytestさんが良い感じにゴニョゴニョしてくれます。

他にもテストする上で必要な依存モジュールがあれば同様にimportできます。

※ModuleNotFoundError対策

ただ、テストするモジュールをimportするのはこれでいいんですけど、テストされるモジュール同士に依存関係がある場合が厄介で、例えば上記のフォルダ構成だと、__main__.pyから他のモジュールをimportする際は

# __main__.py
from Domain.Class1 import Class1

って書く事になりますが、こいつをテストするとModuleNotFoundErrorになります。キェェ。Domainなんてモジュールありませんけどォ?!って言われます。キエェ。

対処方としては、お行儀が悪くても__main__.pyに当たるファイル(実行ファイル)をPROJECTフォルダから出しておいて、全てのimportを

from PROJECT.Domain.Class1 import Class1

という記述で書くのが一番手っ取り早いと言えば手っ取り早いです。お行儀は悪いです。

お行儀の良い対処方としては、開発中のパッケージをパッケージとしてpipでインストールしちゃう、というのがあるそうですが、ちょっと直感的じゃないな……と思ったので、「要するにsys.pathにPROJECTまでのパスが通りゃいいんだよね」ということでちょっと裏技っぽい方法で切り抜けることにしました。fixtureという仕組みを使うので、後述のfixtureの項目で説明します。

基本のテスト

# PROJECT/Domain/Class1.py
class Class1:
  def __init__(self, x, y):
    self.x = x
    self.y = y

  def add():
    return x + y

という様な、インスタンス変数としてxとy、合計値を返すメソッドを持つクラスがあったとしまして、

そのテストとして

# tests/Domain/test_Class1.py
from PROJECT.Domain.Class1 import Class1
def test_class1():
  assert class1(1,2).add() = 3

を用意します。

・test_で始まる名前の関数を作る
・assert [テストしたい処理] == [期待される結果]

という形で書けばOKです。
assert文を複数並べて

# tests/Domain/test_Class1.py
from PROJECT.Domain.Class1 import Class1
def test_class1():
  assert class1(1,2).add() == 3
  assert class1(3,4).add() == 7
  assert class1(5,6).add() == 10
  assert class1(7,8).add() == 15

のように記述して一気にテストする事も出来ます。
ただこの場合、3つめのassertが間違った答えを期待してしまっているので、3つめでコケ、4つめのテストは実施されません。

単体テストを実行する

$ pytest tests\Domain\test_Class1.py

で実行できます

例外を期待するテスト

ちゃんと期待した例外送出される~?というテスト。
先ほどのClass1に

# PROJECT/Domain/Class1.py
class Class1:
  def __init__(self, x, y):
    self.x = x
    self.y = y

  def add():
    return x + y
  
  def div():
    return x / y

ってな感じで割り算メソッドを追加します。そうすると

# tests/Domain/test_Class1.py
from PROJECT.Domain.Class1 import Class1
def test_class1():
  assert class1(1,0).add() = 0

このようなテストを実行した場合、ZeroDivisionErrorが送出されるはずです。これがちゃんと送出されてるかい?と言うテストを、このままやるとテスト自体が失敗扱いで落ちてしまいます。

そんなときは

# tests/Domain/test_Class1.py
from PROJECT.Domain.Class1 import Class1
def test_class1_exeption():
  with pytest.raises(ZeroDivisionError):
    class1(1,0)

のように、withを使って「pytest.raises(期待されるエラー型)」と記述します。

4・準備が必要なテストをする

単純なクラスならこれで良いんですが、例えば「顧客データクラスに収まった顧客データを受け取って、必要なデータを取り出してSQLを発行するクラス」なんてものがあったとして、先に「顧客データクラスのインスタンス」を用意しないとテスト出来ないようなクラスの場合、準備が必要です。

そんなときに便利なのがfixture(ふぃくすちゃー)機能です。

# PROJECT/Domain/User.py
class User:
  def __init__(self, name, id, tel, address, age, hoge, huga):
    self.name = name
    self.id = id
    self.tel = tel
    self.address = address
    self.age = age
    self.hoge = hoge
    self.huga = huga
# PROJECT/Infrastructure/Sql.py
class Sql:
  def __init__(self, user):
    self.user = user

  def select():
    return  "何か良い感じに必要なSELECT文が帰ってくる"

これを愚直にテスト書こうとすると

# tests/Infrastructure/Sql.py
from PROJECT.Domain.User import User
from PROJECT.Infrastructure.Sql import Sql

def test_class1():
  test_user = User("ユーザー名", "ID", "電話番号", "長々とした住所", "年齢", "hoge", "huga")
  assert Sql(test_user).select() = "何か良い感じに必要なSELECT文が帰ってくる"

と、test関数の中がごちゃついてきます。これくらいならまだ良いけど、もっと長々としたJSONが必要だったりなんだったりするとたいそう大変です。

そこで、test_userのインスタンス作成を切り出します。

# tests/Infrastructure/test_Sql.py

import pytest
from PROJECT.Domain.User import User
from PROJECT.Infrastructure.Sql import Sql

@pytest.fixture
def test_user():
  return User("ユーザー名", "ID", "電話番号", "長々とした住所", "年齢", "hoge", "huga")

def test_class1(test_user):
  assert Sql(test_user).select() = "何か良い感じに必要なSELECT文が帰ってくる"

1・@pytest.fixtureアノテーションを付与して、test_user関数を作る
2・1で作った関数名を引数にして、test_class1を作る
3・test_Sql.pyを実行すると、pytestが勝手に、test_userを先に実行してその戻り値をtest_class1に渡してテストを実行してくれる

これで、テストクラス内をassert文だけに出来ます。やったね。

fixtureを共有する

こことこことここのテストは同じUserデータが入る前提でテストしないとおかしなことになっちゃう。という時に、全テストファイルにfixtureをベタ書きしていては大変です。同じテストデータを入れたいところは使い回しましょう。いえい。

fixtureを共有したいフォルダ内に「conftest.py」ファイルを作り、そこに上記の@pytest.fixtureアノテーションが付いている関数を移植します。

# tests/Infrastructure/conftest.py

import pytest
from PROJECT.Domain.User import User

@pytest.fixture
def test_user():
  return User("ユーザー名", "ID", "電話番号", "長々とした住所", "年齢", "hoge", "huga")
# tests/Infrastructure/test_Sql.py

from PROJECT.Domain.User import User
from PROJECT.Infrastructure.Sql import Sql

# test_user はconftest.pyが自動で呼ばれて自動で作られる
def test_class1(test_user):
  assert Sql(test_user).select() = "何か良い感じに必要なSELECT文が帰ってくる"

こうすることで、この場合はInfrastructureフォルダ内でtest_userを共有できます。
test_Sql.pyの方は特に特別な設定は不要で、test_userだけtest_Class1の引数に入れておけばあとはpytestがよしなにやってくれます

testsフォルダ直下に作れば全部のテストで共有できますが、いたずらにスコープ大きくすると今度は影響範囲が大きくなりすぎるのでその辺りは適切に。

※conftest.pyを使ってModuleNotFoundError対策をする

fixtureを書くためのconftest.pyが「テストやる前に勝手に動いてくれる」という特性を利用して、testsフォルダ直下のconftest.pyを用意して、そこにsys.pathへの追記命令を書くことで無理矢理PATH通しちゃえ☆ っていうやり方です。

こちらを参考にさせて頂きました→pytest入門 - 闘うITエンジニアの覚え書き

# conftest.py
import sys
import os
sys.path.append(os.path.abspath(os.path.dirname(os.path.abspath(__file__)) + "/../PROJECT/"))

sys.pathに、conftest.pyの親フォルダ(=tests)より一階層上にあるPROJECTフォルダを追記しやがれください、という命令です。実行時のみ有効なので環境変数を汚しません。
これで、例えばServiceフォルダの中身がDomainフォルダの中身を呼ぶような処理のテストも動くようになります。やったね。

5・全てのテストをまとめて実行する

testsフォルダ直下、もしくはプロジェクトルートで

$ pytest

だけを実行すると、test_で始まるファイルの中の、test_で始まる関数が全部実行されます。
途中から実行したり、前回Faildだったテストだけを実行したりなどの便利なオプションもあるようですが必要になったらまたメモします。


この記事が気に入ったらサポートをしてみませんか?