Skip to content

Pytest — Nền tảng Testing Python Chuyên nghiệp

Thứ Sáu, 23h. Pipeline CI/CD báo đỏ — 47 test fail sau khi merge một pull request "chỉ refactor nhỏ". Team dành cả cuối tuần rollback, viết lại test, rồi phát hiện nguyên nhân gốc: test suite không có isolation, global state rò rỉ giữa các test, và không ai biết fixture nào đang share ở scope nào. Đó là cái giá của một test suite được viết theo kiểu "cho có" thay vì được thiết kế (design) nghiêm túc.

Pytest là framework testing tiêu chuẩn công nghiệp cho Python — không phải vì cú pháp ngắn gọn (dù đó là một lợi thế), mà vì kiến trúc plugin mở rộng, hệ thống fixture dependency injection, và assertion introspection giúp debug nhanh gấp nhiều lần so với unittest. Khi bạn viết assert result == expected và test fail, pytest tự động phân tích AST (Abstract Syntax Tree) để hiển thị chính xác giá trị nào khác giá trị nào — không cần self.assertEqual, không cần custom message.

Một insight ngay lập tức: nếu bạn đang dùng print() để debug test, bạn đang lãng phí thời gian. Chạy pytest --tb=long -vv cho bạn toàn bộ call stack, giá trị biến, và diff chi tiết — tất cả tự động.


Bức tranh tư duy

Hãy nghĩ pytest như dây chuyền kiểm tra chất lượng (QC) trong nhà máy sản xuất.

Mỗi hàm test_* là một trạm kiểm tra (inspection station) — nó nhận sản phẩm (dữ liệu đầu vào), kiểm tra theo tiêu chuẩn (assertions), và đánh dấu đạt/không đạt. Fixture là nguyên liệu và công cụ được chuẩn bị sẵn cho từng trạm — trạm nào cần gì thì "khai báo" trong tham số, hệ thống tự cung cấp (dependency injection). Conftest.py là kho nguyên liệu chung của cả dây chuyền. Marker là nhãn phân loại — sản phẩm nào cần kiểm tra nhanh (unit), sản phẩm nào cần kiểm tra toàn diện (integration), sản phẩm nào tạm bỏ qua vì đang chờ linh kiện (skip).

                        ┌─────────────────────────────────┐
                        │        pytest runner             │
                        │   (Quản đốc dây chuyền QC)      │
                        └──────────┬──────────────────────┘

               ┌───────────────────┼───────────────────┐
               ▼                   ▼                   ▼
        ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
        │  test_auth   │    │ test_payment │    │  test_api   │
        │  (Trạm QC 1) │    │  (Trạm QC 2) │    │  (Trạm QC 3) │
        └──────┬──────┘    └──────┬──────┘    └──────┬──────┘
               │                  │                  │
        ┌──────▼──────┐    ┌──────▼──────┐    ┌──────▼──────┐
        │  fixture:    │    │  fixture:    │    │  fixture:    │
        │  db_session  │    │  mock_stripe │    │  api_client  │
        │  (Nguyên liệu)│    │  (Công cụ)   │    │  (Công cụ)   │
        └─────────────┘    └─────────────┘    └─────────────┘
               │                  │                  │
               └──────────────────┼──────────────────┘

                        ┌─────────────────┐
                        │   conftest.py    │
                        │  (Kho chung)     │
                        └─────────────────┘

Khi nào phép so sánh này không còn đúng: dây chuyền nhà máy thực chạy tuần tự, nhưng pytest có thể chạy song song (với pytest-xdist). Ngoài ra, fixture trong pytest có scope — một số fixture tồn tại suốt session, khác với nhà máy nơi nguyên liệu luôn được chuẩn bị mới cho từng sản phẩm.


Cốt lõi kỹ thuật

Test Discovery — Cơ chế tự phát hiện test

Pytest quét codebase theo convention thay vì cấu hình (convention over configuration). Hiểu rõ quy tắc này giúp bạn không bao giờ gặp tình huống "viết test rồi mà pytest không chạy".

Quy tắc mặc định:

Đối tượngConventionVí dụ
FileTên bắt đầu test_ hoặc kết thúc _testtest_auth.py, auth_test.py
ClassTên bắt đầu Test, không có __init__class TestUserService:
Function/MethodTên bắt đầu test_def test_login_success():
Thư mụcĐược liệt kê trong testpathstests/
python
# ✅ Pytest tìm thấy tất cả những test này
def test_user_creation():
    user = User(name="Minh", email="minh@company.vn")
    assert user.is_valid()

class TestPaymentProcessor:
    def test_charge_valid_card(self):
        result = PaymentProcessor().charge(amount=100_000)
        assert result.status == "success"

    def test_reject_expired_card(self):
        with pytest.raises(CardExpiredError):
            PaymentProcessor().charge(card=expired_card)

# ❌ Pytest KHÔNG tìm thấy (thiếu prefix test_)
def verify_email_format():
    pass

# ❌ Pytest KHÔNG tìm thấy (class có __init__)
class TestWithInit:
    def __init__(self):
        self.data = []

Tuỳ chỉnh discovery qua pyproject.toml:

python
# pyproject.toml
# [tool.pytest.ini_options]
# python_files = ["test_*.py", "*_test.py", "check_*.py"]
# python_classes = ["Test*", "Check*"]
# python_functions = ["test_*", "check_*"]

Assertion Introspection — Debug không cần print

Điều làm pytest vượt trội so với unittest nằm ở cơ chế assertion rewriting. Pytest viết lại bytecode của câu lệnh assert tại thời điểm import, nhúng thêm logic thu thập giá trị trung gian. Khi test fail, bạn nhìn thấy chính xác từng giá trị — không cần self.assertEqual(a, b), không cần message thủ công.

python
def test_user_profile_update():
    user = {"name": "Minh", "age": 28, "role": "engineer"}
    expected = {"name": "Minh", "age": 28, "role": "senior"}

    assert user == expected
    # Output khi fail:
    # AssertionError: assert {..., 'role': 'engineer'} == {..., 'role': 'senior'}
    #   Differing items:
    #   {'role': 'engineer'} != {'role': 'senior'}

def test_price_calculation():
    prices = [100_000, 250_000, 75_000]
    total = sum(prices)

    assert total == 400_000
    # Output khi fail:
    # AssertionError: assert 425000 == 400000
    #   +425000
    #   -400000

Pytest hiển thị diff cho string, list, dict, set — tất cả tự động. Với collection lớn, nó highlight chính xác phần tử khác biệt thay vì in ra toàn bộ.

pytest.raises — Kiểm tra exception có chủ đích

Test không chỉ kiểm tra kết quả đúng — test còn phải đảm bảo code fail đúng cách. pytest.raises là context manager kiểm tra rằng một đoạn code bắt buộc phải raise exception.

python
import pytest
import json

def parse_config(raw: str) -> dict:
    """Parse JSON config, raise rõ ràng cho từng loại lỗi."""
    if not raw:
        raise ValueError("Config string không được rỗng")
    if not raw.strip().startswith("{"):
        raise TypeError("Config phải là JSON object, không phải array hay scalar")
    return json.loads(raw)

def test_empty_config_raises_value_error():
    with pytest.raises(ValueError, match=r"không được rỗng"):
        parse_config("")

def test_non_object_config_raises_type_error():
    with pytest.raises(TypeError) as exc_info:
        parse_config("[1, 2, 3]")

    assert "JSON object" in str(exc_info.value)
    assert exc_info.type is TypeError

def test_malformed_json_raises_decode_error():
    with pytest.raises(json.JSONDecodeError):
        parse_config("{invalid json}")

def test_valid_config_no_exception():
    result = parse_config('{"debug": true, "port": 8080}')
    assert result["debug"] is True
    assert result["port"] == 8080

Lưu ý quan trọng: nếu code bên trong pytest.raises không raise exception, test sẽ FAIL — đây là hành vi đúng. Bạn đang khai báo "code này bắt buộc phải lỗi".

Markers — Phân loại và điều khiển test

Marker (đánh dấu) cho phép bạn gắn metadata lên test, sau đó dùng metadata đó để lọc, bỏ qua, hoặc chạy có điều kiện.

skipskipif — bỏ qua test:

python
import pytest
import sys

@pytest.mark.skip(reason="API v3 chưa release, chờ sprint sau")
def test_api_v3_endpoint():
    pass

@pytest.mark.skipif(
    sys.version_info < (3, 11),
    reason="Cần Python 3.11+ cho ExceptionGroup"
)
def test_exception_group_handling():
    try:
        raise ExceptionGroup("batch", [ValueError("a"), TypeError("b")])
    except* ValueError:
        pass

xfail — test được kỳ vọng fail (known bug):

python
@pytest.mark.xfail(reason="Bug JIRA-456: floating point rounding")
def test_currency_rounding():
    assert round(2.675, 2) == 2.68  # Python trả về 2.67

@pytest.mark.xfail(strict=True, reason="Phải fail cho đến khi fix JIRA-789")
def test_strict_expected_failure():
    # Nếu test này bất ngờ PASS → pytest báo FAIL
    # Giúp phát hiện khi bug đã được fix nhưng chưa cập nhật test
    assert False

parametrize — data-driven testing:

python
@pytest.mark.parametrize("input_email,expected_valid", [
    ("minh@company.vn", True),
    ("invalid-email", False),
    ("", False),
    ("user@.com", False),
    ("admin@localhost", True),
], ids=["valid_work", "no_at_sign", "empty", "no_domain", "localhost"])
def test_email_validation(input_email, expected_valid):
    assert validate_email(input_email) == expected_valid

Stacking parametrize tạo tích Descartes (Cartesian product):

python
@pytest.mark.parametrize("method", ["GET", "POST", "PUT", "DELETE"])
@pytest.mark.parametrize("auth", [True, False])
def test_api_endpoint_access(method, auth):
    # Chạy 4 × 2 = 8 test cases
    response = call_api(method=method, authenticated=auth)
    if auth:
        assert response.status_code != 401
    else:
        assert response.status_code in (200, 401)

conftest.py — Kho fixture dùng chung

conftest.py là file đặc biệt mà pytest tự động load — không cần import. Fixture định nghĩa trong conftest.py khả dụng cho mọi test trong cùng thư mục và thư mục con. Đây là cơ chế dependency injection của pytest.

python
# tests/conftest.py
import pytest
from myapp.database import Database
from myapp.models import User

@pytest.fixture
def sample_user():
    """Fixture scope='function' (mặc định) — tạo mới mỗi test."""
    return User(name="Minh", email="minh@test.vn", role="engineer")

@pytest.fixture(scope="session")
def db_connection():
    """Fixture scope='session' — tạo một lần, dùng cho toàn bộ test suite."""
    db = Database.connect("postgresql://localhost/test_db")
    yield db
    db.disconnect()

@pytest.fixture(autouse=True)
def clean_cache():
    """autouse=True — tự động chạy cho MỌI test, không cần khai báo."""
    yield
    cache.clear()

@pytest.fixture
def api_client(db_connection, sample_user):
    """Fixture có thể phụ thuộc fixture khác — pytest tự resolve."""
    client = TestClient(app)
    client.force_login(sample_user)
    return client

Cấu trúc conftest.py theo thư mục cho phép fixture phân tầng:

tests/
├── conftest.py          # Fixtures dùng cho toàn bộ: db, sample_user
├── unit/
│   ├── conftest.py      # Fixtures riêng unit test: mock services
│   └── test_models.py
├── integration/
│   ├── conftest.py      # Fixtures riêng integration: real API client
│   └── test_api.py

Plugin ecosystem — Mở rộng không giới hạn

Pytest được thiết kế từ đầu với kiến trúc plugin. Mọi tính năng — kể cả core — đều là plugin. Một số plugin production-critical:

PluginChức năngKhi nào dùng
pytest-covĐo code coverageMọi project, tích hợp CI/CD
pytest-xdistChạy test song songTest suite > 100 tests
pytest-mockWrapper cho unittest.mockKhi cần mock dependency
pytest-asyncioTest async/await codeFastAPI, aiohttp, asyncio
pytest-timeoutGiới hạn thời gian mỗi testPhát hiện test bị treo
pytest-randomlyXáo trộn thứ tự testPhát hiện test phụ thuộc thứ tự

Thực chiến

Scenario: Tích hợp pytest vào CI/CD pipeline

Team bạn quản lý một REST API viết bằng FastAPI. Mỗi pull request cần chạy qua test suite trước khi merge. Yêu cầu: phân tách unit test và integration test, đo coverage, fail nhanh trên CI, và tạo report cho team lead review.

Bước 1 — Cấu hình pytest cho production:

python
# pyproject.toml
# [tool.pytest.ini_options]
# minversion = "7.0"
# testpaths = ["tests"]
# markers = [
#     "unit: Unit tests — chạy nhanh, không I/O",
#     "integration: Integration tests — cần database/API thật",
#     "slow: Tests chạy > 5 giây",
# ]
# addopts = [
#     "-ra",                 # Hiện summary cho test non-passed
#     "--strict-markers",    # Báo lỗi nếu dùng marker chưa khai báo
#     "--strict-config",     # Báo lỗi nếu config sai
#     "--tb=short",          # Traceback ngắn gọn
# ]
# filterwarnings = [
#     "error",               # Biến warning thành error
#     "ignore::DeprecationWarning:third_party_lib.*",
# ]

Bước 2 — conftest.py với fixture production-grade:

