简体   繁体   中英

Unit tests of Angular Elements (registered with the CustomElementRegistry as a HTML Custom Element) pass individually but fail when run together

I'm using Angular Elements https://angular.io/guide/elements which allows me to create an angular component, define a tag name, and register it as a HTML Custom Element in the CustomElementRegistry https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry . This means my angular components can be created by just adding some html to the DOM, anywhere, any time. It's similar to a Web Component, but it doesn't use Shadow DOM, so it can be targeted with CSS.

I created an angular component called TooltipWidget, so that I can write in the following:

 <my-tooltip-widget>Here's some text which will appear in the tooltip bubble</my-tooltip-widget>

It supports a bunch of attributes (eg for setting an icon) but I won't go into detail on that. I register it like this:

 const tooltipWidgetElement: any = createCustomElement(TooltipWidget, { injector: this.injector }); customElements.define('my-tooltip-widget', tooltipWidgetElement);

It works really well in my angular app, however I'm having problems with unit tests. When I run each test in isolation, they pass. When I run them as a group, I would get this error at first:

NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry': the name "my-tooltip-widget" has already been used with this registry

In my beforeEach() function, I'm creating a testbed using TestBed.configurTestingModule(..) and registereing all of my providers, running compileComponents() - All standard for an angular unit test. Inside compileComponents() I'm also registering my Angular Element with the CustomElementRegistry.

When the second test runs, Karma is obviously not giving me a fresh DOM, so when it tries to register the custom element a second time it fails. So now I conditionally add it if it doesn't exist:

    const tooltipWidgetElement: any = createCustomElement(TooltipWidget, { injector: this.injector });
    if (!customElements.get('my-tooltip-widget')) {
        customElements.define('my-tooltip-widget', tooltipWidgetElement);
    }

That solved that problem, but tests are still failing when run together. This time there is no error, it's just that the custom element is not rendering its output sometimes.

My test file has 9 tests, and when I run all 9, between 3 and 5 fail each time. The first test always succeeds. My test run order is randomised.

The way the test is set up, is that there's a test host component which contains the html for my custom element:

@Component({
    template: `
        <my-tooltip-widget 
            [content]="contentAttribute"
            [show-icon]="showIconAttribute"
            [icon]="iconAttribute"
            [icon-position]="iconPositionAttribute">
            {{ projectedContent }}
        </my-tooltip-widget>`,
})
class TestHostComponent {

    public projectedContent: string = null;
    public contentAttribute: string = null;
    public showIconAttribute: string = null;
    public iconAttribute: string = null;
    public iconPositionAttribute: string = null;
}

Here's what one of the unit tests look like:

it('when the content is projected and no content attribute is set, '
    + 'the projected content appears on the tooltip', async () => {
    // Arrange
    let sut: TestHostComponent = hostFixture.componentInstance;
    const testString: string = 'This is the projected content';
    sut.projectedContent = testString;

    // Act
    hostFixture.detectChanges();
    await hostFixture.whenRenderingDone();

    // Assert
    let debugElement: DebugElement = hostFixture.debugElement
        .query(By.css('.tooltip-content-container .popover-content'));
    expect(debugElement != null).toBeTruthy('The popover content div should be found');
    expect(debugElement.nativeElement.innerHTML).toContain(testString);
});

If I use fit(..) on just two tests, if this test is the first test to run, it will succeed every time. If it's the second test to run, it will fail every time.

So I'll add a console.log to show what html is being rendered when it succeeds and fails, like this:

console.log(debugElement.nativeElement.outerHTML);

A successful test gives the following html:

 <div _ngcontent-a-c129="" class="popover-content"> This is the projected content <:--bindings={ "ng-reflect-ng-template-outlet": "[object Object]" }--></div>

When the test fails, it gives the following html:

 <div _ngcontent-a-c129="" class="popover-content"><:--bindings={ "ng-reflect-ng-template-outlet": "[object Object]" }--></div>

As you can see it's just not outputting the projected content.

My thoughts are that the test runner is not cleaning things up after each test, so a prior test run is affecting a subsequent one. Here's the afterEach(..) function:

afterEach(() => {
    hostFixture.destroy();
});

It doesn't appear to be possible to remove custom components from the CustomElementRegistry in the browser - this is something talked about at length here as something they might do in the future: https://github.com/WICG/webcomponents/issues/754

However I don't think it's really necessary to undefine and redefine the custom elements between test runs, and I don't see how leaving the elements in the CustomElementRegistry would cause them to no longer work after the first test run.

I'm wondering whether it's a timing related issue, and I've tried using setTimeout(), fakeAsync() and tick(), re-calling hostFixture.detectChanges() and await hostFixture.whenRenderingDone(). When I try: await hostFixture.whenStable() it hangs, so I can't use that.

Another thought I had is to somehow send a signal to the tooltip angular component to force it to redraw itself. That's what hostFixture.detectChanges() does, but that's only on the test host, not the actual tooltip widget, so perhaps it's not getting through to the custom element underneath it?

Angular 13.3.11 Karma 6.4.1 Jasmine 3.99

UPDATE

I tried to trigger the component instances to run changeDetectorRef.detectChanges() themselves, by sending an rxjs subscription notification to them. It turns out that they don't receive it on the second test. Here's what I think is happening:

  1. For the first test, it creates the TestBed and registers the Angular Element.
  2. The injector passed into the createCustomElement(...) call is coming from the first TestBed instance.
  3. When we create the second TestBed instance for the second test, we don't re-define the custom elements, so we are not passing in the new instance of the Injector. The angular wiring is still pointing to the old instance of the injector from the first test run.
  4. When we then create another instance of the HTML custom component, it's trying to inject services from the disposed TestBed. It's not erroring, but it seems the communication doesn't work and the component is dead. The angular libraries are unable to do their job, including content projection.

I think it would solve this if we could delete the custom elements from the CustomElementRegistry and recreate them... but we can't do that yet as it's not supported.

So what can be done? Put all of the 9 tests into one test so they all use the same TestBed.

The best solution here is that the web standards people ensure that there is a way to undefine custom elements. If the people at Angular wanted to do something, it would be great if they could provide a way for us to pass in a new instance of the injector to previously defined angular elements so that future instances could use it.

It seems like restructuring your approach to testing could solve the problem. Why not register a different tag for each test? Your beforeEach() could handle the specifics, the implementation could be done in a few different ways, but it could look something like this:

let increment = 0;
let currentElementTag;

beforeEach(() => {
  currentElementTag = `my-tooltip-widget-${++increment}`;
  const tooltipWidgetElement: any = createCustomElement(TooltipWidget, {
    injector: getInjector()
  });
  customElements.define(currentElementTag, tooltipWidgetElement);
});

Update your template accordingly and this approach should work fine.


Alternatively, you could use Caridy's redefine-custom-elements NPM module.

npm install --save-dev redefine-custom-elements

In your test setup file, it should be as simple as importing the lib:

import "redefine-custom-elements";

I'm using this in Storybook to allow hot reloading of custom elements and it works great.


Side note: web standards authors will require a real-world use case when considering a request. Unit tests are specific and contrived, so don't really count as real-world use cases. By adding convenience for writing tests, the authors might have to make sacrifices in other ways. That's not to say that there may not be real-world use cases, I just don't think this is one.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM