You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Which @angular/* package(s) are relevant/related to the feature request?
core
Description
In order to test scenarios that involve timers (e.g. a component that uses setTimeout or setInterval), Angular provides means to manipulate time in tests using the fakeAsync function (+ associated functions like tick, flush, flushMicrotasks etc...).
While this approach offers a testing framework agnostic approach of manipulating time, it has the following limitations:
It requires Zone.js, so by definition, it is not zoneless-ready.
As it requires Zone.js, it also requires async/await to be downleveled during the build process.
It doesn't provide a way of controlling the clock (e.g. set current date, then tick and finally observe the date change).
It requires the system under test to be executed in a fake zone, which can make it more challenging to integrate with some untraditional testing patterns.
The most important point here is that this is not zoneless-ready.
The most common fake timer around is @sinonjs/fake-timers. Previously known as lolex. It is used under the hood in Jest, Vitest, etc...
The solutions below are based on this library as an example but in general, any fake timer library can be used and the same principles & problems apply. 😅
1.1. Using 3rd party fake timers + "real" requestAnimationFrame
The first problem we have is that we can't make any general assumption on what which functions will be faked by a fake timer library.
Just as an example, the default configurations can vary:
Tool
Default configuration for faking rAF
@sinonjs/fake-timers
true
Jest
true
Vitest
false
By making sure that rAF is not faked (which is harder than one might think, cf. below), users can rely on ComponentFixture#whenStable() making the test look something like this:
vi.useFakeTimers({toFake: ['setTimeout']});test('should say hello after 1s',async()=>{constfixture=TestBed.createComponent(GreetingsComponent);awaitvi.advanceTimersByTimeAsync(1000);awaitfixture.whenStable();expect(fixture.nativeElement.textContent).toBe('Hello!');});
Warning
Given vitest defaults of not faking rAF, it might feel that vi.useFakeTimers() would be enough but it's not!
The problem is that the result will vary depending on the testing environment you are using (real browser, happy-dom, jsdom, etc...).
For example: jsdom is using setInterval under the hood to emulate rAF(i.e. setInterval(callback, 1000 / 60)) but as setInterval is faked by default, rAF will be faked as well. happy-dom uses setImmediate to emulate rAF. Luckily it grabs the real setImmediate function before it is faked so it will always work.
This means that while Angular could detect if rAF is faked and provide a warning, it would still not be enough.
Angular would have to implement heuristics to detect environments like jsdom where the provided implementation relies on setInterval and provide a warning if setInterval is faked in that case.
This also makes it impossible to fake both setTimeout and setInterval when using jsdom(except if jsdom is changed similarly to happy-dom in order to grab the original setInterval before it is faked).
Warning
By default Jest also fakes queueMicrotask which is used by Angular to schedule microtasks.
Users must make sure that queueMicrotask is not faked when using Jest by adding it to the doNotFake option.
Pros & Cons
👍 The test in itself is straightforward
👍 This doesn't require any framework changes.
🛑 By relying on rAF only to schedule change detection, tests will be a bit slower.
🛑 This approach is very fragile as it relies on the fact that rAF is not faked.
🛑 This is not perfectly symmetric to production as this is equivalent of implementing a change detection scheduler that only relies on rAF. This means that there are extreme cases where some macrotasks scheduled by the user could happen before the CD triggered by rAF while this wouldn't happen in production because CD would setTimeout would win the race. That being said, this is a really rare case that probably reflects some poor design choice.
1.2. Using 3rd party fake timers + fake rAF
With a fake timer that also fakes rAF, the app will simply never be stable as long as we don't flush the fake timers.
In other words, the following test will timeout:
vi.useFakeTimers({toFake: ['setTimeout','requestAnimationFrame']});test('will timeout',async()=>{constfixture=TestBed.createComponent(GreetingsComponent);awaitfixture.whenStable();// this never resolves});
More interestingly, (given the same fake timer configuration), the following test will also timeout:
test('will timeout',async()=>{constfixture=TestBed.createComponent(GreetingsComponent);awaitvi.advanceTimersByTimeAsync(1000);awaitfixture.whenStable();// this never resolves});
the call to vi.advanceTimersByTimeAsync(1000) will trigger the callback from the setTimeout(callback, 1000) in the component,
by updating a signal, the callback, will notify the change detection scheduler to schedule a change detection cycle,
this cycle will be scheduled with a race between setTimeout and rAF which are faked and will only be triggered if we advance by an extra millisecond.
This extra millisecond is introduced on purpose by @sinonjs/fake-timers(probably in order to provide more control when timers are "nested" or "chained").
While there are workarounds to avoid this freeze, like advancing the timer millisecond by millisecond and checking the stability, they are not necessarily straightforward nor intuitive:
If rAF is faked, users need a deep understanding of the fake timer library and zoneless change detection scheduling.
Pros & Cons
👍 This solution is less fragile than the previous one as it doesn't require the user to make sure that rAF is not faked.
👍 Tests are faster than using "real" rAF (Cf. solution 1.1.)
👍 This doesn't require any framework changes.
👍 This is a bit more symmetric to production than the previous solution.
🛑 Tests have to somehow advance the timer to trigger CD and therefore they are less straightfoward than a simple call to ComponentFixture#whenStable()
🛑 The extra millisecond can be annoying.
2. Using a microtask change detection scheduler for testing
By providing a microtask change detection scheduler instead of the zoneless one (setTimeout & rAF race), tests are more straightforward as extra change detection cycles will be triggered in the same timer tick (so no extra 1ms introduced by @sinonjs/fake-timers).
Which @angular/* package(s) are relevant/related to the feature request?
core
Description
In order to test scenarios that involve timers (e.g. a component that uses
setTimeout
orsetInterval
), Angular provides means to manipulate time in tests using thefakeAsync
function (+ associated functions liketick
,flush
,flushMicrotasks
etc...).While this approach offers a testing framework agnostic approach of manipulating time, it has the following limitations:
async/await
to be downleveled during the build process.The most important point here is that this is not zoneless-ready.
requestAnimationFrame
rAF
Timer
serviceProposed Solutions and Workarounds
Source code
Examples of most of the different proposed solutions and alternatives presented below can be found here: https://stackblitz.com/edit/angular-zoneless-testing?file=README.md,src%2Fzoneless-fake-timers.spec.ts
1. Using 3rd party fake timers
The most common fake timer around is
@sinonjs/fake-timers
. Previously known aslolex
. It is used under the hood in Jest, Vitest, etc...The solutions below are based on this library as an example but in general, any fake timer library can be used and the same principles & problems apply. 😅
1.1. Using 3rd party fake timers + "real"
requestAnimationFrame
The first problem we have is that we can't make any general assumption on what which functions will be faked by a fake timer library.
Just as an example, the default configurations can vary:
rAF
@sinonjs/fake-timers
By making sure that
rAF
is not faked (which is harder than one might think, cf. below), users can rely onComponentFixture#whenStable()
making the test look something like this:Warning
Given vitest defaults of not faking
rAF
, it might feel thatvi.useFakeTimers()
would be enough but it's not!The problem is that the result will vary depending on the testing environment you are using (real browser, happy-dom, jsdom, etc...).
For example:
jsdom
is usingsetInterval
under the hood to emulaterAF
(i.e.setInterval(callback, 1000 / 60)
) but assetInterval
is faked by default,rAF
will be faked as well.happy-dom
usessetImmediate
to emulaterAF
. Luckily it grabs the realsetImmediate
function before it is faked so it will always work.This means that while Angular could detect if
rAF
is faked and provide a warning, it would still not be enough.Angular would have to implement heuristics to detect environments like
jsdom
where the provided implementation relies onsetInterval
and provide a warning ifsetInterval
is faked in that case.This also makes it impossible to fake both
setTimeout
andsetInterval
when usingjsdom
(except ifjsdom
is changed similarly tohappy-dom
in order to grab the originalsetInterval
before it is faked).Warning
By default Jest also fakes
queueMicrotask
which is used by Angular to schedule microtasks.Users must make sure that
queueMicrotask
is not faked when using Jest by adding it to thedoNotFake
option.Pros & Cons
rAF
only to schedule change detection, tests will be a bit slower.rAF
is not faked.rAF
. This means that there are extreme cases where some macrotasks scheduled by the user could happen before the CD triggered byrAF
while this wouldn't happen in production because CD wouldsetTimeout
would win the race. That being said, this is a really rare case that probably reflects some poor design choice.1.2. Using 3rd party fake timers + fake
rAF
With a fake timer that also fakes
rAF
, the app will simply never be stable as long as we don't flush the fake timers.In other words, the following test will timeout:
More interestingly, (given the same fake timer configuration), the following test will also timeout:
while this one will pass:
Here is why:
vi.advanceTimersByTimeAsync(1000)
will trigger the callback from thesetTimeout(callback, 1000)
in the component,setTimeout
andrAF
which are faked and will only be triggered if we advance by an extra millisecond.This extra millisecond is introduced on purpose by
@sinonjs/fake-timers
(probably in order to provide more control when timers are "nested" or "chained").Cf. https://github.com/sinonjs/fake-timers#:~:text=If%20called%20during%20a%20tick%20the%20callback%20won%27t%20fire%20until%201%20millisecond%20has%20ticked%20by.
& https://github.com/sinonjs/fake-timers/blob/341203310225bf5cd3d7396b2fcf276c5e218347/src/fake-timers-src.js#L645C58-L645C68
While there are workarounds to avoid this freeze, like advancing the timer millisecond by millisecond and checking the stability, they are not necessarily straightforward nor intuitive:
If
rAF
is faked, users need a deep understanding of the fake timer library and zoneless change detection scheduling.Pros & Cons
rAF
is not faked.rAF
(Cf. solution 1.1.)ComponentFixture#whenStable()
2. Using a microtask change detection scheduler for testing
By providing a microtask change detection scheduler instead of the zoneless one (
setTimeout
&rAF
race), tests are more straightforward as extra change detection cycles will be triggered in the same timer tick (so no extra 1ms introduced by@sinonjs/fake-timers
).Pros & Cons
rAF
(Cf. solution 1.1.)rAF
is not faked.3. Introducing a
Timer
serviceThe abstraction + dependency injection approach in Angular has proven to be very powerful. Why not use it here too?
Angular could introduce a
Timer
service wrapping native timers (i.e.setTimeout
,setInterval
) + an alternative fake implementation for testing.This could also be name something like
TaskScheduler
and also wrap other scheduling functions likerequestIdleCallback
, etc...Pros & Cons
rAF
(Cf. solution 1.1.)rAF
is not faked.setTimeout
internally.4. Avoiding fake timers using configurable delays
The most testing framework agnostic approach is to simply avoid using fake timers and instead use configurable delays.
The test could look something like this:
Comparison
rAF
rAF
Timer
serviceSide notes
Promise.resolve().then(callback)
instead ofqueueMicrotask(callback)
for Angular internals to avoid fake timers interference.❤️ Special thanks to @alxhub and @atscott for your patience, inspiration and ideas. 🙏
The text was updated successfully, but these errors were encountered: