Repository: angular
Issue: Future of fake timer testing in a zoneless world
Labels: area: testing,area: zones

### 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.

---

<!-- TOC -->
* [Proposed Solutions and Workarounds](#proposed-solutions-and-workarounds)
  * [Source code](#source-code)
  * [1. Using 3rd party fake timers](#1-using-3rd-party-fake-timers)
    * [1.1. Using 3rd party fake timers + "real" `requestAnimationFrame`](#11-using-3rd-party-fake-timers--real-requestanimationframe)
      * [Pros & Cons](#pros--cons)
    * [1.2. Using 3rd party fake timers + fake `rAF`](#12-using-3rd-party-fake-timers--fake-raf)
      * [Pros & Cons](#pros--cons-1)
  * [2. Using a microtask change detection scheduler for testing](#2-using-a-microtask-change-detection-scheduler-for-testing)
      * [Pros & Cons](#pros--cons-2)
  * [3. Introducing a `Timer` service](#3-introducing-a-timer-service)
      * [Pros & Cons](#pros--cons-3)
  * [4. Avoiding fake timers using configurable delays](#4-avoiding-fake-timers-using-configurable-delays)
* [Comparison](#comparison)
* [Side notes](#side-notes)
<!-- TOC -->

# Proposed 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`](https://github.com/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:

```ts
vi.useFakeTimers({ toFake: ['setTimeout'] });

test('should say hello after 1s', async () => {
  const fixture = TestBed.createComponent(GreetingsComponent);

  await vi.advanceTimersByTimeAsync(1000);

  await fixture.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:

```ts
vi.useFakeTimers({ toFake: ['setTimeout', 'requestAnimationFrame'] });

test('will timeout', async () => {
  const fixture = TestBed.createComponent(GreetingsComponent);

  await fixture.whenStable(); // this never resolves
});
```

More interestingly, (given the same fake timer configuration), the following test will also timeout:

```ts
test('will timeout', async () => {
  const fixture = TestBed.createComponent(GreetingsComponent);

  await vi.advanceTimersByTimeAsync(1000);
  await fixture.whenStable(); // this never resolves
});
```

while this one will pass:

```ts
test('will pass', async () => {
  const fixture = TestBed.createComponent(GreetingsComponent);

  await vi.advanceTimersByTimeAsync(1001);
  await fixture.whenStable();
  expect(fixture.nativeElement.textContent).toBe('Hello!');
});
```

Here is why:

- 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")_.

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:

```ts
async function advanceTimeAndWaitStable(duration = 0) {
  await vi.advanceTimersByTimeAsync(duration);
  while (!fixture.isStable()) {
    await vi.advanceTimersByTimeAsync(1);
    duration++;
  }
  return duration;
}
```

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.](#11-using-3rd-party-fake-timers--real-requestanimationframe))
- 👍 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`).

```ts
TestBed.configureTestingModule({
  providers: [
    ɵprovideZonelessChangeDetection(),
    {
      provide: ɵChangeDetectionScheduler,
      useExisting: TestingChangeDetectionScheduler,
    },
  ],
});

await vi.advanceTimersByTimeAsync(1000);
await fixture.whenStable();

expect(fixture.nativeElement.textContent).toBe('Hello!');
```

#### Pros & Cons

- 👍 Tests are straightforward.
- 👍 Timers are precise.
- 👍 Tests are faster than using "real" `rAF` (Cf. [solution 1.1.](#11-using-3rd-party-fake-timers--real-requestanimationframe))
- 👍 This solution is less fragile as it doesn't require the user to make sure that `rAF` is not faked.
- 🛑 This is not symmetric to production.

## 3. Introducing a `Timer` service

The 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.

```ts
TestBed.configureTestingModule({
  providers: [provideTestingTimer()],
});
const fakeTimer = TestBed.inject(Timer);
await fakeTimer.advanceBy(1000);
await fixture.whenStable();
```

This could also be name something like `TaskScheduler` and also wrap other scheduling functions like `requestIdleCallback`, etc...

#### Pros & Cons

- 👍 Tests are straightforward.
- 👍 Timers are precise.
- 👍 Tests are faster than using "real" `rAF` (Cf. [solution 1.1.](#11-using-3rd-party-fake-timers--real-requestanimationframe))
- 👍 This solution is less fragile as it doesn't require the user to make sure that `rAF` is not faked.
- 👍 This solution is testing framework agnostic.
- 🛑 This can require major changes in existing code base and will not work with 3rd party code using `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:
```ts
TestBed.configureTestingModule({
  providers: [{provide: MY_GREETINGS_MESSAGE_DELAY, useValue: 5}],
});
const fixture = TestBed.createComponent(GreetingsComponent);
await new Promise<void>(resolve => setTimeout(resolve, 5));
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toBe('Hello!');
```

# Comparison

| Proposed Solution                                                                                   | Simplicity | Test Symmetry | Robustness | Exhaustiveness\* | Speed | Testing Framework Agnosticity |
|-----------------------------------------------------------------------------------------------------|------------|---------------|------------|------------------|-------|-------------------------------|
| [3rd party fake timers + real `rAF`](#11-using-3rd-party-fake-timers--real-requestanimationframe)   | ⭐️         | ⭐️            | -          | ⭐️               | -     | -                             |
| [3rd party fake timers + fake `rAF`](#12-using-3rd-party-fake-timers--fake-raf)                     | -          | ⭐️            | ⭐️         | ⭐️               | ⭐️    | -                             |
| [Microtask Change Detection Scheduler](#2-using-a-microtask-change-detection-scheduler-for-testing) | ⭐️⭐️       | -             | ⭐️⭐️       | ⭐️               | ⭐️    | -                             |
| [Introducing a `Timer` service](#3-introducing-a-timer-service)                                     | ⭐️         | ⭐️⭐️          | ⭐️⭐️       | -                | ⭐️    | ⭐️                            |
| [Avoiding fake timers using configurable delays](#4-avoiding-fake-timers-using-configurable-delays) | ⭐️         | ⭐️⭐️          | ⭐️⭐️       | -️               | ⭐️    | ⭐️️                           |

> \*Exhaustiveness: how many edge cases are covered by the solution.

# Side notes

- It would be safer to use `Promise.resolve().then(callback)` instead of `queueMicrotask(callback)` for Angular internals to avoid fake timers interference.
- Solutions [2](#2-using-a-microtask-change-detection-scheduler-for-testing) & [3](#3-creating-a-timer-service-and-encouraging) can also help with SSR & stability in general.

❤️ Special thanks to @alxhub and @atscott for your patience, inspiration and ideas. 🙏