↓ Chuyển đến nội dung chính

Python Django Pytest

pytest-testmon: chỉ chạy những bài kiểm thử mà thay đổi code của bạn ảnh hưởng tới

pytest-testmon là một plugin của pytest, chỉ chạy những bài kiểm thử mà một thay đổi code có thể ảnh hưởng tới: phân tích tác động kiểm thử (test impact analysis) cho Python. Nó dùng coverage.py để ghi lại mỗi bài kiểm thử đã thực thi những khối code nào, rồi lần sau chỉ chạy những bài có khối code đã thay đổi, cộng với những bài đã thất bại. Bài này kể vì sao tôi thêm nó vào một bộ kiểm thử Django chậm gồm 2,248 bài (một yêu cầu tính năng nhỏ không nên phải chờ cả bộ kiểm thử), nó quyết định chạy gì ra sao, hướng dẫn từng bước kèm script bọc lo phần nó không nhìn thấy, thiết lập coverage.py mà nếu thiếu thì trên Python 3.14 trở lên nó sẽ âm thầm bỏ sót bài kiểm thử, nó giúp gì cho một agent viết code, và vì sao CI lại chỉ nhận một phiên bản thô hơn.

pytest-testmon: chỉ chạy những bài kiểm thử mà thay đổi code của bạn ảnh hưởng tới

pytest-testmon là một plugin của pytest, chỉ chạy những bài kiểm thử mà thay đổi của bạn có thể ảnh hưởng tới. Nó dùng coverage.py để theo dõi xem mỗi bài kiểm thử thực thi những dòng code nào, rồi lưu lại mã kiểm tra (checksum) của từng khối code mà bài đó đã chạm vào. Ở lần chạy sau, nó đem các checksum này so với code đang nằm trên đĩa, và chỉ chạy những bài có khối code đã thay đổi, cộng thêm những bài thất bại ở lần trước, nên nó làm luôn cả việc của pytest --lf. Kỹ thuật chung này gọi là phân tích tác động kiểm thử (test impact analysis), và testmon là một cách miễn phí, mã nguồn mở để có được nó trong Python. Nó cũng hoàn toàn không phụ thuộc vào git: thứ nó đem ra so là những gì nó thấy ở lần chạy trước, dù code đó đã commit hay chưa.

Tuần này tôi đã thêm nó vào bộ kiểm thử Python của ứng dụng chợ mà tôi đang làm, và lý do không đơn thuần là muốn chạy nhanh hơn.

Vì sao tôi thêm nó
#

Một yêu cầu tính năng (feature request) thường rất nhỏ: một màn hình, một endpoint, hoặc một quy tắc cần hoạt động khác đi. Thế nhưng việc phải chạy toàn bộ bộ kiểm thử khiến yêu cầu nào cũng phình to hơn thực tế. Bộ kiểm thử có 2,248 bài kiểm thử Django, chạy với bốn tiến trình mất khoảng ba phút rưỡi, còn trên một tiến trình là 11 phút, và lần sửa nào cũng chạy hết toàn bộ, dù gần như tất cả các bài đều kiểm tra những thứ mà yêu cầu đó chưa hề động tới. Khi một bài chẳng liên quan gì bị thất bại, hoặc đơn giản là lần chạy cứ thong thả kéo dài, thì tính năng phải ngồi chờ. Nó bị giữ lại bởi những chuyện nằm ngoài yêu cầu tính năng, và thứ giữ nó lại lại chính là bộ kiểm thử của tôi.

Cách còn lại cũng chẳng tốt hơn: bỏ qua lần chạy toàn bộ, tự đoán xem tệp kiểm thử nào bao phủ thay đổi, và thỉnh thoảng đoán sai. Làm vậy thì nhanh, nhưng không chính xác, mà một thay đổi chưa được kiểm thử đã bị tôi đem phát hành thì cũng là một yêu cầu tính năng tôi sẽ phải làm lại lần thứ hai.

