Vitest `--changed`: Smarter Test Selection for CI
Use Vitest --changed to run tests based on changed files. But to get the most out of it, write independent unit tests with clear dependency boundaries.
In a project with hundreds or even thousands of unit tests, running the entire test suite every time you change a single line of code can become a significant cost.
One useful feature Vitest provides for this problem is --changed.
However, to really take advantage of --changed, we need to look at a deeper question:
How should unit tests be designed so that a small code change only affects a small and predictable set of tests?
The answer lies in an important principle:
A unit test should test an independent unit and control the dependencies outside that unit’s scope.
1. What is vitest --changed?#
Normally, when you run:
vitestbashVitest finds and runs the relevant tests in the project.
With:
vitest --changedbashVitest can run tests based on changed files.
When no value is provided, Vitest considers uncommitted changes, including both staged and unstaged changes.
For example:
src/
├── user/
│ ├── user.service.ts
│ └── user.service.test.ts
├── payment/
│ ├── payment.service.ts
│ └── payment.service.test.ts
└── order/
├── order.service.ts
└── order.service.test.tstextIf you only modify user.service.ts, then:
vitest --changedbashcan help focus testing on the parts related to that change instead of running the entire suite by default.
This becomes particularly useful as the test suite grows.
2. But --changed raises an important question#
Why can we assume that changing user.service.ts does not require running every test?
Because we assume the units that depend on it have a relatively clear scope of impact.
This is closely related to the nature of unit testing.
A good unit test should not have too many real dependencies involved in the test.
Suppose we have this service:
class UserService {
constructor(
private readonly userRepository: UserRepository,
) {}
async getUserName(id: string) {
const user = await this.userRepository.findById(id)
return user.name
}
}tsWe want to test UserService.getUserName(). A poor approach uses a real database:
UserService → UserRepository → Database → NetworktextThis means the test depends on the repository implementation, database, schema, data, network, and environment. Changes to any of these can break the test.
3. Unit Tests Should Control External Dependencies#
Instead, mock the 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')tsUserService only cares about the contract userRepository.findById(id). It does not need to know whether the repository uses SQL, PostgreSQL, Redis, HTTP API, or ORM.
We control the dependency with mock data. As a result, the unit test becomes more deterministic.
4. Mock Data Is Not Just About Making Tests Easier#
A common misconception is “Mocking is just a way to avoid using a database.”
The more important benefit is mocking lets us control the scope of a test’s dependencies.
Mock
↓
UserService → UserRepository
↓
{ name: "Alice" }textInput controlled. Dependency controlled. Output controlled. This makes unit tests reliable.
5. Unit Testing Does Not Mean “You Must Not Call Other Components”#
In practice, a unit almost always has dependencies:
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)
}
}tsWhat we avoid is using real implementations in the unit test:
Mock PaymentService
↓
OrderService
↑
Mock OrderRepositorytextconst 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()tsNo real payment gateway, database, or network needed. We know exactly which unit is being tested.
6. Why Does This Matter for --changed?#
--changed becomes most useful when the test suite has clear dependencies and unit tests are well isolated.
With 500 test files, a developer changes src/user/UserService.ts. If dependencies are clear:
UserService → mock Repository → mock EmailServicetextWe run vitest --changed and get quick feedback.
If tests rely on real implementations (database, Redis, external API), a small change can have unpredictable effects. The assumption “File A changed → run tests related to A” becomes unreliable.
7. --changed Does Not Mean Stop Running the Full Suite#
--changed is for fast feedback during development, not a replacement for the full test suite.
Developer changes code → vitest --changed → Fast feedback → Commit → CI → Run full suitetextLocal:
vitest --changedbashCI:
vitest runbash
--changedoptimizes the developer feedback loop; it does not replace the full test suite.
8. Run Tests Based on a Commit#
--changed is not limited to the current working tree:
vitest --changed HEAD~1
vitest --changed 09a9920
vitest --changed origin/developbashUseful for checking changes against a specific branch or commit.
9. Coverage Benefits Too#
vitest --changed --coveragebashCoverage report contains only files related to changes — faster, more focused feedback.
10. --changed Doesn’t Mean Your Dependency Graph Is Perfect#
--changed is not a promise that “only the changed file’s tests need to run.”
Vitest has forceRerunTriggers. Changes to Vitest config or package.json can trigger the full suite — this is sensible since the impact is hard to isolate.
11. Good Unit Tests Have Clear Boundaries#
OUTSIDE
│
┌─────────▼─────────┐
│ UNIT │
│ Business Logic │
└─────────┬─────────┘
│
OUTSIDEtextThings outside the unit (database, HTTP API, filesystem, message queues, time, random, environment) should be controlled when appropriate:
vi.mock('./payment-service')
vi.useFakeTimers()tsThe goal is control dependencies outside the unit’s responsibility, not mock everything.
12. Not Every Dependency Should Be Mocked#
If we mock everything, tests become detached from real implementation.
The principle: isolate the unit being tested and control dependencies that could make the test non-deterministic or pull in too much external implementation.
13. Unit, Integration, and E2E Tests Have Different Roles#
E2E
/ \
Integration
/ \
Unit Teststext- Unit: fast, deterministic, easy debug, fast feedback.
- Integration: multiple components together (Service → Repository → Test Database).
- E2E: user’s perspective (Browser → API → Service → Database).
Not every test should be a unit test.
14. Control Impact, Don’t Eliminate Dependencies#
A unit test only needs to know which dependencies are within scope and which should be controlled:
┌─────────────────────────────┐
│ UserService │
│ REAL CODE │
└──────────────┬──────────────┘
│
controlled boundary
│
┌───────┴───────┐
│ │
Mock Mock
Repository EmailServicetextThis is what keeps unit tests independent.
15. From Good Unit Tests to --changed#
Independent unit tests
↓
Controlled dependencies
↓
Clear scope of impact
↓
Easier test partitioning
↓
More useful --changed
↓
Faster feedback
↓
Faster developer iterationtext--changed encourages thinking about the dependency graph of the test suite — a codebase with good unit tests has clear boundaries between business logic and external dependencies.
Conclusion#
vitest --changed is valuable for large test suites needing fast feedback. But its real power depends on how we design our unit tests.
A good unit test should:
- focus on a specific unit and behavior;
- control dependencies outside the unit’s scope;
- use mocks/stubs/test doubles when real dependencies introduce side effects or non-determinism;
- avoid relying on real databases, networks, or environment data unless those are the test subject;
- let integration tests and E2E tests handle component interactions.
The more independent your unit tests and the clearer your dependency boundaries, the more reliable selective test execution becomes — and the faster your feedback loop gets.
vitest --changed becomes genuinely valuable not because we want to test less, but because we want to test the right scope at the right time. For final confidence before merging or deploying, the full test suite remains the essential safety net.