Skip to main content

Controlling Time in Tests

Components often have legitimate reasons to rely on time-based behavior:

  • Debouncing or throttling user input
  • Delaying UI feedback: toast auto-dismiss, loading state minimum duration, etc.
  • Polling
  • Countdowns
  • Animations and visual effects
  • Different behavior depending on the current date and time
  • etc.

But when it comes to testing, you'll typically face one of two scenarios:

The timing is the behavior under test โ€” you want to verify that a debounce waits the right amount of time or that a toast disappears after 5 seconds.

The timing is just in the way โ€” the feature you are testing happens to involve a timer, but the timing itself is not what you are verifying. You just need it to get out of the way.

Testing the Time-based Behavior Itselfโ€‹

When the timing is the behavior you're testing โ€” "does the debounce wait 300ms before emitting?" or "does the toast dismiss after 5 seconds?" โ€” you need a way to assert what happens at specific points in time.

The two most common approaches for this are:

Fake Timers in "Manual" Modeโ€‹

Most testing frameworks provide a way to monkey patch the global clock with fake timers.

Vitest is no exception and provides a vi.useFakeTimers() method which installs a fake clock that intercepts calls to setTimeout, setInterval, requestAnimationFrame, Date.now, etc. (It is internally powered by the @sinonjs/fake-timers library)

Default behavior of Vitest fake timers is "manual" mode

The default behavior of Vitest fake timers is to pause all timers unless you manually advance the time.

In this mode, time stands still until you explicitly advance it using methods like vi.advanceTimersByTimeAsync(). This gives you the ability to assert conditions at precise points in time โ€” "at 290ms, the debounce has not fired; at 310ms, it has."

Side effects of fake timers

A major side effect of using fake timers with "manual" mode is that it will pause all timers โ€” including Angular's internal scheduling. This means Angular can't process change detection, resolve promises, or update the DOM until you advance time.

An example is the following test which will time out because Angular's internal synchronization timers are never triggered. Thus, Angular can't become stable.

it('waits for stability forever', async () => {
vi.useFakeTimers();

const fixture = TestBed.createComponent(Greetings);

await fixture.whenStable(); // โŒ This will never resolve and the test will timeout.
});

To work around this, you must call vi.runAllTimersAsync() after creating the component to flush Angular's synchronization timers before interacting with it.

Assuming that we want to test that the form's submit button is disabled while waiting for a 300ms debounce to finish, we can proceed like this:

import { onTestFinished } from 'vitest';

it('disables submit button while waiting for debounce', async () => {
vi.useFakeTimers();
onTestFinished(() => {
vi.useRealTimers();
});

TestBed.createComponent(CookbookForm);

await vi.runAllTimersAsync();

// ... fill the form ...

// Advance by 290ms (debounce duration - 10ms).
// ๐Ÿค” why 10ms and not 1ms? See "Time is Not a Precise Science" section.
await vi.advanceTimersByTimeAsync(290);

// The button is disabled because the debounce is still pending.
await expect
.element(page.getByRole('button', { name: 'Submit' }))
.toBeDisabled();
});

Dynamic Timing Configurationโ€‹

Another approach is to use dynamic configuration through dependency injection to override the time durations from your tests instead of hardcoding them.

form-timing.config.ts
@Injectable({ providedIn: 'root' })
export class FormsTimingConfig {
datepickerDebounce = 1_000;
inputDebounce = 300;
}

export function provideFormsTimingConfig(
config: FormsTimingConfig,
): Provider[] {
return [{ provide: FormsTimingConfig, useValue: config }];
}

const MAX_TIMEOUT = Math.pow(2, 31) - 1;
export function provideTestingFormsTimingConfig(
mode: 'instant-debounce' | 'never-ending-debounce',
) {
const debounce = mode === 'instant-debounce' ? 0 : MAX_TIMEOUT;
return provideFormsTimingConfig({
datepickerDebounce: debounce,
inputDebounce: debounce,
});
}

With this approach, tests can set durations to 0 (instant) or MAX_TIMEOUT (never fires) to verify that something fires "eventually" or "never" โ€” without relying on exact timing and without intercepting any timers. Angular's internal scheduling runs normally, and there is no risk of side effects on other timers.

it('disables submit button while waiting for debounce', async () => {
TestBed.configureTestingModule({
providers: [provideTestingFormsTimingConfig('never-ending-debounce')],
});

// ... create component and fill the form ...

await expect
.element(page.getByRole('button', { name: 'Submit' }))
.toBeDisabled();
});

it('enables submit button after debounce', async () => {
TestBed.configureTestingModule({
providers: [provideTestingFormsTimingConfig('instant-debounce')],
});

// ... create component and fill the form ...

await expect
.element(page.getByRole('button', { name: 'Submit' }))
.toBeEnabled();
});