Điều tôi muốn là làm nhanh mà không làm ẩu: nhận một yêu cầu, sửa code, biết chắc nó chạy đúng, phát hành, rồi chuyển sang yêu cầu tiếp theo. Muốn vậy thì những bài kiểm thử tôi phải chờ phải đúng là những bài mà thay đổi đó có thể chạm tới, không thừa một bài, không thiếu một bài. testmon cho tôi đúng tập hợp đó dựa trên dữ liệu đã ghi lại, chứ không phải dựa trên phỏng đoán. Bộ kiểm thử đầy đủ thì vẫn chạy, nhưng chạy trong CI, ở một chỗ không cản trở việc tiếp theo tôi đang làm.

Cách nó quyết định chạy bài nào
#

testmon chia mỗi tệp Python thành các khối: phần cấp cao nhất của module, từng class, từng hàm, trong đó thân của các hàm lồng bên trong được tách ra khỏi khối bao quanh nó. Nó lưu checksum của từng khối chứ không bao giờ lưu bản thân code. Trong lúc một bài kiểm thử chạy, coverage.py ghi lại những dòng được thực thi, còn testmon ghi nhận các dòng đó thuộc về khối nào. Ở đây không có phân tích tĩnh (static analysis), cũng không có chuyện đoán mò dựa vào các câu import. Một bài kiểm thử phụ thuộc vào đúng những khối mà nó đã chạy qua.

Lấy ví dụ một module như sau:

# pricing.py
def subtotal(items):
    return sum(item.price * item.quantity for item in items)

def discount(total, code):
    if code == "WELCOME10":
        return total * 0.9
    return total

Một bài kiểm thử chỉ gọi subtotal() sẽ phụ thuộc vào phần cấp cao nhất của module và thân hàm subtotal. Bạn có viết lại discount() thế nào đi nữa thì testmon cũng không chạy lại bài đó, vì nó chưa từng thực thi dòng nào của discount. Ngược lại, chỉ cần đổi một ký tự bên trong subtotal là bài đó chạy lại, kể cả khi thay đổi ấy không thể làm kết quả khác đi. testmon không cố tỏ ra thông minh với các tác dụng phụ: khối nào bạn đã thực thi mà bị thay đổi thì bạn chạy lại.

Nó còn theo dõi cả những thứ bao quanh code. Bản ghi bao gồm phiên bản Python, các gói đã cài cùng phiên bản của chúng, và các biến môi trường như DJANGO_SETTINGS_MODULE. Nâng cấp Django thì mọi bài từng chạm vào Django lại bị tính là bị ảnh hưởng, mà trong một ứng dụng Django thì đó là tất cả.

Những gì nó không nhìn thấy
#

testmon theo dõi phần code Python mà bài kiểm thử thực thi. Nó không theo dõi những tệp khác mà bài kiểm thử đọc: template, JSON, văn bản, hay bất cứ thứ gì khác trên đĩa. Nó không theo dõi các dịch vụ được gọi qua mạng. Và những gì chạy một lần trước khi các bài kiểm thử bắt đầu thì nó không thể gắn vào một bài cụ thể nào. Trong một dự án Django, điều đó có nghĩa là:

  • Migration. Chúng chỉ chạy một lần, lúc tạo cơ sở dữ liệu kiểm thử, nên không bài kiểm thử nào có chúng trong bản ghi của mình. Script bọc ở bên dưới sẽ dựng lại cơ sở dữ liệu kiểm thử khi có migration mới.
  • Settings và code khởi động. Chúng chạy trong lúc pytest khởi động, nằm ngoài mọi bài kiểm thử riêng lẻ.
  • Template và tệp dữ liệu. Template không phải là Python, nên sửa một template sẽ không làm chạy lại bài nào render nó.

Bộ kiểm thử của tôi còn có thêm vài trường hợp riêng. Một số bài đọc một tệp JSON chứa dữ liệu mẫu (fixture) dùng chung với bộ kiểm thử của ứng dụng di động. Một số bài kiểm tra gói web đã dựng (bundle), và một số khác kiểm tra đầu ra của một trang tĩnh được sinh ra ngay trong cùng kho mã. Đổi bất cứ thứ nào trong số đó, testmon sẽ vui vẻ báo cho bạn rằng chẳng cần chạy gì cả. Đó là lý do phần hướng dẫn bên dưới bọc testmon trong một script, để script nhận ra những tệp này và chạy toàn bộ khi có tệp nào đã thay đổi.

Cách thêm nó vào một dự án Django
#

