Skip to main content

V20 Cranks the Heat on Half-Baked Tests

warning

In Angular 20, the TestBed is becoming more error-sensitive.

More specifically, errors thrown in event or output listeners might break your tests.

TL;DR
  1. Add a ThrowingErrorHandler to your tests to see if any of your tests will break in v20.
  2. 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.
  3. 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.

@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');

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.

info

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.

src/throwing-error-handler.ts
@Injectable()
class ThrowingErrorHandler implements ErrorHandler {
handleError(error: unknown) {
throw error;
}
}

function provideThrowingErrorHandler(): EnvironmentProviders {
return makeEnvironmentProviders([
{
provide: ErrorHandler,
useClass: ThrowingErrorHandler,
},
]);
}
src/test-setup.ts
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.

src/throwing-error-handler.ts
@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,
},
]);
}
src/cook-button.spec.ts
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,
},
]);
}

For more detailed understanding, you can dive into the related PRs.