Nắm Vững Python Unit Testing và TDD với Pytest: Hướng Dẫn Thực Hành

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Bắt Đầu Nhanh: Trải Nghiệm Pytest Đầu Tiên Của Bạn (5 Phút)

Chào bạn, đồng nghiệp kỹ sư! Bạn có bao giờ thấy mình đẩy code lên môi trường sản xuất, rồi nín thở cầu mong mọi thứ không bùng nổ? Tôi đã từng như vậy. Đó là lý do tại sao tôi là người ủng hộ mạnh mẽ việc kiểm thử kỹ lưỡng. Hôm nay, chúng ta sẽ đi sâu vào kiểm thử đơn vị (unit testing) và Phát triển Hướng Kiểm Thử (TDD) với framework kiểm thử tuyệt vời của Python, Pytest. Đây thực sự là một yếu tố thay đổi cuộc chơi để viết mã nguồn mạnh mẽ, dễ bảo trì.

Kiểm Thử Đơn Vị và TDD là gì?

  • Kiểm Thử Đơn Vị (Unit Testing): Là việc kiểm thử các phần nhỏ nhất, riêng lẻ của mã nguồn – các hàm hoặc phương thức riêng lẻ – để đảm bảo chúng hoạt động chính xác như mong đợi. Hãy hình dung nó như việc kiểm tra từng bánh răng trong một cỗ máy một cách độc lập trước khi lắp ráp toàn bộ.
  • Phát Triển Hướng Kiểm Thử (Test-Driven Development – TDD): Đây là một phương pháp phát triển mà bạn viết các bài kiểm thử trước khi bạn viết mã nguồn. Nó tuân theo một chu kỳ đơn giản: Đỏ (viết một bài kiểm thử thất bại), Xanh (viết vừa đủ mã nguồn để bài kiểm thử vượt qua), Tái cấu trúc (cải thiện mã nguồn của bạn mà không làm hỏng bài kiểm thử).

Tại Sao Lại Là Pytest?

Python có mô-đun unittest tích hợp sẵn, hoàn toàn có khả năng. Nhưng Pytest thì sao? Nó làm cho mọi thứ dễ dàng hơn nhiều. Nó ngắn gọn, mạnh mẽ và cực kỳ linh hoạt. Các câu lệnh assert đơn giản hơn, các fixture mạnh mẽ và khả năng mở rộng của nó khiến tôi luôn chọn nó cho các dự án Python.

Thiết Lập Pytest

Trước hết, hãy cài đặt Pytest. Nếu bạn đang sử dụng môi trường ảo (điều bạn chắc chắn nên làm!), hãy kích hoạt nó trước.


pip install pytest

Bài Kiểm Thử Đầu Tiên Của Bạn

Hãy hình dung chúng ta cần một hàm đơn giản để cộng hai số. Theo phương pháp TDD, chúng ta sẽ viết bài kiểm thử trước. Tạo một tệp có tên test_calculations.py:


# test_calculations.py
def add(a, b):
    # Hàm này chưa tồn tại, vì vậy bài kiểm thử này ban đầu sẽ thất bại
    pass # Chúng ta sẽ điền vào sau

def test_add_two_numbers():
    assert add(1, 2) == 3
    assert add(0, 0) == 0
    assert add(-1, 1) == 0

Bây giờ, chạy Pytest từ terminal của bạn:


pytest

Bạn sẽ thấy một thất bại đáng tự hào, đó chính xác là điều chúng ta muốn trong TDD (giai đoạn Đỏ!).

Bây giờ, hãy làm cho bài kiểm thử đó vượt qua. Tạo một tệp có tên calculations.py (hoặc đặt nó vào cùng một tệp cho ví dụ nhanh này):


# calculations.py
def add(a, b):
    return a + b

Và sửa đổi test_calculations.py để nhập nó:


# test_calculations.py
from calculations import add

def test_add_two_numbers():
    assert add(1, 2) == 3
    assert add(0, 0) == 0
    assert add(-1, 1) == 0

Chạy pytest một lần nữa:


pytest

Bùm! Bài kiểm thử của bạn vượt qua (giai đoạn Xanh). Đó là vòng lặp cơ bản: viết một bài kiểm thử thất bại, sau đó viết mã để làm cho nó vượt qua.

Đi Sâu: Vượt Ra Ngoài Kiến Thức Cơ Bản với Pytest

Bây giờ chúng ta đã nắm được cơ bản, hãy cùng khám phá một số tính năng mạnh mẽ của Pytest giúp tối ưu hóa quy trình kiểm thử của chúng ta.

