Viết hàm hook trong Pytest: Tùy chỉnh hành vi thông qua cơ chế mở rộng

Xác thực và thực thi hàm hook

Pytest sử dụng cơ chế hook để cho phép các plugin can thiệp vào quy trình chạy kiểm thử. Khi một hook được gọi, Pytest sẽ tìm tất cả các triển khai đã đăng ký phù hợp với định nghĩa chuẩn và thực hiện chúng theo thứ tự nhất định.

Ví dụ điển hình là pytest_collection_modifyitems(config, items), được gọi sau khi Pytest thu thập xong danh sách các test case. Khi bạn viết một plugin có chứa hàm này:

def pytest_collection_modifyitems(config, items):
    # Thực hiện thay đổi trên danh sách test items
    items[:] = [item for item in items if "slow" not in item.keywords]

Pytest sẽ chỉ truyền vào các tham số mà bạn khai báo — ở đây là configitems. Dù định nghĩa gốc có thể bao gồm thêm session, việc bỏ qua tham số không dùng đến vẫn an toàn. Điều này giúp đảm bảo tính tương thích ngược khi Pytest bổ sung tham số mới trong các phiên bản sau.

Lưu ý: Chỉ những hook liên quan trực tiếp đến việc chạy test (ví dụ pytest_runtest_*) mới được phép ném ra ngoại lệ. Các hook khác nếu gây lỗi sẽ làm gián đoạn toàn bộ quá trình thực thi.

Tùy chọn firstresult: Trả về kết quả đầu tiên hợp lệ

Một số hook được đánh dấu với firstresult=True, nghĩa là quá trình gọi sẽ dừng ngay khi gặp một plugin trả về giá trị khác None. Kết quả này sẽ được sử dụng làm đầu ra duy nhất của toàn bộ hook call.

Ví dụ, nếu hai plugin cùng triển khai một hook dạng firstresult=True:

  • Plugin A trả về "preferred" → Pytest sử dụng giá trị này và bỏ qua Plugin B.
  • Plugin A trả về None → Plugin B vẫn được gọi.

Cơ chế này hữu ích khi cần xác định hành vi ưu tiên mà không yêu cầu tất cả plugin phải đồng thuận.

Sử dụng hookwrapper để bao bọc thực thi

Thêm từ phiên bản 2.7, hookwrapper=True cho phép một hàm hook đóng vai trò như một lớp bao quanh các triển khai khác. Hàm này phải là một generator và chỉ được yield đúng một lần.

Ví dụ minh họa cách đo thời gian thực thi một test function:

@pytest.hookimpl(hookwrapper=True)
def pytest_pyfunc_call(pyfuncitem):
    start_time = time.time()
    print(f"Bắt đầu thực thi: {pyfuncitem.name}")
    
    outcome = yield
    
    duration = time.time() - start_time
    print(f"Kết thúc: {pyfuncitem.name}, Thời gian: {duration:.2f}s")
    
    # Có thể thay đổi kết quả nếu cần
    if outcome.excinfo is None:
        original = outcome.get_result()
        outcome.force_result(original + " [đã xử lý]")

Trước yield: thực hiện tác vụ tiền xử lý.
Sau yield: truy cập đối tượng outcome để kiểm tra kết quả hoặc lỗi, rồi thực hiện hậu xử lý.

Đối tượng outcome cung cấp các phương thức như get_result() (ném exception nếu thất bại) và force_result(value) để ghi đè kết quả trả về.

Thứ tự thực thi hook: tryfirst, trylast và hookwrapper

Khi nhiều plugin cùng triển khai một hook, thứ tự gọi phụ thuộc vào các tùy chọn cấu hình:

# Plugin A: ưu tiên thực thi sớm nhất
@pytest.hookimpl(tryfirst=True)
def pytest_collection_modifyitems(items):
    items.reverse()

# Plugin B: trì hoãn đến khi các plugin khác hoàn tất
@pytest.hookimpl(trylast=True)
def pytest_collection_modifyitems(items):
    items.sort(key=lambda x: x.name)

# Plugin C: bao bọc toàn bộ quá trình
@pytest.hookimpl(hookwrapper=True)
def pytest_collection_modifyitems(items):
    print("Bắt đầu xử lý danh sách test")
    yield
    print("Hoàn tất xử lý danh sách test")

Thứ tự thực thi:

  1. Plugin C (hookwrapper) – phần trước yield.
  2. Plugin A (tryfirst=True).
  3. Plugin B (trylast=True).
  4. Plugin C – phần sau yield.

Các hookwrapper luôn được ưu tiên thực thi đầu tiên (trước cả tryfirst), và hoàn tất cuối cùng.

Đăng ký hook mới cho plugin

Một plugin có thể khai báo các hook riêng để các plugin khác phản hồi. Việc này thực hiện qua add_hookspecs() trong giai đoạn đăng ký:

# myplugin/hooks.py
class MyHookSpec:
    """Định nghĩa các hook tùy chỉnh"""
    def pytest_myfeature_start(self):
        """Gọi khi chức năng bắt đầu."""

# myplugin/main.py
def pytest_configure(config):
    config.pluginmanager.add_hookspecs(MyHookSpec)

Sau khi đăng ký, bất kỳ plugin nào (kể cả conftest.py) có thể triển khai pytest_myfeature_start để tích hợp.

Kết hợp với plugin bên thứ ba

Khi muốn sử dụng hook từ một plugin tùy chọn (ví dụ pytest-xdist), nên tránh đăng ký trực tiếp để tránh lỗi khi plugin chưa cài đặt.

Giải pháp: Đăng ký trì hoãn dựa trên sự hiện diện của plugin:

class XDistIntegration:
    def pytest_testnodedown(self, node, error):
        print(f"Node {node} bị ngắt kết nối: {error}")

def pytest_configure(config):
    if config.pluginmanager.hasplugin("xdist"):
        config.pluginmanager.register(XDistIntegration())

Cách này đảm bảo hook chỉ được kích hoạt khi môi trường hỗ trợ, tăng độ tin cậy và dễ bảo trì.

Thẻ: pytest python-testing Hooks plugin-development pytest-plugin

Đăng vào ngày 28 tháng 6 lúc 03:58