Trước khi Để AI Tác Động Vào Codebase, Hãy Viết Test Ngay
Bài viết chia sẻ kinh nghiệm thực tế khi áp dụng AI coding agents vào dự án Laravel 9 cũ kỹ. Tác giả khẳng định việc viết kiểm thử (test) là bước tiên quyết để biến AI thành trợ lý đắc lực, giúp tránh những lỗi "tự tin nhưng sai lầm" và đảm bảo chất lượng mã nguồn.

Kho chứa mã nguồn mà chẳng ai muốn đụng vào
Tôi đã kế thừa một ứng dụng Laravel 9 — một phần mềm nghiệp vụ với các quy trình làm việc phức tạp, kiểm soát quyền dựa trên vai trò và các tích hợp bên thứ ba. Nó vẫn hoạt động. Người dùng phụ thuộc vào nó. Và nó có chính xác... bằng không kiểm thử tự động.
Không test. Không linting (kiểm tra mã). Không CI pipeline. Chỉ có một kho git, một lời cầu nguyện và lệnh git push origin main.
Tôi biết mình muốn sử dụng AI coding agents (cụ thể là Claude Code) để tăng tốc độ phát triển. Nhưng có một vấn đề khi để AI viết mã trên một codebase không có kiểm thử: bạn sẽ nhận lại những thứ hỗn độn. Hỗn độn cực nhanh. Những thứ sai lầm một cách đầy tự tin.
Một tác nhân AI không có kiểm thử giống như một kỹ sư mới vào nghề không có người review code. Nó sẽ tạo ra thứ trông có vẻ đúng, biên dịch tốt, và sau đó sập đổ ở môi trường sản xuất (production) lúc 2 giờ sáng.
Vì vậy, trước khi tôi để tác nhân AI chạm vào một dòng code nào, tôi đã viết test.
Kiểm thử là cương reins, không phải yên ngựa
Có một quan niệm sai lầm phổ biến rằng kiểm thử là thứ bạn thêm vào sau khi đã xây dựng xong ứng dụng. Một tính năng "có thì tốt". Một ô tích để báo cáo độ phủ mã.
Khi bạn làm việc với một tác nhân AI, kiểm thử là thành phần quan trọng nhất trong kho chứa của bạn. Quan trọng hơn cả chính mã nguồn đó. Đây là lý do:
Kiểm thử là thông số kỹ thuật duy nhất có thể được kiểm tra bằng máy về những gì code của bạn nên làm.
Tài liệu có thể nói dối. Chú thích bị lỗi thời. Nhưng một bài test thất bại là một sự thật không thể chối cãi. Khi bạn bảo tác nhân "triển khai tính năng X" và nó viết mã vượt qua tất cả các bài test hiện có cùng với các bài test mới bạn viết cho X — bạn có bằng chứng thực nghiệm rằng nó đã hoạt động. Không phải cảm tính. Mà là bằng chứng.
Cuốn Kỹ thuật phần mềm hiện đại (Modern Software Engineering) của Dave Farley nhấn mạnh điểm này: tối ưu hóa cho phản hồi nhanh. Kiểm thử là cơ chế phản hồi nhanh nhất mà bạn có. Nó biến câu hỏi "cái này có hoạt động không?" từ một cuộc điều tra thủ công thành một lệnh: make test.
Bắt đầu từ con số 0: Kiểm thử đặc tả (Characterization Tests)
Bạn không thể áp dụng TDD (Phát triển hướng theo kiểm thử) cho một codebase cũ (legacy) vốn đã tồn tại. Mã đã ở đó, làm một việc gì đó, và người dùng phụ thuộc vào việc đó. Vì vậy, bạn bắt đầu với các kiểm thử đặc tả (characterization tests).
Một kiểm thử đặc tả không khẳng định code nên làm gì. Nó khẳng định code đang làm gì. Bạn đang "khóa chặt" hành vi hiện tại để có thể refactor một cách an toàn sau này.
Đối với ứng dụng Laravel, điều này có nghĩa là các bài test tính năng HTTP:
/** @test */
public function admin_can_view_orders_index()
{
$admin = UserFactory::admin()->create();
$this->actingAs($admin);
$response = $this->get('/orders');
$response->assertOk();
$response->assertViewIs('orders.index');
}
Không hào nhoáng chút nào. Nhưng giờ thì bạn biết: nếu bạn thay đổi gì đó trong OrdersController@index và bài test này thất bại, bạn đã phá vỡ hành vi hiện có.
Tôi bắt đầu với những controller rủi ro cao nhất — những thứ xử lý tiền, đơn hàng và dữ liệu người dùng — và bọc chúng bằng các bài test tính năng. Tôi đã sử dụng Claude để giúp viết các bài kiểm thử đặc tả ban đầu. Tôi sẽ chỉ cho nó một controller, mô tả hành vi mong đợi, và yêu cầu nó tạo ra khung test. Tôi vẫn review mọi bài test và điều chỉnh các xác nhận (assertions), nhưng Claude đã tăng tốc phần nhàm chán — đọc code cũ và chuyển dịch "endpoint này làm gì?" thành các xác nhận có thể thực thi được. Nó biến một tuần làm việc thủ công thành chỉ vài ngày.
Nó tốn thời gian. Nó nhàm chán ngay cả khi có trợ giúp. Và đó là khoản đầu tư tốt nhất tôi đã thực hiện trong toàn bộ dự án.
Vấn đề cơ sở dữ liệu kiểm thử
Cài đặt test ban đầu sử dụng SQLite. Khởi động nhanh, không cần cấu hình, và... sai lệch một cách tinh tế.
SQLite không thực thi các ràng buộc giống như MySQL. Khóa ngoại hoạt động khác nhau. Các hàm ngày tháng khác nhau. Tìm kiếm văn bản đầy đủ (fulltext search) không tồn tại. Các bài test đang vượt qua trên một cơ sở dữ liệu không khớp với môi trường sản xuất.
Tôi đã chuyển sang MySQL 8.0 chạy trong Docker với tmpfs (lưu trữ trên RAM) để tăng tốc độ. Các bài test giờ chạy trên cùng một engine với production. Một số bài test bị lỗi. Tốt lắm! Đó là những bài test đang nói dối tôi.
Giao diện UserFactory
Mọi bài test đều cần người dùng với các vai trò và quyền hạn cụ thể. Các model factories có sẵn của Laravel thì ổn cho các trường hợp đơn giản, nhưng ứng dụng này có hệ thống vai trò/quyền hạn phức tạp. Việc tạo người dùng với vai trò, quyền hạn, tổ chức và các mối quan hệ đúng đắn tốn hơn 10 dòng code thiết lập cho mỗi bài test.
Vì vậy, tôi đã xây dựng một UserFactory kiểm thử với API trôi chảy (fluent API):
use Facades\Tests\Setup\UserFactory;
// Một người dùng admin, được cấu hình đầy đủ
$admin = UserFactory::admin()->create();
// Một người dùng thường với quyền hạn cụ thể
$user = UserFactory::withPermissions('orders.manage')->create();
// Một admin tổ chức trong một tổ chức cụ thể
$orgAdmin = UserFactory::orgAdmin()
->withOrganization($org)
->create();
Việc này làm được hai điều:
- Làm cho bài test dễ đọc — bạn có thể quét qua phần thiết lập (Arrange) và ngay lập tức biết ai đang làm gì.
- Làm cho việc viết thiết lập test trở nên dễ dàng cho tác nhân AI — API này dễ khám phá và khó sử dụng sai.
Điểm thứ hai quan trọng hơn bạn nghĩ. Khi Claude viết một bài test, nó cần tạo người dùng. Nếu cách đúng đắn để làm việc đó bị chôn vùi trong 10 dòng trạng thái factory và gán vai trò, tác nhân sẽ đoán sai. Nếu đó là UserFactory::admin()->create(), tác nhân sẽ làm đúng mỗi lần.
Thiết kế cơ sở hạ tầng test cho người dùng đúng đắn "ngu ngốc" nhất — vì đó là cách tác nhân sẽ sử dụng nó.
TDD như một Giao thức Giao tiếp
Đây là nơi mọi thứ trở nên thú vị. Một khi tôi có bộ test mà tôi tin tưởng, tôi bắt đầu sử dụng TDD không chỉ như một thực hành phát triển, mà như một giao thức giao tiếp với Claude.
Quy trình làm việc:
- Tôi viết các bài test thất bại. Đây là cách tôi nói cho tác nhân chính xác những gì tôi muốn, bằng một ngôn ngữ không mơ hồ.
- Claude triển khai thay đổi nhỏ nhất để làm cho test vượt qua.
- Tôi review việc triển khai đó.
Các bài test là thông số kỹ thuật. Chúng không phải văn xuôi mà tác nhân có thể hiểu sai. Chúng là các xác nhận có thể thực thi. Khi tôi viết:
/** @test */
public function user_cannot_approve_their_own_order()
{
$user = UserFactory::withPermissions('orders.manage')->create();
$order = Order::factory()->pending()->for($user)->create();
$this->be($user, 'sanctum');
$this->postJson("/api/orders/{$order->id}/approve")
->assertForbidden();
}
Không có sự mơ hồ nào cả. Tác nhân biết chính xác ý nghĩa của "người dùng không thể phê duyệt đơn hàng của chính họ" về mặt mã trạng thái HTTP, định tuyến và quy tắc ủy quyền.
Đây là nhận thức then chốt từ toàn bộ chuỗi bài này: kiểm thử không chỉ là bảo đảm chất lượng. Chúng là giao diện chính giữa bạn và tác nhân AI của bạn.
Con số biết nói
Bắt đầu từ con số 0, tôi đã xây dựng lên hơn 2.700 bài test PHP và bộ test Vitest ngày càng mở rộng cho frontend React. Bộ suite đầy đủ mất 10–20 phút (MySQL rất kỹ lưỡng, không nhanh). Nhưng khoản đầu tư đó đã mang lại cho tôi thứ mà không một lượng review code nào có thể làm được:
Sự tự tin.
Sự tự tin rằng khi Claude thực hiện một thay đổi, tôi có thể xác minh nó hoạt động với một lệnh duy nhất. Sự tự tin rằng việc refactor sẽ không phá vỡ mọi thứ. Sự tự tin rằng 200 commit tiếp theo sẽ được triển khai an toàn.
Và sự tự tin đó là điều khiến mọi thứ trong phần còn lại của chuỗi bài này trở nên khả thi.
Trên thực tế nó trông như thế nào
Đây là mục tiêu trong Makefile chạy bộ suite đầy đủ:
test:
docker compose exec app php artisan test $(ARGS)
Đơn giản. Một lệnh. Mọi nhà phát triển (và mọi tác nhân) đều sử dụng cùng một lệnh. Không cần thiết lập đặc biệt, không có tình huống "chạy trên máy của tôi thì được".
Cấu hình test nằm trong .env.testing, cơ sở dữ liệu chạy trong Docker, và toàn bộ thứ này có thể tái tạo trên bất kỳ máy nào hoặc trong CI. Nếu nó vượt qua locally, nó sẽ vượt qua trong pipeline.
Bài học cốt lõi
Nếu bạn đang nghĩ đến việc sử dụng các tác nhân AI trên codebase của mình, hãy làm những việc này trước:
- Chạy các bài test của bạn trên một cơ sở dữ liệu thực — không phải SQLite, không phải mocks, mà là engine thực tế bạn chạy trong production.
- Tạo một factory người dùng test với API đơn giản, trôi chảy giúp rõ ràng cách tạo người dùng với các vai trò đúng.
- Bọc mã rủi ro cao nhất của bạn trong các kiểm thử đặc tả — khóa hành vi hiện tại trước khi bạn thay đổi bất cứ thứ gì.
- Làm cho lệnh
make testhoạt động — một lệnh, không cấu hình, kết quả giống nhau mỗi lần.
Bạn không thể tin tưởng một tác nhân mà bạn không thể xác minh. Kiểm thử là cách bạn xác minh.
Bài viết liên quan

Phần mềm
Anthropic ra mắt Claude Opus 4.7: Nâng cấp mạnh mẽ cho lập trình nhưng vẫn thua Mythos Preview
16 tháng 4, 2026

Công nghệ
Qwen3.6-35B-A3B: Quyền năng Lập trình Agentic, Nay Đã Mở Cửa Cho Tất Cả
16 tháng 4, 2026

Công nghệ
Spotify thắng kiện 322 triệu USD từ nhóm pirate Anna's Archive nhưng đối mặt với bài toán thu hồi
16 tháng 4, 2026