python
# tests/conftest.py
import pytest
import os
from unittest.mock import MagicMock
from myapp.database import SessionLocal, engine, Base
from myapp.main import create_app

@pytest.fixture(scope="session")
def app():
    """Tạo app instance một lần cho toàn bộ session."""
    application = create_app(testing=True)
    yield application

@pytest.fixture(scope="function")
def db_session():
    """Mỗi test nhận database session riêng, rollback sau khi xong."""
    Base.metadata.create_all(bind=engine)
    session = SessionLocal()
    try:
        yield session
    finally:
        session.rollback()
        session.close()

@pytest.fixture
def authenticated_client(app, db_session):
    """Client đã đăng nhập — fixture phụ thuộc fixture khác."""
    from myapp.auth import create_test_token
    client = app.test_client()
    token = create_test_token(user_id=1, role="admin")
    client.headers = {"Authorization": f"Bearer {token}"}
    return client

Bước 3 — Test có marker phân loại rõ ràng:

python
# tests/unit/test_validators.py
import pytest
from myapp.validators import validate_phone_number

@pytest.mark.unit
class TestPhoneValidation:
    @pytest.mark.parametrize("phone,expected", [
        ("+84901234567", True),
        ("0901234567", True),
        ("84901234567", True),
        ("123", False),
        ("", False),
        ("not-a-phone", False),
        ("+84 90 123 4567", True),
    ], ids=[
        "international_format",
        "local_format",
        "no_plus_prefix",
        "too_short",
        "empty",
        "alpha_chars",
        "with_spaces",
    ])
    def test_phone_validation(self, phone, expected):
        assert validate_phone_number(phone) == expected

# tests/integration/test_user_api.py
import pytest

@pytest.mark.integration
class TestUserAPI:
    def test_create_user_returns_201(self, authenticated_client, db_session):
        response = authenticated_client.post("/api/users", json={
            "name": "Nguyễn Văn A",
            "email": "a.nguyen@company.vn",
        })
        assert response.status_code == 201
        assert response.json()["email"] == "a.nguyen@company.vn"

    def test_create_duplicate_user_returns_409(
        self, authenticated_client, db_session
    ):
        payload = {"name": "Duplicate", "email": "dup@company.vn"}
        authenticated_client.post("/api/users", json=payload)
        response = authenticated_client.post("/api/users", json=payload)
        assert response.status_code == 409

Bước 4 — CI/CD script chạy phân tầng:

python
# scripts/run_tests.py
"""
Script chạy test cho CI/CD pipeline.
Chạy unit test trước (nhanh, fail-fast), integration test sau.
"""
import subprocess
import sys

def run_unit_tests() -> int:
    """Chạy unit tests — nhanh, không I/O, fail ngay khi có lỗi."""
    return subprocess.call([
        sys.executable, "-m", "pytest",
        "-m", "unit",
        "-x",                        # Dừng ngay khi fail
        "--timeout=10",              # Mỗi test tối đa 10 giây
        "--cov=myapp",
        "--cov-report=term-missing",
        "--cov-fail-under=80",       # Fail nếu coverage < 80%
        "--junitxml=reports/unit.xml",
    ])

def run_integration_tests() -> int:
    """Chạy integration tests — chỉ khi unit tests pass."""
    return subprocess.call([
        sys.executable, "-m", "pytest",
        "-m", "integration",
        "--timeout=60",
        "--junitxml=reports/integration.xml",
    ])

if __name__ == "__main__":
    unit_result = run_unit_tests()
    if unit_result != 0:
        print("Unit tests FAILED — bỏ qua integration tests")
        sys.exit(unit_result)

    integration_result = run_integration_tests()
    sys.exit(integration_result)

Tại sao chọn cách tiếp cận này: chạy unit test trước vì nhanh (< 30 giây) và phát hiện lỗi logic ngay. Integration test chạy sau vì tốn thời gian (kết nối DB, API). Nếu unit test fail, không cần chạy integration — tiết kiệm thời gian CI và tài nguyên runner.


Sai lầm điển hình

Sai lầm 1: Test phụ thuộc global state

Vấn đề: tests chia sẻ biến global, kết quả phụ thuộc vào thứ tự chạy.

python
# SAI
_cache = {}

def test_add_to_cache():
    _cache["user_1"] = {"name": "Minh"}
    assert "user_1" in _cache

def test_cache_is_empty():
    # FAIL nếu test_add_to_cache chạy trước!
    assert len(_cache) == 0

