Skip to main content

V20 Flushes flushEffects Down the Sink

warning

In Angular 20, TestBed.flushEffects() behavior changes.
Without anticipation, migrating to Angular 20 might break some of your tests.

It is also deprecated in favor of TestBed.tick() and they both have the same behavior.

Initially, TestBed.flushEffects() was planned for removal in Angular 20. As the documentation did not highlight that it was in Developer Preview, the team listened to the community's feedback and kept it for a smoother migration.

TestBed.tick() is not a drop-in replacement for TestBed.flushEffects() — it does more than just flushing root effects. It triggers Angular synchronization (change detection, root effects, component effects, etc...), making tests more symmetric to production, and therefore more reliable.

In most cases, that's an improvement, but some tests with questionable design might break.

TL;DR
  1. Monkey-patch TestBed.flushEffects() temporarily and fix broken tests before migrating to v20 and TestBed.tick().
  2. Prefer using a utility such as runInAngular() when narrowing down your tests to reactive logic that lives beneath components and services.
  3. Think twice before narrowing down your tests to such granularity.

TestBed.tick() Might Not Be What You Need

Angular's synchronization should be treated as an implementation detail. Tests should generally avoid interfering with it.

Let's start with a typical test using TestBed.tick():

test('Favs auto-saves the favorite recipe in the storage', async () => {
const storage = TestBed.inject(StorageFake);
const favs = TestBed.inject(Favs);

favs.recipe.set('burger');

TestBed.tick();

expect(storage.getSync('favorite-recipe')).toBe('"burger"'); // ✅
});

@Injectable({ providedIn: 'root' })
class Favs {
readonly recipe = signal('babaganoush');
readonly saving = autoSave('favorite-recipe', this.recipe);
}

function autoSave<T>(key: string, data: Signal<T>) {
const storage = inject(Storage);
/* WARNING: as of 20.0.0-rc.0, `resource` is still **experimental**. */
const syncResource = resource({
params: data,
loader: async ({ params: value }) => {
await Promise.resolve();
storage.set(key, JSON.stringify(value));
},
});
return syncResource.isLoading;
}

After refactoring our code to wait for some async operation to complete, the test fails because the assertion is made before the microtask is flushed:

test('Favs auto-saves the favorite recipe in the storage', async () => {
const storage = TestBed.inject(StorageFake);
const favs = TestBed.inject(Favs);

favs.recipe.set('burger');

TestBed.tick();

expect(storage.getSync('favorite-recipe')).toBe('"burger"'); // ❌ the microtask was not flushed yet
});

@Injectable({ providedIn: 'root' })
class Favs {
readonly recipe = signal('babaganoush');
readonly saving = autoSave('favorite-recipe', this.recipe);
}

function autoSave<T>(key: string, data: Signal<T>) {
const storage = inject(Storage);
/* WARNING: as of 20.0.0-rc.0, `resource` is still **experimental**. */
const syncResource = resource({
params: data,
loader: async ({ params: value }) => {
await Promise.resolve();
storage.set(key, JSON.stringify(value));
},
});
return syncResource.isLoading;
}

Alternatives

1. Wait for Stability

Use applicationRef.whenStable() to ensure all pending tasks are completed:

test('Favs auto-saves the favorite recipe in the storage', async () => {
const storage = TestBed.inject(StorageFake);
const favs = TestBed.inject(Favs);

favs.recipe.set('burger');

await TestBed.inject(ApplicationRef).whenStable();

expect(storage.getSync('favorite-recipe')).toBe('"burger"'); // ✅
});
info

Under the hood, resource tells Angular that it is loading by leveraging the PendingTasks service.

If you want the same behavior in your own utilities, you should use pendingTasks.run().

2. Polling

Use Vitest's expect.poll() — or Angular Testing Library's waitFor utility for other testing frameworks:

test('Favs auto-saves the favorite recipe in the storage', async () => {
const storage = TestBed.inject(StorageFake);
const favs = TestBed.inject(Favs);

favs.recipe.set('burger');

await expect.poll(() => storage.getSync('favorite-recipe')).toBe('"burger"'); // ✅
});
Potential for False Negatives

Polling may seem robust, but it can yield false negatives: the result might appear valid during a brief window, only to become invalid once the application stabilizes.

Testing Signal Factories

You will often want to test your signal factories such as autoSave without leveraging a component or service. Given that under the hood, it is using dependency injection, you will have to run it in an injection context.

test('autoSave auto-saves when signal changes', async () => {
const storage = TestBed.inject(StorageFake);

const recipe = signal('babaganoush');

TestBed.runInInjectionContext(() => autoSave('favorite-recipe', recipe));

recipe.set('burger');

await TestBed.inject(ApplicationRef).whenStable();

expect(storage.getSync('favorite-recipe')).toBe('"burger"'); // ✅
});

To improve readability, you can implement a runInAngular utility function:

test('autoSave auto-saves when signal changes', async () => {
const storage = TestBed.inject(StorageFake);

const recipe = signal('babaganoush');

await runInAngular(() => {
autoSave('favorite-recipe', recipe);
recipe.set('burger');
});

expect(storage.getSync('favorite-recipe')).toBe('"burger"'); // ✅
});

async function runInAngular<RETURN>(
fn: () => RETURN | Promise<RETURN>,
): Promise<RETURN> {
return TestBed.runInInjectionContext(async () => {
const appRef = inject(ApplicationRef);
const result = await fn();
await appRef.whenStable();
return result;
});
}

Incremental Migration

Before migrating to Angular 20, you can already check whether TestBed.tick() breaks anything by monkey-patching TestBed.flushEffects():

src/test-setup.ts
/* DO NOT KEEP THIS. IT'S ONLY FOR MIGRATION PREPARATION. */
import { TestBed } from '@angular/core/testing';

TestBed.flushEffects = () => TestBed.inject(ApplicationRef).tick();

In the rare occurrence where switch to tick() causes trouble:

  1. I'd love to see your tests 😊.
  2. You can implement a transitional utility function to avoid the big-bang switch:
export function triggerTick() {
TestBed.inject(ApplicationRef).tick();
}

You can then incrementally replace calls to TestBed.flushEffects() with triggerTick() and fix your broken tests before migrating to Angular 20.

Happy migration!

✅ Want more tips on how to write future-proof tests?
Join my Pragmatic Angular Testing Course.

Additional Resources

Today’s Dash: runInAngular

Ready to be Copied, Stirred, and Served.

async function runInAngular<RETURN>(
fn: () => RETURN | Promise<RETURN>,
): Promise<RETURN> {
return TestBed.runInInjectionContext(async () => {
const appRef = inject(ApplicationRef);
const result = await fn();
await appRef.whenStable();
return result;
});
}

For more detailed understanding, you can dive into the related PRs.

Chapter Updates

  • 2025-05-19: TestBed.flushEffects() to be resurrected and deprecated.