Rainer Hahnekamp
Posted on March 7, 2024
Signals as lightweight "reactive primitive" will shape the future of Angular applications. At the time of this writing, signal()
and computed()
are stable, and effect()
is in developer preview.
Developer preview doesn't mean we have an unstable feature but that the Angular team can introduce breaking changes within a major version. The feature is stable; otherwise, it would have the experimental label.
Especially with Signal Inputs, Signals find more and more usage in everyday tasks. It is time to consider integrating Signals into our codebases.
And that requires us to be able to write tests as well.
If you are more of a visual learner, here's a video for you:
Signals & Reactivity in a nutshell
With computed()
and effect(),
we create derived Signals or run side effects if a Signal's value changes. That is the reactive nature of Signals.
Compared to RxJs, reactivity does not require a manual subscription. Angular does that for you.
Signals, i.e., created by signal()
or computed()
, need to run inside a so-called Reactive Context. In Angular, there are two of them:
- A Component's template
- The
effect()
function
In other words, a Signal becomes reactive whenever we call a Signal inside a template or an effect()
.
When a "reactive Signal" changes, the Signal will notify consumers who are computed(),
effect()
or a template. computed()
could have further consumers that could also have consumers on their end, and so on...
Depending on the Reactive Context, a DOM update (template) or a side effect (effect()
) runs.
The Reactive Context runs during the Change Detection: Only the last value gets through. Even if a Signal changes multiple times between Change Detection runs. That means a computed(),
an effect(),
and the template processes the last value, not the intermediary ones.
This behavior makes total sense when looking at Signals from the perspective of a frontend framework. If we have three synchronous (intermediate) changes, why do we want to update the DOM three times? End users wouldn't see the new value if it were technically possible because it would change in the following rendering frame. It is much better to wait until the Signal reaches its "stable state" and then start updating the DOM.
This behavior is known under the term "Glitch-free effect" or "Push/Pull"
Maybe this animation might help you understand the "Glitch-free effect" as well.
Component under Test
Our Component is a basket in an online shop where users can increase or decrease the amount of products:
@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 price is a computed value. It updates on any change of the products
Signal.
The same is true for SyncService
. It has the following implementation:
@Injectable({ providedIn: 'root' })
export class SyncService {
sync(products: Product[]) {
console.log(products);
}
}
Not that much 😀, but we'd like to keep the example as short as possible.
Testing with Change Detection
Given the central role of Change Detection for Signals, it should be evident that testing them becomes much easier if the Change Detection is part of the test.
That's always the case when our tests communicate with the Component via the DOM and create the Component via TestBed.createComponent
.
Testing computed()
This is a test that verifies the total price via 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');
});
That test works as expected. No surprises!
Everything is alright if we run the Change Detection in the right places. That right place is after an event, like a click, and at the beginning to initialize the Component.
Testing effect()
It becomes slightly trickier if we include the effect()
in our test.
Since effect()
runs the SyncService
, we want to count its calls.
During every Change Detection, the effect()
checks if the value of products
has changed. If yes, the effect()
runs the SyncService
.
We cannot assert the execution of the SyncService
via the DOM. That's why we have to apply a spy on the SyncService
instance. For that we require access to the componentInstance
.
The updated 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);
});
We can verify that the effect()
only runs when products
change and the Change Detection is executed.
The following two tests have to fail:
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 can increase the amount in our basket, and we should see that after another run of the Change Detection, the SyncService
should have run two times:
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);
});
Let's move to another testing type where the Change Detection is unavailable.
Testing without Change Detection
We extract the logic of the BasketComponent
into a new Service, 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);
}
}
We keep the old tests. It now covers the Component and the two Services.
How would a test look like that only covers the BasketService
?
We cannot run fixture.detectChanges()
anymore because there is no Component and therefore no ComponentFixture
.
Testing computed()
A test for SignalService
that asserts the totalPrice
Signal would look like this:
it('should test the BasketService', () => {
const basketService = TestBed.inject(BasketService);
expect(basketService.totalPrice()).toBe(18);
basketService.increase(1);
expect(basketService.totalPrice()).toBe(21);
});
The test above works. Why? Doesn't the totalPrice
require a Change Detection run to become reactive?
Yes, that's true. In our case, though, we don't use totalPrice()
in a reactive way. We call it directly.
At any given time, a Signal based on a computed()
knows if its dependencies have changed (i.e., it is dirty) and would have to do a re-processing.
To start the re-processing, it waits for somebody to request its value.
In the Component test, the "caller" was the Change Detection. In our test, it was us.
Testing effect()
Whereas computed()
Signals are simple to test without Change Detection, the real challenge is the effect()
.
As computed()
, the effect()
knows internally if it is dirty (one of its dependencies changed its value). Unfortunately, we can't access it like a computed()
Signal.
In Angular 16, the only way to test an effect()
was to wrap the Service into a "Helper Component". Since Angular 17, we have a new function, which "calls" an effect: TestBed.flushEffects()
.
Let's see that one in action:
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);
});
Again, we could observe the same behavior as with the Component test. For an effect()
to run, two things are necessary:
- The
effect()
needs to be dirty and - there has to be an "active access".
These tests will fail again:
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);
});
Summary
We must know the "Glitch-free" effect if we test code that uses Signals.
Whenever Change Detection is part of the test, the test behaves in the same way as our application.
For those testing types where the Change Detection is not included, we have to call Signals directly and run TestBed.flushEffects()
to execute "dirty side effects".
You can access the repository at https://github.com/rainerhahnekamp/how-do-i-test
If you encounter a testing challenge you'd like me to address here, please get in touch with me!
For additional updates, connect with me on LinkedIn, X, and explore our website for workshops and consulting services on testing.
Posted on March 7, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.