- Viết test với
pytest— function, fixture, parametrize, marker, conftest. - Quản lý side effect bằng
unittest.mockvàpytest-mock. - Đo độ phủ test với
coverage/pytest-covvà biết khi nào % coverage là lừa dối. - Viết
pyproject.tomlđầy đủ — metadata, dependency, config cho tất cả tool. - Build
.whlvà.tar.gzbằngpython -m build. - Publish package lên PyPI bằng
twine. - Lint + format bằng
ruff(Rust, thayflake8+black+isort). - Cài pre-commit hook chặn commit code bẩn ngay tại máy lập trình.
Khi xe chạy nhanh, phanh tốt giúp bạn dám tăng tốc. Tooling production cũng vậy: test giúp bạn dám refactor lớn, type checker bắt bug trước khi runtime, linter/format giữ codebase nhất quán dù nhiều người chạm vào, packaging đảm bảo người khác cài và chạy được code của bạn. Chương này không thêm tính năng — nó thêm tự tin.
1. pytest — vì sao thay unittest
Python có sẵn framework test trong stdlib: unittest. Nhưng phần lớn
codebase Python 2026 dùng pytest (3rd-party). Lý do?
unittest (stdlib) | pytest (3rd-party) | |
|---|---|---|
| Cú pháp | Class TestCase, method setUp/tearDown | Function thuần, def test_* |
| Assertion | self.assertEqual(a, b) | assert a == b — Python built-in |
| Setup/teardown | Method trong class | Fixture dạng function — composable |
| Parametrize | Phải subclass hoặc dùng subTest | @pytest.mark.parametrize — 1 dòng |
| Plugin | Hạn chế | Hệ sinh thái lớn: pytest-cov, pytest-mock, pytest-asyncio, … |
| Verbose | Có (boilerplate class) | Tối thiểu |
So sánh trực tiếp cùng 1 test:
import unittest
from calc import add
class TestAdd(unittest.TestCase):
def test_positive(self):
self.assertEqual(add(1, 2), 3)
def test_negative(self):
self.assertEqual(add(-1, -2), -3)
if __name__ == "__main__":
unittest.main()
from calc import add
def test_positive():
assert add(1, 2) == 3
def test_negative():
assert add(-1, -2) == -3
Ít boilerplate, dễ đọc, và quan trọng: pytest "rewrite" câu assert
để in ra biểu thức chi tiết khi fail (giá trị 2 vế, kiểu, diff), thay vì chỉ
AssertionError.
pip install pytest pytest-cov pytest-mock. Chạy test:
pytest (tự tìm file test_*.py hoặc *_test.py).
2. Test function cơ bản
Quy ước "thu thập" của pytest (test discovery):
- File: tên bắt đầu bằng
test_hoặc kết thúc bằng_test.py. - Function: tên bắt đầu bằng
test_. - Class (tuỳ chọn): tên bắt đầu bằng
Test, không có__init__.
Cấu trúc folder điển hình:
my-project/
├─ src/
│ └─ mypkg/
│ ├─ __init__.py
│ └─ calc.py
├─ tests/
│ ├─ test_calc.py
│ └─ test_string.py
├─ pyproject.toml
└─ README.md
File test ví dụ:
from mypkg.calc import add, divide
import pytest
def test_add_basic():
assert add(2, 3) == 5
def test_divide_zero_raises():
with pytest.raises(ZeroDivisionError):
divide(10, 0)
def test_divide_close():
assert divide(1, 3) == pytest.approx(0.333, rel=1e-2)
Chạy:
$ pytest # chạy tất cả test
$ pytest tests/test_calc.py # chỉ 1 file
$ pytest tests/test_calc.py::test_add_basic # chỉ 1 test
$ pytest -v # verbose
$ pytest -k "divide" # lọc theo tên (substring)
$ pytest -x # dừng ngay khi test đầu tiên fail
$ pytest --lf # chỉ chạy lại các test fail lần trước
3. Fixture — setup/teardown thanh lịch
Fixture là function được pytest "tiêm" vào test khi test khai báo
tham số cùng tên. Đây là cách pytest thay setUp/tearDown của
unittest bằng cơ chế dependency injection nhẹ.
import pytest
from mypkg.db import connect
@pytest.fixture
def db():
conn = connect(":memory:") # setup
yield conn # trao cho test
conn.close() # teardown (sau yield)
def test_insert(db): # pytest tự inject fixture `db`
db.execute("INSERT INTO users VALUES (1, 'alice')")
row = db.execute("SELECT * FROM users").fetchone()
assert row == (1, "alice")
Mô hình yield:
- Code trước
yield= phần setup. - Giá trị sau
yield= thứ "trao" cho test (tương đương return). - Code sau
yield= phần teardown, luôn chạy kể cả test fail (dopytestbọc bằngtry/finally).
Một fixture có thể phụ thuộc fixture khác — dependency injection bắc cầu:
@pytest.fixture
def user(db): # user phụ thuộc db
db.execute("INSERT INTO users VALUES (1, 'alice')")
yield {"id": 1, "name": "alice"}
def test_user_can_login(user):
assert user["name"] == "alice"
4. Fixture scope — phạm vi sống của fixture
Mặc định mỗi test có một instance fixture riêng. Đôi khi setup tốn kém (vd: dựng
Docker container) — ta muốn dùng chung. Tham số scope giải quyết:
scope | Lifetime | Khi dùng |
|---|---|---|
"function" | Mỗi test 1 instance | Mặc định. An toàn, không state leak. |
"class" | Sống suốt một TestClass | Test gom vào class chia sẻ resource. |
"module" | 1 file .py | Resource đắt mà tất cả test trong file dùng (vd: file fixture data). |
"package" | 1 thư mục test | Hiếm dùng. |
"session" | Toàn bộ pytest run | Docker, server thật, model ML lớn. |
@pytest.fixture(scope="session")
def docker_postgres():
container = start_postgres_container()
yield container
container.stop()
Scope càng rộng, test càng dễ phụ thuộc thứ tự chạy. Ví dụ db scope
"session" mà test_a insert row, test_b mong db rỗng
— fail. Quy tắc: chia sẻ resource mắc tiền, không chia sẻ trạng thái.
Nếu cần chia sẻ connection mà reset state: dùng transaction rollback hoặc
TRUNCATE ở fixture function-scope con.
5. @pytest.mark.parametrize — nhiều input, 1 function
Thay vì copy-paste 10 test với input khác nhau, dùng parametrize:
import pytest
from mypkg.calc import add
@pytest.mark.parametrize(
"a, b, expected",
[
(1, 2, 3),
(4, 5, 9),
(-1, 1, 0),
(0, 0, 0),
(100, 200, 300),
],
)
def test_add(a, b, expected):
assert add(a, b) == expected
Khi chạy:
$ pytest -v
tests/test_calc.py::test_add[1-2-3] PASSED
tests/test_calc.py::test_add[4-5-9] PASSED
tests/test_calc.py::test_add[-1-1-0] PASSED
tests/test_calc.py::test_add[0-0-0] PASSED
tests/test_calc.py::test_add[100-200-300] PASSED
5 test riêng biệt, tên có "id" tự sinh từ tham số. Có thể đặt id rõ ràng cho
dễ đọc:
@pytest.mark.parametrize(
"s, expected",
[
pytest.param("abba", True, id="even-palindrome"),
pytest.param("abcba", True, id="odd-palindrome"),
pytest.param("hello", False, id="not-palindrome"),
pytest.param("", True, id="empty-edge"),
],
)
def test_is_palindrome(s, expected):
assert is_palindrome(s) == expected
Có thể stack nhiều parametrize → tích Đề-các:
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_combo(x, y): # 3 × 2 = 6 test case
assert x + y > 0
6. Marker — gắn nhãn và lọc test
Marker là "tag" bạn gán cho test để phân loại. Marker phổ biến:
@pytest.mark.skip, @pytest.mark.xfail,
@pytest.mark.parametrize. Bạn còn tự định nghĩa được:
@pytest.mark.slow
def test_train_full_model():
# mất 5 phút
...
@pytest.mark.integration
def test_real_api_call():
# cần network thật
...
Khai báo marker trong pyproject.toml để tránh warning:
[tool.pytest.ini_options]
markers = [
"slow: chậm hơn 1 giây, skip ở dev mặc định",
"integration: chạm vào hệ thống ngoài (db, network)",
]
Chạy chọn lọc:
$ pytest -m slow # chỉ test có marker slow
$ pytest -m "not slow" # bỏ qua slow
$ pytest -m "integration and not slow"
Skip/xfail có điều kiện:
import sys
@pytest.mark.skipif(sys.platform == "win32", reason="chỉ chạy trên Unix")
def test_unix_only():
...
@pytest.mark.xfail(reason="bug #1234 đang sửa")
def test_known_failure():
assert broken_function() == 42
7. conftest.py — fixture dùng chung
Nếu một fixture được nhiều file test dùng, đừng import qua lại — đặt vào
conftest.py. pytest tự động nạp fixture trong
conftest.py cho mọi test cùng thư mục (và thư mục con):
tests/
├─ conftest.py # fixture chung toàn project
├─ test_calc.py
├─ unit/
│ ├─ conftest.py # fixture chỉ unit/
│ └─ test_pure.py
└─ integration/
└─ test_api.py
import pytest
from mypkg.db import connect
@pytest.fixture
def db():
conn = connect(":memory:")
conn.execute("CREATE TABLE users (id INT, name TEXT)")
yield conn
conn.close()
@pytest.fixture
def sample_data():
return {"users": [{"id": 1, "name": "alice"}]}
Test trong test_calc.py chỉ cần khai báo tham số db hoặc
sample_data — không cần import:
def test_insert_user(db, sample_data):
user = sample_data["users"][0]
db.execute("INSERT INTO users VALUES (?, ?)", (user["id"], user["name"]))
assert db.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 1
conftest.py
Fixture đặt ở mức "nhỏ nhất mà nhiều file cần dùng". Đặt quá cao (root tests/)
khi chỉ 2 file cần → cồng kềnh. Đặt quá thấp → duplicate. Phổ biến: 1
conftest.py ở tests/ cho fixture chung (db, config), thêm
conftest.py trong sub-folder nếu cần.
8. unittest.mock — thay phần phụ thuộc bên ngoài
Test pure logic dễ. Khó là test code gọi network, ghi file, truy vấn database thật. Giải pháp: mock — thay object thật bằng object giả mà ta kiểm soát hành vi.
Module unittest.mock (stdlib) cung cấp Mock, MagicMock, patch:
from unittest.mock import MagicMock
m = MagicMock()
m.greet.return_value = "hello"
print(m.greet("world")) # "hello"
m.greet.assert_called_with("world")
m.greet.assert_called_once()
Quan trọng nhất: patch — thay tạm thời một object trong module bằng mock,
phục hồi sau test (dùng context manager hoặc decorator):
# mypkg/user_service.py
import requests
def fetch_user(user_id: int) -> dict:
r = requests.get(f"https://api.example.com/users/{user_id}")
r.raise_for_status()
return r.json()
from unittest.mock import patch, MagicMock
from mypkg.user_service import fetch_user
@patch("mypkg.user_service.requests.get")
def test_fetch_user(mock_get):
fake_response = MagicMock()
fake_response.json.return_value = {"id": 1, "name": "alice"}
fake_response.raise_for_status.return_value = None
mock_get.return_value = fake_response
result = fetch_user(1)
assert result == {"id": 1, "name": "alice"}
mock_get.assert_called_once_with("https://api.example.com/users/1")
Cú pháp đúng: @patch("mypkg.user_service.requests.get") — patch
requests.get tại module user_service, vì user_service.py
đã import requests và đang dùng tham chiếu cục bộ của nó. Patch
"requests.get" chung sẽ không thay tham chiếu mà user_service
đang giữ → mock không hoạt động.
Nhiều decorator @patch stack → thứ tự tham số ngược (bottom-up):
@patch("mypkg.svc.send_email")
@patch("mypkg.svc.log_event")
@patch("mypkg.svc.db_save")
def test_signup(mock_db, mock_log, mock_email):
# ↑ ↑ ↑
# decorator gần nhất xa nhất
...
9. pytest-mock — wrap đẹp hơn
Plugin pytest-mock cung cấp fixture mocker bao quanh
unittest.mock — không cần stack decorator, không cần nhớ phục hồi:
def test_fetch_user(mocker):
fake_response = mocker.MagicMock()
fake_response.json.return_value = {"id": 1, "name": "alice"}
mock_get = mocker.patch("mypkg.user_service.requests.get", return_value=fake_response)
result = fetch_user(1)
assert result["name"] == "alice"
mock_get.assert_called_once()
Lợi ích:
- Mock tự revert sau test, không cần context manager.
- Khai báo cùng dòng — gọn.
- Có
mocker.spy()— quan sát mà vẫn chạy hàm thật.
10. Coverage — đo độ phủ test
coverage.py đo dòng nào / nhánh nào của code được test execute. Tích hợp với
pytest qua plugin pytest-cov:
$ pip install pytest-cov
$ pytest --cov=src
$ pytest --cov=src --cov-report=term-missing # in dòng nào không cover
$ pytest --cov=src --cov-report=html # report HTML đẹp
Output ví dụ:
Name Stmts Miss Cover Missing
---------------------------------------------------
src/mypkg/calc.py 12 0 100%
src/mypkg/db.py 25 3 88% 42-44
src/mypkg/auth.py 40 12 70% 55-58, 67-72
---------------------------------------------------
TOTAL 77 15 81%
Bật branch coverage (đo cả nhánh if/else, không chỉ dòng):
$ pytest --cov=src --cov-branch
Cấu hình coverage trong pyproject.toml:
[tool.coverage.run]
source = ["src"]
branch = true
omit = ["*/tests/*", "*/__init__.py"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"raise NotImplementedError",
"if __name__ == .__main__.:",
]
fail_under = 80
Coverage chỉ đo "dòng được chạy", không đo "assertion đúng". Bạn có thể viết:
def test_useless():
add(1, 2) # gọi hàm, không assert gì
→ coverage 100% nhưng test vô dụng (không có assert). Coverage là điều
kiện cần, không đủ. Target hợp lý: ≥80% cho
library cốt lõi, ≥60% cho application logic, kèm property-based testing
(hypothesis) và code review kỹ.
11. pyproject.toml — file config trung tâm
Trước 2021, project Python rải config khắp nơi: setup.py (build),
setup.cfg (metadata), MANIFEST.in (include data),
requirements.txt (deps), pytest.ini, mypy.ini,
tox.ini… PEP 621 thống nhất tất cả vào
pyproject.toml.
File pyproject.toml đầy đủ cho 1 project hiện đại:
# ---------- Build system ----------
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# ---------- Project metadata (PEP 621) ----------
[project]
name = "my-utils"
version = "0.1.0"
description = "Bộ tiện ích Python ví dụ"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [
{ name = "Việt Anh", email = "vietanh@example.com" },
]
keywords = ["util", "helper"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
dependencies = [
"requests>=2.31",
"pydantic>=2",
]
[project.optional-dependencies]
dev = [
"pytest>=8",
"pytest-cov>=4",
"pytest-mock>=3",
"ruff>=0.5",
"mypy>=1.10",
"pre-commit>=3",
]
[project.urls]
Homepage = "https://github.com/me/my-utils"
Repository = "https://github.com/me/my-utils"
Issues = "https://github.com/me/my-utils/issues"
# Tạo CLI entry point: gõ `my-utils` trong terminal → gọi main.py:main
[project.scripts]
my-utils = "my_utils.cli:main"
# ---------- pytest ----------
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra -q --strict-markers"
markers = [
"slow: chạy chậm, skip ở dev",
"integration: cần dịch vụ ngoài",
]
# ---------- coverage ----------
[tool.coverage.run]
source = ["src"]
branch = true
[tool.coverage.report]
fail_under = 80
show_missing = true
# ---------- mypy ----------
[tool.mypy]
python_version = "3.11"
strict = true
warn_unused_ignores = true
disallow_any_generics = true
no_implicit_optional = true
files = ["src"]
[[tool.mypy.overrides]]
module = ["tests.*"]
disallow_untyped_defs = false # test cho phép viết loose hơn
# ---------- ruff (lint + format) ----------
[tool.ruff]
line-length = 100
target-version = "py311"
src = ["src", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # bugbear
"UP", # pyupgrade
"N", # pep8-naming
]
ignore = ["E501"] # để format quản line-length
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
build-backend là engine build package. Lựa chọn 2026:
- hatchling — modern, nhanh, mặc định của Hatch. Khuyến nghị cho dự án mới.
- setuptools — legacy nhưng vẫn rộng rãi. Vẫn được hỗ trợ.
- poetry-core — đi cùng Poetry. OK nếu bạn đã dùng Poetry.
- pdm-backend — đi cùng PDM.
12. Build wheel — đóng gói phân phối
Python có 2 định dạng phân phối:
| Định dạng | Đuôi | Nội dung |
|---|---|---|
| sdist (source distribution) | .tar.gz | Source code thô — pip phải build lại. |
| wheel (binary distribution) | .whl | Đã build sẵn — pip chỉ giải nén → cài nhanh. |
Công cụ chính thức: build (frontend agnostic):
$ pip install build
$ python -m build
# sinh ra: dist/my_utils-0.1.0-py3-none-any.whl
# dist/my_utils-0.1.0.tar.gz
Quy ước tên wheel: {name}-{version}-{python}-{abi}-{platform}.whl.
my_utils-0.1.0-py3-none-any.whl— pure Python, chạy mọi platform (lý tưởng).numpy-1.26.0-cp311-cp311-manylinux_2_17_x86_64.whl— có C extension, riêng CPython 3.11 Linux x86_64.
Cài wheel local để test:
$ pip install ./dist/my_utils-0.1.0-py3-none-any.whl
# hoặc
$ pip install dist/*.whl
# Verify
$ python -c "import my_utils; print(my_utils.__version__)"
0.1.0
13. Publish lên PyPI
PyPI (Python Package Index) là kho package công cộng. Để publish, bạn cần (1) tài khoản
PyPI, (2) API token, (3) công cụ upload — twine:
$ pip install twine
# Test trước trên TestPyPI (sandbox)
$ twine upload --repository testpypi dist/*
# Production
$ twine upload dist/*
# Sẽ hỏi username (__token__) và password (pypi-xxxxx API token)
Cấu hình token trong ~/.pypirc để không nhập tay mỗi lần:
[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmcCJDxxxxxx...
[testpypi]
repository = https://test.pypi.org/legacy/
username = __token__
password = pypi-AgENdGVzdC5weXBpLm9yZ...
Một khi upload version 0.1.0 lên PyPI, bạn không thể overwrite
— phải bump 0.1.1. PyPI có thể "yank" (ẩn) version chứ không xoá. Vì vậy
test kỹ trên TestPyPI trước. Và chọn tên package độc đáo: tên bị "chiếm" thì
không lấy lại được.
Workflow CI/CD tự động publish khi tag git (GitHub Actions):
# .github/workflows/publish.yml
name: Publish to PyPI
on:
push:
tags: ["v*"]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- run: pip install build twine
- run: python -m build
- run: twine upload dist/*
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
14. ruff — lint + format hiện đại
Trước 2023, project Python "đúng chuẩn" cần 3 tool:
flake8— lint (style + bug warnings).black— format (opinionated).isort— sắp xếp import.
Ruff (viết bằng Rust) thay cả ba, nhanh hơn ~30 lần. Một command, một dòng config:
$ pip install ruff
$ ruff check . # lint
$ ruff check --fix . # lint + auto-fix lỗi sửa được
$ ruff format . # format (tương đương black)
$ ruff check --fix && ruff format . # combo thường gặp
Hơn 700 rule có sẵn, nhóm theo prefix:
| Prefix | Rule set | Nguồn gốc |
|---|---|---|
E, W | Style errors/warnings | pycodestyle (PEP 8) |
F | Logic errors (unused import, undefined name) | pyflakes |
I | Import sorting | isort |
B | Bug-likely patterns | flake8-bugbear |
UP | Suggest syntax modern hơn | pyupgrade |
N | Naming convention | pep8-naming |
SIM | Simplification gợi ý | flake8-simplify |
D | Docstring style | pydocstyle |
PL | Logic phức tạp | Pylint subset |
Cấu hình trong pyproject.toml đã ở trên (§11). Output mẫu:
$ ruff check src
src/mypkg/auth.py:12:5: F841 Local variable `tmp` is assigned to but never used
src/mypkg/db.py:8:1: I001 Import block is un-sorted or un-formatted
src/mypkg/calc.py:24:5: B007 Loop control variable `i` not used within loop body
Found 3 errors. [* 1 fixable with `--fix` option]
ruff format output gần giống black (cùng triết lý
"opinionated, không tranh cãi"). Khi migrate: xoá black và isort
khỏi dev deps, thêm ruff, chạy ruff format . 1 lần.
Diff lần đầu có thể vài chục dòng — review xong commit, xong.
15. Pre-commit hook — chặn code bẩn từ máy lập trình
Linter/test chỉ hữu ích nếu được chạy. Cách hiệu quả nhất: chạy tự động trước
mỗi git commit. Framework pre-commit (Python tool) quản lý
chuyện này:
$ pip install pre-commit
Khai báo hook trong .pre-commit-config.yaml ở root project:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
additional_dependencies: [pydantic, types-requests]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: local
hooks:
- id: pytest
name: pytest
entry: pytest -x -q
language: system
pass_filenames: false
stages: [pre-push] # test chỉ chạy lúc push, commit nhanh hơn
Cài hook vào git:
$ pre-commit install # cho commit
$ pre-commit install --hook-type pre-push # thêm pre-push
Từ giờ, mỗi git commit:
- Hook chạy
ruff,mypy, … - Nếu pass tất cả → commit bình thường.
- Nếu fail → commit bị reject, kèm log lỗi.
- Nhiều hook auto-fix (vd
ruff --fix,trailing-whitespace) — bạn chỉ cầngit addlại và commit.
Chạy thủ công tất cả hook trên toàn repo (CI hoặc lần đầu setup):
$ pre-commit run --all-files
Đừng dựa chỉ pre-commit. Layer phòng thủ:
- Editor (VS Code, PyCharm) hiển thị lỗi ruff/mypy real-time.
- pre-commit hook chặn ở máy dev.
- CI (GitHub Actions / GitLab CI) chạy lại trên server — phòng người dev disable hook local.
- Branch protection không cho merge khi CI fail.
Bài tập
Bài 1 — pytest parametrize cho is_palindrome
Sử dụng hàm is_palindrome(s: str) -> bool từ Chương 2 (kiểm tra
chuỗi đối xứng, bỏ qua hoa thường và khoảng trắng). Viết test pytest:
- Tạo
tests/test_palindrome.py. - Dùng
@pytest.mark.parametrizevới 10 test case: bao phủ chuỗi rỗng, 1 ký tự, even/odd palindrome, có hoa thường, có khoảng trắng, không phải palindrome. - Đặt
idrõ ràng cho từng case. - Chạy
pytest -v, đảm bảo 10 test PASSED. - Thử đổi 1 case sang giá trị sai → confirm pytest in ra diff rõ ràng.
Gợi ý cấu trúc
import pytest
from mypkg.string_utils import is_palindrome
@pytest.mark.parametrize(
"s, expected",
[
pytest.param("", True, id="empty"),
pytest.param("a", True, id="single-char"),
pytest.param("abba", True, id="even-palindrome"),
pytest.param("abcba", True, id="odd-palindrome"),
pytest.param("AbBa", True, id="case-insensitive"),
pytest.param("race car", True, id="with-space"),
pytest.param("A man a plan a canal Panama", True, id="famous-palindrome"),
pytest.param("hello", False, id="not-palindrome"),
pytest.param("ab", False, id="two-different"),
pytest.param("abc", False, id="three-different"),
],
)
def test_is_palindrome(s, expected):
assert is_palindrome(s) == expected
Bài 2 — Mock requests.get
Viết hàm fetch_user_data(user_id: int) -> dict trong
src/mypkg/user_service.py, gọi
requests.get("https://api.example.com/users/{id}") và trả về JSON.
- Viết test không gọi network thật — dùng
@patchhoặcmocker.patch. - Test case 1: API trả
200+ JSON hợp lệ → hàm trả dict đúng. - Test case 2: API trả
404→ hàm raiserequests.HTTPError. - Test case 3: Verify
requests.getđược gọi đúng URL. - Chạy
pytest -v: cả 3 case PASSED, không có cảnh báo network.
Skeleton
from unittest.mock import MagicMock
import pytest, requests
from mypkg.user_service import fetch_user_data
def test_fetch_success(mocker):
fake = mocker.MagicMock()
fake.json.return_value = {"id": 1, "name": "alice"}
fake.raise_for_status.return_value = None
mock_get = mocker.patch("mypkg.user_service.requests.get", return_value=fake)
result = fetch_user_data(1)
assert result == {"id": 1, "name": "alice"}
mock_get.assert_called_once_with("https://api.example.com/users/1")
def test_fetch_404(mocker):
fake = mocker.MagicMock()
fake.raise_for_status.side_effect = requests.HTTPError("404")
mocker.patch("mypkg.user_service.requests.get", return_value=fake)
with pytest.raises(requests.HTTPError):
fetch_user_data(999)
Bài 3 — Setup project với pyproject.toml + ruff + mypy + pytest
Tạo project mini my-mini với cấu trúc src/my_mini/__init__.py
+ tests/. Trong __init__.py viết 2-3 function đơn giản
(add, divide, …) có type hint.
- Viết
pyproject.tomlđầy đủ:[project],[project.optional-dependencies] dev,[tool.pytest.ini_options],[tool.mypy] strict=true,[tool.ruff]vớiselect = ["E", "F", "I", "B", "UP"]. - Cài:
pip install -e ".[dev]". - Chạy cả 3:
ruff check src,mypy src,pytest. Tất cả phải pass (green). - Cố ý phá: thêm import không dùng →
ruffbáoF401. Thêm function không type →mypybáo. Sửa lại cho pass.
Mẫu cấu trúc thư mục
my-mini/
├─ src/
│ └─ my_mini/
│ ├─ __init__.py
│ └─ math_utils.py
├─ tests/
│ └─ test_math_utils.py
└─ pyproject.toml
Bài 4 — Build wheel và cài local
Dùng project my-mini từ Bài 3 (hoặc tạo mới my-utils).
Mục tiêu: build wheel và cài vào virtualenv khác để verify package "ship được".
- Cài tool build:
pip install build. - Chạy
python -m build. Quan sátdist/có 2 file:.whlvà.tar.gz. - Tạo virtualenv mới:
python -m venv /tmp/test-env&&source /tmp/test-env/bin/activate. - Cài từ wheel:
pip install ./dist/my_utils-0.1.0-py3-none-any.whl. - Verify: mở
python,import my_utils, gọi function. Nếu thành công → package hợp lệ. - Bonus: Khai báo
[project.scripts] my-utils = "my_utils.cli:main", viết hàmmain()in chào hỏi. Sau khi cài wheel, gõmy-utilstrong terminal → chạy được.
Bài 5 — pre-commit hook chặn code bẩn
Trong project Bài 3, tạo .pre-commit-config.yaml với 3 hook:
ruff, ruff-format, mypy.
- Cài:
pip install pre-commit,pre-commit install. - Test commit thành công: code sạch →
git commit -m "feat: clean"pass. - Test commit bị reject: cố ý thêm 1 import không dùng
import os, sửa biến không type →git commitbị reject với log lỗi ruff/mypy. - Sửa lỗi (hoặc dùng
--fix),git addlại, commit lại → pass. - Bonus: thêm hook
pytestở stagepre-push. Test với commit có function broken → push bị chặn.
Gợi ý debug
Nếu hook không chạy: kiểm tra .git/hooks/pre-commit có tồn tại (do
pre-commit install tạo). Chạy thủ công:
pre-commit run --all-files để xem hook làm gì.
Bypass tạm thời (dùng khi cần thiết): git commit --no-verify —
nhưng tránh lạm dụng.
Quiz
Giữa pytest và unittest, cái nào built-in Python stdlib?
Xem đáp án
unittest nằm trong stdlib — không cần cài thêm. pytest là
package 3rd-party, phải pip install pytest. Tuy vậy, phần lớn project
Python 2026 vẫn chọn pytest vì cú pháp gọn, fixture mạnh, hệ sinh thái
plugin (cov, mock, asyncio, …) phong phú.
Scope mặc định của @pytest.fixture là gì?
Xem đáp án
"function". Mỗi test có một instance fixture riêng — setup và teardown
chạy lại từ đầu. An toàn nhất, không bị state leak. Mở rộng lên "module"
hay "session" chỉ khi setup tốn kém (Docker, file lớn, model ML) và bạn
chắc fixture không "đọng" trạng thái xuyên test.
@pytest.mark.parametrize có thể thay thế nhiều test function viết tay không?
Xem đáp án
Có. Một function với parametrize trở thành N test riêng
biệt (theo số tuple input). Pytest tự sinh tên (test_add[1-2-3]) hoặc
bạn đặt id rõ. Mỗi case có report riêng — fail 1 case không làm fail
các case khác. Đây là cách DRY hiệu quả nhất khi bạn cần test cùng
logic với nhiều input.
Khi stack nhiều @patch decorator, thứ tự tham số function thế nào?
Xem đáp án
Bottom-up: decorator gần function nhất tương ứng tham số đầu tiên. Ví dụ:
@patch("x") # mock_x → tham số cuối
@patch("y") # mock_y → tham số giữa
@patch("z") # mock_z → tham số đầu (gần function nhất)
def test_foo(mock_z, mock_y, mock_x): ...
Lý do: decorator được áp dụng từ dưới lên (Python decorator semantics), nên decorator gần function nhất "wrap" trong cùng → đi đầu trong arg list. Nhớ mẹo: "closest decorator = first arg".
Coverage 100% có nghĩa code không có bug?
Xem đáp án
Không. Coverage đo dòng/nhánh được execute, không đo assertion đúng. Test có thể gọi hàm mà không kiểm tra kết quả, hoặc kiểm tra quá lỏng. 100% coverage + 0 assertion = vô dụng.
Coverage là điều kiện cần (dòng không cover chắc chắn có thể buggy),
không phải điều kiện đủ. Cần kết hợp: assertion ý nghĩa,
edge case, property-based test (hypothesis), và mutation testing
(mutmut) để đo chất lượng test thật sự.
pyproject.toml thay thế những file cấu hình nào?
Xem đáp án
Theo PEP 517, 518, 621, pyproject.toml hợp nhất:
setup.py— build script (giờ chỉ cần khai báo[build-system]).setup.cfg— metadata (chuyển vào[project]).MANIFEST.in— một phần (declare data files qua build backend).requirements.txt— list dependency (chuyển vàodependencies).pytest.ini,tox.ini,mypy.ini,.flake8— config tool (chuyển vào[tool.X]).
Một file duy nhất, format TOML rõ ràng. Đây là chuẩn 2026 cho mọi project Python mới.
ruff format và black khác nhau ra sao?
Xem đáp án
Cả hai theo triết lý "opinionated formatter" — ít cấu hình, output gần như giống nhau. Khác biệt chính:
- Tốc độ: ruff viết bằng Rust, nhanh hơn black ~30 lần. Trên codebase lớn cảm thấy rõ.
- Tích hợp: ruff cùng tool lint + format → 1 binary, 1 config. Black tách riêng (cần thêm
flake8hoặcisort). - Output: cố ý tương thích black 99%. Diff khi migrate thường ≤ 1% dòng.
Khuyến nghị 2026: dùng ruff cho dự án mới. Project cũ đã dùng black —
migrate sang ruff khi rảnh, gain hiệu năng đáng kể trên CI.
Pre-commit hook chạy vào thời điểm nào? Nếu hook fail, điều gì xảy ra?
Xem đáp án
Theo cấu hình mặc định, hook chạy trước khi git tạo commit (giai
đoạn pre-commit của git). Có thể cấu hình thêm pre-push,
commit-msg, …
Nếu hook fail (return code ≠ 0), git hủy commit. Bạn thấy
log lỗi, sửa, git add lại, thử commit lại. Một số hook auto-fix (ruff
--fix, format) — file đã được sửa nhưng chưa stage, bạn cần
git add rồi commit lần 2.
Bypass khẩn cấp: git commit --no-verify — bỏ qua hook. Tránh lạm dụng,
vì CI vẫn sẽ chạy lại các kiểm tra này.
Tổng kết
Sau chương 10 — chương cuối của Python sub-pillar — bạn đã master:
- pytest — function test, fixture (yield, scope), parametrize, marker, conftest.
- Mock —
unittest.mockvàpytest-mock, patch ở "nơi sử dụng", stack decorator. - Coverage — đo độ phủ line/branch, biết giới hạn của metric % coverage.
- pyproject.toml — file config trung tâm cho metadata, deps, pytest, mypy, ruff, coverage.
- Build & publish —
python -m buildtạo wheel + sdist,twineupload PyPI. - Ruff — lint + format hiện đại, Rust-speed, thay flake8 + black + isort.
- Pre-commit — hook chặn code bẩn từ máy dev, layer phòng thủ đầu.
- Defense in depth — editor → pre-commit → CI → branch protection.
Kết nối
- Chương 1 (Setup) — virtualenv mà bạn dùng để cách ly môi trường giờ kết hợp với
pyproject.tomlđể khoá deps dev/prod. - Chương 2-4 (cơ bản, data structure, function) — code bạn viết giờ có test bảo vệ, refactor không sợ.
- Chương 5 (OOP) — repository pattern, dependency injection thấy rõ giá trị khi bạn cần mock trong test.
- Chương 6 (error handling) —
pytest.raisesverify exception đúng loại + message. - Chương 7 (decorator) — bạn hiểu thực sự
@pytest.fixture,@pytest.mark.parametrize,@patchhoạt động ra sao. - Chương 8 (async) — test async cần
pytest-asyncio, mockAsyncMock. - Chương 9 (typing) — mypy strict pair với pytest = "double safety net".
- Pillar DevOps — CI/CD pipeline GitHub Actions/GitLab CI chạy chính các lệnh chương này:
ruff check,mypy,pytest --cov,python -m build,twine upload. - Pillar System Design — code quality + test coverage là tiền đề bắt buộc cho hệ thống production chịu tải.
10 chương Python — từ setup môi trường, biến, data structure, function, OOP, error handling, decorator, async, typing, đến testing & packaging production. Tổng cộng ~9000 dòng kiến thức Python.
Cộng với 12 chương JavaScript/TypeScript + 23 chương Dart đã hoàn thành trước đó, bạn đã đi qua 45 chương trong Pillar 9 — Programming Languages, ~30.000 dòng nội dung. Đây là kho kiến thức ngôn ngữ lập trình đủ rộng để:
- Đọc hiểu mọi codebase Python modern: Django, FastAPI, Flask, Pandas, PyTorch, …
- Phỏng vấn vị trí Backend Python, Data Engineer, ML Engineer, DevOps tự tin.
- Đóng góp pull request vào dự án open-source Python lớn.
- Build và publish package của chính bạn lên PyPI để cộng đồng dùng.
- Đặt nền móng vững cho học framework cụ thể mà không bị tắc ở "syntax Python lạ".
Bước tiếp theo đề xuất:
- Đào sâu framework Python: Django cho web full-stack monolith, FastAPI cho API hiện đại async, Pandas + NumPy + scikit-learn cho data science, hoặc PyTorch cho ML/AI.
- Chuyển sang Pillar khác nếu bạn đã có Pillar 9 vững — sub-pillar tiếp theo trong roadmap IT Basic của bạn. Ngôn ngữ chỉ là công cụ; kiến trúc hệ thống, database, networking, OS, DSA, OOP, system design, CI/CD mới là insight định hình kỹ sư senior.
- Project thực chiến: dùng những gì đã học để build 1 sản phẩm end-to-end. Ví dụ: API CRUD bằng FastAPI + PostgreSQL + Docker, deploy lên cloud với CI/CD GitHub Actions. Đây mới là cách kiến thức "đi vào tay".
Ngôn ngữ học xong rồi. Giờ là lúc xây thứ thật. Chúc bạn code vui!