Tại sao sai: pytest không đảm bảo thứ tự chạy test (và pytest-randomly sẽ xáo trộn thứ tự). Khi test A "rò rỉ" state sang test B, bạn tạo ra flaky test — loại bug khó debug nhất vì nó chỉ fail trong một số điều kiện nhất định.

python
# ĐÚNG — mỗi test nhận state riêng qua fixture
import pytest

@pytest.fixture
def cache():
    return {}

def test_add_to_cache(cache):
    cache["user_1"] = {"name": "Minh"}
    assert "user_1" in cache

def test_cache_is_empty(cache):
    assert len(cache) == 0  # Luôn pass — cache mới cho mỗi test

Sai lầm 2: Hardcode đường dẫn file trong test

Vấn đề: test chạy trên máy developer nhưng fail trên CI runner.

python
# SAI
def test_read_config():
    config = load_config("C:/Users/minh/project/config.json")
    assert config["database"]["host"] == "localhost"

Tại sao sai: đường dẫn tuyệt đối chỉ tồn tại trên máy bạn. CI runner dùng Linux, thư mục hoàn toàn khác. Ngay cả colleague cũng có thể đặt project ở thư mục khác.

python
# ĐÚNG — dùng tmp_path fixture (pytest built-in)
import json

def test_read_config(tmp_path):
    config_data = {"database": {"host": "localhost", "port": 5432}}
    config_file = tmp_path / "config.json"
    config_file.write_text(json.dumps(config_data))

    config = load_config(str(config_file))
    assert config["database"]["host"] == "localhost"

Sai lầm 3: Test chỉ kiểm tra "không lỗi" mà không assert kết quả

Vấn đề: test pass nhưng không chứng minh code hoạt động đúng.

python
# SAI
def test_process_order():
    order = create_order(items=["A", "B"], total=500_000)
    process_order(order)
    # Test "pass" nhưng không kiểm tra gì cả!

Tại sao sai: test này chỉ chứng minh process_order không raise exception. Nếu hàm trả về sai kết quả, cập nhật sai database, hoặc gửi sai email — test vẫn xanh. Đây là false confidence (sự tự tin giả).

python
# ĐÚNG — assert cụ thể cho từng hành vi mong đợi
def test_process_order(db_session):
    order = create_order(items=["A", "B"], total=500_000)
    result = process_order(order)

    assert result.status == "confirmed"
    assert result.total == 500_000
    assert len(result.items) == 2

    saved_order = db_session.query(Order).filter_by(id=order.id).first()
    assert saved_order is not None
    assert saved_order.status == "confirmed"

Sai lầm 4: Dùng time.sleep trong test

Vấn đề: test phụ thuộc vào thời gian thực, flaky trên CI.

python
# SAI
import time

def test_rate_limiter():
    limiter = RateLimiter(max_calls=2, period=1.0)
    limiter.call()
    limiter.call()

    with pytest.raises(RateLimitExceeded):
        limiter.call()

    time.sleep(1.1)  # Chờ hết period — flaky trên CI chậm!
    limiter.call()    # Phải thành công sau khi hết period

Tại sao sai: CI runner có thể chậm hơn máy local (shared resource, container overhead). time.sleep(1.1) có thể không đủ, khiến test fail ngẫu nhiên.

python
# ĐÚNG — mock thời gian thay vì chờ thật
from unittest.mock import patch

def test_rate_limiter():
    with patch("myapp.rate_limiter.time") as mock_time:
        mock_time.monotonic.side_effect = [0.0, 0.1, 0.2, 1.5]

        limiter = RateLimiter(max_calls=2, period=1.0)
        limiter.call()   # t=0.0
        limiter.call()   # t=0.1

        with pytest.raises(RateLimitExceeded):
            limiter.call()  # t=0.2 — vẫn trong period

        limiter.call()  # t=1.5 — đã qua period, thành công

Sai lầm 5: Không đăng ký custom marker

Vấn đề: warning spam trong output, rồi marker bị typo không ai phát hiện.

python
# SAI — dùng marker chưa đăng ký
@pytest.mark.integation  # Typo! Đúng là "integration"
def test_api_call():
    pass
# pytest chạy bình thường, warning bị ignore, test không bao giờ
# được chọn khi chạy `pytest -m integration`

Tại sao sai: không có --strict-markers, pytest chỉ cảnh báo (warning) khi gặp marker lạ — và warning thường bị đội dev ignore. Kết quả: test quan trọng bị bỏ sót trong CI vì marker bị đánh vần sai.

python
# ĐÚNG — đăng ký marker + bật strict mode
# pyproject.toml:
# [tool.pytest.ini_options]
# markers = ["integration: integration tests cần database thật"]
# addopts = ["--strict-markers"]

