🔖
pytestでファイルやFile::Class::Functionを指定しての実行を楽にしたい
(確認した環境: 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を設定します。
別途書いたのでご参考。
前提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