🌊

Pytestを実装する

2023/08/20に公開

Python のテストとして馴染み深いライブラリで「Pytest」があります。

https://github.com/pytest-dev/pytest

Pytest は、ユニットテストを支援するためのモジュールとして準備されています。

また、一般的にテストは「ユニットテスト」と「結合テスト」というものが存在します。

例えば、DB の Model に書かれているメソッドやデータ登録が想定した結果になることを確認するのがユニットテストとして扱われています。

あくまでシステム全体としてというよりは、機能として問題ないかをチェックするテストです。

一方、結合テストはユニットテストを経た構成要素を結合し、サブシステムとしてまとまった単位で動作のチェックを行うことを指します。

システム内の機能が連携できているか検証できます。

ユニットテストのメリットは、問題点を特定し易くシステムとして独立してテスト可能であるため、テストを書くのに工数がかかりません。

一方、システム全体の動作を保証するものではないため、その保証ができるのは結合テストのメリットです。

その分結合テストは、デメリットとして時間がかかってしまいます。

Pytestの導入

それでは、実際に pytest を導入してみましょう。

Python の環境はできている想定で進めていきます。

もしできていない人は、venvなどを利用しながら開発仮想環境を構築してください。

$ pip install pytest

$ pytest --version
pytest 7.4.0

version 7.4 が入りました。

pytest では、ファイル名に test_*.py や *_test.py とすることで自動的にテストファイルと認識してくれます。

どこのディレクトリにおいても上記ファイルを認識しますが、できればテストファイルを管理したいので、tests/を作ると良いです。

test_basic.py として下記を作ります。

def inc(x):
    return x + 1

def test_answer():
    assert inc(3) == 5

下記を実行し、失敗することを確認しましょう。

$ pytest
==================================== test session starts ====================================
platform darwin -- Python 3.9.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /streamlit_pytest
collected 1 item

tests/test_basic.py F                                                                                                                                                          [100%]

==================================== FAILURES ====================================
____________________________________________________________________________________ test_answer _____________________________________________________________________________________

    def test_answer():
>       assert inc(3) == 5
E       assert 4 == 5
E        +  where 4 = inc(3)

tests/test_basic.py:6: AssertionError
==================================== short test summary info ====================================
FAILED tests/test_basic.py::test_answer - assert 4 == 5
==================================== 1 failed in 0.04s ====================================

例外処理が起きることもチェック可能です。

import pytest

def f():
    raise SystemExit(1)

def test_mytest():
    with pytest.raises(SystemExit):
        f()

テストのグループ化

pytest では、複数のテストを含むクラスを簡単に作成できます。

class TestClass:
    def test_one(self):
        x = "this"
        assert "h" in x

    def test_two(self):
        x = "hello"
        assert hasattr(x, "check")

デバッグ

テストファイル内で print を書いても出力されません。

下記コマンドで出力できるようになります。

$ pytest -s

オプションはたくさんありますが、下記でオプション一覧を確認可能です。

$ pytest --help

プラグイン

pytest に導入すると便利なプラグインを紹介します。

pytest-pycodestyle

--pep8PEP8 準拠チェックを有効にするプラグインとして、pytest-pycodestyleがあります。

元々は、pytest-pep8というプラグインでした。

https://github.com/henry0312/pytest-pycodestyle

$ pip install pytest-pycodestyle
$ pytest --pycodestyle ...

設定をこちらで変更可能です。

[pycodestyle]
max-line-length = 127

[tool:pytest]
# これでpytestだけでデフォルトコマンドを指定できるようです。
addopts = --pycodestyle

pyflakes

pyflakes でソースコードをチェックします。

https://pypi.org/project/pytest-flakes/

https://github.com/asmeurer/pytest-flakes

$ pip install pytest-flakes

$ pytest --flakes
==================================== test session starts ====================================
platform darwin -- Python 3.9.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /Users/naoki/Documents/matsuzaki-app/streamlit_pytest
plugins: flakes-4.0.5
collected 5 items

main.py s                                                                                                                                                                      [ 20%]
pages/page1.py s                                                                                                                                                               [ 40%]
pages/page2.py s                                                                                                                                                               [ 60%]
tests/test_basic.py ..                                                                                                                                                         [100%]

==================================== 2 passed, 3 skipped in 0.01s ====================================

Pytestの細かな機能

ここからは一歩進んだ Pytest の機能を紹介します。

Pytestの fixturesについて

@pytest.fixtureデコレータを利用することで、それぞれのテストで前処理と後処理が利用できるようになります。

import pytest

class TestFixtures:
    @pytest.fixture()
    def sample(self):
        print("前処理")
        yield
        print("後処理")

    def test_hoge(self, sample):
        print("本処理")
        assert 1 == 1

こうすることで、test_hoge関数が呼ばれた際、引数にsampleが渡されているので、sample関数が呼ばれます。

sample関数には、yieldが書かれており、ここでtest_hogeの処理が実行されるようになります。

いわゆる、fixtures によって前処理と後処理を実行できるようになります。

fixtures には、scope が定義できます。

scope指定 詳細
scope=’function’ テスト関数ごとに、一回実行(デフォルト)
scope=’class’ テストクラスごとに、一回実行
scope=’module’ モジュール(ファイル)ごとに、一回実行
scope=’package’ パッケージごとに、一回実行
scope=’セッション’ セッション(pytestコマンドでテストを実施)ごとに、一回実行。
import pytest

@pytest.fixture(scope="function")
def fixture_function():
    """
    テスト関数ごとに、一回実行
    """
    print("前処理: function")
    yield
    print("後処理: function")

@pytest.fixture(scope="class")
def fixture_class():
    """
    テストクラスごとに、一回実行
    """
    print("前処理: class")
    yield
    print("後処理: class")

@pytest.fixture(scope="module")
def fixture_module():
    """
    モジュール(ファイル)ごとに、一回実行
    """
    print("前処理: module")
    yield
    print("後処理: module")

@pytest.fixture(scope="session")
def fixture_session():
    """
    セッション(pytestコマンドでテストを実施)ごとに、一回実行
    """
    print("前処理: session")
    yield
    print("後処理: session")

class TestFixtureScope_2:
    def test_1(self, fixture_function, fixture_class, fixture_module, fixture_session):
        pass

    def test_2(self, fixture_function, fixture_class, fixture_module, fixture_session):
        pass

class TestFixtureScope_1:
    def test_1(self, fixture_function, fixture_class, fixture_module, fixture_session):
        pass

    def test_2(self, fixture_function, fixture_class, fixture_module, fixture_session):
        pass
$ pytest

tests/unit/test_scope.py ..前処理: session
前処理: module
前処理: class
前処理: function
.後処理: function
前処理: function
.後処理: function
後処理: class
前処理: class
前処理: function
.後処理: function
前処理: function
.後処理: function
後処理: class
後処理: module
後処理: session

このように、scope によって実行単位を変えることが可能です。

fixturesを共有

複数のテストファイルで fixture を共有したい時は、conftest.py に定義すれば、簡単に共有ができます。

conftest.pyのサンプルは以下の通りです。

import pytest

@pytest.fixture(scope="session")
def conf():
    print("conftest:前処理")
    yield
    print("conftest:後処理")

あとは利用側で conf を引数に渡してあげるだけです。

Pytestの Autouse fixturesについて

すべてのテストで依存することがわかっているフィクスチャ (あるいは複数) を用意したいことがあります。

autousefixtures は、すべてのテストが自動的にそれらをリクエストする便利な方法です。

これにより、冗長なリクエストの多くをカットできます。

fixtures のデコレータに autouse=True を渡すことで、フィクスチャを自動生成フィクスチャにできます。

import pytest

@pytest.fixture
def first_entry():
    return "a"