# Giờ pytest sẽ báo ERROR ngay khi gặp marker chưa đăng ký:
# ERROR: 'integation' not found in `markers` configuration option

Under the Hood

Cơ chế discovery chi tiết

Khi bạn chạy pytest, quá trình sau diễn ra:

  1. Collect phase: pytest đệ quy duyệt testpaths, tìm file match pattern python_files
  2. Import & rewrite: mỗi file test được import, đồng thời AST được rewrite để thêm assertion introspection
  3. Item generation: từ mỗi module, pytest thu thập function/method match python_functions trong class match python_classes
  4. Fixture resolution: cho mỗi test item, pytest xây dependency graph của fixtures cần thiết
  5. Run phase: thực thi từng test — setup fixtures → chạy test → teardown fixtures

Assertion rewriting cụ thể hoạt động như thế nào

Pytest dùng import hook (_pytest.assertion.rewrite) để can thiệp vào quá trình import module test. Khi Python import test_math.py, pytest:

python
# Code bạn viết:
assert calculate_tax(1_000_000) == 100_000

# Pytest rewrite thành (đại ý):
__result = calculate_tax(1_000_000)
__expected = 100_000
if not (__result == __expected):
    raise AssertionError(
        f"assert {__result!r} == {__expected!r}\n"
        f"  +where {__result!r} = calculate_tax(1000000)"
    )

Cơ chế này hoàn toàn transparent — bạn viết assert bình thường, pytest lo phần còn lại.

Plugin architecture

Pytest sử dụng pluggy — hệ thống hook cho phép plugin can thiệp vào mọi giai đoạn:

pytest_configure       → Khởi tạo config, đăng ký marker
pytest_collect_file    → Quyết định file nào được collect
pytest_runtest_setup   → Trước khi chạy mỗi test
pytest_runtest_call    → Chạy test thật sự
pytest_runtest_teardown → Sau khi test xong
pytest_terminal_summary → In kết quả cuối cùng

Bạn có thể viết plugin riêng trong conftest.py:

python
# conftest.py — plugin đo thời gian mỗi test
import time

def pytest_runtest_call(item):
    start = time.monotonic()
    yield
    duration = time.monotonic() - start
    if duration > 5.0:
        print(f"\n⚠ SLOW TEST: {item.nodeid} took {duration:.2f}s")

Performance considerations

Kỹ thuậtKhi nào dùngTác động
pytest-xdist -n autoTest suite > 200 testsGiảm 50-70% thời gian
scope="session" fixtureDB connection, heavy setupTránh tạo lại mỗi test
--lf (last failed)Development loop nhanhChỉ chạy test vừa fail
--co (collect only)Debug discoveryKhông chạy test, chỉ liệt kê
-x (fail fast)CI/CD pipelineDừng ngay khi có lỗi

Trade-offs

Quyết địnhƯu điểmNhược điểm
scope="session" fixtureNhanh — tạo 1 lần dùng mọi nơiRisk share state giữa tests
autouse=True fixtureKhông cần khai báo trong mỗi testChạy ẩn — khó debug khi lỗi
pytest-xdist parallelThời gian chạy giảm mạnhKhông dùng được shared fixture giữa workers
parametrize nhiều caseCoverage cao, ít codeOutput dài, khó đọc khi fail nhiều
--strict-markersPhát hiện typo sớmCần đăng ký mọi marker — overhead ban đầu

Checklist ghi nhớ

✅ Checklist triển khai

Test Discovery & Cấu trúc

  • [ ] File test đặt tên test_*.py hoặc *_test.py
  • [ ] Class test bắt đầu bằng Test và KHÔNG có __init__
  • [ ] Function test bắt đầu bằng test_
  • [ ] Cấu hình testpaths, markers, addopts trong pyproject.toml

Assertions & Exception Testing

  • [ ] Dùng plain assert — pytest tự thêm introspection
  • [ ] Dùng pytest.raises(ExceptionType, match=r"pattern") để kiểm tra exception
  • [ ] Assert cụ thể: kiểm tra giá trị trả về, state thay đổi, side effects

Markers & Parametrize

  • [ ] Đăng ký mọi custom marker trong config và bật --strict-markers
  • [ ] Dùng @pytest.mark.parametrize thay vì copy-paste test với data khác
  • [ ] Thêm ids= cho parametrize để output dễ đọc khi fail
  • [ ] Dùng skip/xfailreason= rõ ràng — không bỏ qua test "im lặng"