Các bước dưới đây giả định bạn dùng pytest và pytest-django, cùng với uv để quản lý thư viện phụ thuộc. Nếu dùng pip thì thay uv add --dev bằng pip install và bỏ uv run đi.

1. Cài đặt.

uv add --dev pytest-testmon

2. Bảo đảm pytest chạy được bộ kiểm thử. testmon là một plugin của pytest, nên khi chạy bằng python manage.py test thì nó không làm gì cả. pytest-django cần biết module settings và các mẫu tên tệp mà bài kiểm thử của bạn dùng:

# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings
python_files = tests.py test_*.py *_tests.py
python_classes = Test* *Tests *TestCase

Nếu từ trước đến giờ bộ kiểm thử chỉ chạy bằng trình chạy kiểm thử của Django, hãy chạy nó bằng pytest một lần và sửa hết những chỗ khác biệt trước khi thêm testmon. Nếu không, bạn sẽ phải gỡ lỗi hai thứ cùng một lúc.

3. Đừng để bản ghi lọt vào git. testmon ghi tệp .testmondata vào thư mục gốc của pytest. Đây là một cơ sở dữ liệu SQLite, nên bạn sẽ thấy cả các tệp -shm và -wal nằm cạnh nó, và script bên dưới còn thêm một tệp đánh dấu thời điểm (stamp). Chỉ một mẫu là đủ cho tất cả:

# .gitignore
.testmondata*

4. Ghi lại toàn bộ bộ kiểm thử một lần. testmon chưa chọn được gì cho đến khi nó đã thấy mọi bài kiểm thử chạy qua:

uv run pytest --testmon

Trong một bản checkout mới tinh, lần chạy đầu tiên đó là toàn bộ bộ kiểm thử trên một tiến trình. Ở dự án của tôi, lần đó mất khoảng 12 phút cho 2,248 bài. Từ đó trở đi, cũng lệnh này sẽ chỉ chạy những bài kiểm thử bị ảnh hưởng. Không có gì thay đổi thì nó không chạy gì cả.

Có hai chỗ dễ vấp. testmon cần plugin cache của pytest: lần thử đầu tiên của tôi chạy với -p no:cacheprovider và bị sập với lỗi KeyError: 'lf'. Còn nếu bạn dùng Python 3.14 trở lên, hãy đặt COVERAGE_CORE=ctrace trước khi ghi. Lý do được giải thích ở phần “Lỗi trông như một lần chạy toàn xanh” phía dưới. Đây là dòng quan trọng nhất trong script bên dưới.

5. Bọc nó lại để lo những tệp nó không nhìn thấy. Đây là phần giúp bạn yên tâm dùng nó hằng ngày. Script giữ một tệp stamp từ lúc bắt đầu lần chạy trước. Nếu có tệp nào mà testmon không lần theo được lại mới hơn tệp stamp, script sẽ chạy toàn bộ và ghi lại từ đầu. Một migration mới thì chỉ khiến script dựng lại cơ sở dữ liệu kiểm thử. Còn không thì để testmon tự chọn. Một ví dụ đã cắt gọn:

#!/usr/bin/env bash
# scripts/test-changed.sh: the tests a change can affect, or all when in doubt.
#   scripts/test-changed.sh            # affected tests
#   scripts/test-changed.sh --all      # everything, re-recorded
#   scripts/test-changed.sh -k orders  # anything else goes to pytest
set -euo pipefail
cd "$(dirname "$0")/.."

# coverage's C tracer: from Python 3.14 coverage defaults to sys.monitoring,
# which reports a line only the first time a process runs it, so testmon
# would credit each line to one test. Changing this means --all.
export COVERAGE_CORE=ctrace

STAMP=.testmondata.started
full=false
rebuild=false
if [ "${1:-}" = "--all" ]; then
    full=true; shift
elif [ ! -f .testmondata ] || [ ! -f "$STAMP" ]; then
    echo "No record yet: running everything once."
    full=true
