Question

Cypress Component Testing in Angular Shadow Dom Components

I am trying to run Cypress component testing on a library of Angular components. Currently, all of my components have shadowDom turned on. Which seems to be causing me a problem when running multiple tests on a single component, as cypress appears to be struggling to destroy shadowDom components between it statements.

I tried three different scenarios to get something that works and they all have problems, which I will outline below. The questions I have are:

  1. Am I doing something wrong to work with ShadowDom Angular components?
  2. If this is a Cypress issue, is there a work around I can apply to get this up and running?

I have made a minimum reproduction here: https://github.com/jclark86613/cypress-shadow-comps

Scenario 1

I mount the component in a beforeEach the first test will pass, but the second (identical) test will fail.

ShadowDom = false, this scenario works ShadowDom = true, this scenario does not work

cy.ts

describe('TestShadowComponent', () => {
  beforeEach(() => {
    mount(TestShadowComponent);
  })
  it('should display the title', () => {
    cy.get('button', {includeShadowDom: true}).should('have.text', 'My Button')
  })
  it('should display the title', () => {
    cy.get('button', {includeShadowDom: true}).should('have.text', 'My Button')
  })
})

error

TypeError
Cannot set property message of [object DOMException] which has only a getter

Because this error occurred during a before each hook we are skipping the remaining tests in the current suite: TestShadowComponent

Scenario 2

I mount the component in each it block. This also always fails on the second identical test, but with a different error.

ShadowDom = false, this senario works ShadowDom = true, this senario does not work

cy.ts

describe('TestShadowComponent', () => {
  it('should display the title', () => {
    mount(TestShadowComponent);
    cy.get('button', {includeShadowDom: true}).should('have.text', 'My Button')
  })
  it('should display the title', () => {
    mount(TestShadowComponent);
    cy.get('button', {includeShadowDom: true}).should('have.text', 'My Button')
  })
})

error

NotSupportedError
Failed to execute 'attachShadow' on 'Element': Shadow root cannot be created on a host which already hosts a shadow tree.

Scenario 3

I mount the component once in a before block. This scenario works for my basic tests, but they no longer detect the output spies. I suspect that using before to mount a component is bad practice as it can allow state to bleed between tests. however, if turn shadowDom off and change before to beforeEach this scenario begins to work.

cy.ts

describe('TestShadowComponent', () => {
  before(() => {
    mount(TestShadowComponent, {autoSpyOutputs: true});
  })
  it('should display the title', () => {
    cy.get('button', {includeShadowDom: true}).should('have.text', 'My Button')
  })
  it('should display the title', () => {
    cy.get('button', {includeShadowDom: true}).should('have.text', 'My Button')
  })

  it('should click the button', () => {
    cy.get('button', {includeShadowDom: true}).click()
    // does click spy exists
    cy.get('@clickSpy').should('have.been.called')
  })
})

error

CypressError
cy.get() could not find a registered alias for: @clickSpy.
You have not aliased anything yet.
 4  75  4
1 Jan 1970

Solution

 7

The problem is with the un-mounting of the component between tests, which Cypress does to have a clean slate for each new test.

It looks like it removes the TestShadowComponent but not the shadow-root attached to the parent element (the mounting point withing the web page).

Hence the message Shadow root cannot be created on a host which already hosts a shadow tree when the second mount(TestShadowComponent) is called.

Cypress does not expect the component to make changes to the mounting point, but the Angular option encapsulation: ViewEncapsulation.ShadowDom adds the shadow-root in order to protect styles from bleeding over to other components

This is the HTML you see at runtime during the test:

<div data-cy-root id="root0" ng-version="17.3.11">   // supplied by Cypress
  #shadow-root (open)                               // Angular attaches shadow-root
    <style></style>                                 // and adds styles inside
    <button>My Button</button>                      // this is the component
</div>

Using a custom cleanup to remove the shadow-root

You can fix it by doing additional cleanup in an afterEach() hook

describe('TestShadowComponent', () => {

  afterEach(() => {
    cy.get('[data-cy-root]')                    // get the attachment point element
      .then($el => {
        const el = $el[0]                       // working with raw element
        const newRoot = el.cloneNode()          // make a clone
        el.parentElement!.appendChild(newRoot)  // add it to the page
        el.remove()                             // remove the original
      })
  })

  it('should display the title', () => {
    mount(TestShadowComponent)
    cy.get('button', {includeShadowDom: true}).should('have.text', 'My Button')
  })

  it('should display the title', () => {
    mount(TestShadowComponent)
    cy.get('button', {includeShadowDom: true}).should('have.text', 'My Button')
  })
})

enter image description here


Refs

Angular - View encapsulation

The Cypress cleanup code: npm/angular/src/mount.ts

function cleanup () {
  // Not public, we need to call this to remove the last component from the DOM
  try {
    (getTestBed() as any).tearDownTestingModule()
  } catch (e) {
    const notSupportedError = new Error(`Failed to teardown component. The version of Angular you are using may not be officially supported.`)

    ;(notSupportedError as any).docsUrl = 'https://on.cypress.io/component-framework-configuration'
    throw notSupportedError
  }

  getTestBed().resetTestingModule()
  activeFixture = null
}

Using a wrapper component to take the shadow root

You can also use a wrapper component inside the mount() as a sort of buffer between the component you want to test and the Cypress attachment element.

That way, the wrapper takes the shadow-root instead of the data-cy-root element, and it is removed along with TestShadowComponent during the cleanup.

The runtime HTML now looks like this (compare it to above)

<div data-cy-root id="root0" ng-version="17.3.11">
  <app-test-shadow>
    #shadow-root (open)               
      <style></style>                                   
      <button>My Button</button>             
  </app-test-shadow>
</div>

Implementation:

@Component({
  template: `<app-test-shadow />`
})
class WrapperComponent {}

describe('with wrapper', () => {

  it('should display the title', () => {
    cy.mount(WrapperComponent, {
      declarations: [TestShadowComponent],
    })
    cy.get('button', {includeShadowDom: true}).should('have.text', 'My Button')
  })

  it('should display the title', () => {
    cy.mount(WrapperComponent, {
      declarations: [TestShadowComponent],
    })
    cy.get('button', {includeShadowDom: true}).should('have.text', 'My Button')
  })
})

Ref Cypress example:
cypress-component-testing-apps/angular/src/app/button /button.component.cy.ts

2024-07-09
TesterDick