Tuần này trên Hacker News có bài AI is forcing us to pay off 40 years of technical debt (289 điểm). Mình đồng ý với tiêu đề, nhưng thực tế ở các team mình từng làm thì hơi khác: AI không trả nợ kỹ thuật giúp bạn. Nó làm bạn dám đụng vào những module mà trước giờ ai cũng né. Mà dám đụng thì cũng dễ làm hỏng hơn. Copilot hay Claude viết lại một hàm 400 dòng trong 10 giây, code nhìn sạch hơn hẳn, review lướt thấy hợp lý. Rồi hai tuần sau khách hàng ở Đà Nẵng báo phí ship sai 3.000đ, vì bản cũ có một nhánh if xử lý làm tròn mà không ai biết là nó tồn tại.
Cái thiếu ở đây là một cách chứng minh rằng code mới làm đúng y như code cũ, kể cả những chỗ sai. Kỹ thuật cho việc này đã có từ lâu, Michael Feathers gọi nó là characterization tests (còn gọi là golden master testing). Bài này mình chia sẻ cách mình áp dụng nó với Python 3.12, pytest 8.x, syrupy 4.x và mutmut 3.x trước khi cho AI refactor bất cứ thứ gì.
Characterization test khác unit test ở chỗ nào?
Unit test thông thường kiểm tra code nên làm gì, dựa trên spec. Còn characterization test ghi lại code đang làm gì, không quan tâm hành vi đó đúng hay sai. Với legacy code thì spec thường không có, hoặc có mà đã lỗi thời từ 2019.
Quy trình gồm 4 bước:
- Tạo một tập input đủ rộng, bao phủ càng nhiều nhánh càng tốt.
- Chạy code cũ, lưu toàn bộ output lại làm snapshot (golden master).
- Cho AI (hoặc chính bạn) refactor.
- Chạy lại, so output với snapshot. Lệch một byte thôi cũng fail.
flowchart LR A[Legacy code] --> B[Sinh input grid] B --> C[Ghi snapshot] C --> D{Mutation test đủ mạnh?} D -- Chưa --> B D -- Rồi --> E[AI refactor từng bước nhỏ] E --> F{Snapshot khớp?} F -- Khớp --> G[Commit] F -- Lệch --> H[git reset và thu nhỏ bước] H --> E Điểm quan trọng nhất là bước D, phần lớn mọi người bỏ qua nó. Mình sẽ nói kỹ ở phần sau.
Ghi snapshot với pytest và syrupy
Giả sử bạn có hàm tính phí ship kiểu legacy điển hình: nhiều tham số, đầy if/elif, có magic number:
# src/legacy/shipping.py def calc_fee(weight_kg, province, is_member, coupon=None): fee = 15000 if weight_kg > 2: fee += (weight_kg - 2) * 5000 if province not in ('HN', 'HCM'): fee = fee * 1.3 if province == 'DN': fee = round(fee, -3) # ai đó thêm năm 2021, không ai nhớ vì sao if is_member: fee *= 0.9 if coupon == 'FREESHIP' and weight_kg <= 5: return 0 return int(fee) Đừng viết từng test case bằng tay. Hãy tạo tích Descartes của các giá trị biên, rồi để syrupy snapshot toàn bộ kết quả:
# tests/test_shipping_characterization.py import itertools import pytest from legacy.shipping import calc_fee WEIGHTS = [0, 0.5, 2, 2.01, 3.7, 5, 5.01, 20] PROVINCES = ['HN', 'HCM', 'DN', 'CT', '', None] MEMBER = [True, False] COUPONS = [None, 'FREESHIP', 'freeship', 'INVALID'] CASES = list(itertools.product(WEIGHTS, PROVINCES, MEMBER, COUPONS)) def run_safely(*args): # Exception cũng là hành vi, phải ghi lại luôn try: return ('ok', calc_fee(*args)) except Exception as e: return ('raise', type(e).__name__) def test_calc_fee_golden_master(snapshot): results = {repr(c): run_safely(*c) for c in CASES} assert results == snapshot Cài đặt và chạy:
pip install 'pytest>=8' 'syrupy>=4' 'mutmut>=3' coverage # Lần đầu: ghi golden master (384 case) vào tests/__snapshots__/ pytest tests/test_shipping_characterization.py --snapshot-update # Kiểm tra branch coverage, nhắm tới 100% cho module sắp refactor coverage run --branch -m pytest tests/test_shipping_characterization.py coverage report -m --include='src/legacy/shipping.py' Có hai điều cần lưu ý. Thứ nhất, nhớ commit file snapshot vào git, nó chính là spec của bạn. Thứ hai, hãy đưa vào những input “vô lý” như None hay chuỗi rỗng. Code production thường nhận đúng những giá trị đó, và AI rất hay “sửa giúp” bằng cách thêm validation. Thêm validation nghĩa là hành vi đã thay đổi.
Coverage 100% chưa đủ: dùng mutation testing để kiểm tra lại test
Đây là bước D trong sơ đồ. Coverage chỉ cho biết dòng code đã được chạy qua, chứ không cho biết test có phát hiện được khi dòng đó bị thay đổi hay không. Ví dụ nếu WEIGHTS không có giá trị 2.01 thì phép so sánh > 2 bị đổi thành >= 2 vẫn pass, dù coverage vẫn báo 100%.
mutmut tự động tạo ra các “đột biến” trong code (đổi > thành >=, 0.9 thành 1.9, xóa return...) rồi chạy test cho từng đột biến. Đột biến nào sống sót tức là test của bạn có lỗ hổng.
# pyproject.toml # [tool.mutmut] # paths_to_mutate = ['src/legacy/'] # tests_dir = ['tests/'] mutmut run mutmut results # liệt kê các mutant còn sống mutmut show <mutant-id> # xem diff cụ thể của mutant đó Với mỗi mutant sống sót, bạn bổ sung input vào grid cho tới khi nó bị “giết”. Với ví dụ trên, lần đầu mình chạy thì còn sống 3 mutant: round(fee, -3) thành round(fee, -2) (do thiếu weight khiến fee ở DN lẻ hàng trăm), <= 5 thành < 5, và 0.9 thành 0.91. Thêm WEIGHTS = [..., 2.3, 4.9] là xử lý xong cả ba. Mục tiêu nên đặt là mutation score từ 95% trở lên trước khi bắt đầu refactor.
Cho AI refactor theo vòng lặp ngắn
Đã có lưới an toàn rồi thì đừng bảo AI “viết lại toàn bộ module cho sạch”. Diff 400 dòng mà fail thì bạn sẽ không biết bước nào làm hỏng. Mình dùng một vòng lặp đơn giản: mỗi bước là một thay đổi nhỏ (extract function, đổi tên, thay magic number bằng constant), test pass thì commit, fail thì revert.
sequenceDiagram participant Dev participant AI as AI assistant participant T as pytest + syrupy participant G as git Dev->>AI: Extract hàm tính phụ phí tỉnh, giữ nguyên hành vi AI-->>Dev: Patch nhỏ Dev->>T: Chạy snapshot test alt Khớp T-->>G: git commit else Lệch T-->>G: git checkout -- src/ Dev->>AI: Gửi diff snapshot và yêu cầu làm lại end Đoạn script nhỏ mình hay dùng:
#!/usr/bin/env bash # safe-step.sh: chạy sau mỗi lần apply patch của AI set -euo pipefail MSG=${1:-'refactor: small step'} if pytest -q tests/test_shipping_characterization.py; then git add -A && git commit -qm "$MSG" echo 'OK: committed' else git diff --stat git checkout -- src/ echo 'FAIL: reverted. Hãy thu nhỏ bước refactor lại.' exit 1 fi Một mẹo khi prompt: hãy dán luôn đoạn diff snapshot bị lệch cho AI xem. Output dạng 'DN': 22000 → 21450 giúp model hiểu chính xác nó đã làm mất nhánh làm tròn nào, hiệu quả hơn nhiều so với câu “test fail rồi, sửa lại đi”.
Khi refactor xong và code đã đọc được, lúc đó mới là lúc sửa bug có chủ đích. Ví dụ nếu business xác nhận việc 'freeship' viết thường không được giảm giá là bug, bạn sửa code, chạy --snapshot-update, rồi review diff của file snapshot trong PR. Reviewer nhìn vào đó sẽ thấy rõ những case nào thay đổi hành vi, thay vì phải tự suy ra từ diff code.
Kết luận
AI giúp chi phí viết lại code gần như bằng 0, nhưng chi phí để xác minh code mới vẫn y như trước. Characterization test là cách rẻ nhất để giảm phần chi phí xác minh đó. Checklist mình áp dụng cho mọi module legacy trước khi cho AI đụng vào:
- Snapshot trước, refactor sau. Dùng
itertools.productđể sinh input grid quanh các giá trị biên, có cảNone, chuỗi rỗng và exception. - Branch coverage 100% trên module mục tiêu (
coverage run --branch). - Mutation score ≥ 95% với
mutmut. Mutant nào còn sống thì thêm input cho tới khi nó bị giết. - Mỗi bước refactor nhỏ là một commit, dùng script tự revert khi fail. Không chấp nhận patch “viết lại toàn bộ”.
- Tách refactor ra khỏi bug fix. Refactor thì snapshot phải giữ nguyên. Sửa bug thì cập nhật snapshot và review diff snapshot trong PR.
- Refactor xong thì viết thêm unit test theo spec thật, và có thể xóa bớt các snapshot quá chi tiết để test không bị giòn.
Nợ kỹ thuật 40 năm không trả xong trong một sprint. Nhưng nếu mỗi module bạn động vào đều có một golden master đi kèm, thì ít nhất bạn sẽ không tạo thêm nợ mới khi đang trả nợ cũ.
