Test Teardown and Angular Elements

Trevor Karjanis · October 13, 2022

A common pattern is to create Angular elements in the constructor or ngBootstrap method of its associated module. This allows elements to use the module’s injector, inheriting it’s scope and lifecycle. A conditional check is required, because customElements.define will throw if the registry already has an entry.

@NgModule()
export class AppModule {
  constructor(injector: Injector) {
    if (!customElement.get('custom-element')) {
      const el = createCustomElement(CustomComponent, { injector: this.injector });
      customElements.define('custom-element', el);
    }
  }
}

As of Angular 13, ModuleTeardownOptions.destroyAfterEach defaults to true. The test module passed to TestBed.initTestEnvironment will be destroyed and the DOM cleared after every test. This should result in “faster, less memory-intensive, and less interdependent [tests].” For tests with an Angular element defined, however, its associated injector will be destroyed after the first test - resulting in the following error.

Error: NG0205: Injector has already been destroyed.
error properties: Object({ code: 205 })
Error: NG0205: Injector has already been destroyed.
    at R3Injector.assertNotDestroyed (node_modules/@angular/core/fesm2020/core.mjs:6850:19)
    at R3Injector.get (node_modules/@angular/core/fesm2020/core.mjs:6758:14)
    at new ComponentNgElementStrategy (node_modules/@angular/elements/fesm2020/elements.mjs:219:37)
    at ComponentNgElementStrategyFactory.create (node_modules/@angular/elements/fesm2020/elements.mjs:176:16)
    at NgElementImpl.ngElementStrategy (node_modules/@angular/elements/fesm2020/elements.mjs:469:37)
    at NgElementImpl.apply (node_modules/@angular/elements/fesm2020/elements.mjs:498:22)
    at _ZoneDelegate.invoke (node_modules/zone.js/fesm2015/zone.js:372:26)
    at ProxyZoneSpec.onInvoke (node_modules/zone.js/fesm2015/zone-testing.js:287:39)
    at _ZoneDelegate.invoke (node_modules/zone.js/fesm2015/zone.js:371:52)
    at Zone.runGuarded (node_modules/zone.js/fesm2015/zone.js:144:47)

Debugging

To determine the problematic element, check the beginning of the above callstack. Run the tests in watch mode, and set a breakpoint where the exception is thrown (r3_injector.ts line 303 as of 14.2.0). Once hit, a few steps up the stack will be the ComponentNgElementStrategy constructor (element.mjs line 219 corresponding to component-factory-strategy.ts line 88). Inpect the componentFactory argument to reveal the component.

Resolution

Often the custom element isn’t necessary for the test. Write the test so that constructing it isn’t required. Alternatively, disable destroyAfterEach for the affected set of tests.

beforeEach(() => {
  TestBed.configureTestingModule({
    teardown: { destroyAfterEach: true }
  });
});

Developer Preview

As of Angular 14.2.0, standalone elements are available for preview (PR#46475). This offers an opportunity to create an independent application environment with createApplication which decouples elements from the test module.

beforeAll(async () => {
  if (!customElement.get('custom-element')) {
    const application = await createApplication({ providers: [] });
    const el = createCustomElement(CustomComponent, { injector: appRef.injector });
    customElements.define('custom-element', el);
  }
});

Read more on the Angular blog.

Twitter, Facebook