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

:::note info
こちらは、以前（2021/01/10）に別サービスで展開した内容を Qiita に移行したものです。
:::

# はじめに

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

![ay82u5stv7ygo5uvotgtfvz6es86.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51857/0ca1ab29-db31-6569-fadf-4e1afaa2444a.png)


## 開発環境
> Windows 10
> Python 3.7.7
> Pytest

## サンプルファイル

> [GitHub](https://github.com/takavfx/LEARN_pytest_pyside)

# 初期整備

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

```:ディレクトリ
root
  |- sample
  |    |- __init__.py
  |    |- gui.py
  |- tests
  |    |- __init__.py
  |    |- conftest.py
  |    |- unit
  |        |- test_gui.py
  |- requirments.txt
```

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

```python: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()
    
```

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

![uaikyvvxwi8jgnq9klca6wibg54g.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51857/0f280d24-ca2d-9113-97b2-418619d6e326.png)



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

```txt
PySide2
Pytest
```

## venv環境を作成する

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


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

```python:tests/conftest.py
import os
import sys

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

Pytest公式では、setup.pyを用いた、`pip install -e .`を使って、インストールする事が[推奨されている](https://docs.pytest.org/en/latest/goodpractices.html)が、それぞれの環境により(僕の場合は、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が自動で拾いに行ってくれる。

## テストを書いていく

```python: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づつであるということを保証することができる**。

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

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

```shell
pytest .
```

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

```python
======================================= 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 . ` を実行した際に、次の結果が得られる。

```python
======================================= 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も同じく機能をたくさん持っているので、このスタートアップを経て、様々なテスト実装を行えて良ければと考える。