V20 Cranks the Heat on Half-Baked Tests
In Angular 20, the TestBed
is becoming more error-sensitive.
More specifically, errors thrown in event or output listeners might break your tests.
- Add a
ThrowingErrorHandler
to your tests to see if any of your tests will break in v20. - If you can't solve the problem, swallow the specific errors with a custom
ErrorHandler
. This will help you keep a list of errors to fix later without creating new problems. - Avoid disabling
rethrowApplicationErrors
option as this will open the door for even more false negatives.
Previously, in Angular...โ
Pre 18.2.0-next.3
Eraโ
Before version 18.2.0-next.3, most errors were ignored by the tests. For instance, the test below would pass, and the error would simply be logged and ignored.
- Simple Example
- Realistic Example
@Component({
template: `
<p>Welcome {{ name() }}</p>
<p>You've got {{ notificationCount() }} notifications</p>
`,
})
class Greetings {
name = signal('Younes');
notificationCount() {
// ๐ค wondering why this would happen in real life?
// Cf. "Realistic Example" tab โฌ๏ธ.
throw new Error('๐ฅ');
}
}
const fixture = TestBed.createComponent(Greetings);
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('Welcome');
// Yeah! Our Spying Stub (or "Mock" if you prefer) is properly typed.
// But, oups! We forgot to pre-program a valid return value for `getNotifications`,
// so it's returning `undefined` instead of an `Observable<Notification[]>`.
const notificationsRepository: Mocked<NotificationsRepository> = {
getNotifications: vi.fn(),
};
@Component({
template: `
<p>Welcome {{ name() }}</p>
<p>You've got {{ notificationCount() }} notifications</p>
`,
})
class Greetings {
name = signal('Younes');
notificationCount = toSignal(
inject(NotificationsRepository)
.getNotifications()
.pipe(map((notifications) => notifications.length)),
// ^ TypeError: Cannot read properties of undefined (reading 'pipe')
);
}
const fixture = TestBed.createComponent(Greetings);
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('Welcome');
Prefer Fakes to Spying Stubs or "Mocks". Cf. "Fake it till you make it" Chapter.
Post 18.2.0-next.3
Era โ The Hidden Flag Episodeโ
A crucial thing in testing is avoiding false negatives: tests that pass while they shouldn't.
In Angular 18.2.0-next.3, the Angular Team, and especially Andrew Scott started working on making the TestBed more error-sensitive. It started with a hidden flag to increase the error sensitivity on Google's internal codebase.
Interestingly, this broke ~200 tests internally at Google. This clearly highlights that such false negatives are not uncommon. Of course, it is hard to tell whether the components were really broken or not (e.g. unrealistic data, or unrealistic mocking).
Post 19.0.0-next.0
Eraโ
19.0.0-next.0 introduced the new rethrowApplicationErrors
option to the TestBed.configureTestingModule()
. It is set to true
by default.
This causes the TestBed
to rethrow the errors that are caught by Angular instead of swallowing them.
If the error happens while you are waiting for stability with fixture.whenStable()
, the promise will reject with the error.
Post 20.0.0-next.5
Eraโ
20.0.0-next.5 went a step further by rethrowing errors coming from event or output listeners.
The following test would now throw an error:
import { fireEvent, screen } from '@testing-library/dom';
@Component({
template: `<button (click)="cook()">Cook</button>`,
})
class CookButton {
private readonly _cooked = signal(false);
cook() {
if (this._cooked()) {
throw new Error('๐ฅ');
}
this._cooked.set(true);
}
}
TestBed.createComponent(CookButton);
const buttonEl = await screen.findByRole('button');
await fireEvent.click(buttonEl);
/* Second click overcooks and throws an error. */
await fireEvent.click(buttonEl);
Another example: triggering side-effect events such as mouseenter
when clicking with @testing-library/user-event
, and the mouseenter
listener throws because some test double is not realistic (Cf. "Fake it till you make it" Chapter).
How to prepare for this change?โ
1. Rethrow errorsโ
To reproduce v20's behavior in v19 โ or earlier โ and make sure that your tests are not affected, you can implement a custom ErrorHandler
that rethrows errors instead of swallowing them.
@Injectable()
class ThrowingErrorHandler implements ErrorHandler {
handleError(error: unknown) {
throw error;
}
}
function provideThrowingErrorHandler(): EnvironmentProviders {
return makeEnvironmentProviders([
{
provide: ErrorHandler,
useClass: ThrowingErrorHandler,
},
]);
}
TestBed.configureTestingModule({
providers: [provideThrowingErrorHandler()],
});
2. Swallow specific errors if you need more time to fix themโ
If breaks some tests, and you need more time to fix them, you could make the error handler temporarily ignore the test's specific errors.
@Injectable()
class ThrowingErrorHandler implements ErrorHandler {
private _swallowingPredicates: ((error: unknown) => boolean)[] = [];
handleError(error: unknown) {
if (this._swallowingPredicates.some((predicate) => predicate(error))) {
console.warn('ThrowingErrorHandler swallowed error:', error);
return;
}
throw error;
}
swallowError(predicate: (error: any) => boolean) {
this._swallowingPredicates.push(predicate);
}
}
function provideThrowingErrorHandler(): EnvironmentProviders {
return makeEnvironmentProviders([
ThrowingErrorHandler,
{
provide: ErrorHandler,
useExisting: ThrowingErrorHandler,
},
]);
}
TestBed.inject(ThrowingErrorHandler).swallowError((error) =>
error?.message?.includes('๐ฅ'),
);
TestBed.createComponent(CookButton);
const buttonEl = await screen.findByRole('button');
await fireEvent.click(buttonEl);
await fireEvent.click(buttonEl);
3. Disable rethrowApplicationErrors
but keep the ThrowingErrorHandler
โ
If you really can't fix the errors by the time you migrate to v20, you can disable rethrowApplicationErrors
but keep ThrowingErrorHandler
to avoid introducing new errors.
โ Want more tips on how to write future-proof tests?
Join my Pragmatic Angular Testing Course.
Special Thanksโ
Special thanks to @AndrewScott for raising my awareness about this issue while discussing Flushing flushEffects
.
Additional Resourcesโ
Today's Dash: ThrowingErrorHandler
โ
Ready to be Copied, Stirred, and Served.
@Injectable()
class ThrowingErrorHandler implements ErrorHandler {
private _swallowingPredicates: ((error: unknown) => boolean)[] = [];
handleError(error: unknown) {
if (this._swallowingPredicates.some((predicate) => predicate(error))) {
console.warn('ThrowingErrorHandler swallowed error:', error);
return;
}
throw error;
}
swallowError(predicate: (error: any) => boolean) {
this._swallowingPredicates.push(predicate);
}
}
function provideThrowingErrorHandler(): EnvironmentProviders {
return makeEnvironmentProviders([
ThrowingErrorHandler,
{
provide: ErrorHandler,
useExisting: ThrowingErrorHandler,
},
]);
}
Related Angular PRsโ
For more detailed understanding, you can dive into the related PRs.