@pytest.fixture
def order(first_entry):
    return []

@pytest.fixture(autouse=True)
def append_first(order, first_entry):
    return order.append(first_entry)

def test_string_only(order, first_entry):
    assert order == [first_entry]

def test_string_and_int(order, first_entry):
    order.append(2)
    assert order == [first_entry, 2]

上記のように autouse=True を書くことで、引数にその関数を渡さなくても、テストで必ず実行されます。

例えば、test_string_only関数で、まず初めに append_first関数が呼ばれます。

そのappend_firstでは、order に “a”を入れる処理がされています。

そのため、 order == [first_entry]が true となるのです。

テスト関数のパラメータ化

パラメータ化という概念で、@pytest.mark.parametrize がありますが、ここでは説明を省きます。

https://docs.pytest.org/en/7.3.x/how-to/parametrize.html#pytest-mark-parametrize-parametrizing-test-functions

E2Eテストとは

End to End:エンドツーエンドの略です。

あらゆる構成要素を組み込んだ状態で、テスト対象システム全体の品質を保証する手法です。

UI 操作をともなうことから「UI テスト」と呼ばれる場合もありますが、バックエンドとの通信などもテスト観点に含まれるため、より上位の概念といえます。

テストの中でも実際のユーザーの動きに近いテストが可能となるため、品質を担保できるテストとも言えます。

課題としては、やはり導入コストがかかることです。

しかし、この playwright は Pytest で実施できるためのプラグインを用意してくれているため、それを使えば簡単に導入できそうです。

Pytestにブラウザテストを導入

ブラウザテストで、「playwright」というものがあります。

https://playwright.dev/python/

これを利用することで、ブラウザ遷移等の E2E テストが可能となります。

Playwright は、Chromium、WebKit、Firefox などの最新のレンダリング エンジンをすべてサポートしています。

Windows、Linux、macOS 上で、ローカルまたは CI 上で、ヘッドレスまたはネイティブ モバイル エミュレーションを使用したヘッドでテストします。

PytestとPlaywrightの利用

Playwright では、公式のPlaywright Pytest プラグインを使用してエンドツーエンドのテストを作成することをお勧めします。

コンテキストの分離を提供し、すぐに複数のブラウザー構成で実行できます。

Pytest プラグインは Playwright の同期バージョンを利用します。

インストールと準備

$ pip install pytest-playwright

必要なブラウザをインストールします。

$ playwright install

あとは、pytest のようにテストコードを書いていけば OK です。

今回は tests/browser/test_basic.py と設定します。

サンプルを掲載します。

import re
from playwright.sync_api import Page, expect

def test_homepage_has_Playwright_in_title_and_get_started_link_linking_to_the_intro_page(page: Page):
    page.goto("https://playwright.dev/")

    # Expect a title "to contain" a substring.
    expect(page).to_have_title(re.compile("Playwright"))

    # create a locator
    get_started = page.get_by_role("link", name="Get started")

    # Expect an attribute "to be strictly equal" to the value.
    expect(get_started).to_have_attribute("href", "/docs/intro")

    # Click the get started link.
    get_started.click()

    # Expects the URL to contain intro.
    expect(page).to_have_url(re.compile(".*intro"))

to_have_titleto_have_urlはページに対するアサーションとなっています。

  • 指定したタイトルを持っているか
  • 指定した URL になっているか

をチェックします。

一方、to_have_attributeto_be_checkedは、ロケーターに対するアサーションとなっています。

  • 要素に指定した属性を持ち合わせているか
  • 要素(ここではチェックボックス)はチェックされた状態であるか

をチェックします。

この辺りの他のアサーションに関しては、下記ページを参考ください。

https://playwright.dev/python/docs/test-assertions

ロケーターの指定

また、ロケーターをどうやって検索すれば良いかについては、下記が参考になります。

https://playwright.dev/python/docs/locators

get_by_role で取得するための role の指定は様々あります。