Fixtures: Thiết Lập Môi Trường Kiểm Thử Của Bạn

Các bài kiểm thử thường cần một thiết lập cụ thể—một kết nối cơ sở dữ liệu, một tệp tạm thời hoặc một đối tượng đã được khởi tạo. Các fixture của Pytest xử lý điều này một cách khéo léo. Chúng là các hàm chạy trước các bài kiểm thử (hoặc các mô-đun/phiên kiểm thử) và có thể cung cấp dữ liệu hoặc tài nguyên.

Hãy hình dung chúng ta đang xây dựng một tiện ích chuỗi đơn giản để đảo ngược các chuỗi. Chúng ta có thể muốn kiểm thử nhiều đầu vào. Hãy bắt đầu với chu trình TDD:


# test_string_utils.py
import pytest
# from string_utils import reverse_string # Sẽ thêm vào sau

def test_reverse_string_basic():
    assert reverse_string("hello") == "olleh"

def test_reverse_string_empty():
    assert reverse_string("") == ""

def test_reverse_string_palindrome():
    assert reverse_string("madam") == "madam"

Chạy pytest, thấy nó thất bại. Bây giờ, hãy triển khai reverse_string trong string_utils.py:


# string_utils.py
def reverse_string(s):
    return s[::-1]

Và cập nhật test_string_utils.py để nhập nó. Các bài kiểm thử vượt qua. Tuyệt vời!

Nếu chúng ta muốn kiểm thử một hàm xử lý danh sách các từ, và chúng ta luôn cần một danh sách các từ tiêu chuẩn thì sao? Một fixture là hoàn hảo:


# test_string_utils.py
import pytest
from string_utils import reverse_string

@pytest.fixture
def sample_words():
    return ["apple", "banana", "cherry"]

def test_reverse_string_basic():
    assert reverse_string("hello") == "olleh"

def test_reverse_string_empty():
    assert reverse_string("") == ""

def test_reverse_string_palindrome():
    assert reverse_string("madam") == "madam"

def process_words(words):
    return [word.upper() for word in words] # Giả sử hàm này cũng nằm trong string_utils.py

def test_process_words_uppercase(sample_words):
    expected = ["APPLE", "BANANA", "CHERRY"]
    assert process_words(sample_words) == expected

Pytest tự động chèn fixture sample_words vào hàm kiểm thử. Rõ ràng phải không?

Tham Số Hóa (Parametrization): Kiểm Thử Nhiều Kịch Bản Hiệu Quả

Thay vì viết nhiều bài kiểm thử cho các kịch bản tương tự, decorator @pytest.mark.parametrize của Pytest cho phép bạn chạy cùng một hàm kiểm thử với các bộ đầu vào và đầu ra mong đợi khác nhau.


# test_string_utils.py (tiếp theo)
# ... (mã nguồn trước đó)

@pytest.mark.parametrize("input_string, expected_output", [
    ("python", "nohtyp"),
    ("racecar", "racecar"),
    ("", ""),
    ("a", "a"),
])
def test_reverse_string_parametrized(input_string, expected_output):
    assert reverse_string(input_string) == expected_output

Điều này dễ đọc và dễ bảo trì hơn nhiều so với việc sao chép các hàm kiểm thử.

TDD Trong Thực Tế: Đỏ, Xanh, Tái Cấu Trúc

Hãy cùng tìm hiểu chu trình TDD cho một hàm tính giai thừa của một số.

ĐỎ: Viết một bài kiểm thử thất bại (test_factorial.py)


# test_factorial.py
import pytest
# from my_math import factorial # Sẽ thêm vào sau

def test_factorial_zero():
    assert factorial(0) == 1

def test_factorial_one():
    assert factorial(1) == 1

def test_factorial_positive():
    assert factorial(5) == 120

def test_factorial_negative_raises_error():
    with pytest.raises(ValueError):
        factorial(-1)

Chạy pytest. Tất cả các bài kiểm thử đều thất bại (hoặc gây ra NameError). Đỏ!

XANH: Viết vừa đủ mã nguồn để các bài kiểm thử vượt qua (my_math.py)


# my_math.py
def factorial(n):
    if n < 0:
        raise ValueError("Giai thừa không được định nghĩa cho số âm")
    if n == 0 or n == 1:
        return 1
    result = 1
    for i in range(2, n + 1):
        result *= i
    return result

Cập nhật test_factorial.py để nhập factorial. Chạy pytest. Tất cả các bài kiểm thử đều vượt qua. Xanh!

TÁI CẤU TRÚC: Cải thiện mã nguồn (my_math.py)

