Pythonスキルの習得

Python ユニットテスト まとめ(pytest)

python ユニットテスト まとめ pytest

Pythonでユニットテストを実施するに当たり、よく使われる機能を網羅してみました。

下記の悩みを持っている方にも、参考となる内容になっています。

  • 例外テストしたいけど、どうやるんだっけ?
  • テストのためだけに、一時フォルダを作るって面倒
  • テストをするために仲間のコードが必要なんだけど、まだ仲間のコードが完成していないけど・・・
  • 仮想環境でコードを作っているんだけど、デプロイ時にきちんと動作してくれるか心配
  • UIのテストって大変そう。
  • DBのテスト、面倒くさい・・

本記事では、pytestを使用しています。

「pytest」は、標準で使用できる「unittest」の上位互換です。

「unittest」のコードも、「pytest」から実行可能です。

目的別に、使い方をまとめました。

記事自体は長くなっているので、目次より、必要箇所だけ見て頂ければ幸いです。

pytestが初めての方は、下記記事で、インストール方法から基本的な使い方まで紹介しております。

python ユニットテスト 入門 pytest
Python ユニットテスト 入門(pytest)プログラミングで、品質担保の1つであるソフトウェアテストの、導入方法から実行までをご紹介致します。使用言語は、Pythonです。...

併せて、参考にして頂ければと思います。

(本記事は網羅的に内容をご紹介しているため、上記記事と重複した内容も含まれます。)

インストール方法

pipでインストール可能です。

pip install pytest

目的別、各テスト方法のご紹介

基本的なテストコード

クラスを使った方法をご紹介します。(こちらの方が応用が効くと思います。)

add()という自作関数を作ったという前提で、この関数をテストする場合を考えてみます。

class TestAdd(object):
    def test_add(self):
        assert add(10, 5) == 15
        # ⬆ テストしたい関数

クラス名は「Test〇〇」がお約束です。
〇〇は任意名です。

メソッド名は、「test_△△」がお約束です。△△も何でもOKです。

それぞれのテスト前後で、決まったコードを実行したい。

class TestCalculation(object):

    # 各テストの開始前に実行
    def setup_method(self, method):
        print('テスト開始前')

    # 各テストの開始前に実行
    def teardown_method(self, method):
        print('テスト開始後')

    def test_1(self):
        print('test1')

    def test_2(self):
        print('test2')

上記コードのsetup_method()と、teardown_method()になります。上記コードを実行すると、下記の結果が得られます。

テスト開始前
PASSED                      [ 50%]test1
テスト開始後
テスト開始前
PASSED                      [100%]test2
テスト開始後

test1とtest2の前後で、実行されている事が分かります。

テスト全体で開始前、開始後に1度だけ実行されるコードの紹介

「setup_class」が全体開始前、「teardown_class」が全体終了時に実行されます。
class TestCalculation(object):
    @classmethod
    def setup_class(cls):
        print('開始前に実行')

    @classmethod
    def teardown_class(cls):
        print('終了時に実行')

    def test1(self):
        assert add(10, 5) == 15

    def test2(self):
        assert add(10, -1) == 9

例外テスト

決まった例外が発生するかを検証するためのコードです。

下記コードは、「ValueError」例外が発生する事を確認するコードです。

コード中の「something.exist()」が、テストしたいメソッドになっています。

import pytest

class TestCalculation(object):
    def test_extest(self):
        with pytest.raises(ValueError):
            something.exist()
              # ⬆ テストしたいメソッド

テスト時、一時的にフォルダを作成して検証したい(fixture)

fixtureという機能を使用します。

解説犬

fixutreというのは、pytestがデフォルトで持っている、様々な機能だよ。

(外部設定ファイルを読み込むとか、キャッシュが利用できる等)

具体的なコードで説明致します。

class TestCalculation(object):

    def test(self, tmpdir):
        print(tmpdir)

上記コ-ド中のtest(self, tmpdir)の第2引数がfixutreになります。

この第2引数の名前により、機能が変わります。

  • tmpdir → 一時フォルダを作成して、そのパス情報が入っている。テスト後、消去
  • cache → キャッシュを扱う事ができる。
  • 他にも様々な機能有り → 公式サイトをご参照下さい。

