`@angular/builder:unit-test` doesn't support `fakeAsync`
Command
test
Is this a regression?
- [ ] Yes, this behavior used to work in the previous version
The previous version in which this bug was not present was
No response
Description
Using fakeAsync wrapper in unit tests served by the @angular/builder:unit-test builder with vitest runner causes an Expected to be running in 'ProxyZone', but it was not found error.
Minimal Reproduction
Clone the reproduction repo (https://github.com/th0r/ng-vitest-fake-async-bug) and run npm i && npm run test. You'll see the following error:
Expected to be running in 'ProxyZone', but it was not found.
Exception or Error
Error: Expected to be running in 'ProxyZone', but it was not found.
❯ _ProxyZoneSpec.assertPresent node_modules/zone.js/fesm2015/zone-testing.js:2042:18
❯ fakeAsyncFn node_modules/zone.js/fesm2015/zone-testing.js:1697:44
❯ new ZoneAwarePromise node_modules/zone.js/fesm2015/zone.js:2701:24
Your Environment
Angular CLI: 20.2.1
Node: 22.16.0
Package Manager: npm 10.9.3
OS: darwin arm64
Angular: 20.2.3
... common, compiler, compiler-cli, core, forms
... platform-browser, router
Package Version
------------------------------------
@angular-devkit/architect 0.2002.1
@angular-devkit/core 20.2.1
@angular-devkit/schematics 20.2.1
@angular/build 20.2.1
@angular/cli 20.2.1
@schematics/angular 20.2.1
rxjs 7.8.2
typescript 5.9.2
zone.js 0.15.1
Anything else relevant?
No response
Hi, you're repro repo is private.
Ooops, sorry. Made it public
This is a limitation of zone.js itself. To automatically support fakeAsync, zone.js needs to specifically patch each test runtime (jasmine, jest, vitest, etc.). It currently does not patch vitest and it, unfortunately, could only hypothetically patch the global mode for vitest's test API.
I understand that, but karma is deprecated. Do ng team have some view on the future of the unit testing framework that will be used instead of it? If it's not vitest then what is it going to be?
I'd recommend to stay away from fakeAsync for (new) unit tests, as it fundamentally relies on zone.js taking control of all microtasks to drive its own event loop—making it possible to have synchronous tests even with promises—but modern APIs such as dynamic imports cannot be taken control over this way. With the zoneless effort, zone.js itself is being made optional entirely.
I'd recommend to stay away from fakeAsync for (new) unit tests
So what do you suggest to use instead in a zone.js based app? Shall we use vi.useFakeTimers instead?
Fake timers correspond with macrotask activities by installing a virtual clock for e.g. Date, setTimeout/setInterval or other time-related APIs, which is a bit different from microticks. For microticks, using async method bodies allows you to either await any promises, but if they are internal (i.e. not exposed to the test) then you'd have to "skip over" the microtask queue, e.g. by awaiting a macrotask:
import {setTimeout} from 'node:timers/promises';
await setTimeout(0);
Note that await Promise.resolve(); is also possible to await a single microtick, although that may not be sufficient to have drained the microtask queue entirely. The exact ordering of the microtask queue is hard to control/predict, as any await/Promise.prototype.then usage incurs one additional microtick. Hence the most reliable way is to jump all microticks entirely by awaiting a macrotask instead.
I mean, if I have e.g. setTimeout(5000) in the component's code I'd like to avoid waiting 5 seconds in the unit tests. What should be used instead of fakeAsync in this case? Of course I mean some zone.js-friendly solution.
You're mixing up fake timers with fake async; timers can be driven by a virtual clock through e.g. vi.useFakeTimers, but driving the event loop as zone.js attempts to do is much more invasive.
Fake timers are achieved by patching just some time-related APIs whereas zone.js has to patch all async APIs. The latter requires that native async/await is downleveled to pre-ES2017 code using generators—negatively impacting the debugging experience (e.g. async stack tagging) quite a bit, plus negative implications on code size—as native async/await cannot otherwise be made synchronous.
vi.useFakeTimers can be used and you should stick to the async variants vi.tickAsync/vi.runAllTimersAsync/etc to allow the microtask queue to drain between each task.