Mã nguồn của chúng ta hoạt động, nhưng liệu chúng ta có thể làm cho nó Pythonic hơn hoặc hiệu quả hơn không? Có thể là một cách tiếp cận đệ quy:


# my_math.py
def factorial(n):
    if n < 0:
        raise ValueError("Giai thừa không được định nghĩa cho số âm")
    if n == 0:
        return 1
    return n * factorial(n - 1) # Giải pháp đệ quy

Chạy pytest một lần nữa. Các bài kiểm thử vẫn vượt qua. Tuyệt vời! Chu trình Đỏ-Xanh-Tái cấu trúc này mang lại cho bạn sự tự tin khi thay đổi mã nguồn, vì bạn biết rằng các bài kiểm thử sẽ phát hiện bất kỳ lỗi hồi quy nào.

Sử Dụng Nâng Cao: Đưa Kiểm Thử Lên Một Tầm Cao Mới

Khi các dự án phát triển, bạn sẽ gặp phải những tình huống mà kiểm thử đơn vị cơ bản không đủ. Đây là lúc các tính năng nâng cao của Pytest trở nên hữu ích.

Mocking và Patching Các Phụ Thuộc Bên Ngoài

Kiểm thử đơn vị nên được cô lập. Điều này có nghĩa là nếu hàm của bạn gọi một API bên ngoài, truy cập cơ sở dữ liệu hoặc tương tác với hệ thống tệp, bạn không muốn các phụ thuộc bên ngoài đó chạy trong quá trình kiểm thử đơn vị. Đó là lúc mocking phát huy tác dụng. Bạn thay thế phụ thuộc thực tế bằng một đối tượng ‘mock’ hoạt động giống như thật nhưng được kiểm soát bởi bài kiểm thử của bạn.

Pytest tích hợp đẹp mắt với mô-đun unittest.mock tích hợp sẵn của Python, và cũng có plugin pytest-mock để có cú pháp mượt mà hơn nữa.

Giả sử bạn có một hàm lấy dữ liệu người dùng từ một API bên ngoài:


# user_service.py
import requests

def get_user_data(user_id):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    response.raise_for_status() # Ném HTTPError cho các phản hồi xấu (4xx hoặc 5xx)
    return response.json()

Chúng ta không muốn bài kiểm thử đơn vị của mình thực sự truy cập api.example.com. Chúng ta có thể mock requests.get:


# test_user_service.py
from unittest.mock import Mock
import pytest
from user_service import get_user_data

def test_get_user_data_success(mocker):
    mock_response = Mock()
    mock_response.status_code = 200
    mock_response.json.return_value = {"id": 1, "name": "Người Dùng Thử Nghiệm"}
    
    mocker.patch('user_service.requests.get', return_value=mock_response)
    
    user_data = get_user_data(1)
    assert user_data == {"id": 1, "name": "Người Dùng Thử Nghiệm"}

def test_get_user_data_not_found(mocker):
    mock_response = Mock()
    mock_response.status_code = 404
    mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError
    
    mocker.patch('user_service.requests.get', return_value=mock_response)
    
    with pytest.raises(requests.exceptions.HTTPError):
        get_user_data(999)

Ở đây, chúng ta sử dụng fixture mocker của pytest-mock (cài đặt với pip install pytest-mock). Nó đơn giản hóa việc patching đáng kể, đảm bảo rằng requests.get ban đầu được khôi phục sau bài kiểm thử.

Đo Lường Độ Bao Phủ Kiểm Thử với pytest-cov

Mã nguồn của bạn đang được kiểm thử bao nhiêu phần trăm? pytest-cov cung cấp các báo cáo độ bao phủ, cho bạn biết những dòng nào được bài kiểm thử của bạn chạm tới và những dòng nào không. Đây là một công cụ thiết yếu để xác định các khu vực chưa được kiểm thử.


pip install pytest-cov
pytest --cov=your_module_name --cov-report=term-missing

Thay thế your_module_name bằng tên gói hoặc mô-đun của bạn (ví dụ: calculations hoặc string_utils). Báo cáo sẽ hiển thị cho bạn chi tiết về độ bao phủ, bao gồm các dòng bị thiếu bài kiểm thử.

Tích Hợp với CI/CD

Tự động hóa các bài kiểm thử của bạn là rất quan trọng. Tích hợp Pytest vào quy trình Tích hợp Liên tục (CI) của bạn (ví dụ: GitHub Actions, GitLab CI, Jenkins). Điều này đảm bảo rằng mọi thay đổi mã nguồn đều được kiểm thử tự động, phát hiện các lỗi hồi quy sớm.

