How to Test Debounce Timing
When testing a debounced input, you sometimes need to verify that the debounce actually waits before triggering. This recipe shows you how to test the debounce behavior itself using Vitest's fake timers in "manual" mode.
๐ฝ๏ธ Before You Startโ
๐๏ธ Controlling Time in Tests
Understand why time-based behavior is challenging to test and how fake timers and dynamic timing configuration address different scenarios.
The Goalโ
We will take the example of a CookbookFilterForm component with a debounced text input โ the filterChange output is emitted 300ms after the user stops typing.
Let's assume you want to verify that filterChange is not emitted while the debounce is still pending, and that it is only emitted after the debounce delay has passed.
With real timers, there is no reliable way to assert that something has not happened yet. You would need to wait "long enough" and hope the debounce hasn't fired โ a brittle and slow approach. The alternative is to use fake timers.
1. Listen to the Component's Outputโ
First, let's create a mount function that we will reuse across the different tests in this file. It mounts the component and listens to the filterChange output with a Vitest spy.
We use Angular's outputBinding to listen to component outputs. (See Angular docs).
import { outputBinding } from '@angular/core';
function mountFilterForm() {
const filterChangeSpy = vi.fn<(filter: Filter) => void>();
TestBed.createComponent(CookbookFilterForm, {
bindings: [outputBinding<Filter>('filterChange', filterChangeSpy)],
});
return { filterChangeSpy };
}
2. Enable Fake Timersโ
To take control of time, replace the real timers with Vitest's fake timers using vi.useFakeTimers(). Since the default mode is manual, all timers will be paused until you explicitly advance time.
Since fake timers pause all timers โ including Angular's internal scheduling โ you need to call vi.runAllTimersAsync() after creating the component to make sure Angular is fully initialized.
Fake timers must be installed before creating the component. Otherwise, you might mix real timers with fake timers and get unexpected behavior.
async function mountFilterForm() {
const filterChangeSpy = vi.fn<(filter: Filter) => void>();
vi.useFakeTimers();
TestBed.createComponent(CookbookFilterForm, {
bindings: [outputBinding<Filter>('filterChange', filterChangeSpy)],
});
await vi.runAllTimersAsync();
return { filterChangeSpy };
}
3. Advance Time Manuallyโ
Now you can use vi.advanceTimersByTimeAsync() to advance time and assert the debounce behavior:
describe(CookbookFilterForm.name, () => {
it('does not emit filterChange while debounce is pending', async () => {
const { filterChangeSpy } = await mountFilterForm();
await page
.getByRole('textbox', { name: 'Keywords' })
.fill('Angular Testing');
// Advance by 290ms (debounce duration - 10ms).
await vi.advanceTimersByTimeAsync(290);
// filterChange has NOT been emitted yet.
expect(filterChangeSpy).not.toHaveBeenCalled();
});
it('emits filterChange after debounce', async () => {
const { filterChangeSpy } = await mountFilterForm();
await page
.getByRole('textbox', { name: 'Keywords' })
.fill('Angular Testing');
// Advance by 310ms (debounce duration + 10ms).
await vi.advanceTimersByTimeAsync(310);
expect(filterChangeSpy).toHaveBeenCalledExactlyOnceWith({
keywords: 'Angular Testing',
});
});
});
You might wonder why we use 290ms and 310ms instead of 299ms and 300ms. This is because time is not a precise science โ nested timers can add extra milliseconds. Using a small margin makes your tests more resilient and a bit more structure-insensitive.
Async suffixUnless you really know what you are doing, always use the async versions like vi.advanceTimersByTimeAsync and vi.runAllTimersAsync. They flush the microtasks queue and produce a behavior that is more symmetric to production.
4. Restore the Real Timersโ
To avoid affecting other tests, restore real timers after each test. Use Vitest's onTestFinished hook to keep setup and teardown colocated.
import { onTestFinished } from 'vitest';
describe(CookbookFilterForm.name, () => {
it('does not emit filterChange while debounce is pending', async () => {
const { filterChangeSpy } = await mountFilterForm();
await page
.getByRole('textbox', { name: 'Keywords' })
.fill('Angular Testing');
await vi.advanceTimersByTimeAsync(290);
expect(filterChangeSpy).not.toHaveBeenCalled();
});
it('emits filterChange after debounce', async () => {
const { filterChangeSpy } = await mountFilterForm();
await page
.getByRole('textbox', { name: 'Keywords' })
.fill('Angular Testing');
await vi.advanceTimersByTimeAsync(310);
expect(filterChangeSpy).toHaveBeenCalledExactlyOnceWith({
keywords: 'Angular Testing',
});
});
});
async function mountFilterForm() {
const filterChangeSpy = vi.fn<(filter: Filter) => void>();
vi.useFakeTimers();
onTestFinished(() => {
vi.useRealTimers();
});
TestBed.createComponent(CookbookFilterForm, {
bindings: [outputBinding<Filter>('filterChange', filterChangeSpy)],
});
await vi.runAllTimersAsync();
return { filterChangeSpy };
}
Get the Full Pictureโ
Now you know how to test debounce timing with fake timers. See how this fits into a Full Pragmatic Angular Testing Strategy โ with hands-on exercises and live guidance.

Source Codeโ

