Giao diện
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ượng | Convention | Ví dụ |
|---|---|---|
| File | Tên bắt đầu test_ hoặc kết thúc _test | test_auth.py, auth_test.py |
| Class | Tên bắt đầu Test, không có __init__ | class TestUserService: |
| Function/Method | Tên bắt đầu test_ | def test_login_success(): |
| Thư mục | Được liệt kê trong testpaths | tests/ |
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
# -400000Pytest 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"] == 8080Lư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.
skip và skipif — 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:
passxfail — 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 Falseparametrize — 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_validStacking 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 clientCấ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.pyPlugin 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:
| Plugin | Chức năng | Khi nào dùng |
|---|---|---|
pytest-cov | Đo code coverage | Mọi project, tích hợp CI/CD |
pytest-xdist | Chạy test song song | Test suite > 100 tests |
pytest-mock | Wrapper cho unittest.mock | Khi cần mock dependency |
pytest-asyncio | Test async/await code | FastAPI, aiohttp, asyncio |
pytest-timeout | Giới hạn thời gian mỗi test | Phát hiện test bị treo |
pytest-randomly | Xáo trộn thứ tự test | Phá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 clientBướ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 == 409Bướ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) == 0Tạ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 periodTạ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 optionUnder the Hood
Cơ chế discovery chi tiết
Khi bạn chạy pytest, quá trình sau diễn ra:
- Collect phase: pytest đệ quy duyệt
testpaths, tìm file match patternpython_files - Import & rewrite: mỗi file test được import, đồng thời AST được rewrite để thêm assertion introspection
- Item generation: từ mỗi module, pytest thu thập function/method match
python_functionstrong class matchpython_classes - Fixture resolution: cho mỗi test item, pytest xây dependency graph của fixtures cần thiết
- 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ùngBạ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ật | Khi nào dùng | Tác động |
|---|---|---|
pytest-xdist -n auto | Test suite > 200 tests | Giảm 50-70% thời gian |
scope="session" fixture | DB connection, heavy setup | Tránh tạo lại mỗi test |
--lf (last failed) | Development loop nhanh | Chỉ chạy test vừa fail |
--co (collect only) | Debug discovery | Không chạy test, chỉ liệt kê |
-x (fail fast) | CI/CD pipeline | Dừng ngay khi có lỗi |
Trade-offs
| Quyết định | Ưu điểm | Nhược điểm |
|---|---|---|
scope="session" fixture | Nhanh — tạo 1 lần dùng mọi nơi | Risk share state giữa tests |
autouse=True fixture | Không cần khai báo trong mỗi test | Chạy ẩn — khó debug khi lỗi |
pytest-xdist parallel | Thời gian chạy giảm mạnh | Không dùng được shared fixture giữa workers |
parametrize nhiều case | Coverage cao, ít code | Output dài, khó đọc khi fail nhiều |
--strict-markers | Phát hiện typo sớm | Cầ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_*.pyhoặc*_test.py - [ ] Class test bắt đầu bằng
Testvà KHÔNG có__init__ - [ ] Function test bắt đầu bằng
test_ - [ ] Cấu hình
testpaths,markers,addoptstrongpyproject.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.parametrizethay vì copy-paste test với data khác - [ ] Thêm
ids=cho parametrize để output dễ đọc khi fail - [ ] Dùng
skip/xfailcóreason=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_pathcho file I/O thay vì đường dẫn cứng - [ ] Fixture cleanup trong
yield— setup trước yield, teardown sau yield - [ ]
conftest.pycho 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.pyvới fixturetask_managervàsample_tasks - Test
add,complete,pendingkể cả edge cases - Dùng
parametrizecho 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