PySide & Pytest での テスト駆動開発 スタートアップ

7 min読了の目安(約6400字TECH技術記事

はじめに

PySide勉強会での、「PySide & Pytest で テスト駆動開発スタートアップ」の補足記事。
および、PySideとPytestでテスト駆動開発をするためのメモ。

開発環境

Windows 10
Python 3.7.7
Pytest

サンプルファイル

GitHub

初期整備

ディレクトリとファイルの準備

root
  |- sample
  |    |- __init__.py
  |    |- gui.py
  |- tests
  |    |- __init__.py
  |    |- conftest.py
  |    |- unit
  |        |- test_gui.py
  |- requirments.txt

ここでのツールのソースとなるgui.pyは以下のようなものを用意した。

sample/gui.py
import sys
from PySide2 import QtCore
from PySide2 import QtWidgets


class SampleDialog(QtWidgets.QDialog):

    def __init__(self, *args):
        super(SampleDialog, self).__init__(*args)
        
        self.number = 0

        self.setWindowTitle('Hello, World!')
        self.resize(300, 200)

        layout = QtWidgets.QVBoxLayout()

        self.label = QtWidgets.QLabel(str(self.number))

        layout.addWidget(self.label)

        self.button = QtWidgets.QPushButton('Add Count')
        self.button.clicked.connect(self.add_count)
        self.button.setMinimumSize(200, 100)

        layout.addWidget(self.button)

        self.setLayout(layout)
        self.resize(200, 100)


    def add_count(self):
        self.number += 2
        self.label.setText(str(self.number))


def main():
    app = QtWidgets.QApplication.instance()
    if app is None:
        app = QtWidgets.QApplication(sys.argv)
    gui = SampleDialog()
    gui.show()
    
    app.exec_()
    

if __name__ == '__main__':
    main()
    

実行した際の見た目は、次の様になる。

requirements.txt に必要モジュールを書く

PySide2
Pytest

venv環境を作成する

  1. rootディレクトリに行く
  2. python -m venv .venv
  3. venv環境を有効にする: .venv\Script\Activate.bat
  4. pip install -r requirements.txt

conftest.pyでモジュールへのパスをつなぐ

tests/conftest.py
import os
import sys

sys.path.append(os.path.dirname(os.path.abspath(__file__)))

Pytest公式では、setup.pyを用いた、pip install -e .を使って、インストールする事が推奨されているが、それぞれの環境により(僕の場合は、Techcnial Artistチームの(DCCツールも含めた)ツールリリース環境の場合)、必ずしもpip環境を提供するわけにはいかない場合がある。

そういった場合に、余計なディレクトリ構成を取りたくない事があるので、conftest.pyを使用して、Pytestの実行時にrootにパスを通しておく。

もちろん、 pip install を前提とした配布環境の場合は pip intall -e . を出来るように、 setup.pyをroot下に配置し、 pip install 出来るようにしておくと良い。

また、同様に、Mayaなどの外部ツールや社内ライブラリなどで必要なモジュールがあるみたいな時には、ここでパスを通しに行くと良い。

テストコードの実装

Pytestでのテストファイル

とりあえず基本的な挙動で覚えておくのは以下のところ。
(実際はもっとあるのでドキュメント参照されたし)

  • Pytestは、test_*.py や *_test.pyというファイルを検索して、自動で取得しにいく。
  • さらに、そのファイルの中のtest_*という関数を探して実行する。
  • さらに、その関数をまとめたい場合には、Test*(Ex: TestGui)といったクラスを作ると、これもまたPytestが自動で拾いに行ってくれる。

テストを書いていく

tests/unit/test_gui.py
import sys

from PySide2 import QtCore
from PySide2 import QtWidgets
from PySide2 import QtTest


def test_add_count():
    from sample import gui

    app = QtWidgets.QApplication.instance()
    if app is None:
        app = QtWidgets.QApplication(sys.argv)
    gui = gui.SampleDialog()
    gui.show()

    QtTest.QTest.mouseClick(gui.button, QtCore.Qt.LeftButton)
    n1 = gui.number

    QtTest.QTest.mouseClick(gui.button, QtCore.Qt.LeftButton)
    n2 = gui.number
    assert abs(n2 - n1) == 1

QtTestを使う

ここで出てくるのが、QtTestで、PySideはこうしてテスト用のモジュールを提供してくれている。

ユーザーは、「『ボタンをクリックすることによって』、ラベルの数字が1上がる」という事を認識するので、この動作が正しくなるように、『ボタンをクリックすることによって』というのをテスト上でシミュレートする必要がある。
ここでQtTestは、 QtTest.QTest.mouseClick() を使う事によって、その動作をシミュレートする機能を提供する。

つまり、上記のtest_add_countでは、次のことをQtTestで行っている

  1. 最初のQtTest.QTest.mousClicke()で、「guiのボタンを左マウスクリックする」
  2. その時のnumberクラス変数の値をn1として格納する
  3. 次のQtTest.QTest.mousClicke()で、「guiのボタンを左マウスクリックする」
  4. 二回目のクリックした状態でのnumberクラス変数の値をn2として格納する
  5. assertでその二つの格納した値を比較し、差が1であることを確認する

これにより、 ボタンをクリックした際の変更が、正しくカウントの上昇が1づつであるということを保証することができる

テストコマンドを実行する

テストを実行する際のコマンドをルートディレクトリでとりあえずこれを実行すればいい。

pytest .

すると、次の様な結果が得られる。

======================================= test session starts ========================================
platform win32 -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: D:\Develop\Python\_learn\test_pytest_pyside
collected 1 item

tests\unit\test_gui.py F                                                                      [100%]

============================================= FAILURES =============================================
__________________________________________ test_add_count __________________________________________

    def test_add_count():
        from sample import gui

        app = QtWidgets.QApplication.instance()
        if app is None:
            app = QtWidgets.QApplication(sys.argv)
        gui = gui.SampleDialog()
        gui.show()

        QtTest.QTest.mouseClick(gui.button, QtCore.Qt.LeftButton)
        n1 = gui.number

        QtTest.QTest.mouseClick(gui.button, QtCore.Qt.LeftButton)
        n2 = gui.number
>       assert abs(n2 - n1) == 1
E       assert 2 == 1
E        +  where 2 = abs((4 - 2))

tests\unit\test_gui.py:22: AssertionError
===================================== short test summary info ======================================
FAILED tests/unit/test_gui.py::test_add_count - assert 2 == 1
======================================== 1 failed in 0.67s =========================================

これを説明すると、
テスト上のコードを見ての通り、add_countは、最初の実行と次の実行時の値の差が1であってほしいという開発者の意図があるのがわかる
しかしながら、ソースコードを見てみると、 self.number += 2 としてあり、開発者の意図に反して、『ボタンをクリックすることによって』実行された処理の結果の差が2の結果を出してしまっていることが分かる。

では、この self.number += 1 に変え、「1づつ増える(つまり常に増える差は1)」にしてみると、pytest . を実行した際に、次の結果が得られる。

======================================= test session starts ========================================
platform win32 -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: D:\Develop\Python\_learn\test_pytest_pyside
collected 1 item

tests\unit\test_gui.py .                                                                      [100%]

======================================== 1 passed in 0.70s =========================================

この様にして、テストの成功の結果が得られた。

まとめ

Pytestは先のように、自動でテストコードを拾ってきたり、conftest.pyを使用することによって、あらかじめ前提としたい環境設定も用意することができるので、様々な排他的環境を少ないコードで、自動で構築できるようになる。
PySideは、QtTestという機能をあらかじめ用意しており、これを使用することでGuiの挙動をシミュレートできることがわかる。

よくよく調べていくと、Pytestにももっともっと機能が豊富にあり、QtTestも同じく機能をたくさん持っているので、このスタートアップを経て、様々なテスト実装を行えて良ければと考える。