fixtureの独自作成する事も可能です。

上記のテストコードを実行すると、下記の結果が得られます。(一時フォルダが作成されている事が分かります。)

test_calculation.py::TestCalculation::test PASSED                        [ 100%] /private/var/folders/sl/jqcs_sbs3hl486v4mshtk40h0000gn/T/pytest-of-*****/pytest-0/test0

あるテストをスキップしたい。

デコレーションを使用します。

class TestCalculation(object):

    @pytest.mark.skip(reason='理由を書く')
    def test_1(self):
        print('test1')

    @pytest.mark.skipif(is_skip == True, reason='理由を書く')
    def test_2(self):
        print('test2')

上記コードのtest1は無条件でスキップされます。

test2の@pytest.mark.skipifは、第1引数がTrueの時、skipされます。

カバレッジの確認

まずは下記をインストールします。(サードパーティ製です。)

pip install pytest-cov pytest-xdist

実行(コマンドから)

pytset test_calculation.py --cov
# ⬆実行結果(例)
#Name                  Stmts   Miss  Cover
#-----------------------------------------
#calculation.py           10      0   100%
#test_calculation.py      18      1    94%
#-----------------------------------------
#TOTAL                    28      1    96%



# ミスしたときに、どこがミスしたかを詳細表示する
pytset test_calculation.py --cov --cov-report term-missing
# ⬆実行結果
#Name                  Stmts   Miss  Cover   Missing
#---------------------------------------------------
#calculation.py           10      0   100%
#test_calculation.py      18      1    94%   24
#---------------------------------------------------
#TOTAL                    28      1    96%

カバレッジは必ずしも100%にする必要はありません。

明らかに検証する必要がないコードがあるからです。(コンストラクタとか)

またif文等、どこの階層までテストするのか、ルールを決めるのがいいと思います。(第一階層までは少なくとも実施しているように思えます。)

setuptoolsを使った方法

事前に必要なインストール関係をまとめます。

pip install setuptools
pip install pytest-runner

(setuptoolsは、distutils.core.setupの上位互換です。)

以下の操作は、setup.pyを既に作っている事を前提とします。

PyCharmの場合、メニューの「Tools」→「Create setup.py」で簡単に作ることが可能です。

上記操作にて作成した結果、setup.pyファイルが作成され以下の中身(各設定内容は仮です。)になっていると思います。

from setuptools import setup

setup(
    name='unittest_for_blog',
    version='1',
    packages=[''],
    url='test@example.com',
    license='MIT',
    author='Zero__Cheese',
    author_email='test',
    description='something'
)

コード中のsetup関数の中に、テストフォルダを作成します。

下記フォルダのような構成が個人的にはいいと思います。t

testsの中に、「unittests」と「integrationtests」フォルダを作成し、「unittests」フォルダには、実際のコードの構成と同じようにするのがお勧めです。(ここでは、直下にファイルをおいていますが・・)

そして、setup()関数に以下を追加します。

setup(
    # その他は省略
    tests_require=['pytest']
)


#(参考)unittestの場合は以下の通りです。
setup(
     # その他は省略
    test_suits='tests'
)​

