Chương 10 · Testing, Packaging & Tooling

Đưa Python lên Production — Testing, Packaging và Tooling hiện đại

Code đẹp, type chuẩn nhưng nếu không có test đảm bảo hành vi, không có linter giữ chất lượng, không có format nhất quán, và không có cách đóng gói để chia sẻ — thì project Python chỉ là bản nháp. Chương cuối này dạy bạn toàn bộ vòng đời production của một dự án Python năm 2026: pytest, coverage, ruff, mypy, pyproject.toml, build, twine, và pre-commit.

Độ dài: ~1100 dòng Bài tập: 5 Quiz: 8 Prerequisites: Chương 1-9
🎯 Mục tiêu chương
  • Viết test với pytest — function, fixture, parametrize, marker, conftest.
  • Quản lý side effect bằng unittest.mockpytest-mock.
  • Đo độ phủ test với coverage / pytest-cov và 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 .whl.tar.gz bằng python -m build.
  • Publish package lên PyPI bằng twine.
  • Lint + format bằng ruff (Rust, thay flake8 + black + isort).
  • Cài pre-commit hook chặn commit code bẩn ngay tại máy lập trình.
🧠 Mental model — Tooling là "phanh xe" cho dev

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ápClass TestCase, method setUp/tearDownFunction thuần, def test_*
Assertionself.assertEqual(a, b)assert a == b — Python built-in
Setup/teardownMethod trong classFixture dạng function — composable
ParametrizePhải subclass hoặc dùng subTest@pytest.mark.parametrize — 1 dòng
PluginHạn chếHệ sinh thái lớn: pytest-cov, pytest-mock, pytest-asyncio, …
VerboseCó (boilerplate class)Tối thiểu

So sánh trực tiếp cùng 1 test:

unittest style
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()
pytest style
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.

💡 Cài đặt

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ụ:

tests/test_calc.py
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ẹ.

fixture cơ bản
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 (do pytest bọc bằng try/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:

scopeLifetimeKhi dùng
"function"Mỗi test 1 instanceMặc định. An toàn, không state leak.
"class"Sống suốt một TestClassTest gom vào class chia sẻ resource.
"module"1 file .pyResource đắt mà tất cả test trong file dùng (vd: file fixture data).
"package"1 thư mục testHiếm dùng.
"session"Toàn bộ pytest runDocker, server thật, model ML lớn.
@pytest.fixture(scope="session")
def docker_postgres():
    container = start_postgres_container()
    yield container
    container.stop()
🔥 Gotcha — state leak với scope rộng

Scope càng rộng, test càng dễ phụ thuộc thứ tự chạy. Ví dụ db scope "session"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:

parametrize cơ bản
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
tests/conftest.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_datakhô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
💡 Quy tắc đặt 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.pytests/ 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:

basic Mock
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):

patch 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()
tests/test_user_service.py
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")
🔥 Gotcha — patch ở "nơi sử dụng", không phải nơi định nghĩa

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:

với pytest-mock
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.
  • 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 % không phải sự thật

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.iniPEP 621 thống nhất tất cả vào pyproject.toml.

File pyproject.toml đầy đủ cho 1 project hiện đại:

pyproject.toml — đầy đủ
# ---------- 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"
💡 Hatchling vs Setuptools

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ôiNội dung
sdist (source distribution).tar.gzSource 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...
🔥 Tên package là unique và không xoá được

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:

PrefixRule setNguồn gốc
E, WStyle errors/warningspycodestyle (PEP 8)
FLogic errors (unused import, undefined name)pyflakes
IImport sortingisort
BBug-likely patternsflake8-bugbear
UPSuggest syntax modern hơnpyupgrade
NNaming conventionpep8-naming
SIMSimplification gợi ýflake8-simplify
DDocstring stylepydocstyle
PLLogic phức tạpPylint 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]
💡 Migrate từ black + isort sang ruff

ruff format output gần giống black (cùng triết lý "opinionated, không tranh cãi"). Khi migrate: xoá blackisort 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:

.pre-commit-config.yaml
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:

  1. Hook chạy ruff, mypy, …
  2. Nếu pass tất cả → commit bình thường.
  3. Nếu fail → commit bị reject, kèm log lỗi.
  4. Nhiều hook auto-fix (vd ruff --fix, trailing-whitespace) — bạn chỉ cần git add lạ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
🧠 Defense in depth

Đừng dựa chỉ pre-commit. Layer phòng thủ:

  1. Editor (VS Code, PyCharm) hiển thị lỗi ruff/mypy real-time.
  2. pre-commit hook chặn ở máy dev.
  3. CI (GitHub Actions / GitLab CI) chạy lại trên server — phòng người dev disable hook local.
  4. 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.parametrize vớ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 id rõ 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 @patch hoặc mocker.patch.
  • Test case 1: API trả 200 + JSON hợp lệ → hàm trả dict đúng.
  • Test case 2: API trả 404 → hàm raise requests.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ới select = ["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 → ruff báo F401. Thêm function không type → mypy bá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át dist/ có 2 file: .whl.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àm main() in chào hỏi. Sau khi cài wheel, gõ my-utils trong 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 commit bị reject với log lỗi ruff/mypy.
  • Sửa lỗi (hoặc dùng --fix), git add lại, commit lại → pass.
  • Bonus: thêm hook pytest ở stage pre-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

Q1

Giữa pytestunittest, cái nào built-in Python stdlib?

Xem đáp án
✓ Đá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ú.

Q2

Scope mặc định của @pytest.fixture là gì?

Xem đáp án
✓ Đá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.

Q3

@pytest.mark.parametrize có thể thay thế nhiều test function viết tay không?

Xem đáp án
✓ Đá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.

Q4

Khi stack nhiều @patch decorator, thứ tự tham số function thế nào?

Xem đáp án
✓ Đá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".

Q5

Coverage 100% có nghĩa code không có bug?

Xem đáp án
✓ Đá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ự.

Q6

pyproject.toml thay thế những file cấu hình nào?

Xem đáp án
✓ Đá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ào dependencies).
  • 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.

Q7

ruff formatblack khác nhau ra sao?

Xem đáp án
✓ Đá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 flake8 hoặc isort).
  • 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.

Q8

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
✓ Đá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.
  • Mockunittest.mockpytest-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 & publishpython -m build tạo wheel + sdist, twine upload 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.raises verify exception đúng loại + message.
  • Chương 7 (decorator) — bạn hiểu thực sự @pytest.fixture, @pytest.mark.parametrize, @patch hoạt động ra sao.
  • Chương 8 (async) — test async cần pytest-asyncio, mock AsyncMock.
  • 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.
🎉 Chúc mừng — bạn đã hoàn thành Python sub-pillar VÀ toàn bộ Pillar 9!

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:

  1. Đà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.
  2. 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.
  3. 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!