'Test angular component @Output using Storybook and Cypress

I am trying to test the output of an angular component.

I have a checkbox component that output its value using an EventEmitter. The checkbox component is wrapped in a storybook story for demo and testing purposes:

export const basic = () => ({
  moduleMetadata: {
    imports: [InputCheckboxModule],
  },
  template: `
<div style="color: orange">
 <checkbox (changeValue)="changeValue($event)" [selected]="checked" label="Awesome">
 </checkbox>
</div>`,
  props: {
    checked: boolean('checked', true),
    changeValue: action('Value Changed'),
  },
});

I am using an action to capture the value change and log it to the screen.

When writing a cypress e2e for this component however, I am only using the iFrame and not the whole storybook application.

I would like to find a way to test is the output is working. I tried using a spy on the postMessage method in the iFrame but that does not work.

 beforeEach(() => {
      cy.visit('/iframe.html?id=inputcheckboxcomponent--basic', {
        onBeforeLoad(win) {
          cy.spy(window, 'postMessage').as('postMessage');
        },
      });
    });

and then the assertions would be:

  cy.get('@postMessage').should('be.called');

Is there any other way how I could assert the (changeValue)="changeValue($event)" has fired?



Solution 1:[1]

Update 07.05.2022: Via Storybook's @storybook/addon-actions

Inspired by @jb17's answer and cypress-storybook.

/**
 * my-component.component.ts
 */