else
    # Changed since the last run started, and not traceable per test:
    # anything that isn't Python, plus settings and conftest.
    newer="$(find myapp myproject fixtures pytest.ini \
        -type f -newer "$STAMP" ! -path '*/migrations/*' \
        \( ! -name '*.py' -o -path 'myproject/settings*' -o -name 'conftest.py' \) \
        ! -path '*/__pycache__/*' | head -5)"
    if [ -n "$newer" ]; then
        echo "Changed, and not traceable per test:"; printf '  %s\n' $newer
        full=true
    fi
    # A new migration needs a fresh test database, not a full run.
    [ -n "$(find myapp -path '*/migrations/*.py' -newer "$STAMP" | head -1)" ] && rebuild=true
fi

# Stamp the start, so an edit made while the tests run counts next time.
started="$(mktemp .testmondata.run.XXXXXX)"
args=(--testmon --reuse-db)
# --create-db alone deletes the database when the run ends; with
# --reuse-db as well, the rebuilt one is kept for next time.
[ "$rebuild" = true ] && args=(--testmon --reuse-db --create-db)
[ "$full" = true ] && args=(--testmon-noselect --reuse-db --create-db)

status=0
uv run pytest "${args[@]}" -q "$@" || status=$?
# 0: passed, or nothing selected. 1: some failed (still recorded).
# 5: nothing collected at all.
case "$status" in 0|1|5) mv "$started" "$STAMP" ;; *) rm -f "$started" ;; esac
[ "$status" = 5 ] && exit 0
exit "$status"

Có hai chi tiết trong đó đáng giải thích. --reuse-db là cờ của pytest-django để giữ lại cơ sở dữ liệu kiểm thử giữa các lần chạy, và điều này trở nên quan trọng khi một lần chạy chỉ còn mất vài giây: tạo lại cơ sở dữ liệu mỗi lần có khi còn lâu hơn chính các bài kiểm thử. Lần chạy toàn bộ, hoặc lần chạy ngay sau khi có migration mới, thì thêm --create-db để cơ sở dữ liệu có đúng cấu trúc bảng (schema) hiện tại. Hãy giữ --reuse-db đi kèm với nó: nếu chỉ có --create-db thì pytest-django sẽ xóa cơ sở dữ liệu khi lần chạy kết thúc, và lần chạy sau lại phải dựng lại tất cả từ đầu. Còn khi testmon bỏ chọn mọi bài, lần chạy sẽ thoát với mã 0, nên “không có gì thay đổi” được tính là qua. Mã thoát (exit code) 5 là cách pytest báo “không thu thập được bài kiểm thử nào”, và script cũng coi trường hợp đó là ổn.

Hãy để ý những gì không có trong danh sách: pyproject.toml và tệp khóa phiên bản (lock file). Phiên bản đầu tiên của tôi chạy toàn bộ mỗi khi tệp lock thay đổi, khiến phương án dự phòng chậm chạp kia bị kích hoạt gần như ngày nào cũng có, và mọi thứ còn tệ hơn cả lúc chưa có script. testmon vốn đã tự theo dõi phiên bản của các gói đã cài, nên chuyện này nó không cần ai giúp.

6. Cho nó cơ sở dữ liệu và bộ nhớ đệm riêng. Nếu trên cùng máy còn có thứ khác chạy kiểm thử, chẳng hạn một terminal thứ hai, một agent viết code (coding agent) hay manage.py test, hãy trỏ script tới một cơ sở dữ liệu kiểm thử riêng và một số hiệu bộ nhớ đệm riêng (trong ví dụ là database số 8 của Redis), để cơ sở dữ liệu được giữ lại không bao giờ đụng phải lần chạy của người khác:

export DATABASE_URL="postgresql://myapp:myapp@localhost:5432/myapp_changed"
export REDIS_URL="redis://localhost:6379/8"

7. Xóa sạch bộ nhớ đệm đó trước mỗi lần chạy. Chuyện này tôi phải vấp mới biết. Các lần chạy một phần bắt đầu thất bại với những lỗi KeyError mà lần chạy toàn bộ không hề gặp. Nguyên do là một số nhãn danh mục được cache mà không có thời hạn hết hạn, và một cơ sở dữ liệu kiểm thử giữ lại từ lần chạy này lại gặp phải bộ nhớ đệm vẫn còn “ấm” từ một lần chạy trước đó nữa. Xóa cache ngay đầu script là hết lỗi:

uv run python -c "import os, redis; redis.Redis.from_url(os.environ['REDIS_URL']).flushdb()"

