🔖

pytestでファイルやFile::Class::Functionを指定しての実行を楽にしたい

2023/12/27に公開

(確認した環境: Mac、Ubuntu22)
(python、pytestはバージョン不問)

前提

前提0

VSCodeを使う

(スクリプトは他のIDEでも問題ないですが)

前提1

pytestでファイル単位、テスト関数単位で実行したい。

前提2

pytestのテストファイルの構造が以下のようにClassの中にtestがある。

class TestClass:

    def test_function_1():
        ...
	
    def test_function_2():
        ...
	
    def test_function_3():
        ...

前提3

  • VSCodeの拡張 multi-command を使ってkeybindings.jsonを設定します。

別途書いたのでご参考。
https://zenn.dev/shimo_s3/articles/9f8fb1f3dad29c

前提4

pytestコマンドはpytest -s -p no:warnings --no-header --tb=shortにしています。お好みで変えてください。

やりたいこと

まずtest_function_1を実行して、次にtest_function_2を実行して、というようにファイル内のtest関数だけを手際よく実行したいです。

実行コマンドは

pytest test_file.py::TestClass::test_function_1
pytest test_file.py::TestClass::test_function_2

のようになるのですが、これを作るのが面倒。。
(楽な方法があったらそれでいいのですが)

やったこと

コマンドを作るためのPythonスクリプトを作りました。
使い方

  • testファイルをアクティブにした状態から
  • Pythonスクリプト実行すると以下の操作を行う
    • アクティブなファイルの、カーソル行を含むtest関数を探す
    • 見つけたtest関数、その関数が存在するクラス、ファイルからコマンドを作る
    • コマンドを実行する

できないこと

  • parameterizeを分けられません(test関数全体が実行される)

スクリプト

pythonファイルを作成して任意のフォルダに保存します。フルパスをkeybidings.jsonに記述します。

run_pytest_single_test.py
"""
This Python script identifies the relevant test class and function based on the line number,
then constructs and executes a pytest command in the format pytest options file::class::fn.

Usage:
    python this_file.py <line_num>
    cf. Get arg from line cursor number

Prerequisites for the test class.

class ArbitraryNameClass():

    def test_name_start_from_test():
        ...

    def test_name_start_from_test2():
        ...

"""
import subprocess
import re
import sys


def search_down(start_index, lines):
    """
    Search downward from the given index to find the name of a test function.

    Args:
        start_index (int): The index to start the search from.
        lines (list of str): The lines of the file to search through.

    Returns:
        tuple: The class name and function name found, or None.
    """
    for i in range(start_index, len(lines)):
        line = lines[i]
        if match := re.search(r'^\s*def (test.*)\(', line):
            fn_name = match.groups()[0]
            return search_up(start_index, lines, fn_name=fn_name)


def search_up(start_index, lines, fn_name=None):
    """
    Search upward from the given index to find the name of the class and function.
    If a decorator for pytest found before a fn_name,
    search "down" for the function which the decorator belongs.

    Args:
        start_index (int): The index to start the search from.
        lines (list of str): The lines of the file to search through.
        fn_name (str, optional): The name of the function if already found.

    Returns:
        tuple: The class name and function name.
    """

    cls_name = None

    for i in range(start_index, -1, -1):
        line = lines[i]

        if not fn_name:
            # Check for a decorator or test function
            decorator_match = re.search(
                r'^\s*@(pytest|mock|patch|freezegun)', line)
            function_match = re.search(r'^\s*def (test\w*)\(', line)

            if decorator_match:
                return search_down(i, lines)
            elif function_match:
                fn_name = function_match.group(1)
        else:
            # Once a function is found, look for the class
            class_match = re.search(r'class (\w+)\(?', line)
            if class_match:
                cls_name = class_match.group(1)
                break

    if cls_name is None or fn_name is None:
        raise ValueError(
            "Class name or function name not found in the given range.")

    return cls_name, fn_name


def run_pytest_command(filepath, cls_name, fn_name):
    """
    Constructs and executes the pytest command.

    Args:
        filepath (str): The path to the test file.
        cls_name (str): The name of the test class.
        fn_name (str): The name of the test function.
    """
    command = f"pytest -s -p no:warnings --no-header --tb=short {filepath}::{cls_name}::{fn_name}"
    print(f"Running command: {command}")

    cp = subprocess.run([command], shell=True, capture_output=True)
    res = cp.stdout.decode("utf-8")
    print(res)


if __name__ == "__main__":
    args = sys.argv
    target_file_path = args[1]
    start_index = int(args[2]) - 1

    with open(target_file_path, "r") as f:
        lines = f.readlines()

    # Adjust index and length
    if start_index > len(lines):
        sys.exit()  # Wrong input
    elif start_index == len(lines):
        start_index -= 1

    cls_name, fn_name = search_up(start_index, lines)

    try:
        cls_name, fn_name = search_up(start_index, lines)
        run_pytest_command(target_file_path, cls_name, fn_name)
    except ValueError as e:
        print(f"Error: {e}")
        sys.exit(1)

keybindings.json

1つのテスト関数だけを実行するとき

カーソルの行を引数として渡してPythonスクリプトを実行しています。

  {
    "key": "alt+t",
    "command": "extension.multiCommand.execute",
    "args": {
      "sequence": [
        {
          "command": "workbench.action.files.saveAll"
        },
        {
          "command": "workbench.action.terminal.sendSequence",
          "args": {
            "text": "python3 full/path/to/run_pytest_single_test.py ${file} ${lineNumber}\n"
          }
        }
      ]
    }
  },

テストファイル全体を実行するとき

ファイル名を使ってpytestを呼んでいます。

  {
    "key": "shift+alt+t",
    "command": "extension.multiCommand.execute",
    "args": {
      "sequence": [
        {
          "command": "workbench.action.files.saveAll"
        },
        {
          "command": "workbench.action.terminal.sendSequence",
          "args": {
            "text": "pytest -s  -p no:warnings --no-header --tb=short ${file}\n"
          }
        }
      ]
    }
  },

まとめ

  • pytestで1つのテスト関数、1つのファイルを実行するときに便利な設定を紹介しました。

Discussion