Pytest自動化發現測試資料並進行資料驅動-支援YAML/JSON/INI/CSV資料檔案

韩志超發表於2024-11-13

需求

  • 在測試框架中,往往需要測試資料和程式碼分離,使用CSV或JSON等資料檔案儲存資料,使用程式碼編寫測試邏輯
  • 一個用例過程往往可以測試多組資料,Pytest原生的引數化往往需要我們自己手動讀取資料檔案,比較麻煩又略顯混亂
  • 我們如何能把資料檔案按約定的目錄和檔名存起來,檔案中可以存一組或多組資料,用例自動根據裡面的資料進行資料驅動呢?
  • 另外,為了靈活,我們儘量可以支援多種資料型別,如YAML/JSON/INI/CSV等
  • 是否能僅自動化發現測試資料,不啟用資料驅動

約定

image

  1. 我們約定,用例使用case_data這個fixture時,可以自動獲取到用例對應資料,如
# filename: test_a.py
def test_a_01(case_data):
    print(case_data)
  1. 我們新增一個自定義引數和配置項 datapath,設定為測試資料的根目錄,新增一個ddt引數和配置項,設定是否啟用ddt模式,預設不啟用,如
# filename: pytest.ini
[pytest]
datapath=testdata
ddt=true
  1. 我們約定 測試用例(測試函式)的資料,存放在 測試資料的根目錄/測試檔名/目錄下 (暫時忽略模組),測試資料檔名(不包含字尾) 必須 與 測試函式名相同,如 test_a.py中test_a_01用例對應資料
testdata/
	test_a/
		test_a_01.yaml
  1. 測試資料檔案.yaml/.json/.ini/.csv任選一種,同時載入優先順序為 .yaml > .json > .ini > .csv
  2. ini檔案 分段名 如 [data1]"為該資料的id標識,並支援自動繼承[DEFAULT]段預設資料,如
# filename: test_a_01.ini
[DEFAULT]
age=15

[data1]
name=張三

[data2]
name=李四
age=16

[data3]
name=王五
age=17
  1. json或yaml檔案,如為字典格式,則每個key為該資料的id標識,如為列表格式,每組資料中如果有_id欄位,則視為資料id標識,如不包含_id,則資料無id標識,如
# filename: test_a_01.yaml
data1:
  name: 張三
  age: 15
data2:
  name: 李四
  age: 16
data3:
  name: 張三
  age: 17

# filename: test_a_01.yaml
- _id: data1
  name: 張三
  age: 15
- _id: data2
  name: 李四
  age: 16
- _id: data3
  name: 張三
  age: 17
  1. csv檔案第一行必須欄位標題行,如name,age,每組資料中如果有_id欄位,則視為資料id標識,如不包含_id,則資料無id標識,如

test_a_01.csv

_id,name,age
data1,張三,15
data2,李四,16
data3,王五,17

實現

實現思路:使用pytest hooks方法pytest_generate_tests來根據資料檔案內容動態生成用例
所需依賴:pip install pytest filez pyyaml

  1. 新增自定義配置項
# filename: conftest.py
def pytest_addoption(parser):
    # 制定測試資料根目錄
    parser.addoption("--datapath", action="store", help="testdata dir path")
    parser.addini('datapath', help='testdata dir path')
	# 是否啟用資料驅動
    parser.addoption("--ddt", action="store_true", help="enable data driven test for testdata")
    parser.addini('ddt', help='enable data driven test for testdata')
  1. 核心實現
# filename: conftest.py
def pytest_generate_tests(metafunc):
    if "case_data" in metafunc.fixturenames:
        config = metafunc.config
        datapath = config.getoption("--datapath") or config.getini("datapath")
        ddt = config.getoption("--ddt") or config.getini("ddt")
        # 如果配置的datapath為絕對路徑,則直接視為測試資料根目錄
        if datapath.startswith("/"):
            testdata_dir = Path(datapath)
        else:
            # 如果不是絕對路徑則前面新增測試專案根目錄
            testdata_dir = config.rootdir / datapath
        # 用例檔名(不帶副檔名),如test_a
        testfile_name = metafunc.definition.fspath.purebasename
        # 用例函式名,如test_a_01
        testcase_name = metafunc.definition.name
        # 用例節點id,如test_a.py::test_a_01
        testcase_node_id = metafunc.definition.nodeid

        # 讀取對應資料檔案
        testcase_csv_datafile = testdata_dir / testfile_name / f'{testcase_name}.csv'
        testcase_ini_datafile = testdata_dir / testfile_name / f'{testcase_name}.ini'
        testcase_json_datafile = testdata_dir / testfile_name / f'{testcase_name}.json'
        testcase_yaml_datafile = testdata_dir / testfile_name / f'{testcase_name}.yaml'
        if testcase_yaml_datafile.exists():
            # 讀取yaml檔案
            with open(testcase_yaml_datafile) as f:
                file_data = yaml.safe_load(f)
        elif testcase_json_datafile.exists():
            # 讀取json檔案
            file_data = file.load(testcase_json_datafile)
        elif testcase_ini_datafile.exists():
            # 讀取ini檔案
            file_data = file.load(testcase_ini_datafile)
        elif testcase_csv_datafile.exists():
            # 讀取csv檔案-帶標題行
            file_data = file.load(testcase_csv_datafile, header=True)
        else:
            return

        # 對列表和字典格式資料進行處理
        data, ids = None, None
        if isinstance(file_data, list):
            data = file_data
            if len(file_data) > 0 and file_data[0].get('_id'):
                ids = [item.get('_id') for item in file_data]
                data = [item for item in file_data if item!='_id']
        elif isinstance(file_data, dict):
            ids = list(file_data.keys())
            data = list(file_data.values())
        else:
            logging.warning(f"測試用例 {testcase_node_id} 資料格式錯誤, 應為列表或字典格式")

        if ddt and isinstance(data, list):
            metafunc.parametrize("case_data", data, ids=ids, scope="function")
        else:  # 不啟用ddt時,整體作為一個資料
            metafunc.parametrize("case_data", [file_data], scope="function")

用例執行效果如下
image

相關文章