Testing Angular Signals and Effects: TestBed.tick(), Inputs and Resources [2026]
Signals make application state easier to test than it has ever been in Angular. A signal() is a plain function you can call, a computed() gives you its value synchronously, and neither needs a component or a fake event to observe. The friction appears at the edges: an effect that never seems to run in a test, a signal input that rejects assignment, a resource() that's still loading when your assertion runs, a debounce that makes the test wait for real time.
This is lesson 11.2 of the Angular Tutorial. It builds on lesson 11.1, where you set up Vitest with ng test, and on the signals lessons in Module 3, especially effect() vs computed(). You'll learn which signal APIs you can test directly, how to flush effects with TestBed.tick(), how to drive signal inputs and models, how to test toSignal() and resource(), and how to control time with Vitest's fake timers.
What needs Angular and what doesn't #
The first question for any signal test is whether you need TestBed at all:
| API | Needs an injection context? | How to test it |
|---|---|---|
signal(), computed(), linkedSignal() |
No | Call them directly in a plain test |
effect() |
Yes | Create it in TestBed.runInInjectionContext() or a component, then TestBed.tick() |
input(), model(), output() |
Yes (component fields) | Create the component, use fixture.componentRef.setInput() |
toSignal(), toObservable() |
Yes | TestBed.runInInjectionContext() |
resource(), httpResource() |
Yes | Inject an Injector, TestBed.tick(), then await ApplicationRef.whenStable() |
Keep business rules in the "No" row where you can. A computed() in a service is tested in microseconds, with no fixture and no DOM.
Testing signals and computed directly #
import { describe, it, expect } from 'vitest';
import { signal, computed } from '@angular/core';
describe('cart totals', () => {
it('recomputes when an item is added', () => {
const items = signal([{ price: 10, qty: 2 }]);
const total = computed(() => items().reduce((s, i) => s + i.price * i.qty, 0));
expect(total()).toBe(20);
items.update(list => [...list, { price: 5, qty: 1 }]);
expect(total()).toBe(25);
});
});
computed() is lazy and synchronous: reading it after a set or update always returns the fresh value. There's nothing to flush.
linkedSignal() is just as direct. Its interesting behaviour is the reset, so test both halves: that it can be written, and that a source change overrides the write:
it('resets the page when the query changes', () => {
const query = signal('lamp');
const page = linkedSignal({ source: query, computation: () => 1 });
page.set(3);
expect(page()).toBe(3); // writable like a normal signal
query.set('desk');
expect(page()).toBe(1); // recomputed from the new source
});
A signal-based store service is nearly as simple. TestBed.inject() gives you the instance with its dependencies resolved:
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly items = signal<CartItem[]>([]);
readonly count = computed(() => this.items().reduce((n, i) => n + i.qty, 0));
readonly isEmpty = computed(() => this.count() === 0);
add(item: CartItem) {
this.items.update(list => [...list, item]);
}
}
it('tracks count and emptiness', () => {
const store = TestBed.inject(CartStore);
expect(store.isEmpty()).toBe(true);
store.add({ id: 'a', price: 10, qty: 3 });
expect(store.count()).toBe(3);
expect(store.isEmpty()).toBe(false);
});
Test through the public API (add, count) rather than reaching for the private items signal. If a test needs to set internal state directly, that's usually a sign the service is missing a method.
Testing effects with TestBed.tick() #
Effects are asynchronous by design: Angular schedules them and runs them as part of change detection, not at the moment a signal changes. In a test, nothing runs them until you ask. TestBed.tick() runs the pending work needed to synchronise the application, including effects:
@Injectable({ providedIn: 'root' })
export class ThemeStore {
readonly theme = signal<'light' | 'dark'>('light');
constructor() {
effect(() => localStorage.setItem('theme', this.theme()));
}
}
it('persists the theme', () => {
const store = TestBed.inject(ThemeStore);
const setItem = vi.spyOn(Storage.prototype, 'setItem');
TestBed.tick(); // initial run
expect(setItem).toHaveBeenLastCalledWith('theme', 'light');
store.theme.set('dark');
expect(setItem).not.toHaveBeenCalledWith('theme', 'dark'); // not yet
TestBed.tick();
expect(setItem).toHaveBeenLastCalledWith('theme', 'dark');
});
The middle assertion is worth keeping in at least one test: it documents that the effect is deferred, which is exactly the behaviour that surprises people.
You'll still see TestBed.flushEffects() in older code. It's deprecated in favour of TestBed.tick(), which does the same job and more.
To test a standalone effect without a service, create it in an injection context:
it('logs every change', () => {
const count = signal(0);
const log: number[] = [];
TestBed.runInInjectionContext(() => {
effect(() => log.push(count()));
});
TestBed.tick();
count.set(1);
count.set(2);
TestBed.tick();
expect(log).toEqual([0, 2]); // effects see the latest value, not every intermediate one
});
That last expectation is a useful one to internalise: effects are not event listeners. Two synchronous set calls produce one effect run.
Signal inputs, models and outputs #
You can't assign to a signal input (component.product = ... is a type error, because input() returns a read-only signal). Set it the way a parent template would, through the component reference:
@Component({
selector: 'app-price-tag',
template: `<span class="price">{{ formatted() }}</span>`,
})
export class PriceTag {
amount = input.required<number>();
currency = input('EUR');
formatted = computed(() =>
new Intl.NumberFormat('en', { style: 'currency', currency: this.currency() }).format(this.amount()),
);
}
it('formats the amount', async () => {
const fixture = TestBed.createComponent(PriceTag);
fixture.componentRef.setInput('amount', 12.5);
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('€12.50');
fixture.componentRef.setInput('currency', 'USD');
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('$12.50');
});
Set required inputs before the first whenStable(); rendering a component whose required input has no value throws NG0950. setInput uses the public input name, so for input(..., { alias: 'value' }) you pass 'value'.
Outputs and models expose subscribe(), so you can listen without a host template:
it('emits the new quantity', async () => {
const fixture = TestBed.createComponent(QuantityPicker); // qty = model(1)
const emitted: number[] = [];
fixture.componentInstance.qty.subscribe(v => emitted.push(v));
await fixture.whenStable();
fixture.nativeElement.querySelector('button.increment').click();
await fixture.whenStable();
expect(emitted).toEqual([2]);
});
Lesson 11.3 shows the alternative: a small host component that binds [(qty)], which also verifies the template-facing contract.
Testing toSignal() #
toSignal() subscribes immediately and needs an injection context so it can unsubscribe on destroy. Feed it a Subject you control:
it('reflects the latest emission', () => {
const source = new Subject<number>();
const value = TestBed.runInInjectionContext(() => toSignal(source, { initialValue: 0 }));
expect(value()).toBe(0);
source.next(5);
expect(value()).toBe(5); // synchronous: no tick needed to read it
});
Reading the signal is synchronous. If something derived from it is an effect or a template, you still need TestBed.tick() or whenStable() for that consumer to run.
Testing resource() and httpResource() #
A resource starts its loader from an internal effect and resolves asynchronously, so a test has two waits: one to start the request, one for the result to settle:
it('loads the product for the current id', async () => {
const api = { get: vi.fn(async (id: number) => ({ id, name: `Product ${id}` })) };
const id = signal(1);
const product = resource({
params: () => ({ id: id() }),
loader: ({ params }) => api.get(params.id),
injector: TestBed.inject(Injector),
});
TestBed.tick(); // starts the loader
await TestBed.inject(ApplicationRef).whenStable(); // waits for it to settle
expect(product.status()).toBe('resolved');
expect(product.value()).toEqual({ id: 1, name: 'Product 1' });
id.set(2);
TestBed.tick();
await TestBed.inject(ApplicationRef).whenStable();
expect(api.get).toHaveBeenLastCalledWith(2);
});
For httpResource(), use the same two waits with HttpTestingController in between: TestBed.tick(), then expectOne() and flush() the request, then await whenStable() before asserting on value(). Lesson 11.4 covers HttpTestingController in detail.
Controlling time with Vitest fake timers #
Code that waits, such as a toast that dismisses itself or a debounce, shouldn't make tests wait. Vitest can replace timers with a controllable clock:
@Injectable({ providedIn: 'root' })
export class Toasts {
readonly current = signal<string | null>(null);
show(message: string, ms = 3000) {
this.current.set(message);
setTimeout(() => this.current.set(null), ms);
}
}
describe('Toasts', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('dismisses after the timeout', async () => {
const toasts = TestBed.inject(Toasts);
toasts.show('Saved');
expect(toasts.current()).toBe('Saved');
await vi.advanceTimersByTimeAsync(2999);
expect(toasts.current()).toBe('Saved');
await vi.advanceTimersByTimeAsync(1);
expect(toasts.current()).toBeNull();
});
});
Prefer the Async variants (advanceTimersByTimeAsync, runAllTimersAsync): they let promises resolve between timer callbacks, which matters as soon as async code and timers mix. Always restore real timers in afterEach, or the next test inherits a frozen clock.
In zoneless tests, vi.useFakeTimers() replaces the zone-based fakeAsync() and tick() helpers, which depend on zone.js.
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
| Effect never runs in the test | Effects are scheduled, not synchronous | Call TestBed.tick() (or await fixture.whenStable() for component effects) |
NG0203: inject() must be called from an injection context |
Created effect(), toSignal() or resource() in a plain test body |
Wrap in TestBed.runInInjectionContext() or pass injector |
NG0950 when creating a component |
Required input not set before first render | fixture.componentRef.setInput() before whenStable() |
Type error assigning component.amount = 5 |
Signal inputs are read-only | Use setInput('amount', 5) |
Resource still loading at the assertion |
Only started it, didn't wait for it | TestBed.tick() then await ApplicationRef.whenStable() |
| Later tests time out after a fake-timer test | Fake timers left installed | vi.useRealTimers() in afterEach |
What's next #
Lesson 11.3 moves from individual signals to whole components: TestBed with standalone imports, zoneless change detection in tests, whenStable() versus detectChanges(), OnPush, overriding providers, and Angular CDK component harnesses for stable, readable DOM interactions.
Try it yourself #
Pick a service in your app that holds state in signals and has at least one effect(). Write one test for its computed() values without a component, and one test that proves the effect runs only after TestBed.tick(). If either is hard to write, look at whether the service exposes the right public methods.
search_documentationEffects don’t run when a signal changes; Angular schedules them and runs them during its next synchronisation. In the app that happens automatically. In a test, nothing triggers it, so add TestBed.tick() after theme.set('dark') and before the assertion. If your code still calls TestBed.flushEffects(), switch to tick(), since flushEffects() is deprecated. Two more things to check in ThemeStore.spec.ts: create the spy before the first tick() so you also see the initial run, and remember that two set() calls before a tick() produce one effect run with the latest value, so assert on toHaveBeenLastCalledWith rather than a call count.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