Đó chính là testmon làm đúng như tài liệu của nó đã nói: mỗi lần chạy một tập con khác nhau sẽ làm lộ ra những bài kiểm thử chỉ qua được nhờ những gì đã chạy trước chúng. Chỗ cần sửa là phần thiết lập kiểm thử, không phải testmon.

8. Nắm các cờ để dùng khi testmon chọn sai.

  • --testmon-noselect chạy mọi bài kiểm thử nhưng đưa những bài có khả năng thất bại lên trước. testmon tự chuyển sang chế độ này khi bạn truyền thêm -k, -m, --lf hoặc node ID của một bài kiểm thử.
  • --testmon-forceselect chỉ chạy những bài bị ảnh hưởng mà đồng thời khớp với -k hoặc -m của bạn.
  • --testmon-nocollect dùng bản ghi hiện có nhưng không ghi thêm gì vào. Chế độ này bị bật bắt buộc khi chạy dưới trình gỡ lỗi (debugger) hoặc khi coverage đang chạy sẵn.
  • --no-testmon tắt nó cho một lần chạy, dùng khi bạn đã đặt nó trong addopts.
  • --testmon-env=<name> giữ các bản ghi riêng biệt trong cùng một tệp, dành cho bộ kiểm thử mà bạn chạy dưới hai module settings khác nhau.

Nếu dự án có cả phần JavaScript thì Jest có sẵn một phiên bản riêng của tính năng này. jest --changedSince=main chạy những bài kiểm thử import bất cứ thứ gì đã thay đổi kể từ main, dựa theo các câu import chứ không dựa theo coverage. Tôi đã thêm nó vào package.json của ứng dụng dưới tên npm run test:changed, để cả hai nửa của dự án có cùng một thói quen. Một lần sửa ở một module tiện ích đã chọn ra 7 trong số 121 tệp kiểm thử của ứng dụng.

Nó tiết kiệm được gì
#

Khi script bọc đã vào chỗ, và sau khi sửa lỗi được kể ở phần tiếp theo, tôi đo thời gian của nó, tính cả phần khởi động:

Thứ đã thay đổiSố bài đã chạyThời gian
Không có gì01.0 s
Một lần sửa trong phần code đứng sau một banner ở màn hình chính178.5 s
Hoàn tác lần sửa đó178.7 s
Toàn bộ bộ kiểm thử, để so sánh (4 tiến trình)2,248khoảng 3½ phút

Với một lần sửa thường ngày, như vậy là nhanh hơn khoảng 25 lần so với bộ kiểm thử chạy song song, còn khi không có gì thay đổi thì nhanh hơn khoảng hai trăm lần. Bản ghi cũng cho thấy điều tương tự, ở phạm vi rộng hơn. Trong 307 tệp Python không phải tệp kiểm thử mà bộ kiểm thử chạy qua, thay đổi ở tệp trung vị chạm tới 22 bài kiểm thử, tức khoảng 8 giây thời gian kiểm thử. Ở đầu bên kia là vài module lõi: gần như bài kiểm thử nào cũng chạy qua chúng, nên một thay đổi ở đó chạm tới khoảng 1,500 bài, và testmon sẽ chạy lại phần lớn bộ kiểm thử. Đó là câu trả lời đúng, chứ không phải một thất bại. Các con số này là giới hạn trên tính theo từng tệp: testmon làm việc theo từng khối, nên đổi một hàm sẽ chỉ chọn những bài đã chạy qua hàm đó.

Cái giá lộ ra khi script bọc phải quay về phương án dự phòng. Một lần chạy toàn bộ cũng là một lần ghi trên một tiến trình giống như lần đầu, mất khoảng 12 phút, tức là còn chậm hơn bộ kiểm thử chạy song song mà nó thay thế. Vì vậy, danh sách các tệp kích hoạt nó nên ngắn hết mức có thể, miễn là vẫn trung thực.

Lỗi trông như một lần chạy toàn xanh
#

Những con số đầu tiên của tôi còn đẹp hơn thế này: một lần sửa, 6 bài kiểm thử, 3.3 giây. Chúng sai, và không có gì trên màn hình cho biết điều đó.

