Signals & Reactivity: A Quick Overview
Angular's Signals are a compact "reactive primitive" that is poised to influence the direction of future Angular apps. As of now, signal() and computed() are stable APIs, while effect() is offered in developer preview.
Being in developer preview doesn't signify instability. Rather, it means the Angular team reserves the right to make breaking changes within the same major version. If the feature were not production-ready, it would carry the experimental label.
Signals are becoming part of standard workflows, especially with Signal Inputs gaining traction. It's becoming increasingly relevant to consider adopting Signals in our projects, which in turn means we need test strategies for them.
If you're a visual learner, check out this video:
The Mechanics of Signals & Reactivity
We use computed() to produce derived signals and effect() to trigger side effects upon value changes, which constitutes their reactive nature. Unlike RxJs, reactivity here doesn't need a manual subscription; Angular handles it automatically.
Signals produced by signal() or computed() must operate within a "Reactive Context." There are two such contexts in Angular:
- A Component's template
- The
effect()function
Thus, a Signal becomes reactive when it gets referenced either in a template or inside an effect().
When a reactive Signal's value changes, its consumers — whether computed(), effect(), or a template — get notified. A computed() can, in turn, have its own downstream consumers, creating a chain of dependencies.
The type of Reactive Context dictates what happens on change: a template triggers a DOM update, while effect() runs a side effect.
The Reactive Context runs during Change Detection. Importantly, only the latest value ever passes through; even if a Signal changes multiple times before a Change Detection cycle, computed(), effect(), and the template only see the most recent value.
This makes sense from a frontend framework's viewpoint. If three synchronous changes occur in between cycles, why render the DOM three times? Even if it were feasible, the intermediate values would never actually be displayed, as that would mean different rendering frames. The smarter approach is to wait for the Signal's value to settle before updating the DOM.
This behavior is referred to as a "Glitch-free effect" or "Push/Pull".
This animation may help illustrate the “Glitch-free effect”:
The Examined Component
Our example is a shopping cart basket where users can adjust product quantities:
@Component({
selector: 'app-basket',
template: `<h2>Basket</h2>
<div class="w-[640px]">
<div class="grid grid-cols-4 gap-4 auto-cols-fr">
<div class="font-bold">Name</div>
<div class="font-bold">Price</div>
<div class="font-bold">Amount</div>
<div> </div>
@for (product of products(); track product.id) {
<div>{{ product.name }}</div>
<div>{{ product.price }}</div>
<div>{{ product.amount }}</div>
<div>
<button
mat-raised-button
(click)="decrease(product.id)"
data-testid="btn-decrease"
>
<mat-icon>remove</mat-icon>
</button>
<button
mat-raised-button
(click)="increase(product.id)"
data-testid="btn-increase"
>
<mat-icon>add</mat-icon>
</button>
</div>
}
<div class="font-bold">Total</div>
<div class="font-bold" data-testid="total">{{ totalPrice() }}</div>
</div>
</div>`,
standalone: true,
imports: [MatButton, MatIcon],
})
export default class BasketComponent {
products = signal([
{ id: 1, name: 'Coffee', price: 3, amount: 1 },
{ id: 2, name: 'Schnitzel', price: 15, amount: 1 },
]);
syncService = inject(SyncService);
constructor() {
effect(() => this.syncService.sync(this.products()));
}
totalPrice = computed(() =>
this.products().reduce(
(total, product) => total + product.price * product.amount,
0,
),
);
decrease(id: number) {
this.#change(id, (product) =>
product.amount > 0 ? { ...product, amount: product.amount - 1 } : product,
);
}
increase(id: number) {
this.#change(id, (product) => ({ ...product, amount: product.amount + 1 }));
}
#change(id: number, callback: (product: Product) => Product) {
this.products.update((products) =>
// some logic to update the products
);
}
}
The total shown is a computed() value, refreshing whenever the products Signal changes.
The SyncService functions similarly. Its implementation looks like this:
@Injectable({ providedIn: 'root' })
export class SyncService {
sync(products: Product[]) {
console.log(products);
}
}
Quite concise 😀, but we prefer keeping the example straightforward.
Testing In Conjunction with Change Detection
Given how crucial Change Detection is for Signals, it’s clear that testing becomes simpler when the test includes it. This is always the case when we interact with the Component through the DOM and instantiate it using TestBed.createComponent.
Verifying computed()
Here’s a test that checks the total price through the DOM:
it('should increase the quantity of the product', () => {
const fixture = TestBed.configureTestingModule({
imports: [BasketComponent],
}).createComponent(BasketComponent);
fixture.detectChanges();
const total: HTMLDivElement = fixture.debugElement.query(
By.css('[data-testid="total"]'),
).nativeElement;
expect(total.textContent).toBe('18');
fixture.debugElement
.query(By.css('[data-testid="btn-increase"]'))
.nativeElement.click();
fixture.detectChanges();
expect(total.textContent).toBe('21');
});
This test behaves exactly as anticipated; it’s predictable.
The key is to trigger Change Detection at the proper moments: after user events like clicks, and once initially to set up the component's state.
Testing effect()
Adding effect() to the equation introduces a bit more complexity.
Our effect() invokes SyncService, so we track how many times it gets called.
During each Change Detection cycle, the effect() checks if products has changed since the last cycle. If it has, the effect() calls SyncService.
We can't observe the SyncService's execution from the DOM, so we have to spy on its instance. This requires accessing componentInstance.
Here's the adjusted test:
it('should run the SyncService', () => {
const fixture = TestBed.configureTestingModule({
imports: [BasketComponent],
}).createComponent(BasketComponent);
const syncService = TestBed.inject(SyncService);
const spy = spyOn(syncService, 'sync');
fixture.detectChanges();
expect(spy).toHaveBeenCalledTimes(1);
});
This confirms the effect() executes only when products change, and only following a Change Detection run.
One test that we'd expect to fail is when the value doesn't change but Change Detection runs; another fails if the value changes without triggering Change Detection:
it('should run the SyncService', () => {
const fixture = TestBed.configureTestingModule({
imports: [BasketComponent],
}).createComponent(BasketComponent);
const syncService = TestBed.inject(SyncService);
const spy = spyOn(syncService, 'sync');
// Change Detection did not run
expect(spy).toHaveBeenCalledTimes(1);
})
it('should run the SyncService', () => {
const fixture = TestBed.configureTestingModule({
imports: [BasketComponent],
}).createComponent(BasketComponent);
const syncService = TestBed.inject(SyncService);
const spy = spyOn(syncService, 'sync');
fixture.detectChanges();
expect(spy).toHaveBeenCalledTimes(1);
// no change to total, so no effect
fixture.detectChanges();
expect(spy).toHaveBeenCalledTimes(2);
})
We add an item to the cart, and after another Change Detection cycle, we see the SyncService has been invoked twice:
it('should run the SyncService', () => {
const fixture = TestBed.configureTestingModule({
imports: [BasketComponent],
}).createComponent(BasketComponent);
const syncService = TestBed.inject(SyncService);
const spy = spyOn(syncService, 'sync');
fixture.detectChanges();
expect(spy).toHaveBeenCalledTimes(1);
const total: HTMLDivElement = fixture.debugElement.query(
By.css('[data-testid="total"]'),
).nativeElement;
expect(total.textContent).toBe('18');
fixture.debugElement
.query(By.css('[data-testid="btn-increase"]'))
.nativeElement.click();
fixture.detectChanges();
expect(spy).toHaveBeenCalledTimes(2);
});
Now, let's explore a different testing scenario that doesn't rely on Change Detection.
Testing Without Change Detection
We'll move the logic from BasketComponent into a standalone BasketService:
@Injectable({ providedIn: 'root' })
export class BasketService {
products = signal([
{
id: 1,
name: 'Coffee',
price: 3,
amount: 1,
},
{ id: 2, name: 'Schnitzel', price: 15, amount: 1 },
]);
syncService = inject(SyncService);
constructor() {
effect(() => this.syncService.sync(this.products()));
}
totalPrice = computed(() =>
this.products().reduce(
(total, product) => total + product.price * product.amount,
0,
),
);
decrease(id: number) {
this.#change(id, (product) =>
product.amount > 0 ? { ...product, amount: product.amount - 1 } : product,
);
}
increase(id: number) {
this.#change(id, (product) => ({ ...product, amount: product.amount + 1 }));
}
#change(id: number, callback: (product: Product) => Product) {
this.products.update((products) =>
products.map((product) => {
if (product.id === id && product.amount > 0) {
return callback(product);
} else {
return product;
}
}),
);
}
}
@Component({
selector: 'app-basket',
template: '<!-- template as before -->',
standalone: true,
imports: [MatButton, MatIcon],
})
export default class BasketComponent {
basketService = inject(BasketService);
products = this.basketService.products;
totalPrice = this.basketService.totalPrice;
decrease(id: number) {
this.basketService.decrease(id);
}
increase(id: number) {
this.basketService.increase(id);
}
}
The original tests remain and now exercise the Component along with both services.
What would a test focusing solely on BasketService look like? Since there's no component, our access to fixture.detectChanges() and the ComponentFixture is gone.
Putting computed() to the Test
To verify that the totalPrice signal in SignalService behaves as expected, the test below is what you'd write:
it('should test the BasketService', () => {
const basketService = TestBed.inject(BasketService);
expect(basketService.totalPrice()).toBe(18);
basketService.increase(1);
expect(basketService.totalPrice()).toBe(21);
});
That test passes. But how? Shouldn't the computed value remain stale unless Change Detection kicks in to refresh it?
That's a fair assumption. Here, however, totalPrice() is never consumed in a reactive context — we're simply invoking it as a plain function.
At any moment, a computed() signal tracks whether its source signals have changed, marking itself as dirty. It doesn't recalculate until something explicitly asks for its current value.
In the Component test, Change Detection played that role. In this unit test, we're the ones triggering the evaluation.
Validating effect()
While computed() signals don't require Change Detection for straightforward tests, effect() is where things get tricky.
Like computed(), an effect() is internally aware of becoming stale when its dependencies shift. The difference is that you can't simply call it like you would a computed signal to force its execution.
In Angular 16, the go-to strategy was embedding the service within a host Component. Angular 17 brought a cleaner solution: TestBed.flushEffects(), which explicitly invokes pending effects. Let's see it in practice:
it('should test the BasketService', () => {
const syncService = TestBed.inject(SyncService);
const spy = spyOn(syncService, 'sync');
const basketService = TestBed.inject(BasketService);
TestBed.flushEffects();
basketService.increase(1);
TestBed.flushEffects();
expect(spy).toHaveBeenCalledTimes(2);
});
Just as in the Component-based approach, two criteria must be met for the effect to fire: it must be marked dirty, and someting must actively trigger it.
If those conditions aren't met, tests fail:
it('should test the BasketService', () => {
const syncService = TestBed.inject(SyncService);
const spy = spyOn(syncService, 'sync');
TestBed.inject(BasketService);
// effect didn't run
expect(spy).toHaveBeenCalledTimes(1);
});
it('should test the BasketService', () => {
const syncService = TestBed.inject(SyncService);
const spy = spyOn(syncService, 'sync');
TestBed.inject(BasketService);
TestBed.flushEffects();
expect(spy).toHaveBeenCalledTimes(1);
// effect not dirty
TestBed.flushEffects();
expect(spy).toHaveBeenCalledTimes(2);
});
Wrap-up
When testing code that involves Signals, recognizing the glitch-free mechanism is essential.
When Change Detection is active in a test, everything works as it would in the live application.
For tests that skip Change Detection, you'll need to manually read signals to mark effects as dirty and then call TestBed.flushEffects() to flush any pending side effects.
Get the full code examples from the repository: https://github.com/rainerhahnekamp/how-do-i-test
Have a testing problem you'd like covered? Reach out, and I might feature it here.
Stay in touch on LinkedIn or X, and check out our offerings for workshops and consulting on testing.