次に、setupファイルのcofigファイル(setup.cfg)を作ります。(setup.pyと同じ階層に

[aliases]
test=pytest
# ⬆ testが呼ばれたら、pytest を実行するという維持

[tool:pytest]
python_files = tests/*
# ⬆ testするファイルを指定

以上で設定は完了です。

下記コマンドにて、テストが実行できます。

python setup.py test

これで問題ないことを確認してから、以下のコマンドでパッケージを作って、それを配布するのがいいと思います。(この場合、テストファイルも一緒に配布されます。)

python setup.py sdist

仮想環境を新規作成、その中でテストをしたい(tox)

この機能は、仮想環境(virtualenv)を作ってくれて、テストを実行してくれるものになります。

下記のニーズを満たしてくれます。

  • 自分のPCのPythonバージョンが異なる
  • テストしたいライブラリが、自分のPCに入っていない
  • 本番環境と同じ環境でテストしたい。
  • 自分のPC環境を壊したくない

pipインストールが必要です。

pip install tox

No pyproject.toml or setup.py file foun

その後に、tox.iniを作ります。(setup.pyと同じ階層に作ります。)

tox.iniの内容は、下記のように記述します。

[tox] 
# Python3.9を使用
envlist = py39

# python3.9に対する設定
[testenv:py39]

# virtuealenvに入れたいライブラリ
deps = pytest
# requirement.txtを読込みたい場合は
# deps = -rrequirements.txt

# 実行したいコマンド
commands = pytest -s tests

実行は下記のコマンドです。(ターミナル(Windowsの場合は、コマンドプロンプト)より)

tox

# 以下、2回目以降実行する時
# 以前作った環境を再使用する場合
tox

# 既に作った環境を使ってテストしたい場合
# py39 が作られていたとします。
tox -e py39

# 以前に作った、仮想環境を削除してから実行
tox --recreate

実行結果 virtualenvを作ってくれて、pytestを実行しているのが分かると思います。

setup() の中の、install_requires に書いたものも併せてインストールしてくれます。

seleniumでのUI自動テスト

selenimuはwebサイトの画面を自動でテストしてくれるものです。

pipインストールが必要です。

pip install selenium

ドライバのインストールが別途、必要になります。

Macの場合

brew install geckodriver

Windowsの場合

geckodriverをDLしてきて、実行ファイルと同じ階層に入れる必要があります。
DLリンク先 → こちら

例えば、私のHPで、検索画面に「電動ガン」と打込みクリック、遷移ページで「電動ガンをプログラミングして自動発射させて遊ぶ」が表示されている事を確認するテストをしてみます。(下図参照)

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait

class TestMyHp(object):
    def setup_method(self, method):
        self.driver = webdriver.Firefox()

    def teardown_method(self, method):
        self.driver.close()

    def test_python_org(self):
        # どこのHPに行くか?
        self.driver.get('https://zero-cheese.com/')
        # 検索部分のセレクターを取得、文字入力後、キーボードのリターンを入力
        elem1 = self.driver.find_element_by_css_selector('#s')
        elem1.clear()
        elem1.send_keys('電動ガン')
        elem1.send_keys(Keys.RETURN)

        # 遷移した画面で、該当する文字が表示されているかを確認する
        # 遷移には時間がかかるので、下記コードの場合、10sec待つという設定。
        #    もしくは、指定したXPATHが取得できるまで待つ というコードです。
        elem2 = WebDriverWait(self.driver, 10).until(
            EC.presence_of_element_located(
                (By.XPATH, '//*[@id="main-contents"]/section/div/div/article/a/div/div/h2'))
        )
        assert elem2.text == '電動ガンをプログラミングして自動発射させて遊ぶ'

呼出し先が外部 or できていない場合(Mock)

外部サイトからDLして来るなど、テストを実行したいが、相手先がまだできていない時に使用します。

例えばコード中に下記コードがあり、requests..get先のHPがない場合を想定します。

def call_api(self, month):
    response = requests.get('https://zero-cheese.com/')
    if response.ok:
        return response.text
    else:
        return 'No Site'

上記コードのrequests.get部分を呼び出すと、返ってくるものを設定できるのが、mock使用の一例です。

具体的にコードを見ていきます。

import pytest
from unittest.mock import patch

from main import call_api
# ⬆ 自作コードの呼出し


class TestPatchTest(object):
    def test_call_api(self):
        # どこにパッチを指定するかを記述
        #    下記の場合、mainモジュールのrequests.getメソッドに設定
        with patch('main.requests.get') as mocked_get:
            # 上記requests.getの返り値の「OK」値を設定
            mocked_get.return_value.ok = True
            # 上記requests.getの返り値の「text」値を設定
            mocked_get.return_value.text = 'success'

            # patchで設定した(この場合、request.get)の引数をチェック
            # 下記で指定した引数でなければ、NGを表示
            mocked_get.assert_called_with('https://zero-cheese.com/')
            # 自作関数を実行した結果、戻り値テスト
            assert call_api() == 'success'

            # 新たに値を設定する際は、値をリセットする
            mocked_get.reset_mock()
            mocked_get.return_value.ok = False
            mocked_get.return_value.text = 'No Site'
            assert call_api() == 'No Site'

FlaskでのDBテスト

こちらの内容は、FlaskとDB、DB操作のためのORMの知識が必要になります。(本コードのみ、unittestで作っております。)

必要に応じて、読んで頂ければと思います。

Flaksの単体テストには、以下のモジュールが必要です。

pip install Flask-Testing

例えば、ログインサービス作っており、Flaskの設定で下記のようなコードがあったとします。

import os

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager

# Flask-Loginライブラリとアプリケーションをつなぐ
login_manager = LoginManager() 
# ログインの関数
login_manager.login_view = 'app.login'
# ログインにリダイレクトした際のメッセージ
login_manager.login_message = 'ログインしてください'

basedir = os.path.abspath(os.path.dirname(__name__))
db = SQLAlchemy()
migrate = Migrate()

def create_app():
    app = Flask(__name__)
    app.config['SECRET_KEY'] = '複雑な文字列が好ましい'
    app.config['SQLALCHEMY_DATABASE_URI'] = \
        'sqlite:///' + os.path.join(basedir, 'data.sqlite')
    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
    db.init_app(app)
    migrate.init_app(app, db)
    login_manager.init_app(app)
    return app

次にテストコードです。

単体テストのクラス作成には、「flask_testing.TestCase」の継承が必要です。またクラス内に、create_app(self)メソッドを定義して、テストの設定を定義します。

その場合のテストが下記になります。

import os
import unittest

from flask import url_for
from flask_testing import TestCase

# ⬇ 自作モジュール
# 上記コードの読込み
from flaskr import create_app, db
# DBモデルで下記を作っていたとする。
from flaskr.models import User 

app = create_app()


class TestSomethig(TestCase):

    # テスト開始前に実行される。
    def create_app(self):
        app.config['TESTING'] = True
        # ⬇CSFRのプロテクションを無効にする。(単体テストをしやすくする。)
        app.config['WTF_CSRF_ENABLED'] = False
        # DB SQLiteを使用する場合
        app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///path/test.db'
        return app

    # 各テスト毎に呼ばれる(開始前)
    def setUp(self):
        db.create_all()  # テーブル全作成

    # 各テスト毎に呼ばれる(終了後)
    def tearDown(self):
        db.session.remove()  # セッション削除
        db.drop_all()  # テーブル全削除

    def test_register(self):
        # self.clientを使用すると、作ったアプリと通信ができる。(get, post等)
        with self.client as client:
            # DBにUser登録をして、正しく登録されているかをチェックする。
            # Userクラスで、DBモデルを作ったとする。
            self.assertEqual(User.query.count(), 0)
            response = client.post(url_for('app.register'),
                                   data={
                                       'username': 'test',
                                       'password': 'password',
                                        }
                                   )
            # get送信の場合は、client.get(url_for('app.logout')) のように使う。

            self.assertEqual(User.query.count(), 1)
            self.assert_status(response, 302)  # ステータスコード(redirectの場合302)
            self.assert_redirects(response, url_for('app.login'))  # リダイレクト先の確認


if __name__ == '__main__':
    unittest.main()

上記コードの「create_app(self)」の中身を外に出す事ができます。

# 外部に設定を記述
class TestConfig:
    TESTING = True
    WTF_CSRF_ENABLED = False
    basedir = os.path.abspath(os.path.dirname(__name__))
    SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'test.db')


class TestSomethig(TestCase):

    # テスト開始前に実行される。
    def create_app(self):
        # 外部設定を読込む
        # TestConfigを記述したモジュールが、test の場合
        app.config.from_object('test.TestConfig')
        return app

まとめ

本編では、ユニットテストの実践という事で、様々なものを紹介しました。

Pythonのテスト手法はたくさんありますが、その中でも比較的使用すると思った手法をご紹介致しました。

参考になれば幸いです。