【pytest】 フィクスチャーの定義へジャンプするスクリプト

変更履歴
- 2022-09-18: pytest 使用版スクリプトを追加

## フィクスチャーはどこで定義しているのか

pytest の**フィクスチャー**(fixture)を使うと、テスト データ などがツリー状に整理されるようになりますが、それにも関わらずどこで定義されているかを見付けることは非常に難しいです。 Visual Studio Code などの IDE が定義へのジャンプに対応していれば難しくはないのですが、2022年現在は対応していません。 その機能を持った Visual Studio Code の拡張機能があるようですが、まともに動作しません。

そこで、フィクスチャーがどこにあるかを検索する簡単な Linux 用 bash スクリプトを作りました。 これを使えば、テスト関数が使うフィクスチャーの内容がすぐに分かるようになります。


## そもそも pytest のフィクスチャーとは

Python の テスト スイート の 1つである pytest が用意しているフィクスチャーとは、そもそも何でしょうか。 公式の説明を見てみましょう。

https://docs.pytest.org/en/latest/explanation/fixtures.html

さっぱり分かりませんね。 しかし、ほんの少しですが説明が書いてあります。

    Fixtures define the steps and data that constitute the arrange phase of a test

意訳すると、次のようになります。

- テスト データ を定義する（ときにフィクスチャーを使うと何かメリットがある）
- テスト環境を準備するコードを定義する（ときにフィクスチャーを使うと何かメリットがある）

メリットについていろいろ書いてありますが、間接的なので伝わりづらく、大袈裟なので不審に思うでしょう。 つまり公式でありながら本質から外れた説明をしているので公式の文書は読まなくていいです。 そんな状況なので、理解している人は少なく、Python の標準とは異なるフィクスチャー固有かつ暗黙的な機能を使うことは普通に考えれば不採用にすべきです。

しかし、この記事で分かりやすく説明したことをおおくの方が理解し、定義にジャンプして多くのコードを見ていけば、状況は変わって採用してもよくなるでしょう。


## 最初のサンプル - 引数から関数を呼び出す

公式の最初のサンプルは非常に複雑なので、もっとシンプルなサンプルを示します。

test_1.py

    import pytest

    @pytest.fixture
    def data():
        return {'a': 1, 'b': 2}

    def test(data):
        assert data['a'] == 1
        assert data['b'] == 2

最初に呼び出そうとする関数は test という名前を含み、@pytest.fixture が付いていない関数です。 上記の場合、test 関数です。 テスト関数はテストケースごとにあります。

しかし、細かく見ていくと最初に呼び出される関数は test 関数ではありません。test 関数が呼ばれる前に data 関数が呼ばれます。

最初に呼び出される関数ではなく最初に呼び出そうとする関数を意識すべき理由は、test 関数の data 引数を data 関数の呼び出しと考えれば、実際の動作と同じになるからです。 つまり、下記のようなコードが暗黙的に pytest によって実行されるからです。

    test(data())

（それ以外に暗黙的に呼ばれる関数については基本ではないので割愛します）


## 定義にジャンプできない原因

下記のコードの data の部分から data 関数の定義にジャンプすることはできません。

    def test(data):

なぜなら、data は関数の引数（仮引数）なので、それ自体が定義だからです。 data 引数を data 関数に関連づけているのは pytest 独自の仕様です。

ちなみに、下記の暗黙的に実行されるコードを明示的に書けば、data の定義にジャンプすることができます。

    test(data())

（実際はクラスのスコープの関係で self などを書く必要がありますが、ややこしいので簡易的に表現しています）


## 2つ目のサンプル - conftest.py ファイルに定義する

次に基本的なサンプルは conftest.py ファイルを使うサンプルです。 公式の最初のほうのサンプルにはありませんが、これを理解すれば、フィクスチャーに関する仕様をほぼすべて読めるようになるぐらい重要なサンプルです。

https://docs.pytest.org/en/latest/reference/fixtures.html

例によって公式のサンプルは非常に複雑なので、もっとシンプルなサンプルを示します。

test_2.py

    def test(data):
        assert data['a'] == 1
        assert data['b'] == 2

conftest.py

    import pytest

    @pytest.fixture
    def data():
        return {'a': 1, 'b': 2}

このサンプルと最初のサンプルの違いは、data 関数の定義が conftest.py ファイルに分けたことだけです。 test_2.py に conftest.py の data を import するコードは不要です。 こう書いてもきちんと動作し、実行する内容は最初のサンプルと同じです。

このように書けるメリットは後ほど説明します。

フィクスチャーの関数定義を書く場所は、conftest.py ファイルだけでなく親フォルダーの conftest.py ファイルや、その親フォルダーの conftest.py ファイル、またその親フォルダーの conftest.py ファイル …（以下略）のどこでも構いません。

