Modern Angular
Why Your Async Validator Stays PENDING Under Vitest Fake Timers
This one does not throw. The test runs, nothing errors, and the assertion fails on a value that should be there: the control is still PENDING. It is the quiet cousin of the ProxyZone error, and the fix is a single word. Vitest fake timers fire timers but do not drain Promise microtasks, so the validation result never gets applied before you assert.
The failure that looks impossible
There is no ProxyZone error here. The spec runs, nothing throws, and then it fails on a value that should obviously be set. The async validator was supposed to mark the control invalid, but control.status is still PENDING and control.errors is null. Nothing in the output explains why.
This shows up two ways: a fakeAsync test you migrated mechanically (swapped tick for vi.advanceTimersByTime and called it done), or a fresh test that controls time with fake timers. Either way, the timer fired. The result just is not there yet.
it('flags a taken username', () => {
vi.useFakeTimers();
const control = new FormControl('taken', {
asyncValidators: [uniqueUsernameValidator(['taken'])],
});
vi.advanceTimersByTime(200); // fires the timer, not the microtask
expect(control.status).toBe('INVALID'); // received: 'PENDING'
});Why: a timer is not a microtask
vi.advanceTimersByTime() fires timers, synchronously. A timer is a macrotask: setTimeout, setInterval, and the RxJS schedulers that ride them. The async validator's debounce is a timer, so the advance does fire it.
But the validator's result is applied when its Promise resolves, and a Promise resolution is a microtask, not a timer. The synchronous advance never drains the microtask queue, so at the moment you assert, the Promise callback that would set the error has not run. The control is still PENDING. The failure looks impossible because the validator did run. Its answer is just sitting in a queue you never flushed.
| Mechanism | Kind | Fired by advanceTimersByTime (sync)? |
|---|---|---|
setTimeout, setInterval | Macrotask (timer) | Yes |
RxJS debounceTime, async scheduler | Macrotask (rides setInterval) | Yes |
Promise.then, async validator result, whenStable | Microtask | No, needs the async advance |
The fix is one word: Async
Switch advanceTimersByTime for advanceTimersByTimeAsync, make the test async, and await it. The async variant advances time and drains the microtasks in between, so the Promise resolves and the result lands before your assertion. That is the entire fix.
it('flags a taken username', async () => { // now async
vi.useFakeTimers();
const control = new FormControl('taken', {
asyncValidators: [uniqueUsernameValidator(['taken'])],
});
await vi.advanceTimersByTimeAsync(200); // time AND microtasks
expect(control.status).toBe('INVALID'); // passes
expect(control.errors).toEqual({ taken: true });
});It is Promises, not async in general
Here is where people overcorrect and start awaiting everything. They should not. Not every asynchronous-looking test needs the async advance. An RxJS stream that feeds an HttpClient call is synchronous inside a test: vi.advanceTimersByTime fires the debounce, and HttpTestingController.flush() applies the response synchronously. There is no Promise in that chain, so there is no microtask to drain.
The trap is specifically a Promise: an async validator, a .then, a whenStable(), or a service method that returns a Promise. See one of those, reach for the async advance. When the chain is RxJS and HttpClient end to end, the synchronous advance and a flush() are correct, and adding await just hides what is actually happening.
const httpMock = TestBed.inject(HttpTestingController);
vi.advanceTimersByTime(300); // fires the debounce -> http.get
const req = httpMock.expectOne('/api/search?q=ab');
req.flush([{ id: 1, label: 'Abacaxi' }]); // applied synchronously, no Promise
expect(seen).toEqual([[{ id: 1, label: 'Abacaxi' }]]);How to recognize it in five seconds
The tell is the shape of the failure: the test runs, nothing throws, and a value is simply missing (PENDING, null, undefined) where it should be set. The moment you suspect timing, ask one question. Is there a Promise in the chain? If yes, change advanceTimersByTime to await advanceTimersByTimeAsync. If the value appears, that was it.
This is the silent half of the same problem the ProxyZone error announces loudly, and both come from the split that Vitest fake timers make explicit: time is one job, microtasks are another.
Reusable artifact
Diagnosing a silent Vitest timing failure
- Symptom: the test runs, nothing throws, but a value is missing (
PENDING,null,undefined). - Check the chain for a Promise: async validator,
.then,whenStable, a Promise-returning service. - If there is one, make the test
asyncand useawait vi.advanceTimersByTimeAsync(ms). - If it is RxJS plus
HttpTestingControllerend to end, keep the synchronous advance andflush(); there is no microtask to drain. - Still stuck? Try
await vi.runAllTimersAsync()to drain every pending timer and its microtasks. - Reset with
vi.useRealTimers()inafterEach.
FAQ
Why does my Angular async validator stay PENDING in a Vitest test?
Because vi.advanceTimersByTime() fires the debounce timer but does not drain the Promise microtask that applies the validation result. The control resolves in a microtask that never ran before your assertion, so it stays PENDING. Use await vi.advanceTimersByTimeAsync(ms), which advances time and drains microtasks.
advanceTimersByTime is not working, the value is still undefined. Why?
It is working, but only on timers. Anything applied through a Promise (an async validator, a .then, whenStable) lands in a microtask, and the synchronous advance does not drain microtasks. Switch to the async variant and await it.
What is the difference between a timer and a microtask here?
A timer (setTimeout, setInterval, and the RxJS schedulers on top of them) is a macrotask, and advanceTimersByTime fires it. A Promise resolution is a microtask, drained only by the async advance (advanceTimersByTimeAsync, runAllTimersAsync) or a real await.
Do my HttpClient tests need advanceTimersByTimeAsync too?
Usually not. An RxJS stream feeding HttpClient is synchronous in a test: advance the timer for the debounce, then HttpTestingController.flush() applies the response synchronously. You only need the async advance if a Promise sits in the chain, for example a .then after the response.
Does runAllTimers fix a PENDING validator?
No, use the async version: await vi.runAllTimersAsync(). The async timer helpers drain microtasks between timers, which is what lets the validator's Promise resolve. The synchronous runAllTimers has the same blind spot as advanceTimersByTime.
Sources checked
- https://angular.dev/guide/testing
- https://angular.dev/guide/testing/migrating-to-vitest
- https://angular.dev/api/core/testing/TestBed
- https://vitest.dev/api/vi
- https://vitest.dev/guide/mocking
- https://blog.angular.dev/announcing-angular-v21-57946c34f14b
Angular Vitest Migration Kit
Migrating this suite to Vitest?
The Angular Vitest Migration Kit sizes your migration by risk, then packages the config, the fakeAsync cookbook, an AI-agent skill, and a runnable demo. The conversions in this article, done for you.
Verified on Angular 21.2.14 / Vitest 4.1.8. Includes the migration estimator, the PDF guide, copy-paste config, an AI-agent skill, and a runnable demo.
André Ramosavailable for remote roles, UTC−3Get in touch →