@Component({
  selector: 'my-component',
  template: `<button (click)="outputChange.emit('test-argument')"></button>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MyComponent {
  @Output()
  outputChange = new EventEmitter<string>();
}
/**
 * my-component.stories.ts
 */
export default {
  title: 'MyComponent',
  component: MyComponent,
  argTypes: {
    outputChange: { action: 'outputChange' },
  },
} as Meta<MyComponent>;

const Template: Story<MyComponent> = (args: MyComponent) => ({
  props: args,
});

export const Primary = Template.bind({});
Primary.args = {};
/**
 * my-component.spec.ts
 */
describe('MyComponent @Output Test', () => {
  beforeEach(() =>
    cy.visit('/iframe.html?id=mycomponent--primary', {
      onLoad: registerActionsAsAlias(), // ???
    })
  );

  it('triggers output', () => {
    cy.get('button').click();

    // Get spy via alias set by `registerActionsAsAlias()`
    cy.get('@outputChange').should('have.been.calledWith', 'test-argument');
  });
});
/**
 * somewhere.ts
 */
import { ActionDisplay } from '@storybook/addon-actions';
import { AddonStore } from '@storybook/addons';

export function registerActionsAsAlias(): (win: Cypress.AUTWindow) => void {
  // Store spies in the returned functions' closure
  const actionSpies = {};

  return (win: Cypress.AUTWindow) => {
    // https://github.com/storybookjs/storybook/blob/master/lib/addons/src/index.ts
    const addons: AddonStore = win['__STORYBOOK_ADDONS'];

    if (addons) {
      // https://github.com/storybookjs/storybook/blob/master/addons/actions/src/constants.ts
      addons.getChannel().addListener('storybook/actions/action-event', (event: ActionDisplay) => {
        if (!actionSpies[event.data.name]) {
          actionSpies[event.data.name] = cy.spy().as(event.data.name);
        }

        actionSpies[event.data.name](event.data.args);
      });
    }
  };
}

Approach 1: Template

We could bind the last emitted value to the template and check it.

{
  moduleMetadata: { imports: [InputCheckboxModule] },
  template: `
   <checkbox (changeValue)="value = $event" [selected]="checked" label="Awesome">
   </checkbox>
  
   <div id="changeValue">{{ value }}</div> <!-- ??? -->
 `,
}
it("emits `changeValue`", () => {
 // ...

 cy.get("#changeValue").contains("true"); // ???
});

Approach 2: Window

We could assign the last emitted value to the global window object, retrieve it in Cypress and validate the value.

export default {
  title: "InputCheckbox",
  component: InputCheckboxComponent,
  argTypes: {
    selected: { type: "boolean", defaultValue: false },
    label: { type: "string", defaultValue: "Default label" },
  },
} as Meta;


const Template: Story<InputCheckboxComponent> = (
  args: InputCheckboxComponent
) =>
  ({
    moduleMetadata: { imports: [InputCheckboxModule] },
    component: InputCheckboxComponent,
    props: args,
  } as StoryFnAngularReturnType);


export const E2E = Template.bind({});
E2E.args = {
  label: 'E2e label',
  selected: true,
  changeValue: value => (window.changeValue = value), // ???
};

it("emits `changeValue`", () => {
  // ...

  cy.window().its("changeValue").should("equal", true); // ???
});

Approach 3: Angular

We could use Angular's functions stored in the global namespace under ng in order to get a reference to the Angular component and spy on the output.

?? Attention:

  • ng.getComponent() is only available when Angular runs in development mode. I.e. enableProdMode() is not called.
  • Set process.env.NODE_ENV = "development"; in .storybook/main.js to prevent Storybook to build Angular in prod mode (see source).
export const E2E = Template.bind({});
E2E.args = {
  label: 'E2e label',
  selected: true,
  // Story stays unchanged
};

describe("InputCheckbox", () => {
  beforeEach(() => {
    cy.visit(
      "/iframe.html?id=inputcheckboxcomponent--e-2-e",
      registerComponentOutputs("checkbox") // ???
    );
  });

  it("emits `changeValue`", () => {
    // ...

    cy.get("@changeValue").should("be.calledWith", true); // ???
  });
});
function registerComponentOutputs(
  componentSelector: string
): Partial<Cypress.VisitOptions> {
  return {
    // https://docs.cypress.io/api/commands/visit.html#Provide-an-onLoad-callback-function
    onLoad(win) {
      const componentElement: HTMLElement = win.document.querySelector(
        componentSelector
      );
      // https://angular.io/api/core/global/ngGetComponent
      const component = win.ng.getComponent(componentElement);

      // Spy on all `EventEmitters` (i.e. `emit()`) and create equally named alias
      Object.keys(component)
        .filter(key => !!component[key].emit)
        .forEach(key => cy.spy(component[key], "emit").as(key)); // ???
    },
  };
}

Summary

  • I like in approach 1 that there is no magic. It is easy to read and understand. Unfortunately, it requires to specify a template with the additional element used to validate the output.
  • Approach 2 has the advantage that we no longer need to specify a template. But we need to add for each @Output which we'd like to test additional code. Furthermore, it uses the global window in order to "communicate".
  • Apprach 3 also doesn't require a template. It has the advantage that the Storybook code (story) doesn't need any adjustments. We only need to pass a parameter to cy.visit() (which most likely is already used) in order to be able to perform a check. Therefore, it feels like a scalable solution if we'd like to test more components via Storybook's iframe. Last but not least, we retrieve a reference to the Angular component. With this we would also be able to call methods or set properties directly on the component itself. This combined with ng.applyChanges seems to open some doors for additional test cases.

Solution 2:[2]

You're spying on window.postMessage(), which is a method that enables cross-origin communication between window objects (pop-ups, pages, iframes, ...).

The iFrame in Storybook doesn't communicate any messages to another window object, but you can install Kuker or another external web debugger on your application to spy on the messages between both and thus make the Cypress spy method working.

If you choose to install Kuker on your angular application, here is how to do it:

npm install -S kuker-emitters

Add the Kuker Chrome extension as well to make it work.

Solution 3:[3]

If you are using the cypress-storybook package and the @storybook/addon-actions, there is a method which can be used for this usecase, which provides the easiest solution in my opinion.

With the Storybook-actions addon you declare your @Output events like this

export default {
    title: 'Components/YourComponent',
    component: YourComponent,
    decorators: [
        moduleMetadata({
            imports: [YourModule]
        })
     ]
 } as Meta;

const Template: Story<YourStory> = (args: YourComponent) => ({
   props: args
 });

export const default = Template.bind({});
default.args = {
  // ...
  changeValue: action('Value Changed'), // action from @storybook/addon-actions
};

In your cypress test you could now call the cy.storyAction() method and apply expect statements to it.

it('should execute event', () => {
  // ...
  cy.storyAction('Value Changed').should('have.been.calledWith', 'value');
})

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Samuel Russo
Solution 3 JB17