test/category/case/test_2a.py

    def test(data):

と

test/category/case/conftest.py

    @pytest.fixture
    def data():

または

test/category/conftest.py

    @pytest.fixture
    def data():

または

test/conftest.py

    @pytest.fixture
    def data():

上記は、3つの data 関数の定義を書くのではなく、3つのうちどれか 1つに書くという意味です。

（複数書くこともできますが基本ではないので割愛します）


## フィクスチャーの定義を書けない場所

兄弟フォルダーの conftest.py ファイルに書いた data 関数は呼ばれません。

test/other/conftest.py

    @pytest.fixture
    def data():

conftest.py 以外の名前のファイルに書いた data 関数も呼ばれません。（テスト関数を定義しているファイル以外）

test/other.py

    @pytest.fixture
    def data():


## フィクスチャーがやりたいこと

以上で勘のいい人は気づいたと思います。 それは、複数のテスト関数で共通で使うデータの関数や準備処理の関数は、テスト関数の定義が書かれたファイルと、同じフォルダーまたは親フォルダーの conftest.py で定義すると引数から暗黙的に呼び出せるということです。

これが、フィクスチャーがやりたいことです。

処理効率が良くなるように、実際は共通するフィクスチャーの関数であっても呼び出されるのは 1度だけだったりするのですが、それは通常知らなくてもいいことです。


## フィクスチャーの定義を検索する

pytest の定義を検索する機能は pytest にはありませんが、パスを一覧する機能があるのでそれを利用してフィクスチャーの定義を検索するスクリプトを作れます。

テスト関数の例（/home/user1/project/tests/db/test_s.py）

    def test(fixture_example):
        ...

fixture コマンドの入力例

    fixture  fixture_example  /home/user1/project/tests/db/test_s.py

fixture コマンドの出力例（Visual Studio Code なら Ctrl キーを押しながら下記の -- の右にあるパスをクリックするとジャンプします）

    data -- examples/pytest/2nd/conftest.py:4

fixture スクリプト (~/bin/fixture)

    #!/bin/bash
    fixtureName="$1"
    testFilePath="$2"

    pytest --fixtures-per-test "${testFilePath}" | grep "^${fixtureName} --"


## フィクスチャーの定義を検索する - pytest 不要版

前の章で示したスクリプトは pytest が動作できる環境が必要ですが、pytest が使えない環境でも検索できるスクリプトは簡単に作れます。

テスト関数を定義しているファイル、または、conftest.py ファイル（親も含む）に対して、テスト関数の引数名を検索すればいいのです。

テスト関数の例（/home/user1/project/tests/db/test_s.py）

    def test(fixture_example):
        ...

fixture_ コマンドの入力例

    fixture_  fixture_example  /home/user1/project/tests/db/test_s.py

fixture_ コマンドの出力例（検索の様子）

    /home/user1/project/tests/db/test_s.py
    /home/user1/project/tests/db/conftest.py: Not found the file
    /home/user1/project/tests/conftest.py
    /home/user1/project/tests/conftest.py:40: def fixture_example():
    /home/user1/project/conftest.py: Not found the file
    /home/user1/conftest.py: Not found the file
    /home/conftest.py: Not found the file

上記の出力で見つかった定義の場所（Visual Studio Code なら Ctrl キーを押しながら下記をクリックするとジャンプします）

    /home/user1/project/tests/conftest.py:40

fixture_ スクリプト (~/bin/fixture_)

    #!/bin/bash

    function  FindPytestFixture()
    {
        local  function_name="$1"
        local  user_file_path="$2"
        if [ ! -e "${user_file_path}" ]; then
            echo "${user_file_path}: Not found the file"
            return
        fi

        if [ -f "${user_file_path}" ];then
            local  folder_path=${user_file_path%/*}
            local  found=${False}
            echo "${user_file_path}"

            grep -Hn  "def  *${function_name}("  "${user_file_path}"  &&  found=${True}  ||  found=${False}
        else
            local  folder_path="${user_file_path}"
        fi
        local  previous_folder_path=""

        while [ "${folder_path}" != ""  -a  "${folder_path}" != "${previous_folder_path}" ]; do
            local  define_file_path="${folder_path}/conftest.py"
            if [ -f "${define_file_path}" ]; then
                local  found=${False}
                echo "${define_file_path}"

                grep -Hn  "def  *${function_name}("  "${define_file_path}"  \
                    |  sed  "s/\\(:[0-9][0-9]*:\\)\\([^ ]\\)/\\1 \\2/" \
                    &&  found=${True}  ||  found=${False}
            else
                echo "${define_file_path}: Not found the file"
            fi
            previous_folder_path="${folder_path}"
            folder_path="${folder_path%/*}"  #// Go to the parent folder.
        done
    }
    True=0
    False=1

    FindPytestFixture "$@"