angular-cli icon indicating copy to clipboard operation
angular-cli copied to clipboard

`@angular/builder:unit-test` doesn't support `fakeAsync`

Open th0r opened this issue 4 months ago • 11 comments

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

th0r avatar Sep 01 '25 15:09 th0r

Hi, you're repro repo is private.

JeanMeche avatar Sep 01 '25 15:09 JeanMeche

Ooops, sorry. Made it public

th0r avatar Sep 01 '25 15:09 th0r

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.

clydin avatar Sep 05 '25 23:09 clydin

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?

th0r avatar Sep 05 '25 23:09 th0r

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.

JoostK avatar Sep 07 '25 13:09 JoostK

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?

th0r avatar Sep 08 '25 14:09 th0r

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);

JoostK avatar Sep 08 '25 16:09 JoostK

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.

JoostK avatar Sep 08 '25 16:09 JoostK

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.

th0r avatar Sep 08 '25 16:09 th0r

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.

JoostK avatar Sep 08 '25 18:09 JoostK

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.

atscott avatar Sep 08 '25 21:09 atscott