Từ Python 3.14, coverage.py mặc định dùng một lõi (core) mới, xây dựng trên sys.monitoring của trình thông dịch. Lõi này chỉ báo một dòng ở lần đầu tiên một tiến trình chạy qua dòng đó. testmon dựng bản ghi bài nào đã chạy dòng nào từ dữ liệu của coverage, nên mỗi dòng chỉ được tính cho bài kiểm thử đầu tiên tình cờ chạy qua nó, chứ không tính cho bài nào khác. Sửa một dòng mà tám bài kiểm thử cùng dùng, testmon sẽ chỉ chọn một trong số đó.

Lỗi này lộ ra trên một dự án khác của tôi, nhỏ hơn, nơi tôi chép lại cách thiết lập này. Một phần của việc áp dụng nó ở đó là cố ý làm hỏng một dòng rồi so sánh kết quả, và các con số không khớp nhau. Quay lại bộ kiểm thử này, tôi làm đúng phép thử đó. Tôi làm hỏng một dòng trong phần code của banner ở màn hình chính, cụ thể là tiêu đề của một slide. Chạy pytest bình thường thì có 8 bài thất bại. testmon chỉ chọn 4. 4 bài hỏng còn lại không hề được chạy, và đầu ra không có dòng nào cho biết chúng đã bị bỏ qua.

Cách sửa chỉ là một dòng, export COVERAGE_CORE=ctrace, để đưa coverage về lại trình theo dõi (tracer) cổ điển viết bằng C của nó. Đổi tracer thì bản ghi cũng khác, nên tôi ghi lại từ đầu: 12 phút 3 giây cho 2,248 bài. Sau đó, cùng một kiểu sửa làm hỏng code như vậy đã chạy đủ cả 11 bài mà nó làm hỏng.

Nếu phải truyền lại một bài học trước mọi thứ khác trong bài này, tôi sẽ chọn bài học này. Một công cụ chọn bài kiểm thử mà chọn quá ít thì trông y hệt như thành công: ít bài hơn, chạy nhanh hơn, toàn màu xanh. Vì vậy, khi thiết lập một công cụ như thế, hãy cố ý làm hỏng một thứ dùng chung, chạy cả bộ kiểm thử thông thường lẫn công cụ chọn bài, rồi kiểm tra xem hai bên có báo thất bại đúng những bài giống nhau không. Sau đó, làm lại việc này mỗi khi Python hoặc coverage.py lên phiên bản mới.

Khi agent viết code vào cuộc
#

Tôi không còn tự tay làm mọi thay đổi trong kho mã đó nữa. Một agent viết code, trong trường hợp của tôi là Claude Code, làm khá nhiều thay đổi nhỏ, và một agent tốt sẽ chạy kiểm thử sau mỗi lần sửa. Đứng trước một bộ kiểm thử 11 phút, nó cũng chỉ có đúng hai lựa chọn tồi như tôi từng có: ngồi chờ, hoặc đoán xem tệp kiểm thử nào mới quan trọng.

Vì vậy, phần hướng dẫn dành cho agent trong kho mã giờ ghi rằng script bọc kia là cách mặc định để chạy kiểm thử trong lúc làm việc, còn --all dành cho lần chạy toàn bộ. Sau mỗi lần sửa, agent nhận được đúng tập bài kiểm thử chính xác như tôi, chỉ trong vài giây. Đây cũng là lý do bước 6 cho script một cơ sở dữ liệu và bộ nhớ đệm riêng: vào một ngày bận rộn, sẽ có nhiều hơn một phiên làm việc cùng chạy kiểm thử trên một máy, và cơ sở dữ liệu được giữ lại tuyệt đối không được đụng phải lần chạy của người khác.

Không phần nào trong số này đòi hỏi testmon phải có gì dành riêng cho AI. Vẫn là bản ghi đó, vẫn là cách chọn đó. Chỉ là nó trở nên quan trọng hơn khi thứ đang tạo ra thay đổi lại tạo ra rất, rất nhiều thay đổi.

Vì sao CI chỉ nhận một phiên bản thô hơn
#