ロール ロケーターには、ボタン、チェックボックス、見出し、リンク、リスト、テーブルなどが含まれており、 ARIA ロール、ARIA 属性、およびアクセス可能な名前の W3C 仕様に従っています。

https://www.w3.org/TR/html-aria/#docconformance

css でも指定できます。

<button style='display: none'>Invisible</button>
<button>Visible</button>
page.locator("button").locator("visible=true").click()

expect では、第二引数にエラー時のメッセージを表示させることが可能です。

expect(page.get_by_text("Name"), "should be logged in").to_be_visible()

def test_foobar(page: Page) -> None:
>       expect(page.get_by_text("Name"), "should be logged in").to_be_visible()
E       AssertionError: should be logged in
E       Actual value: None
E       Call log:
E       LocatorAssertions.to_be_visible with timeout 5000ms
E       waiting for get_by_text("Name")
E       waiting for get_by_text("Name")

tests/test_foobar.py:22: AssertionError

ロケーターのフィルタリング

<ul>
  <li>
    <h3>Product 1</h3>
    <button>Add to cart</button>
  </li>
  <li>
    <h3>Product 2</h3>
    <button>Add to cart</button>
  </li>
</ul>

例えば上記のように button が 2 つあると、get_by_role(”button”)で探すと 2 つヒットします。

その場合、下記のようにfilterメソッドを使うことで 1 つに絞ることができます。

page.get_by_role("listitem").filter(has_text="Product 2").get_by_role(
    "button", name="Add to cart"
).click()

これは、まず listitem(<li>タグ)を filter で “Product 2”を持っている li タグに絞り、その中で、button を探しています。

スコープを狭めることもできるということですね。

filter に4方法使えます。

  • has_text
  • has_not_text
  • has
  • has_not

has や has_not は、中の要素で検索するときにつかいます。

page.get_by_role("listitem").filter(
    has=page.get_by_role("heading", name="Product 2")
).get_by_role("button", name="Add to cart").click()

こうすることで、<li> タグの中で、Product 2 の heading の role を持つタグに絞り込めます。

ロケーターを利用したイベント

https://playwright.dev/python/docs/input

テスト実行

pytest 同様に実行できます。

(-s をつけてデバッグできるようにします)

$ pytest -s

下記のように実行することで、Playwright Inspector が起動し、ステップ毎に実行できます。

$ PWDEBUG=1 pytest -s

また、pytest はデフォルトがヘッドレスモードなので、下記でブラウザ起動ができます。

$ pytest --headed

特定のブラウザで実行したい場合は、 ブラウザを指定できます。

$ pytest --browser webkit --browser firefox

失敗時にスクリーンショットを撮り、そのファイルを置く場所を指定も可能です。

$ pytest --screenshot only-on-failure --output ./test-results/

テストを並行して実行する場合はこちらです、ただこれは、pytest-xdistがインストールされている必要があります。

$ pytest --numprocesses auto

並行処理については、詳しくは説明しません。

下記を参照ください。

テストジェネレーターの利用

playwright では、実際の操作を記録し、それをテストコードとして利用できる codegen という機能があります。

その後にテストを生成する Web サイトの URL を指定します。

URL はオプションであり、いつでも URL を指定せずにコマンドを実行し、代わりにブラウザ ウィンドウで URL を直接追加できます。

$ playwright codegen demo.playwright.dev/todomvc

ベースURLの設定

URL を設定することで下記のように可能です。

pytest --base-url http://localhost:8080
def test_visit_example(page):
    page.goto("/admin")
    # -> Will result in http://localhost:8080/admin

まとめ

pytest やブラウザテストツールの playwright をうまく使いこなすようになるためには時間がかかりそうですが、ブラウザテストも pytest でできるのはとても便利だと感じます。

もう少し知見を増やし、テストコードを書くことによる品質担保に努力したいです。

GitHubで編集を提案

Discussion