`--changed` trong Vitest: Chạy Test Thông Minh Hơn
Vitest --changed chạy test dựa trên file thay đổi. Nhưng để tận dụng tối đa, unit test cần độc lập và dependency boundary rõ ràng. Phân tích chi tiết.
Trong một dự án có hàng trăm, thậm chí hàng nghìn unit test, việc mỗi lần sửa một dòng code lại chạy toàn bộ test suite có thể trở thành một khoản chi phí đáng kể.
Một trong những tính năng hữu ích của Vitest cho bài toán này là --changed.
Nhưng để thực sự tận dụng được --changed, chúng ta cần nhìn vấn đề sâu hơn: unit test nên được thiết kế như thế nào để một thay đổi nhỏ chỉ ảnh hưởng đến một phạm vi test nhỏ?
Câu trả lời nằm ở một nguyên tắc rất quan trọng:
Unit test nên kiểm thử một unit độc lập và kiểm soát những dependency nằm ngoài phạm vi của unit đó.
1. vitest --changed là gì?#
Thông thường, khi chạy:
vitestbashVitest sẽ tìm và chạy các test phù hợp trong project.
Trong khi đó:
vitest --changedbashcho phép Vitest chạy test dựa trên những file đã thay đổi.
Nếu không truyền giá trị:
vitest --changedbashVitest sẽ xét các thay đổi chưa commit, bao gồm cả staged và unstaged changes.
Ví dụ:
src/
├── user/
│ ├── user.service.ts
│ └── user.service.test.ts
├── payment/
│ ├── payment.service.ts
│ └── payment.service.test.ts
└── order/
├── order.service.ts
└── order.service.test.tstextTa chỉ sửa user.service.ts thì:
vitest --changedbashcó thể giúp tập trung việc chạy test vào phần liên quan đến thay đổi thay vì mặc định chạy toàn bộ suite.
Điều này đặc biệt hữu ích trong quá trình development khi test suite đã lớn.
2. Nhưng --changed đặt ra một câu hỏi quan trọng#
Có một vấn đề thú vị:
Tại sao chúng ta có thể tin rằng thay đổi ở user.service.ts không cần chạy lại tất cả test?
Bởi vì ta đang giả định rằng phạm vi ảnh hưởng tương đối rõ ràng.
Điều này rất gần với bản chất của unit test.
Một unit test tốt không nên có quá nhiều dependency thực tế chen vào quá trình kiểm thử.
Ví dụ, giả sử có service:
class UserService {
constructor(
private readonly userRepository: UserRepository,
) {}
async getUserName(id: string) {
const user = await this.userRepository.findById(id)
return user.name
}
}tsTa muốn test UserService.getUserName().
Một cách không tốt là để test trực tiếp database thật:
UserService
↓
UserRepository
↓
Database
↓
NetworktextLúc này test của UserService phụ thuộc vào implementation của repository, database, schema, dữ liệu, network, môi trường chạy test. Khi một trong những thứ này thay đổi, test có thể bị ảnh hưởng.
3. Unit test nên kiểm soát dependency bên ngoài#
Thay vì vậy, chúng ta có thể mock repository:
const userRepository = {
findById: vi.fn(),
}
const userService = new UserService(userRepository)
userRepository.findById.mockResolvedValue({
id: '1',
name: 'Alice',
})
const result = await userService.getUserName('1')
expect(result).toBe('Alice')tsỞ đây, UserService chỉ quan tâm đến contract userRepository.findById(id). Nó không cần biết bên dưới repository thực hiện SQL, PostgreSQL, Redis, HTTP API hay ORM.
Ta đã kiểm soát dependency bằng mock data. Do đó, unit test trở nên deterministic hơn.
4. Mock data không chỉ giúp test dễ viết#
Một hiểu lầm phổ biến là “Mock chỉ để test không cần database.”
Thực ra lợi ích quan trọng hơn là Mock giúp ta kiểm soát phạm vi ảnh hưởng của test.
Ta không muốn kết quả test phụ thuộc vào database đang có dữ liệu gì, database có đang chạy không, network có ổn định không, repository implementation có thay đổi không.
Thay vào đó, ta muốn test có một thế giới rất nhỏ:
Mock
↓
UserService → UserRepository
↓
{ name: "Alice" }textInput được kiểm soát. Dependency được kiểm soát. Output được kiểm soát. Đó chính là một trong những đặc điểm làm cho unit test trở nên đáng tin cậy.
5. Unit test không có nghĩa là “không được gọi dependency”#
Nói “Unit test không được phụ thuộc vào thành phần code khác” không có nghĩa là unit test không được gọi dependency.
Trong thực tế, một unit gần như luôn có dependency:
class OrderService {
constructor(
private readonly paymentService: PaymentService,
private readonly orderRepository: OrderRepository,
) {}
async createOrder(order: Order) {
await this.paymentService.charge(order.total)
return this.orderRepository.create(order)
}
}tsOrderService phụ thuộc vào PaymentService và OrderRepository.
Điều ta cần tránh là để implementation thật của chúng tham gia vào unit test. Thay vào đó:
Mock PaymentService
↓
OrderService
↑
Mock OrderRepositorytextTest có thể tập trung vào câu hỏi: “Khi payment thành công, OrderService có tạo order hay không?”
const paymentService = { charge: vi.fn() }
const orderRepository = { create: vi.fn() }
paymentService.charge.mockResolvedValue(undefined)
orderRepository.create.mockResolvedValue({ id: 'order-1' })
const service = new OrderService(paymentService, orderRepository)
await service.createOrder({ total: 100 })
expect(paymentService.charge).toHaveBeenCalledWith(100)
expect(orderRepository.create).toHaveBeenCalled()tsTest này không cần payment gateway thật, database thật, network — và quan trọng hơn, ta biết chính xác unit nào đang được kiểm thử.
6. Tại sao điều này liên quan đến --changed?#
--changed có ý nghĩa nhất khi test suite có dependency rõ ràng và unit test có tính cô lập tốt.
Hãy tưởng tượng một project có 500 test files. Developer sửa src/user/UserService.ts.
Nếu test suite được thiết kế tốt — phạm vi ảnh hưởng của thay đổi rõ ràng — ta chạy:
vitest --changedbashvà nhanh chóng nhận feedback.
Ngược lại, nếu unit test làm mọi thứ thông qua implementation thật (database, Redis, external API), một thay đổi nhỏ có thể tạo ảnh hưởng khó đoán. Tư duy “File A thay đổi → chạy test liên quan đến A” trở nên kém hiệu quả.
7. --changed không phải lý do để bỏ chạy toàn bộ test#
--changed phù hợp để feedback nhanh trong quá trình development, không thay thế full test suite.
Một workflow hợp lý:
Developer sửa code
↓
vitest --changed
↓
Feedback nhanh
↓
Commit
↓
CI
↓
Chạy full test suitetextLocal:
vitest --changedbashCI:
vitest runbash
--changedtối ưu developer feedback loop, không thay thế full test suite.
8. Chạy test dựa trên commit#
--changed không chỉ dành cho working tree hiện tại.
vitest --changed HEAD~1
vitest --changed 09a9920
vitest --changed origin/developbashHữu ích khi muốn kiểm tra thay đổi so với một branch hay commit cụ thể:
origin/develop
│
│ diff
↓
current branch
│
↓
related teststext9. Coverage cũng có thể hưởng lợi#
Kết hợp với coverage:
vitest --changed --coveragebashCoverage report chỉ chứa những file liên quan đến các thay đổi — feedback nhanh hơn, report tập trung hơn.
10. --changed không có nghĩa là dependency graph hoàn hảo#
--changed không phải lời hứa “Chỉ cần test file vừa thay đổi là đủ.”
Vitest có forceRerunTriggers. Nếu file trong danh sách đó thay đổi, toàn bộ test suite được chạy.
Thay đổi ở Vitest config và package.json mặc định cũng khiến toàn bộ suite chạy lại — điều này hợp lý vì rất khó đảm bảo chỉ một nhóm test bị ảnh hưởng.
11. Unit test tốt thường có “biên giới” rõ ràng#
OUTSIDE
│
┌─────────▼─────────┐
│ UNIT │
│ Business Logic │
└─────────┬─────────┘
│
OUTSIDEtextNhững thứ bên ngoài unit (database, HTTP API, filesystem, message queue, external service, thời gian, random, environment) nên được kiểm soát khi cần thiết:
vi.mock('./payment-service')
vi.useFakeTimers()tsMục đích không phải “mock tất cả” mà là kiểm soát những dependency không thuộc trách nhiệm của unit đang được test.
12. Không phải dependency nào cũng nên mock#
Nếu mock mọi thứ (A → mock B → mock C → mock D), test có thể quá xa implementation thực tế.
Nguyên tắc không nên là “Unit test = mock tất cả.” Mà là cô lập unit đang kiểm thử và kiểm soát các dependency có thể làm test không deterministic hoặc kéo theo quá nhiều implementation bên ngoài.
13. Unit test, integration test và E2E có vai trò khác nhau#
E2E
/ \
Integration
/ \
Unit Teststext- Unit test: Kiểm tra business logic trong phạm vi nhỏ, nhanh, deterministic, feedback nhanh.
- Integration test: Kiểm tra nhiều thành phần kết hợp (Service → Repository → Test Database).
- E2E: Kiểm tra behavior từ góc nhìn người dùng (Browser → API → Service → Database).
Không nên cố biến tất cả thành unit test.
14. Tư duy quan trọng: kiểm soát ảnh hưởng, không loại bỏ dependency#
Một unit test không cần sống trong thế giới không có dependency. Nó chỉ cần biết dependency nào thuộc phạm vi kiểm thử và dependency nào cần được kiểm soát.
┌─────────────────────────────┐
│ UserService │
│ REAL CODE │
└──────────────┬──────────────┘
│
controlled boundary
│
┌───────┴───────┐
│ │
Mock Mock
Repository EmailServicetextĐây chính là điều giúp unit test giữ được tính độc lập.
15. Từ unit test tốt đến --changed#
Unit test độc lập
↓
Dependency được kiểm soát
↓
Phạm vi ảnh hưởng rõ ràng
↓
Test suite dễ phân vùng
↓
--changed hữu ích hơn
↓
Feedback nhanh hơn
↓
Developer iteration nhanh hơntext--changed khuyến khích chúng ta suy nghĩ về dependency graph của test suite — một codebase có unit test tốt thường có boundary rõ ràng giữa business logic và các dependency bên ngoài.
Kết luận#
vitest --changed là công cụ đáng dùng khi project có test suite lớn. Nhưng để nó thực sự phát huy tác dụng, điều quan trọng nằm ở cách chúng ta viết unit test.
Một unit test tốt nên:
- tập trung vào một unit và behavior cụ thể;
- kiểm soát dependency nằm ngoài phạm vi của unit;
- dùng mock/stub/test double khi dependency thật tạo ra side effect hoặc làm test không deterministic;
- không phụ thuộc vào database, network hay dữ liệu môi trường thật;
- để integration test và E2E đảm nhiệm kiểm tra sự kết hợp giữa các thành phần.
Unit test càng độc lập, dependency boundary càng rõ, thì việc chạy test có chọn lọc càng đáng tin cậy và feedback càng nhanh.
Và đây chính là lúc vitest --changed trở nên thực sự hữu ích — không phải vì chúng ta muốn test ít hơn, mà vì chúng ta muốn test đúng phạm vi ở đúng thời điểm.