testmon không có chế độ dành riêng cho CI. Trên runner nó hoạt động y như trên laptop, và chính tác giả của nó viết rằng việc chia sẻ hoặc cache .testmondata giữa các runner CI kém hiệu quả hơn nhiều so với dịch vụ lưu trữ sẵn (hosted) của họ là testmon.net. Một bản ghi được cache lại cũng sẽ mù theo đúng những cách đã kể ở trên, mà CI của tôi lại chạy bộ kiểm thử bằng trình chạy của Django trên bốn tiến trình, nên tôi không đưa testmon vào CI.

Thay vào đó, CI nhận cùng ý tưởng này nhưng ở cấp độ tệp. Workflow kiểm tra giờ bắt đầu bằng một job nhỏ, lấy diff của lần push rồi xác định nó đã chạm vào nửa nào của kho mã: API Python, ứng dụng di động, cả hai, hay không bên nào. Trước đây, CI chạy cả job Python lẫn job ứng dụng ở mọi lần push, kể cả những lần chỉ sửa tài liệu. Giờ thì một lần push chỉ sửa tài liệu không chạy gì cả, còn một lần push chỉ đụng tới ứng dụng thì bỏ qua bộ kiểm thử Django. Tôi đã chạy thử (dry run) job này trên năm commit thật: commit chỉ sửa tài liệu không chạy gì, commit chỉ sửa ứng dụng bỏ qua job Python, còn commit chỉ sửa backend thì bỏ qua job ứng dụng. Một ví dụ đã cắt gọn:

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      python: ${{ steps.diff.outputs.python }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - id: diff
        env:
          BEFORE: ${{ github.event.before }}
          HEAD: ${{ github.sha }}
        run: |
          FILES="$(git diff --name-only "$BEFORE" "$HEAD")"
          # Python's inputs include the non-Python files its tests read.
          PY='^(myapp/|myproject/|fixtures/shared\.json$|web/dist/|pyproject\.toml$|uv\.lock$)'
          if printf '%s\n' "$FILES" | grep -qE "$PY"; then
            echo "python=true" >> "$GITHUB_OUTPUT"
          else
            echo "python=false" >> "$GITHUB_OUTPUT"
          fi

  python:
    needs: changes
    if: needs.changes.outputs.python == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: uv run python manage.py test --parallel 4 --noinput

Workflow thật còn chạy toàn bộ khi không có khoảng commit nào để so sánh: một nhánh mới, một lần force push hoặc một lần chạy tay.

Điểm mù vẫn là điểm mù cũ, chỉ mang một hình dạng khác. testmon không nhìn thấy tệp JSON, và bộ lọc theo đường dẫn cũng vậy, trừ khi bạn liệt kê tệp đó ra. Vì thế, mẫu ở trên ghi rõ tên tệp fixture dùng chung, gói web đã dựng và trang được sinh ra, kèm một dòng chú thích nhắc người sửa nó giữ cho danh sách luôn đúng. Phiên bản cấp tệp thô hơn, nhưng khi sai thì sai về phía an toàn: chỉ cần bên phía Python có gì thay đổi là cả bộ kiểm thử sẽ chạy.

Có đáng không?
#

Với một bộ kiểm thử chạy mất vài phút thì có. Mục đích chưa bao giờ là bản thân mấy phút đó, mà là để một thay đổi được đánh giá bằng những bài kiểm thử nó có thể ảnh hưởng tới, nhờ vậy một yêu cầu nhỏ vẫn là yêu cầu nhỏ. Nó chỉ tốn một thư viện phụ thuộc, một cờ và một biến môi trường, không thay đổi gì trong cách viết bài kiểm thử, và khi đã đặt tracer viết bằng C, điều tệ nhất nó gây ra là chạy quá nhiều sau khi bạn đổi một thứ mà mọi nơi đều import. Nếu không đặt, trên Python 3.14 trở lên, nó có thể chạy quá ít mà vẫn báo xanh, và đó là lý do phép thử cố ý làm hỏng code kia quan trọng đến vậy. Cái giá thật sự nằm ở những gì nó không nhìn thấy, và script bọc sinh ra là để lo việc đó. Dù vậy, chỗ của testmon là vòng lặp làm việc trên laptop. CI vẫn chạy toàn bộ bộ kiểm thử mỗi khi phía Python có thay đổi, và đó mới là lần chạy tôi tin tưởng trước khi triển khai.

Bình luận

Bình luận được kiểm duyệt trước khi hiển thị.