Một bước quy trình làm việc GitHub Actions đơn giản có thể trông như thế này:


- name: Chạy Kiểm Thử
  run: |
    pip install -r requirements.txt
    pytest --cov=your_app_module --cov-report=xml # Tạo XML cho các công cụ đo độ bao phủ

Điều này đảm bảo rằng nếu bất kỳ bài kiểm thử nào thất bại, bản dựng sẽ thất bại, ngăn chặn mã nguồn lỗi được triển khai.

Mẹo Thực Tế: Quan Điểm Của Tôi Về Kiểm Thử Hiệu Quả

Trong những năm qua, tôi đã chứng kiến việc kiểm thử có thể thành công hoặc thất bại các dự án. Dưới đây là một số lời khuyên quý giá tôi đã rút ra được, thường là qua những bài học khó khăn:

  • Giữ Các Bài Kiểm Thử Cô Lập: Mỗi bài kiểm thử nên chạy độc lập với những bài khác. Nếu bài kiểm thử A phụ thuộc vào bài kiểm thử B, bạn đang gặp vấn đề. Fixtures giúp ích rất nhiều trong việc này, cung cấp một trạng thái sạch cho mỗi bài kiểm thử.

  • Làm cho Các Bài Kiểm Thử Nhanh Chóng: Các bài kiểm thử chậm chạp khiến các nhà phát triển ngại chạy chúng thường xuyên. Nếu bộ kiểm thử của bạn mất hàng phút, bạn sẽ ít có khả năng chạy nó trước mỗi lần commit. Tối ưu hóa các fixture, mock các cuộc gọi bên ngoài và tránh các thao tác I/O nặng nếu có thể.

  • Viết Các Bài Kiểm Thử Dễ Đọc: Các bài kiểm thử là tài liệu. Nếu ai đó không thể hiểu bài kiểm thử của bạn đang cố gắng xác minh điều gì, nó sẽ mất đi nhiều giá trị. Sử dụng tên biến rõ ràng và các khẳng định đơn giản. Mô hình Arrange-Act-Assert là một mẫu tuyệt vời: sắp xếp thiết lập của bạn, thực hiện hành động trên mã đang được kiểm thử, sau đó khẳng định kết quả.

  • Kiểm Thử Các Trường Hợp Biên: Đừng chỉ kiểm thử con đường thành công. Điều gì xảy ra với đầu vào trống, giá trị null, số không, số âm hoặc giá trị cực lớn? Điều gì về các đầu vào không hợp lệ mà lẽ ra phải gây ra lỗi?

  • Đừng Mù Quáng Đạt 100% Độ Bao Phủ: Độ bao phủ là một chỉ số, không phải là mục tiêu. Hãy nhắm đến độ bao phủ cao cho logic nghiệp vụ quan trọng, nhưng đừng lãng phí thời gian viết các bài kiểm thử tầm thường cho các getter/setter đơn giản hoặc mã giao diện người dùng thường xuyên thay đổi. Tập trung vào những gì thực sự quan trọng.

  • Nắm Bắt TDD: Đây là một điều rất quan trọng. Tôi đã triển khai các hệ thống mà logic nghiệp vụ cốt lõi được phát triển hoàn toàn bằng TDD với Pytest. Tôi có thể nói trực tiếp với bạn, việc áp dụng phương pháp này vào sản xuất mang lại kết quả ổn định liên tục. Nó không chỉ là việc tìm lỗi; nó là về việc tự tin thực hiện các thay đổi và tái cấu trúc khi biết rằng các bài kiểm thử luôn hỗ trợ bạn. Nó buộc bạn phải suy nghĩ về API của mã nguồn trước khi viết triển khai, dẫn đến thiết kế tốt hơn.

  • Đặt Tên Bài Kiểm Thử Rõ Ràng: Sử dụng các tên mô tả như test_function_name_scenario (ví dụ: test_add_two_numbers_positive). Điều này giúp việc gỡ lỗi các lỗi trở nên dễ dàng hơn nhiều.

Việc áp dụng kiểm thử đơn vị và TDD với Pytest có thể ban đầu cảm thấy như một gánh nặng, nhưng hãy tin tôi, về lâu dài nó sẽ mang lại lợi ích lớn. Bạn sẽ xây dựng phần mềm linh hoạt hơn, giảm lo lắng khi triển khai và có một mạng lưới an toàn rõ ràng cho sự phát triển trong tương lai. Đó là một khoản đầu tư vào chất lượng mã nguồn và sự an tâm của bạn.

Share: