skip to content

In an Angular test, why doesn't an effect() run right after a signal write, and what does TestBed.tick() do about it?

level: middleimportance: should knowfreq 36%

answer

  1. effects are never synchronous
  2. tied to change detection
  3. one synchronous pass on demand
  4. the old name for it

basics

~20 s

Angular effects run asynchronously as part of change detection, never inside the signal write. TestBed.tick() runs that pending work synchronously, flushing root effects and refreshing test views, so the test can assert the effect's result right away.

solid answer

~40 s

Angular's guide states that effects always execute **asynchronously, during change detection**: root effects (created in services) run before components are checked, view effects before their component is checked. A signal write only marks the effect dirty and schedules work, so an assertion on the very next line sees the old outcome. `TestBed.tick()` runs Angular's pending synchronization **now**: it calls `ApplicationRef.tick()` with every test view included, which flushes dirty root effects and refreshes views and their effects. It replaced `TestBed.flushEffects()`, still present in 22.2 but deprecated. Alternatives are awaiting `fixture.whenStable()` or advancing fake timers in zoneless tests, but for a service with no fixture, `TestBed.tick()` is the direct tool.

go deeper

for a junior

Remember that effects do not run inside the signal write, and that TestBed.tick() makes pending effects run in a test.

for a middle

Explain root versus view effect timing, what TestBed.tick() runs, and the flushEffects rename.

for a senior

Pick the right flush for each test shape, service-only, zone-based fixture or zoneless with fake timers, and keep effects free of state propagation that makes tests loop.

for a principal

Set guidance on when effects are the right tool at all, since effect-heavy services push tests toward extra synchronization steps.

## The surprise A service remembers recent searches and persists them with an effect: ```ts import {Injectable, effect, signal} from '@angular/core'; @Injectable({providedIn: 'root'}) export class RecentSearches { readonly items = signal<string[]>([]); constructor() { effect(() => { localStorage.setItem('recent-searches', JSON.stringify(this.items())); }); } } ``` A test writes the signal and immediately checks `localStorage`: ```ts const recent = TestBed.inject(RecentSearches); recent.items.set(['ssd']); expect(localStorage.getItem('recent-searches')).toBe('["ssd"]'); // fails ``` ## Why: effects belong to change detection Angular's signals guide is precise about timing: - Effects **always execute asynchronously**, during the change detection process. - **Root effects** — created outside components, as in this service — run before any component is checked. - **View effects** — created in components and directives — run before their component is checked. - If an effect's dependencies change while it runs, it re-runs before change detection moves on. Writing a signal only marks dependent effects dirty and tells Angular's scheduler that work is pending. Nothing runs inside `set()`. Even the effect's **first** run waits for change detection, so in this test it has not run at all when the assertion executes. ## What TestBed.tick() does `TestBed.tick()` is documented as "Execute any pending work required to synchronize model to the UI." Its implementation injects `ApplicationRef` and calls `tick()` with **all test views included**, whether or not their fixtures auto-detect. One synchronous pass therefore: 1. flushes dirty **root effects**; 2. refreshes the views that need it, running **view effects** before each component is checked; 3. repeats while effects or views keep becoming dirty, up to Angular's loop limit; 4. runs after-render hooks. ```ts const recent = TestBed.inject(RecentSearches); recent.items.set(['ssd']); TestBed.tick(); expect(localStorage.getItem('recent-searches')).toBe('["ssd"]'); ``` ## Naming history | API | Status in 22.2 | |---|---| | `TestBed.tick()` | Current | | `TestBed.flushEffects()` | Deprecated alias that calls `tick()`; the v20 changelog announced its removal, but the 22.2 source still ships it marked deprecated | | `tick()` from `fakeAsync` | Unrelated: advances a zone.js virtual clock | The name clash with `fakeAsync`'s `tick(ms)` trips people up. `TestBed.tick()` does not move time; it runs Angular's synchronization. A debounce inside an effect still needs time to pass. ## Other ways to let effects run - **With a fixture, zoneless:** `await fixture.whenStable()` waits for the scheduled pass, effects included. - **Zone-based fixture:** `fixture.detectChanges()` flushes root effects before checking the component, mirroring `tick()`'s order. - **Zoneless with fake timers:** advancing the clock lets the queued pass run. - **Service only, no fixture:** `TestBed.tick()` is the direct answer; there is nothing else to await. ## Effects inside components View effects created in a component follow the component's change detection. In a component test: - the effect's first run happens during the first render of the fixture, not at `createComponent`; - after a signal write, the effect runs in the next pass, just before that component is checked; - in a zoneless test, `await fixture.whenStable()` covers it, because the pass is a pending task; - if the fixture is not attached to automatic change detection (a zone-based fixture without auto-detect), `TestBed.tick()` still reaches it, since it includes every test view. The rule is the same everywhere: an effect runs when Angular synchronizes, so the test's job is to make Angular synchronize before asserting. ## Testing an effect that depends on injection context `effect()` must be created in an injection context. In a service constructor that is automatic. For a standalone function that creates an effect, run it with `TestBed.runInInjectionContext(() => ...)`, then write signals and call `TestBed.tick()`. ## Pitfalls - Asserting on an effect's side effect right after the write, without any flush. - Using `TestBed.tick()` to try to fire a timer; it does not advance time. - Writing to signals inside the effect under test and hitting loops; the guide warns against using effects to propagate state for exactly this reason.

  • Does TestBed.tick() advance timers the way fakeAsync's tick(ms) does?
    No. `TestBed.tick()` runs one synchronous `ApplicationRef.tick()` over the test views: pending root effects, view refresh with view effects, and after-render hooks. It does not move any clock. If an effect schedules a timeout, you still need `fakeAsync`'s `tick(ms)` or the runner's fake timers to fire it.
  • How do you test a standalone function that creates an effect, outside any service or component?
    Call it inside `TestBed.runInInjectionContext(() => ...)`, because `effect()` needs an injection context and the effect is tied to that injector's lifetime. Then write the input signals and call `TestBed.tick()` to run the effect synchronously before asserting.

saying these in an interview costs you the question

  • effect() runs synchronously inside every signal write.
  • An effect's first run happens when effect() is called.
  • TestBed.tick() advances the fake clock like fakeAsync's tick.
  • TestBed.flushEffects() is the current recommended name.
  • Root effects run only after all components have been checked.