Refactor an toàn: characterization test và commit tách hành vi Trung cấp
Thời gian đọc ước tính: 6 phút
Cần đọc trước
Trang này trả lời: code “before” của ví dụ xuyên suốt sai ở đâu, và làm sao refactor nó mà chứng minh được không đổi hành vi.
Ví dụ xuyên suốt: CheckoutService “before”
Phần tiêu đề “Ví dụ xuyên suốt: CheckoutService “before””Kiểu code bạn gặp ở review thật: chạy được, có test e2e cho happy path, và đã gây sự cố.
@Injectable()export class CheckoutService { constructor(private readonly db: Pool) {}
async checkout(userId: string, items: any[], coupon: string | null, express: boolean, dryRun: boolean) { if (items && items.length > 0) { const res = await this.db.query('SELECT id, email FROM users WHERE id = $1', [userId]); const user = res.rows[0]; if (user) { let total = 0; for (const i of items) total += i.price * i.qty; if (coupon) { if (coupon === 'VIP10' && new Date().getHours() < 12) total = total * 0.9; } if (total > 500000) { // free ship } else total += express ? 50000 : 30000; if (!dryRun) { try { await fetch('https://pay.example.com/charges', { method: 'POST', body: JSON.stringify({ userId, total }), }); } catch (e) { console.log(e); } // await this.mailer.sendReceipt(user.email, total); await this.db.query('INSERT INTO orders (user_id, total) VALUES ($1, $2)', [userId, total]); } return total; } else { throw new Error('error'); } } return 0; }}Đọc như reviewer, đếm được ít nhất bảy vấn đề:
| # | Chỗ | Hậu quả thật |
|---|---|---|
| 1 | catch (e) { console.log(e) } rồi vẫn INSERT |
Mất mạng tới cổng thanh toán → order được lưu mà chưa thu tiền |
| 2 | Không kiểm tra response.ok |
fetch() chỉ reject khi lỗi mạng; HTTP 402/500 vẫn resolve → cũng lưu order |
| 3 | new Date().getHours() |
Giờ theo timezone của process; container chạy UTC thì “trước 12 giờ” lệch 7 tiếng so với giờ VN |
| 4 | total * 0.9 |
Ra số lẻ như 587293.2000000001 VND (xem demo bên dưới) |
| 5 | express, dryRun |
checkout(id, items, null, true, false): không ai đọc được call site |
| 6 | 500000, 50000, 30000 |
Marketing đổi ngưỡng freeship phải grep số |
| 7 | throw new Error('error'), return 0 |
Client không phân biệt user không tồn tại, giỏ rỗng hay lỗi hệ thống |
Mỗi lỗi này có cặp BAD/GOOD riêng ở trang Lỗi hay gặp.
Quy tắc số một: refactor không đổi hành vi
Phần tiêu đề “Quy tắc số một: refactor không đổi hành vi”- Refactor là đổi cấu trúc mà không đổi hành vi.
- Sửa bug là đổi hành vi.
- Hai việc đi chung một commit thì reviewer không biết diff nào là cố ý, và
git revertkhông gỡ riêng được.
Đang tải diagram…
- Viết characterization test trước. Test ghim hành vi hiện tại, kể cả hành vi sai (số lẻ VND). Muốn test được, tạo một seam nhỏ: truyền
nowvào thay vì gọinew Date()bên trong. - Mỗi commit refactor phải giữ test xanh mà không sửa test.
- Commit sửa hành vi đi sau, đổi test có chủ đích, message ghi rõ
fix:và lý do.
Demo: golden master bắt được refactor “trông tương đương”
Phần tiêu đề “Demo: golden master bắt được refactor “trông tương đương””Thư mục examples/src/clean-code/ có:
legacy-pricing.ts: phần tính tiền của code cũ.pricing.ts: bản đã refactor.characterization.test.ts: sinh 10.000 giỏ hàng ngẫu nhiên với seed cố định, so output hai bên. Thêm một biến thể “vô hại” đổitotal * 0.9thànhtotal * 90 / 100.
npx --prefix examples ts-node examples/src/clean-code/characterization.test.tsOutput thật:
quoteTotalVnd: 10000/10000 case khớp code cũquoteTotalVndPercent: 201 case lệch; ví dụ đầu tiên: legacy=587293.2000000001 percent=587293.2Hai bài học từ hai dòng này:
- Đổi công thức “tương đương về toán” vẫn đổi kết quả dấu phẩy động. Không có golden master thì nó lọt review.
- Code cũ trả
587293.2000000001VND. Đó là bug, nhưng sửa nó (làm tròn) là commit 5, không phải lúc refactor.
Khi nào áp dụng / ngoại lệ
Phần tiêu đề “Khi nào áp dụng / ngoại lệ”| Tình huống | Làm gì |
|---|---|
| Code sống lâu, hay đổi (domain logic, use case, adapter tích hợp) | Áp dụng: đây là nơi chi phí đọc/sửa tích luỹ |
| Sắp sửa một phần code | Refactor phần đó trong commit riêng, trước khi thêm tính năng |
| Muốn mở PR “clean toàn bộ module” | Không, nếu không có lý do nghiệp vụ |
| Script dùng một lần, spike, migration chạy một lần | Ngoại lệ, không cần |
| Hot path đã đo được là chậm | Ưu tiên hiệu năng, comment lý do |
| Code không có test, không hiểu hết hành vi | Viết characterization test trước, chưa refactor gì |
Tự kiểm tra
- Đổi
total * 0.9thànhtotal * 90 / 100có phải refactor thuần không? Demo ở trên trả lời thế nào? - Vì sao
fetch()trong code “before” vẫn dẫn tới lưu order kể cả khi cổng thanh toán trả HTTP 500?
Tiếp theo: Hàm và side effect