- vừa được xem lúc

Test API sử dụng Pytest (Phần 2: Testcase Template và Dynamic Testing Function)

0 0 40

Người đăng: Trần Công Hoàng

Theo Viblo Asia

Đặt vấn đề

Một project web sẽ phải có rất nhiều API, chúng ta sẽ phải viết rất nhiều hàm test và testcase, nếu ta không xử lý, cấu trúc code test một cách tốt nhất thì dù cho phần code base rất đẹp rất chuẩn thì phần code test sẽ chỉ là đống rác lộn xộn và rất khó maintain. Để có thể cấu trúc test tốt, ta bắt đầu từ viết testcase

Cấu trúc của testcase

Ta sẽ tạo folder với cấp cao nhất trong cấu trúc của Project (để tách biệt với phần code base), ta tạo các folder con là tên của các file API, trong các folder con ta tạo các file với tên là từng API trong file API Mỗi API sẽ có một file testcase gồm nhiều testcases với các trường hợp mà ta định kiểm thử (các trường hợp chạy thành công, chạy gặp lỗi) Ta sẽ khởi tạo biến TEST_CASE = {} là một dict Các testcase sẽ là 1 item của dict TEST_CASE với key là tên của testcase, value là nội dung của nó Ví dụ:

TEST_CASE = {} TEST_CASE["LOGIN_FAILED"] = { "INPUT": { "username": "hello", "password": "123", }, "MOCK": [ { "path": "project.service.example_service.database", "return_value": Database( return_delete=None, return_insert_one="1", ), }, ], "OUTPUT": Exception(), "FUNCTION": login,
}

Tên của testcase phía trên là LOGIN_FAILED Ta thấy có 4 phần cho mỗi testcase

  1. INPUT Bao gồm các key-value là tên và giá trị của các param mà hàm xử lý của API sẽ nhận (trong ví dụ trên hàm xử lý của API login sẽ nhận usernamepassword
  2. MOCK Sẽ là danh sách các đường dẫn và giá trị của các hàm/class cần fake giá trị trả về của chúng (Lưu ý đường dẫn sẽ phải trỏ đến nơi mà hàm cần fake được gọi chứ không phải là nơi mà hàm đó được viết) Trong ví dụ trên, ta sử dụng Database, là class giả lập cho database thật, với các giá trị truyền vào bắt đầu bằng return để quy định giá trị trả về của database trong code khi nó gọi các phương thức tương ứng như insert_one (sẽ trả về 1)
  3. OUTPUT Sẽ bao gồm giá trị ta muốn so sánh với kết quả của API khi xử lý đầu vào trong phần INPUT
  4. FUNCTION Là tên của hàm xử lý API (ở đây là hàm login)

Cấu trúc của file test

Việc có 1 Template cho các testcases sẽ giúp ta viết 1 dynamic funciton testing một cách dễ dàng (ta sẽ viết 1 hàm test duy nhất cho tất cả các testcase, hay nói cách khác ta viết để không phải động đến hàm test nữa mà chỉ quan tâm test API nào thì tạo file viết testcase cho API đó) Để làm được điều đó thì trước khi vào file test và tạo hàm test, ta cần viết file conftest.py, với tên file này, Pytest sẽ thực thi nó trước khi chạy hàm test. Ta có thể import các testcase vào từ file này, nhưng ta không phải làm điều đó thủ công bằng tay (sau đó lại phải chạy hàm test bằng command), điều đó rất thiết chuyên nghiệp, thay vào đó ta sẽ sử custom thêm các flag ở command để cho pytest biết là ta cần chạy testcase cho APIs nào

file conftest.py:

import datetime
import os
import importlib import pytest testcases_dir = "testcases" def get_immediate_subdirectories(a_dir): return [ name for name in os.listdir(a_dir) if os.path.isdir(os.path.join(a_dir, name)) and name.endswith("router") ] def get_list_of_files(dirName): listOfFile = os.listdir(dirName) allFiles = list() for entry in listOfFile: fullPath = os.path.join(dirName, entry) if os.path.isdir(fullPath): allFiles = allFiles + get_list_of_files(fullPath) else: allFiles.append(fullPath) return allFiles def get_tree_testcases(): PATHS = {} ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) testcases_path = os.path.split(ROOT_DIR)[0] folder_paths = get_immediate_subdirectories(testcases_path + "/" + testcases_dir) for folder_path in folder_paths: PATHS[folder_path] = [] parent = testcases_path + "/" + testcases_dir + "/" + folder_path + "/" files_path = get_list_of_files(parent) for file_path in files_path: if "__init__" in file_path: continue list_file_name = file_path.split("/") PATHS[folder_path].append( list_file_name[len(list_file_name) - 1].split(".")[0] ) return PATHS def pytest_addoption(parser): parser.addoption("--files", action="store") parser.addoption("--folders", action="store") parser.addoption("--excludes", action="store") def append_path(paths, _folder, _file): new_path = testcases_dir + "." + _folder + "." + _file if new_path not in paths: paths.append(new_path) def generate_path(folders=None, files=None): PATHS = get_tree_testcases() paths = [] if not folders and not files: for _folder, _files in PATHS.items(): for _file in _files: append_path(paths, _folder, _file) elif not files: for _folder, _files in PATHS.items(): for folder in folders: if folder not in _folder: continue for _file in _files: append_path(paths, _folder, _file) else: for _folder, _files in PATHS.items(): if ( folders and any([(folder in _folder) for folder in folders]) ) or not folders: for file in files: for _file in _files: if file not in _file: continue append_path(paths, _folder, _file) return paths def pytest_generate_tests(metafunc): testcases = [] files = None folders = None if metafunc.config.getoption("files"): name_files = metafunc.config.option.files files = name_files.split("+") if metafunc.config.getoption("folders"): name_folders = metafunc.config.option.folders folders = name_folders.split("+") paths = generate_path(folders, files) for path in paths: testcase = importlib.import_module(path).TEST_CASE for name_test, data_test in testcase.items(): data_test["NAME"] = name_test testcases.append(data_test) if metafunc.config.getoption("excludes"): excludes_attr = metafunc.config.option.excludes for _testcase in testcases: _testcase["COMPARISON_EXCLUDE"] = excludes_attr.split("+") tcs = [] metafunc.parametrize("testcase", testcases)