Testing Despite the Time-based Behaviorโ€‹

Once the time-based behavior is tested thoroughly enough, you will probably write tests that focus on other behaviors of the exercised code. As these tests do not care about the time-based behavior itself, they should not be affected by it.

Tests should be composable.

Fake Timers in "Fast-Forward" Modeโ€‹

The problem with using fake timers in "manual" mode is that it couples the test to the time-based behavior. What if there was a fake timers mode that would advance time on its own, only as fast as needed?

Well, Andrew Scott from the Angular Team put effort into adding this feature to several testing tools:

Thank you, Andrew! โค๏ธ

In Vitest, you can enable the "fast-forward" mode by calling vi.setTimerTickMode('nextTimerAsync').

info

vi.setTimerTickMode('nextTimerAsync') is available since Vitest 4.1.0.

In this mode, whenever a macrotask is scheduled (e.g. via setTimeout), the fake clock automatically advances time by the necessary amount and flushes the microtasks queue. Delays are skipped instantly โ€” without requiring you to manually advance time or know the exact durations.

nextTimerAsync is not synchronous

While "automatically" might suggest that time advances synchronously, it doesn't. Hence the name of the tick mode.

When you call setTimeout in your test, the fake clock schedules a real macrotask internally to fast-forward time โ€” so the advance hasn't happened yet on the very next line.

๐Ÿ”ฌ Under the hood (safe to skip)
it('fast-forwards time in a macrotask', async () => {
const realTimeout = setTimeout;
const waitForReal = (d: number) => new Promise((r) => realTimeout(r, d));

// Schedules the macrotask loop that will fast-forward time.
vi.useFakeTimers().setTimerTickMode('nextTimerAsync');

const start = Date.now();

// Fake timer is aware of this timer but not flushing it yet.
setTimeout(() => {}, 1_000_000);

// Therefore fake time did not advance yet.
expect(Date.now() - start).toBe(0);

// By scheduling our own macrotask, we give the chance to the fake timer to flush.
// It flushes each timer โ€” and the microtask queue โ€” in chronological order.
// We wait 1ms because `nextTimerAsync` actually schedules an extra
// macrotask to flush the microtask queue.
await waitForReal(1);

// All timers have been flushed and the time has advanced.
expect(Date.now() - start).toBe(1_000_000);
});

๐Ÿ’ป Here is the relevant code in @sinonjs/fake-timers where the magic happens.

it('fast-forwards time', async () => {
vi.useFakeTimers().setTimerTickMode('nextTimerAsync');

const start = Date.now();
// This would take an hour with real timers,
// but resolves instantly with fast-forward.
await new Promise((resolve) => setTimeout(resolve, 3_600_000));
expect(Date.now() - start).toBe(3_600_000);
});

Thanks to "fast-forward" mode, you can:

  • write tests that focus on other behaviors regardless of any timing-related details such as the debounce,
  • make your tests faster than they would be with real timers.

Dynamic Timing Configurationโ€‹

In most cases, allowing tests to configure the timing is enough. Setting all durations to 0 (instant) makes timing transparent without needing fake timers at all.

Real-life example

An example of this approach is Angular Material's MATERIAL_ANIMATIONS injection token which allows you to disable animations for testing purposes.

Understanding Fake Timer Behaviorโ€‹

Whether you use fake timers in manual or fast-forward mode, there are a few important things to understand about how they work.

Time is Not a Precise Scienceโ€‹

Timing is inherently imprecise with real timers โ€” many factors can affect it. You might expect fake timers to be perfectly precise, but in practice, they aren't.

For example, when a timer is scheduled within a timer callback, @sinonjs/fake-timers will add an additional millisecond. (Cf. docs, code)

it('adds an additional millisecond when a timer is scheduled within a timer callback', async () => {
vi.useFakeTimers();

setTimeout(() => {
console.log('first timeout called');
setTimeout(() => {
console.log('second timeout called');
}, 0);
}, 0);

const start = Date.now();
await vi.advanceTimersByTimeAsync(0); // logs "first timeout called"
await vi.advanceTimersByTimeAsync(0); // does not log anything
await vi.advanceTimersByTimeAsync(1); // logs "second timeout called"
expect(Date.now() - start).toBe(1);
});

Therefore, when using fake timers, you should approximate the time rather than using precise values. Otherwise, tests can be brittle and structure-sensitive. In other words, nesting timers should not break your tests.

Restoring Real Timersโ€‹

Fake timers are global โ€” if not cleaned up, they leak into subsequent tests. Always restore real timers when the test finishes.

Use Vitest's onTestFinished hook to keep setup and teardown colocated. This ensures that real timers are restored whether the test passes or fails, without harming readability and maintainability with beforeEach and afterEach.

import { onTestFinished } from 'vitest';

function setUpFakeTimers() {
vi.useFakeTimers();
onTestFinished(() => {
vi.useRealTimers();
});
}

Async Over Syncโ€‹

You might notice synchronous versions of fake timers methods such as vi.advanceTimersByTime and vi.runAllTimers.

Unless you really know what you are doing, like testing your own framework's internal scheduling engine, you should always use the async versions such as vi.advanceTimersByTimeAsync and vi.runAllTimersAsync.

The async versions produce behavior that is more symmetric to production because they flush the microtasks queue while the synchronous versions can produce behavior that is impossible with real timers.

Do Not Install Fake Timers in the Middle of the Testโ€‹

Fake timers must be installed before any component creation or timer-dependent code runs. Mixing real timers with fake timers is a recipe for disaster.

it('disables submit button while waiting for debounce', async () => {
TestBed.createComponent(CookbookForm);

vi.useFakeTimers(); // โŒ Timers triggered by the component creation will not be intercepted.

...
});

โš–๏ธ Trade-offsโ€‹

๐Ÿ‘ Pros๐Ÿ‘Ž Cons
Fake TimersFull control.Side effects like breaking Angular internals if not used carefully.
Dynamic Timing ConfigurationNo interference with other timers such as Angular's scheduling.
Testing-framework agnostic. (All my thoughts go for those who've been bitten by Angular's fakeAsync)
Can cause things to run in different order than production.
e.g. A form's auto-save and debounce durations order difference between tests and production.

Zone.js vs. Zonelessโ€‹

Vitest's fake timers are the Zone-agnostic way to control time in tests. This is also a transportable skill that you can reuse beyond Angular.

Since Angular 21, Zoneless mode is the default behavior. However, as I mention in my Pragmatic Angular Testing course, even if your app is still Zone-based, or using an older Angular version, I highly recommend writing Zoneless-ready tests. That is why everything described in this chapter is Zoneless-ready.

If your tests work with Zoneless mode, you can be confident that they will work with Zone-based mode as well. This helps you stay future-proof and easily switch to Zoneless when the time is right for you.

If the exercised code still relies on Zone.js, you should turn on automatic synchronization to make the test behavior more symmetric to production:

TestBed.configureTestingModule({
providers: [{ provide: ComponentFixtureAutoDetect, useValue: true }],
});
ComponentFixtureAutoDetect vs. fixture.autoDetectChanges()

ComponentFixtureAutoDetect is similar to calling fixture.autoDetectChanges() but the dependency injection configuration provides a better developer experience as it is easier to factor out.

โš ๏ธ You do not need either of these two if you are writing a Zoneless test.

Decision Treeโ€‹

tip

While there are valid use cases of switching the tick mode between manual and nextTimerAsync ("fast-forward" mode) during a test, I would recommend sticking to one mode per test to reduce the cognitive load for both humans and agents.

Key Takeawaysโ€‹

  • โฑ๏ธ Identify your goal first: is the time-based behavior the thing you are testing, or is it just in the way?
  • ๐Ÿ•น๏ธ Use fake timers in "manual" mode when you need to assert precise timing behavior such as debounce or auto-dismiss.
  • โฉ Use fake timers in "fast-forward" mode when the timing is irrelevant and you just need it out of the way.
  • โš™๏ธ Use dynamic timing configuration when you want to avoid fake timers' side effects on other timers.
  • ๐Ÿ” Always restore real timers with onTestFinished to avoid leaking fake timers across tests.
  • ๐ŸŽฏ Approximate time, don't match it exactly โ€” nested timers add extra milliseconds. Brittle assertions on precise time make tests structure-sensitive.
  • ๐Ÿงฉ Stick to one approach per test to keep the cognitive load low for both humans and agents.

Get the Full Pictureโ€‹

Now you know how to control time in your Angular tests. See how this fits into a Full Pragmatic Angular Testing Strategy โ€” with hands-on exercises and live guidance.

Pragmatic Angular Testing Workshop

Additional Resourcesโ€‹

๐Ÿ“„๏ธ How to Test Debounce Timing

Test debounce timing behavior in Angular tests using Vitest fake timers in manual mode to assert that actions fire at the right time.

๐Ÿ“„๏ธ How to Skip Debounce and Timer Delays

Instantly skip debounce, throttle, and other timer delays in Angular tests using Vitest fake timers in "fast-forward" mode.

Younes Jaaidi

~1 email per month. Unsubscribe anytime.