Fixtures & Isolation

  • [ ] Mỗi test phải độc lập — không phụ thuộc thứ tự chạy hay global state
  • [ ] Dùng tmp_path cho file I/O thay vì đường dẫn cứng
  • [ ] Fixture cleanup trong yield — setup trước yield, teardown sau yield
  • [ ] conftest.py cho fixture dùng chung — không import fixture thủ công

CI/CD & Production

  • [ ] Chạy unit test trước, integration test sau trên CI
  • [ ] Cấu hình --timeout để phát hiện test bị treo
  • [ ] Tạo JUnit XML report (--junitxml) cho CI dashboard
  • [ ] Đo coverage (--cov) với ngưỡng tối thiểu (--cov-fail-under)

Bài tập luyện tập

Bài 1: Foundation — Viết test suite cho hàm xử lý chuỗi

Cho module string_utils.py:

python
def slugify(text: str) -> str:
    """Chuyển text thành URL-safe slug.
    
    Ví dụ: "Xin Chào Việt Nam!" → "xin-chao-viet-nam"
    """
    import re
    import unicodedata
    text = unicodedata.normalize("NFKD", text)
    text = text.encode("ascii", "ignore").decode("ascii")
    text = text.lower().strip()
    text = re.sub(r"[^\w\s-]", "", text)
    text = re.sub(r"[\s_]+", "-", text)
    text = re.sub(r"-+", "-", text)
    return text.strip("-")

Yêu cầu: viết test file test_string_utils.py với:

  • Ít nhất 5 test case dùng parametrize
  • Test edge case: chuỗi rỗng, toàn ký tự đặc biệt, Unicode
  • Test exception nếu input không phải string
Lời giải
python
import pytest
from string_utils import slugify

@pytest.mark.parametrize("input_text,expected_slug", [
    ("Xin Chào Việt Nam", "xin-chao-viet-nam"),
    ("Hello World", "hello-world"),
    ("  spaces  everywhere  ", "spaces-everywhere"),
    ("UPPER CASE", "upper-case"),
    ("special!@#chars$%^", "specialchars"),
    ("already-a-slug", "already-a-slug"),
    ("multiple---dashes", "multiple-dashes"),
    ("underscore_text", "underscore-text"),
], ids=[
    "vietnamese_unicode",
    "basic_english",
    "extra_whitespace",
    "uppercase",
    "special_characters",
    "already_slug",
    "multiple_dashes",
    "underscores",
])
def test_slugify_valid_inputs(input_text, expected_slug):
    assert slugify(input_text) == expected_slug

def test_slugify_empty_string():
    assert slugify("") == ""

def test_slugify_only_special_chars():
    result = slugify("!@#$%^&*()")
    assert result == ""

def test_slugify_non_string_raises():
    with pytest.raises((TypeError, AttributeError)):
        slugify(12345)

    with pytest.raises((TypeError, AttributeError)):
        slugify(None)

Bài 2: Intermediate — Test với fixture và conftest

Xây dựng test suite cho một TaskManager:

python
class Task:
    def __init__(self, title: str, priority: int = 0):
        self.title = title
        self.priority = priority
        self.completed = False

class TaskManager:
    def __init__(self):
        self._tasks: list[Task] = []

    def add(self, task: Task) -> None:
        if not task.title.strip():
            raise ValueError("Task title không được rỗng")
        self._tasks.append(task)

    def complete(self, title: str) -> Task:
        for task in self._tasks:
            if task.title == title and not task.completed:
                task.completed = True
                return task
        raise KeyError(f"Task '{title}' không tồn tại hoặc đã hoàn thành")

    def pending(self) -> list[Task]:
        return sorted(
            [t for t in self._tasks if not t.completed],
            key=lambda t: -t.priority,
        )

Yêu cầu:

  • Viết conftest.py với fixture task_managersample_tasks
  • Test add, complete, pending kể cả edge cases
  • Dùng parametrize cho test validation
Lời giải
python
# conftest.py
import pytest
from task_manager import Task, TaskManager

@pytest.fixture
def task_manager():
    return TaskManager()

@pytest.fixture
def sample_tasks():
    return [
        Task("Deploy v2.0", priority=3),
        Task("Write docs", priority=1),
        Task("Fix login bug", priority=2),
    ]

@pytest.fixture
def populated_manager(task_manager, sample_tasks):
    for task in sample_tasks:
        task_manager.add(task)
    return task_manager

# test_task_manager.py
import pytest
from task_manager import Task

class TestAddTask:
    def test_add_valid_task(self, task_manager):
        task_manager.add(Task("Setup CI"))
        assert len(task_manager.pending()) == 1

    @pytest.mark.parametrize("invalid_title", [
        "",
        "   ",
        "\t\n",
    ], ids=["empty", "spaces_only", "whitespace_only"])
    def test_add_empty_title_raises(self, task_manager, invalid_title):
        with pytest.raises(ValueError, match="không được rỗng"):
            task_manager.add(Task(invalid_title))

class TestComplete:
    def test_complete_existing_task(self, populated_manager):
        result = populated_manager.complete("Fix login bug")
        assert result.completed is True
        assert result.title == "Fix login bug"

    def test_complete_nonexistent_raises(self, populated_manager):
        with pytest.raises(KeyError, match="không tồn tại"):
            populated_manager.complete("Nonexistent task")

    def test_complete_already_done_raises(self, populated_manager):
        populated_manager.complete("Write docs")
        with pytest.raises(KeyError, match="đã hoàn thành"):
            populated_manager.complete("Write docs")

class TestPending:
    def test_pending_sorted_by_priority(self, populated_manager):
        pending = populated_manager.pending()
        priorities = [t.priority for t in pending]
        assert priorities == sorted(priorities, reverse=True)

    def test_pending_excludes_completed(self, populated_manager):
        populated_manager.complete("Deploy v2.0")
        pending_titles = [t.title for t in populated_manager.pending()]
        assert "Deploy v2.0" not in pending_titles
        assert len(pending_titles) == 2

    def test_pending_empty_when_all_completed(self, populated_manager):
        for task_title in ["Deploy v2.0", "Write docs", "Fix login bug"]:
            populated_manager.complete(task_title)
        assert populated_manager.pending() == []

Bài 3: Advanced — Plugin conftest và CI integration

🧠 Quiz

Câu hỏi: Khi một fixture có scope="session" và một fixture có scope="function" cùng được request bởi một test, điều gì xảy ra?

  • [ ] A. Pytest báo lỗi vì conflict scope
  • [ ] B. Cả hai đều được tạo mới cho mỗi test
  • [x] C. Session fixture tạo 1 lần và tái sử dụng, function fixture tạo mới mỗi test
  • [ ] D. Function fixture tự động nâng lên scope session

Giải thích: Pytest quản lý lifecycle của fixture theo scope khai báo. Session fixture tồn tại từ đầu đến cuối toàn bộ test run. Function fixture tạo mới và hủy cho mỗi test function. Hai scope hoạt động độc lập — không conflict, không override. Tuy nhiên, function fixture CÓ THỂ phụ thuộc session fixture (nhưng ngược lại thì không được — session fixture không thể request function fixture vì scope hẹp hơn).

Viết một conftest plugin tự động log tên test và thời gian vào file:

Lời giải
python
# conftest.py
import pytest
import time
import json
from pathlib import Path

class TestTimingPlugin:
    """Plugin ghi lại thời gian chạy mỗi test vào JSON file."""

    def __init__(self, output_path: Path):
        self._output_path = output_path
        self._results: list[dict] = []
        self._start_time: float = 0.0

    @pytest.hookimpl(tryfirst=True)
    def pytest_runtest_setup(self, item):
        self._start_time = time.monotonic()

    @pytest.hookimpl(trylast=True)
    def pytest_runtest_teardown(self, item):
        duration = time.monotonic() - self._start_time
        self._results.append({
            "nodeid": item.nodeid,
            "duration_seconds": round(duration, 4),
            "markers": [m.name for m in item.iter_markers()],
        })

    def pytest_sessionfinish(self, session):
        self._output_path.parent.mkdir(parents=True, exist_ok=True)
        self._output_path.write_text(
            json.dumps(self._results, indent=2, ensure_ascii=False)
        )

def pytest_configure(config):
    output = Path(config.rootdir) / "reports" / "test_timing.json"
    plugin = TestTimingPlugin(output_path=output)
    config.pluginmanager.register(plugin, "test_timing_plugin")

Plugin này:

  • Hook vào pytest_runtest_setup để ghi timestamp bắt đầu
  • Hook vào pytest_runtest_teardown để tính duration
  • Ghi toàn bộ kết quả vào JSON khi session kết thúc
  • JSON file có thể được CI/CD parse để hiển thị dashboard

Liên kết học tiếp

Từ khóa glossary: pytest, test discovery, assertion introspection, marker, parametrize, fixture, conftest.py, plugin, xfail, skipif, scope, dependency injection, CI/CD, test isolation

Tìm kiếm liên quan: kiểm thử Python, viết test Python, pytest cơ bản, chạy test tự động, pytest fixture, pytest marker, tích hợp CI/CD với pytest