Hàm đầu tiên ta cần chú ý là pytest_addoption sẽ định nghĩa ra các flag mà ta sử dụng trong command-line Hàm generate_path sẽ tìm đường dẫn tương ứng với các tên file hay folder mà ta nhập trong command-line Hàm pytest_generate_tests sẽ từ các đường dẫn mà import vào các TEST_CASE trong các file testcase và tạo ra multi-test cho Pytest

file test_master.py

Đây chính là linh hồn của cả phần testing (1 file viết cho nhiều testcase, không phải chỉnh sửa)

from unittest.mock import MagicMock
import mock
import pytest
from typing import TypeVar
from pydantic import BaseModel T = TypeVar("T") def get_dict_from_testing_result(object: T, COMPARITION_EXCLUDE: list = []): res = {} data = object if type(object) is dict else object.__dict__ for attr in COMPARITION_EXCLUDE: if attr in data.keys(): data.pop(attr) for key, value in data.items(): if value == None: continue if isinstance(value, BaseModel): res[key] = get_dict_from_testing_result(value, COMPARITION_EXCLUDE) elif type(value) is not dict: res[key] = value elif len(value.keys()) == 0: continue else: res[key] = get_dict_from_testing_result(value, COMPARITION_EXCLUDE) return res @pytest.mark.asyncio
async def test_master( testcase, mocker,
): for item in testcase["MOCK"]: mocker.patch(item["path"], return_value=item["return_value"]) print(testcase["FUNCTION"].__name__, testcase["NAME"]) if isinstance(testcase["OUTPUT"], Exception): with pytest.raises(testcase["OUTPUT"].__class__) as exception: await testcase["FUNCTION"](*testcase["INPUT"].values()) print(exception.__dict__["_excinfo"]) else: resutl = await testcase["FUNCTION"](*testcase["INPUT"].values()) if "COMPARISON_EXCLUDE" in testcase.keys(): assert get_dict_from_testing_result( resutl, testcase["COMPARISON_EXCLUDE"] ) == get_dict_from_testing_result( testcase["OUTPUT"], testcase["COMPARISON_EXCLUDE"] ) else: assert ( resutl == testcase["OUTPUT"] ), "a few values are realtime so not equal while testing" 

Cách chạy test

pytest [-p no warnings] [-vv] [-s] [--folders param1[+pram2+...]] [--files param1[+param2+...]] [--excludes param1[+param2+...]]

 [-p no warnings] sẽ bỏ qua warnings của pytest [-vv] hiển thị kết quả chạy chi tiết của testcase [-s] hiển thị chi tiết thông tin khi chạy testcase [--folders] xác định folders testcase sẽ chạy [--files] xác định files testcase sẽ chạy [--excludes] không so sánh thuộc tính được liệt kê

Bình luận

Bài viết tương tự

- vừa được xem lúc

Thao tác với File trong Python

Python cung cấp các chức năng cơ bản và phương thức cần thiết để thao tác các file. Bài viết này tôi xin giới thiệu những thao tác cơ bản nhất với file trong Python.

0 0 63

- vừa được xem lúc

Tập tành crawl dữ liệu với Scrapy Framework

Lời mở đầu. Chào mọi người, mấy hôm nay mình có tìm hiểu được 1 chút về Scrapy nên muốn viết vài dòng để xem mình đã học được những gì và làm 1 demo nho nhỏ.

0 0 166

- vừa được xem lúc

Sử dụng Misoca API (oauth2) với Python

Với bài viết này giúp chúng ta có thể nắm được. ・Tìm hiểu cách xử lý API misoca bằng Python.

0 0 49

- vừa được xem lúc

[Series Pandas DataFrame] Phân tích dữ liệu cùng Pandas (Phần 3)

Tiếp tục phần 2 của series Pandas DataFrame nào. Let's go!!. Ở phần trước, các bạn đã biết được cách lấy dữ liệu một row hoặc column trong Pandas DataFame rồi phải không nào. 6 Hoc.

0 0 63

- vừa được xem lúc

Lập trình socket bằng Python

Socket là gì. Một chức năng khác của socket là giúp các tầng TCP hoặc TCP Layer định danh ứng dụng mà dữ liệu sẽ được gửi tới thông qua sự ràng buộc với một cổng port (thể hiện là một con số cụ thể), từ đó tiến hành kết nối giữa client và server.

0 0 79

- vừa được xem lúc

[Series Pandas DataFrame] Phân tích dữ liệu cùng Pandas (Phần 2)

Nào, chúng ta cùng đến với phần 2 của series Pandas DataFrame. Truy xuất Labels và Data. Bạn đã biết cách khởi tạo 1 DataFrame của mình, và giờ bạn có thể truy xuất thông tin từ đó. Với Pandas, bạn có thể thực hiện các thao tác sau:.

0 0 95